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

@ -1,32 +1,68 @@
<?php <?php
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User; use App\Models\User;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
class UserController extends Controller use Inertia\Inertia;
{
public function index() class UserController extends Controller
{ {
return Inertia::render('admin/users/index', [ public function index()
'users' => User::select('id', 'name', 'email', 'role', 'created_at') {
->latest() return Inertia::render('admin/users/index', [
->get() 'users' => User::select('id', 'name', 'email', 'role', 'created_at')
]); ->latest()
} ->get(),
]);
public function update(Request $request, User $user) }
{
// Validasi perpindahan role public function create()
$validated = $request->validate([ {
'role' => 'required|in:admin,employee', return Inertia::render('admin/users/create');
]); }
$user->update($validated); public function store(Request $request)
{
return back()->with('success', "Role {$user->name} berhasil diubah."); $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)
{
$validated = $request->validate([
'role' => ['required', Rule::in(['admin', 'employee'])],
]);
$user->update($validated);
return back()->with('success', "Role {$user->name} berhasil diubah.");
}
}

View File

@ -13,7 +13,7 @@ class DashboardController extends Controller
public function index() public function index()
{ {
if (auth()->user()->role !== 'admin') { if (auth()->user()->role !== 'admin') {
return redirect()->route('employee.dashboard'); return redirect()->route('employee.index');
} }
$stats = [ $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; namespace App\Providers;
use App\Http\Responses\LoginResponse;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@ -15,7 +17,7 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register(): void 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 public function run(): void
{ {
// === DEPARTMENTS ===
$depts = [ $depts = [
['name' => 'Information Technology', 'description' => 'Bagian IT & Development'], ['name' => 'Information Technology', 'description' => 'Bagian IT & Development'],
['name' => 'Human Resources', 'description' => 'Bagian Kepegawaian'], ['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) { foreach ($depts as $dept) {
Department::create($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) { foreach ($positions as $pos) {
Position::create(['name' => $pos]); Position::create(['name' => $pos]);
} }
} }
} }

View File

@ -12,18 +12,60 @@ public function run(): void
{ {
// Akun Admin Utama // Akun Admin Utama
User::create([ User::create([
'name' => 'admin', 'name' => 'admin',
'email' => 'admin@gmail.com', 'email' => 'admin@gmail.com',
'password' => Hash::make('password'), 'password' => Hash::make('password'),
'role' => 'admin', 'role' => 'admin',
]); ]);
// Akun Contoh Karyawan
User::create([ User::create([
'name' => 'Jono Joni', 'name' => 'Jono Joni',
'email' => 'jono@gmail.com', 'email' => 'jono@gmail.com',
'password' => Hash::make('password'), 'password' => Hash::make('password'),
'role' => 'employee', '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 */ /* 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 { LayoutGrid, Users, Briefcase, Building2, SquareUser } from 'lucide-react';
import { NavMain } from '@/components/nav-main'; import { NavMain } from '@/components/nav-main';
import { NavUser } from '@/components/nav-user'; import { NavUser } from '@/components/nav-user';
@ -13,39 +13,39 @@ import {
SidebarMenuItem, SidebarMenuItem,
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import { dashboard } from '@/routes'; import { dashboard } from '@/routes';
import type { NavItem } from '@/types'; import type { NavItem, SharedData } from '@/types';
import AppLogo from './app-logo'; import AppLogo from './app-logo';
export function AppSidebar() { export function AppSidebar() {
const { auth } = usePage().props as any; const { auth } = usePage<SharedData>().props;
const userRole = auth.user.role; const userRole = auth.user.role;
const mainNavItems: NavItem[] = [ const mainNavItems: NavItem[] = [
{ {
title: 'Dashboard', title: 'Dashboard',
href: userRole === 'admin' ? '/dashboard' : '/employee/dashboard', href: userRole === 'admin' ? '/dashboard' : '/employee/index',
icon: LayoutGrid, icon: LayoutGrid,
}, },
...(userRole === 'admin' ? [ ...(userRole === 'admin' ? [
{ {
title: 'Manajemen Karyawan', title: 'Manajemen Karyawan',
href: '/admin/employees', href: '/admin/employees',
icon: Users, icon: Users,
}, },
{ {
title: 'Manajemen Departemen', title: 'Manajemen Departemen',
href: '/admin/departments', href: '/admin/departments',
icon: Building2, icon: Building2,
}, },
{ {
title: 'Manajemen Jabatan', title: 'Manajemen Jabatan',
href: '/admin/positions', href: '/admin/positions',
icon: Briefcase, icon: Briefcase,
}, },
{ {
title: 'Manajemen Pengguna', title: 'Manajemen Pengguna',
href: '/admin/users', href: '/admin/users',
icon: SquareUser, icon: SquareUser,
}, },
] : []), ] : []),
]; ];

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,61 +1,133 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { Head, useForm } from '@inertiajs/react'; import { Head, Link, router, usePage } from '@inertiajs/react';
import React from 'react'; import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import AppLayout from '@/layouts/app-layout'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
export default function Index({ users }: { users: any[] }) { import AppLayout from '@/layouts/app-layout';
const { patch, processing } = useForm(); import { type BreadcrumbItem } from '@/types';
const updateRole = (id: number, role: string) => { interface User {
patch(route('admin.users.update', id), { id: number;
data: { role: role }, name: string;
preserveScroll: true, email: string;
}); role: 'admin' | 'employee';
}; created_at: string;
}
return (
<AppLayout> interface PageProps {
<Head title="Manajemen User" /> users: User[];
<div className="p-4 md:p-8 w-full space-y-6"> [key: string]: unknown;
<h2 className="text-2xl font-bold">Manajemen Akses User</h2> }
<Card>
<CardHeader><CardTitle>Daftar Pengguna</CardTitle></CardHeader> const breadcrumbs: BreadcrumbItem[] = [
<CardContent className="p-0"> { title: 'Manajemen Pengguna', href: '/admin/users' },
<table className="w-full text-sm text-left"> ];
<thead className="bg-zinc-50 border-b">
<tr> export default function Index({ users }: PageProps) {
<th className="p-4">Nama</th> const { flash } = usePage<any>().props;
<th className="p-4 text-center">Role</th> const [processingId, setProcessingId] = useState<number | null>(null);
<th className="p-4 text-right">Aksi</th>
</tr> const updateRole = (id: number, role: string) => {
</thead> setProcessingId(id);
<tbody> router.patch(
{users.map((user) => ( `/admin/users/${id}`,
<tr key={user.id} className="border-b"> { role },
<td className="p-4 font-medium">{user.name}</td> {
<td className="p-4 text-center"> preserveScroll: true,
<span className="bg-zinc-100 px-2 py-1 rounded text-[10px] font-bold"> onFinish: () => setProcessingId(null),
{user.role.toUpperCase()} },
</span> );
</td> };
<td className="p-4 text-right">
<Select defaultValue={user.role} onValueChange={(val) => updateRole(user.id, val)} disabled={processing}> return (
<SelectTrigger className="w-[120px] ml-auto"><SelectValue /></SelectTrigger> <AppLayout breadcrumbs={breadcrumbs}>
<SelectContent> <Head title="Manajemen Pengguna" />
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="employee">Employee</SelectItem> <div className="p-4 md:p-8 w-full space-y-4">
</SelectContent>
</Select> {/* Flash Messages */}
</td> {flash?.success && (
</tr> <div className="p-4 bg-green-50 text-green-700 border border-green-200 rounded-md text-sm">
))} {flash.success}
</tbody> </div>
</table> )}
</CardContent> {flash?.error && (
</Card> <div className="p-4 bg-red-50 text-red-700 border border-red-200 rounded-md text-sm">
</div> {flash.error}
</AppLayout> </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 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>
</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>
</AppLayout>
);
}

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; id: number;
name: string; name: string;
email: string; email: string;
role: 'admin' | 'employee';
avatar?: string; avatar?: string;
email_verified_at: string | null; email_verified_at: string | null;
two_factor_enabled?: boolean; two_factor_enabled?: boolean;

View File

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