Feat: Role Middleware with CRUD Manajemen Pengguna

This commit is contained in:
IlhamIslamy 2026-04-07 23:29:40 +07:00
parent 6255b83d95
commit 9fb75ac5cd
12 changed files with 546 additions and 110 deletions

View File

@ -5,6 +5,8 @@
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
class UserController extends Controller
@ -14,15 +16,49 @@ public function index()
return Inertia::render('admin/users/index', [
'users' => User::select('id', 'name', 'email', 'role', 'created_at')
->latest()
->get()
->get(),
]);
}
public function create()
{
return Inertia::render('admin/users/create');
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|max:255|unique:users,email',
'password' => 'required|string|min:8|confirmed',
'role' => ['required', Rule::in(['admin', 'employee'])],
], [
'name.required' => 'Nama wajib diisi.',
'email.required' => 'Email wajib diisi.',
'email.email' => 'Format email tidak valid.',
'email.unique' => 'Email sudah terdaftar.',
'password.required' => 'Password wajib diisi.',
'password.min' => 'Password minimal 8 karakter.',
'password.confirmed' => 'Konfirmasi password tidak cocok.',
'role.required' => 'Role wajib dipilih.',
'role.in' => 'Role hanya boleh admin atau employee.',
]);
User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
'role' => $validated['role'],
]);
return redirect()->route('admin.users.index')
->with('success', "User {$validated['name']} berhasil ditambahkan.");
}
public function update(Request $request, User $user)
{
// Validasi perpindahan role
$validated = $request->validate([
'role' => 'required|in:admin,employee',
'role' => ['required', Rule::in(['admin', 'employee'])],
]);
$user->update($validated);

View File

@ -13,7 +13,7 @@ class DashboardController extends Controller
public function index()
{
if (auth()->user()->role !== 'admin') {
return redirect()->route('employee.dashboard');
return redirect()->route('employee.index');
}
$stats = [

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Responses;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
class LoginResponse implements LoginResponseContract
{
/**
* Redirect user based on their role after a successful login.
*/
public function toResponse($request): RedirectResponse|JsonResponse
{
$role = $request->user()->role;
$redirectUrl = match ($role) {
'admin' => route('dashboard'),
'employee' => route('employee.index'),
default => route('dashboard'),
};
if ($request->wantsJson()) {
return new JsonResponse(['two_factor' => false], 200);
}
return redirect()->intended($redirectUrl);
}
}

View File

@ -2,11 +2,13 @@
namespace App\Providers;
use App\Http\Responses\LoginResponse;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
class AppServiceProvider extends ServiceProvider
{
@ -15,7 +17,7 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
$this->app->singleton(LoginResponseContract::class, LoginResponse::class);
}
/**

View File

@ -10,20 +10,34 @@ class MasterDataSeeder extends Seeder
{
public function run(): void
{
// === DEPARTMENTS ===
$depts = [
['name' => 'Information Technology', 'description' => 'Bagian IT & Development'],
['name' => 'Human Resources', 'description' => 'Bagian Kepegawaian'],
['name' => 'Finance', 'description' => 'Bagian Keuangan'],
['name' => 'Finance', 'description' => 'Bagian Keuangan & Akuntansi'],
['name' => 'Marketing', 'description' => 'Bagian Pemasaran & Komunikasi'],
['name' => 'Operations', 'description' => 'Bagian Operasional & Logistik'],
];
foreach ($depts as $dept) {
Department::create($dept);
}
$positions = ['Manager', 'Senior Developer', 'Staff Admin', 'HR Specialist'];
// === POSITIONS ===
$positions = [
'Manager',
'Senior Developer',
'Junior Developer',
'Staff Admin',
'HR Specialist',
'Finance Analyst',
'Marketing Staff',
'Operations Staff',
];
foreach ($positions as $pos) {
Position::create(['name' => $pos]);
}
}
}

View File

@ -18,12 +18,54 @@ public function run(): void
'role' => 'admin',
]);
// Akun Contoh Karyawan
User::create([
'name' => 'Jono Joni',
'email' => 'jono@gmail.com',
'password' => Hash::make('password'),
'role' => 'employee',
]);
User::create([
'name' => 'Siti Nurhaliza',
'email' => 'siti.admin@hris.com',
'password' => Hash::make('password'),
'role' => 'admin',
]);
// --- Akun Karyawan (Employee) ---
User::create([
'name' => 'Budi Santoso',
'email' => 'budi.santoso@hris.com',
'password' => Hash::make('password'),
'role' => 'employee',
]);
User::create([
'name' => 'Dewi Rahayu',
'email' => 'dewi.rahayu@hris.com',
'password' => Hash::make('password'),
'role' => 'employee',
]);
User::create([
'name' => 'Rizky Firmansyah',
'email' => 'rizky.firmansyah@hris.com',
'password' => Hash::make('password'),
'role' => 'employee',
]);
User::create([
'name' => 'Anisa Putri',
'email' => 'anisa.putri@hris.com',
'password' => Hash::make('password'),
'role' => 'employee',
]);
User::create([
'name' => 'Hendra Kurniawan',
'email' => 'hendra.kurniawan@hris.com',
'password' => Hash::make('password'),
'role' => 'employee',
]);
}
}

View File

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Link, usePage } from '@inertiajs/react'; // Tambahkan usePage
import { Link, usePage } from '@inertiajs/react';
import { LayoutGrid, Users, Briefcase, Building2, SquareUser } from 'lucide-react';
import { NavMain } from '@/components/nav-main';
import { NavUser } from '@/components/nav-user';
@ -13,17 +13,17 @@ import {
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { dashboard } from '@/routes';
import type { NavItem } from '@/types';
import type { NavItem, SharedData } from '@/types';
import AppLogo from './app-logo';
export function AppSidebar() {
const { auth } = usePage().props as any;
const { auth } = usePage<SharedData>().props;
const userRole = auth.user.role;
const mainNavItems: NavItem[] = [
{
title: 'Dashboard',
href: userRole === 'admin' ? '/dashboard' : '/employee/dashboard',
href: userRole === 'admin' ? '/dashboard' : '/employee/index',
icon: LayoutGrid,
},
...(userRole === 'admin' ? [

View File

@ -0,0 +1,164 @@
import { Head, Link, useForm } from '@inertiajs/react';
import React from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import AppLayout from '@/layouts/app-layout';
import { type BreadcrumbItem } from '@/types';
const breadcrumbs: BreadcrumbItem[] = [
{ title: 'Manajemen Pengguna', href: '/admin/users' },
{ title: 'Tambah User', href: '/admin/users/create' },
];
export default function Create() {
const { data, setData, post, processing, errors } = useForm({
name: '',
email: '',
password: '',
password_confirmation: '',
role: '',
});
const submit = (e: React.FormEvent) => {
e.preventDefault();
post('/admin/users');
};
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Tambah User Baru" />
<div className="p-4 md:p-8 w-full">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold tracking-tight">Tambah User Baru</h2>
<p className="text-muted-foreground">Buat akun pengguna baru beserta hak aksesnya.</p>
</div>
<Button variant="outline" asChild>
<Link href="/admin/users">Kembali</Link>
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Form User</CardTitle>
</CardHeader>
<Separator />
<CardContent className="pt-6">
<form onSubmit={submit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Nama */}
<div className="space-y-2">
<Label htmlFor="name">
Nama Lengkap <span className="text-red-500">*</span>
</Label>
<Input
id="name"
placeholder="Contoh: Budi Santoso"
value={data.name}
onChange={(e) => setData('name', e.target.value)}
autoComplete="off"
/>
{errors.name && (
<p className="text-red-500 text-xs">{errors.name}</p>
)}
</div>
{/* Email */}
<div className="space-y-2">
<Label htmlFor="email">
Email <span className="text-red-500">*</span>
</Label>
<Input
id="email"
type="email"
placeholder="Contoh: budi@hris.com"
value={data.email}
onChange={(e) => setData('email', e.target.value)}
autoComplete="off"
/>
{errors.email && (
<p className="text-red-500 text-xs">{errors.email}</p>
)}
</div>
{/* Password */}
<div className="space-y-2">
<Label htmlFor="password">
Password <span className="text-red-500">*</span>
</Label>
<Input
id="password"
type="password"
placeholder="Minimal 8 karakter"
value={data.password}
onChange={(e) => setData('password', e.target.value)}
autoComplete="new-password"
/>
{errors.password && (
<p className="text-red-500 text-xs">{errors.password}</p>
)}
</div>
{/* Konfirmasi Password */}
<div className="space-y-2">
<Label htmlFor="password_confirmation">
Konfirmasi Password <span className="text-red-500">*</span>
</Label>
<Input
id="password_confirmation"
type="password"
placeholder="Ulangi password di atas"
value={data.password_confirmation}
onChange={(e) => setData('password_confirmation', e.target.value)}
autoComplete="new-password"
/>
{errors.password_confirmation && (
<p className="text-red-500 text-xs">{errors.password_confirmation}</p>
)}
</div>
{/* Role */}
<div className="space-y-2">
<Label htmlFor="role">
Role / Hak Akses <span className="text-red-500">*</span>
</Label>
<Select
value={data.role}
onValueChange={(val) => setData('role', val)}
>
<SelectTrigger id="role" className="w-full">
<SelectValue placeholder="Pilih role..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="employee">Employee</SelectItem>
</SelectContent>
</Select>
{errors.role && (
<p className="text-red-500 text-xs">{errors.role}</p>
)}
</div>
</div>
<div className="flex justify-end gap-4 pt-4">
<Button type="button" variant="ghost" asChild>
<Link href="/admin/users">Batal</Link>
</Button>
<Button type="submit" disabled={processing}>
{processing ? 'Menyimpan...' : 'Simpan User'}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</AppLayout>
);
}

View File

@ -1,58 +1,130 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Head, useForm } from '@inertiajs/react';
import React from 'react';
import { Head, Link, router, usePage } from '@inertiajs/react';
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import AppLayout from '@/layouts/app-layout';
import { type BreadcrumbItem } from '@/types';
export default function Index({ users }: { users: any[] }) {
const { patch, processing } = useForm();
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'employee';
created_at: string;
}
interface PageProps {
users: User[];
[key: string]: unknown;
}
const breadcrumbs: BreadcrumbItem[] = [
{ title: 'Manajemen Pengguna', href: '/admin/users' },
];
export default function Index({ users }: PageProps) {
const { flash } = usePage<any>().props;
const [processingId, setProcessingId] = useState<number | null>(null);
const updateRole = (id: number, role: string) => {
patch(route('admin.users.update', id), {
data: { role: role },
preserveScroll: true,
});
setProcessingId(id);
router.patch(
`/admin/users/${id}`,
{ role },
{
preserveScroll: true,
onFinish: () => setProcessingId(null),
},
);
};
return (
<AppLayout>
<Head title="Manajemen User" />
<div className="p-4 md:p-8 w-full space-y-6">
<h2 className="text-2xl font-bold">Manajemen Akses User</h2>
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Manajemen Pengguna" />
<div className="p-4 md:p-8 w-full space-y-4">
{/* Flash Messages */}
{flash?.success && (
<div className="p-4 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm">
{flash.success}
</div>
)}
{flash?.error && (
<div className="p-4 bg-red-50 text-red-700 border border-red-200 rounded-md text-sm">
{flash.error}
</div>
)}
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Manajemen Akses User</h2>
<p className="text-muted-foreground">Kelola akun dan hak akses pengguna sistem.</p>
</div>
<Button asChild>
<Link href="/admin/users/create">+ Tambah User Baru</Link>
</Button>
</div>
{/* Tabel */}
<Card>
<CardHeader><CardTitle>Daftar Pengguna</CardTitle></CardHeader>
<CardContent className="p-0">
<table className="w-full text-sm text-left">
<thead className="bg-zinc-50 border-b">
<tr>
<th className="p-4">Nama</th>
<th className="p-4 text-center">Role</th>
<th className="p-4 text-right">Aksi</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b">
<td className="p-4 font-medium">{user.name}</td>
<td className="p-4 text-center">
<span className="bg-zinc-100 px-2 py-1 rounded text-[10px] font-bold">
{user.role.toUpperCase()}
</span>
</td>
<td className="p-4 text-right">
<Select defaultValue={user.role} onValueChange={(val) => updateRole(user.id, val)} disabled={processing}>
<SelectTrigger className="w-[120px] ml-auto"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="employee">Employee</SelectItem>
</SelectContent>
</Select>
</td>
<CardHeader className="pb-4">
<CardTitle>Daftar Pengguna</CardTitle>
</CardHeader>
<Separator />
<CardContent className="pt-0">
<div className="relative w-full overflow-auto">
<table className="w-full text-sm text-left">
<thead className="text-muted-foreground bg-zinc-50/50">
<tr className="border-b">
<th className="h-12 px-4 font-medium">Nama</th>
<th className="h-12 px-4 font-medium">Email</th>
<th className="h-12 px-4 font-medium text-center">Role Saat Ini</th>
<th className="h-12 px-4 font-medium text-right">Ubah Role</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{users.length > 0 ? (
users.map((user) => (
<tr key={user.id} className="border-b hover:bg-zinc-50">
<td className="p-4 font-medium">{user.name}</td>
<td className="p-4 text-muted-foreground">{user.email}</td>
<td className="p-4 text-center">
<span className="bg-zinc-100 px-2.5 py-1 rounded-full text-[11px] font-semibold tracking-wide">
{user.role.toUpperCase()}
</span>
</td>
<td className="p-4 text-right">
<Select
defaultValue={user.role}
onValueChange={(val) => updateRole(user.id, val)}
disabled={processingId === user.id}
>
<SelectTrigger className="w-[130px] ml-auto">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="employee">Employee</SelectItem>
</SelectContent>
</Select>
</td>
</tr>
))
) : (
<tr>
<td colSpan={4} className="p-8 text-center text-muted-foreground">
Belum ada data pengguna.
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>

View File

@ -0,0 +1,73 @@
import { Head } from '@inertiajs/react';
import AppLayout from '@/layouts/app-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { usePage } from '@inertiajs/react';
import { User2, Mail, ShieldCheck } from 'lucide-react';
import type { SharedData } from '@/types';
import type { BreadcrumbItem } from '@/types';
const breadcrumbs: BreadcrumbItem[] = [
{ title: 'Portal Saya', href: '/employee/index' },
];
export default function EmployeeIndex() {
const { auth } = usePage<SharedData>().props;
const user = auth.user;
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Portal Karyawan" />
<div className="flex flex-1 flex-col gap-6 p-4 md:p-8">
{/* Header Sambutan */}
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">
Selamat datang, {user.name}!
</h1>
<p className="text-sm text-muted-foreground">
Ini adalah portal SDM Anda. Pantau informasi akun Anda di sini.
</p>
</div>
{/* Kartu Info Akun */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Nama Lengkap</CardTitle>
<User2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="text-lg font-semibold">{user.name}</p>
<p className="text-xs text-muted-foreground">Nama akun terdaftar</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Alamat Email</CardTitle>
<Mail className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="text-lg font-semibold truncate">{user.email}</p>
<p className="text-xs text-muted-foreground">Email untuk login</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Hak Akses</CardTitle>
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent className="flex flex-col gap-2">
<Badge variant="secondary" className="w-fit capitalize">
{user.role}
</Badge>
<p className="text-xs text-muted-foreground">Role akun Anda saat ini</p>
</CardContent>
</Card>
</div>
</div>
</AppLayout>
);
}

View File

@ -2,6 +2,7 @@ export type User = {
id: number;
name: string;
email: string;
role: 'admin' | 'employee';
avatar?: string;
email_verified_at: string | null;
two_factor_enabled?: boolean;

View File

@ -26,14 +26,16 @@
Route::resource('employees', EmployeeController::class);
Route::get('users', [UserController::class, 'index'])->name('users.index');
Route::get('users/create', [UserController::class, 'create'])->name('users.create');
Route::post('users', [UserController::class, 'store'])->name('users.store');
Route::patch('users/{user}', [UserController::class, 'update'])->name('users.update');
});
// KELOMPOK KARYAWAN
Route::middleware(['auth', 'verified', 'role:employee'])->group(function () {
Route::get('/employee/dashboard', function () {
return Inertia::render('employee/dashboard');
})->name('employee.dashboard');
Route::get('/employee/index', function () {
return Inertia::render('employee/index');
})->name('employee.index');
});
require __DIR__.'/settings.php';