feat: Plan 3 — Manajemen Pengguna (Admin User Management)

- Add AdminUserController: CRUD users, assign role, reset password,
  self-delete guard (403 on own account)
- Add admin route group under role:administrator middleware
  (GET/POST/PUT/DELETE /api/admin/users, PUT /api/admin/users/{id}/password)
- Add resources/views/admin/users.blade.php: DaisyUI table + 4 modals
  (create, edit, reset password, delete confirm), stats grid, toast
- Add Admin nav link (administrator-only) to map and data navbars
- 9 new tests covering page access, API CRUD, self-delete guard, password reset
- Mark Plan 3 complete in missing_features.md; update README page list
- Save plan3 to docs/ for reference

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
GuavaPopper
2026-06-03 13:51:08 +07:00
parent 2e2b42f96b
commit 8476c5d643
9 changed files with 1436 additions and 6 deletions
+1
View File
@@ -133,6 +133,7 @@ docker compose exec app php artisan cache:clear
1. **Beranda (`/`)**: Halaman landing dengan latar belakang peta animasi dan navigasi cepat.
2. **Peta Interaktif (`/map`)**: Antarmuka utama untuk melihat sebaran dan melakukan analisis radius.
3. **Data Tabular (`/data`)**: Manajemen data dalam format tabel dengan ringkasan statistik total.
4. **Admin Pengguna (`/admin/users`)**: Manajemen pengguna sistem — tambah, edit, hapus akun, assign role, dan reset password. Hanya dapat diakses oleh Administrator.
---
*Dokumentasi ini dibuat untuk menjelaskan kapabilitas sistem WebGIS Pemetaan Kemiskinan versi Laravel.*
@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
class AdminUserController extends Controller
{
public function index()
{
$users = User::with('roles')->get()->map(fn($u) => [
'id' => $u->id,
'name' => $u->name,
'email' => $u->email,
'role' => $u->getRoleNames()->first() ?? null,
]);
return response()->json($users);
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'password' => 'required|string|min:8',
'role' => 'required|string|exists:roles,name',
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => $validated['password'],
]);
$user->syncRoles([$validated['role']]);
return response()->json(['success' => true, 'data' => [
'id' => $user->id, 'name' => $user->name,
'email' => $user->email, 'role' => $validated['role'],
]]);
}
public function update(Request $request, $id)
{
$user = User::findOrFail($id);
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email,' . $id,
'role' => 'required|string|exists:roles,name',
]);
$user->update(['name' => $validated['name'], 'email' => $validated['email']]);
$user->syncRoles([$validated['role']]);
return response()->json(['success' => true, 'data' => [
'id' => $user->id, 'name' => $user->name,
'email' => $user->email, 'role' => $validated['role'],
]]);
}
public function destroy($id)
{
$user = User::findOrFail($id);
if ($user->id === auth()->id()) {
return response()->json(['message' => 'Tidak dapat menghapus akun sendiri.'], 403);
}
$user->delete();
return response()->json(['success' => true]);
}
public function resetPassword(Request $request, $id)
{
$user = User::findOrFail($id);
$request->validate([
'password' => 'required|string|min:8|confirmed',
]);
$user->update(['password' => $request->password]);
return response()->json(['success' => true]);
}
}
+746
View File
@@ -0,0 +1,746 @@
# Plan 3: Manajemen Pengguna (Admin User Management)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Tambah halaman admin `/admin/users` untuk CRUD pengguna dan assign role — hanya bisa diakses oleh `administrator`.
**Architecture:** Buat `AdminUserController` dengan 5 endpoint JSON (`index`, `store`, `update`, `destroy`, `resetPassword`). Buat view `resources/views/admin/users.blade.php` dengan pola DaisyUI yang sama persis seperti `data.blade.php` (fetch API, toast, modals, table rows). Tambahkan link "Admin" ke navbar di `map.blade.php` dan `data.blade.php` — muncul hanya untuk role `administrator`. Semua route di-protect dengan `role:administrator`.
**Tech Stack:** Laravel 13, Spatie Permission v8, Blade, Vanilla JS (fetch + async/await), DaisyUI 4, Tailwind CSS, Font Awesome 6.
> **Scope note:** Ini adalah Plan 3 dari `missing_features.md` (A-01). Plan 46 adalah subsistem terpisah yang masing-masing butuh plan tersendiri.
---
## Progress
| Task | Status |
|------|--------|
| Task 1: AdminUserController | ✅ Selesai (file dibuat, belum di-commit) |
| Task 2: Routes | ⚠️ Sebagian — `use` statement ditambah, route group belum |
| Task 3: View admin/users.blade.php | ❌ Belum |
| Task 4: Navbar links (map + data) | ❌ Belum |
| Task 5: Tests | ❌ Belum |
| Task 6: Docs | ❌ Belum |
**Commit dulu sebelum lanjut:**
```bash
git add app/Http/Controllers/AdminUserController.php routes/web.php
git commit -m "feat: add AdminUserController, add use statement to routes"
```
---
## File Map
| Action | File |
|--------|------|
| Create | `app/Http/Controllers/AdminUserController.php` |
| Modify | `routes/web.php` |
| Create | `resources/views/admin/users.blade.php` |
| Modify | `resources/views/map.blade.php` |
| Modify | `resources/views/data.blade.php` |
| Create | `tests/Feature/AdminUserTest.php` |
| Modify | `missing_features.md` |
| Modify | `README.md` |
---
## Task 1: Buat AdminUserController ✅
File sudah dibuat di `app/Http/Controllers/AdminUserController.php`.
- [x] **Step 1: Tulis controller**
```php
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
class AdminUserController extends Controller
{
public function index()
{
$users = User::with('roles')->get()->map(fn($u) => [
'id' => $u->id,
'name' => $u->name,
'email' => $u->email,
'role' => $u->getRoleNames()->first() ?? null,
]);
return response()->json($users);
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'password' => 'required|string|min:8',
'role' => 'required|string|exists:roles,name',
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => $validated['password'],
]);
$user->syncRoles([$validated['role']]);
return response()->json(['success' => true, 'data' => [
'id' => $user->id, 'name' => $user->name,
'email' => $user->email, 'role' => $validated['role'],
]]);
}
public function update(Request $request, $id)
{
$user = User::findOrFail($id);
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email,' . $id,
'role' => 'required|string|exists:roles,name',
]);
$user->update(['name' => $validated['name'], 'email' => $validated['email']]);
$user->syncRoles([$validated['role']]);
return response()->json(['success' => true, 'data' => [
'id' => $user->id, 'name' => $user->name,
'email' => $user->email, 'role' => $validated['role'],
]]);
}
public function destroy($id)
{
$user = User::findOrFail($id);
if ($user->id === auth()->id()) {
return response()->json(['message' => 'Tidak dapat menghapus akun sendiri.'], 403);
}
$user->delete();
return response()->json(['success' => true]);
}
public function resetPassword(Request $request, $id)
{
$user = User::findOrFail($id);
$request->validate([
'password' => 'required|string|min:8|confirmed',
]);
$user->update(['password' => $request->password]);
return response()->json(['success' => true]);
}
}
```
- [ ] **Step 2: Commit**
```bash
git add app/Http/Controllers/AdminUserController.php
git commit -m "feat: add AdminUserController with CRUD and password reset"
```
---
## Task 2: Tambah Routes Admin
**Files:**
- Modify: `routes/web.php`
> **STATUS:** `use App\Http\Controllers\AdminUserController;` sudah ditambah di routes/web.php. Tinggal tambahkan route group di Step 1 lalu commit.
- [ ] **Step 1: Tambah route group admin**
Di `routes/web.php`, dalam middleware group `auth`, tambahkan **sebelum** kurung tutup `});` terakhir:
```php
// Admin routes — hanya administrator
Route::middleware('role:administrator')->group(function () {
Route::get('/admin/users', fn() => view('admin.users'))->name('admin.users');
Route::prefix('api/admin')->group(function () {
Route::get('/users', [AdminUserController::class, 'index']);
Route::post('/users', [AdminUserController::class, 'store']);
Route::put('/users/{id}', [AdminUserController::class, 'update']);
Route::delete('/users/{id}', [AdminUserController::class, 'destroy']);
Route::put('/users/{id}/password', [AdminUserController::class, 'resetPassword']);
});
});
```
- [ ] **Step 2: Commit**
```bash
git add routes/web.php
git commit -m "feat: add admin user management routes"
```
---
## Task 3: Buat View Admin Users
**Files:**
- Create: `resources/views/admin/users.blade.php`
Ikuti pola `data.blade.php` (sticky navbar, DaisyUI, JS fetch, toast, modals).
- [ ] **Step 1: Tulis view lengkap**
```html
<!DOCTYPE html>
<html lang="id" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>Manajemen Pengguna - WebGIS</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
* { font-family: 'Plus Jakarta Sans', sans-serif; }
body { font-weight: 500; background-color: #f8fafc; }
h1, h2, h3, h4, h5, h6, .btn, .stat-value { font-weight: 700 !important; letter-spacing: -0.01em; }
</style>
</head>
<body class="min-h-screen">
<!-- NAVBAR -->
<div class="navbar bg-base-100 border-b border-base-200 shadow-sm sticky top-0 z-[1000]">
<div class="flex-1">
<a href="{{ url('/') }}" class="btn btn-ghost text-lg font-bold gap-2 text-primary">
<i class="fa-solid fa-house-chimney-crack"></i>
WebGIS Pemetaan Kemiskinan
</a>
</div>
<div class="flex-none">
<ul class="menu menu-horizontal px-1 gap-1">
<li><a href="{{ url('/map') }}" class="font-medium"><i class="fa-solid fa-map"></i> Peta</a></li>
<li><a href="{{ url('/data') }}" class="font-medium"><i class="fa-solid fa-table-list"></i> Data</a></li>
<li><a href="{{ url('/admin/users') }}" class="font-semibold bg-primary/10 text-primary rounded-lg"><i class="fa-solid fa-users-gear"></i> Admin</a></li>
</ul>
<div class="dropdown dropdown-end ml-2">
<div tabindex="0" role="button" class="btn btn-ghost gap-2">
<div class="avatar placeholder">
<div class="bg-primary text-primary-content rounded-full w-8">
<span class="text-xs font-bold">{{ substr(auth()->user()->name, 0, 1) }}</span>
</div>
</div>
<span class="hidden sm:inline text-sm font-semibold">{{ auth()->user()->name }}</span>
<i class="fa-solid fa-chevron-down text-xs opacity-50"></i>
</div>
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2 shadow-lg border border-base-200 mt-2">
<li class="menu-title">
<span class="text-xs text-base-content/50 capitalize">{{ str_replace('_', ' ', auth()->user()->getRoleNames()->first() ?? 'user') }}</span>
</li>
<li>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="text-error w-full text-left flex items-center gap-2">
<i class="fa-solid fa-right-from-bracket"></i> Keluar
</button>
</form>
</li>
</ul>
</div>
</div>
</div>
<!-- MAIN -->
<main class="max-w-screen-xl mx-auto px-4 py-8">
<!-- Header -->
<div class="flex flex-wrap items-center justify-between gap-4 mb-8">
<div>
<h1 class="text-3xl font-bold flex items-center gap-3">
<i class="fa-solid fa-users-gear text-primary"></i>
Manajemen Pengguna
</h1>
<p class="text-base-content/50 mt-1">Kelola akun pengguna dan hak akses sistem.</p>
</div>
<button onclick="openCreateModal()" class="btn btn-primary shadow-lg gap-2">
<i class="fa-solid fa-user-plus"></i> Tambah Pengguna
</button>
</div>
<!-- Stats -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8" id="statsGrid"></div>
<!-- Table -->
<div class="bg-white rounded-2xl shadow-sm border border-base-200 overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr class="text-xs font-bold uppercase opacity-50">
<th>#</th><th>Nama</th><th>Email</th><th>Role</th>
<th class="text-center">Aksi</th>
</tr>
</thead>
<tbody id="bodyUsers"></tbody>
</table>
</div>
</main>
<!-- Modal: Tambah Pengguna -->
<dialog id="modal_create_user" class="modal">
<div class="modal-box w-11/12 max-w-md">
<h3 class="font-bold text-lg mb-4"><i class="fa-solid fa-user-plus mr-2 text-primary"></i>Tambah Pengguna Baru</h3>
<div class="flex flex-col gap-3">
<div class="form-control">
<label class="label"><span class="label-text font-bold">Nama Lengkap *</span></label>
<input id="create_name" type="text" class="input input-bordered w-full" placeholder="Nama pengguna">
</div>
<div class="form-control">
<label class="label"><span class="label-text font-bold">Email *</span></label>
<input id="create_email" type="email" class="input input-bordered w-full" placeholder="email@domain.com">
</div>
<div class="form-control">
<label class="label"><span class="label-text font-bold">Password *</span></label>
<input id="create_password" type="password" class="input input-bordered w-full" placeholder="Min. 8 karakter">
</div>
<div class="form-control">
<label class="label"><span class="label-text font-bold">Role *</span></label>
<select id="create_role" class="select select-bordered w-full">
<option value="administrator">Administrator</option>
<option value="pengurus_ibadah">Pengurus Ibadah</option>
<option value="petugas_pendataan" selected>Petugas Pendataan</option>
<option value="pemangku_kebijakan">Pemangku Kebijakan</option>
</select>
</div>
</div>
<div class="modal-action">
<button onclick="document.getElementById('modal_create_user').close()" class="btn btn-ghost">Batal</button>
<button onclick="saveCreateUser()" class="btn btn-primary"><i class="fa-solid fa-floppy-disk mr-1"></i>Simpan</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- Modal: Edit Pengguna -->
<dialog id="modal_edit_user" class="modal">
<div class="modal-box w-11/12 max-w-md">
<h3 class="font-bold text-lg mb-4"><i class="fa-solid fa-user-pen mr-2 text-warning"></i>Edit Pengguna</h3>
<input type="hidden" id="edit_user_id">
<div class="flex flex-col gap-3">
<div class="form-control">
<label class="label"><span class="label-text font-bold">Nama Lengkap *</span></label>
<input id="edit_name" type="text" class="input input-bordered w-full">
</div>
<div class="form-control">
<label class="label"><span class="label-text font-bold">Email *</span></label>
<input id="edit_email" type="email" class="input input-bordered w-full">
</div>
<div class="form-control">
<label class="label"><span class="label-text font-bold">Role *</span></label>
<select id="edit_role" class="select select-bordered w-full">
<option value="administrator">Administrator</option>
<option value="pengurus_ibadah">Pengurus Ibadah</option>
<option value="petugas_pendataan">Petugas Pendataan</option>
<option value="pemangku_kebijakan">Pemangku Kebijakan</option>
</select>
</div>
</div>
<div class="modal-action">
<button onclick="document.getElementById('modal_edit_user').close()" class="btn btn-ghost">Batal</button>
<button onclick="saveEditUser()" class="btn btn-warning"><i class="fa-solid fa-floppy-disk mr-1"></i>Simpan Perubahan</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- Modal: Reset Password -->
<dialog id="modal_reset_password" class="modal">
<div class="modal-box w-11/12 max-w-md">
<h3 class="font-bold text-lg mb-4"><i class="fa-solid fa-key mr-2 text-info"></i>Reset Password</h3>
<input type="hidden" id="reset_user_id">
<p class="text-sm text-base-content/60 mb-4" id="reset_user_label"></p>
<div class="flex flex-col gap-3">
<div class="form-control">
<label class="label"><span class="label-text font-bold">Password Baru *</span></label>
<input id="reset_password" type="password" class="input input-bordered w-full" placeholder="Min. 8 karakter">
</div>
<div class="form-control">
<label class="label"><span class="label-text font-bold">Konfirmasi Password *</span></label>
<input id="reset_password_confirmation" type="password" class="input input-bordered w-full" placeholder="Ulangi password baru">
</div>
</div>
<div class="modal-action">
<button onclick="document.getElementById('modal_reset_password').close()" class="btn btn-ghost">Batal</button>
<button onclick="saveResetPassword()" class="btn btn-info text-white"><i class="fa-solid fa-key mr-1"></i>Reset Password</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- Modal: Confirm Delete -->
<dialog id="modal_delete_user" class="modal">
<div class="modal-box max-w-sm text-center">
<div class="text-5xl mb-4 text-error"><i class="fa-solid fa-trash-can"></i></div>
<h3 class="font-bold text-xl mb-2">Hapus Pengguna?</h3>
<p class="text-sm text-base-content/60 mb-6" id="delete_user_label"></p>
<input type="hidden" id="delete_user_id">
<div class="flex gap-2 justify-center">
<button class="btn btn-ghost px-8" onclick="document.getElementById('modal_delete_user').close()">Batal</button>
<button class="btn btn-error px-8 text-white" onclick="confirmDeleteUser()">Ya, Hapus</button>
</div>
</div>
</dialog>
<!-- Toast -->
<div class="toast toast-end toast-bottom z-[9999]" id="toastBox"></div>
<script>
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
let usersData = [];
document.addEventListener('DOMContentLoaded', loadUsers);
async function loadUsers() {
try {
const res = await fetch('{{ url('/api/admin/users') }}', { headers: { 'X-CSRF-TOKEN': csrfToken } });
usersData = await res.json();
renderUsers();
} catch (e) { showToast('Gagal memuat data pengguna', 'error'); }
}
const roleLabels = { administrator: 'Administrator', pengurus_ibadah: 'Pengurus Ibadah', petugas_pendataan: 'Petugas Pendataan', pemangku_kebijakan: 'Pemangku Kebijakan' };
const roleBadge = { administrator: 'badge-error', pengurus_ibadah: 'badge-success', petugas_pendataan: 'badge-warning', pemangku_kebijakan: 'badge-info' };
function renderUsers() {
const counts = { administrator: 0, pengurus_ibadah: 0, petugas_pendataan: 0, pemangku_kebijakan: 0 };
usersData.forEach(u => { if (counts[u.role] !== undefined) counts[u.role]++; });
document.getElementById('statsGrid').innerHTML = Object.entries(roleLabels).map(([key, label]) => `
<div class="stat bg-white shadow rounded-2xl border border-base-200">
<div class="stat-title text-xs uppercase font-bold opacity-50">${label}</div>
<div class="stat-value text-2xl">${counts[key]}</div>
</div>`).join('');
document.getElementById('bodyUsers').innerHTML = usersData.length ? usersData.map((u, i) => `
<tr>
<td class="text-xs font-bold opacity-30">${i + 1}</td>
<td><div class="font-bold">${u.name}</div></td>
<td class="text-sm opacity-70">${u.email}</td>
<td><span class="badge badge-sm ${roleBadge[u.role] || 'badge-neutral'} badge-outline capitalize">${roleLabels[u.role] || u.role}</span></td>
<td class="text-center">
<div class="flex gap-1 justify-center">
<button onclick="openEditModal(${u.id})" class="btn btn-xs btn-warning btn-outline" title="Edit"><i class="fa-solid fa-pen"></i></button>
<button onclick="openResetModal(${u.id})" class="btn btn-xs btn-info btn-outline" title="Reset Password"><i class="fa-solid fa-key"></i></button>
<button onclick="openDeleteModal(${u.id})" class="btn btn-xs btn-error btn-outline" title="Hapus"><i class="fa-solid fa-trash"></i></button>
</div>
</td>
</tr>`).join('') : '<tr><td colspan="5" class="text-center py-10 opacity-30 italic">Belum ada pengguna</td></tr>';
}
function openCreateModal() {
['create_name','create_email','create_password'].forEach(id => document.getElementById(id).value = '');
document.getElementById('create_role').value = 'petugas_pendataan';
document.getElementById('modal_create_user').showModal();
}
async function saveCreateUser() {
const body = { name: document.getElementById('create_name').value, email: document.getElementById('create_email').value, password: document.getElementById('create_password').value, role: document.getElementById('create_role').value };
const res = await fetch('{{ url('/api/admin/users') }}', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: JSON.stringify(body) });
const json = await res.json();
if (json.success) { document.getElementById('modal_create_user').close(); showToast('Pengguna berhasil ditambahkan', 'success'); await loadUsers(); }
else showToast(json.message || 'Gagal menambahkan pengguna', 'error');
}
function openEditModal(id) {
const u = usersData.find(u => u.id === id); if (!u) return;
document.getElementById('edit_user_id').value = u.id;
document.getElementById('edit_name').value = u.name;
document.getElementById('edit_email').value = u.email;
document.getElementById('edit_role').value = u.role || 'petugas_pendataan';
document.getElementById('modal_edit_user').showModal();
}
async function saveEditUser() {
const id = document.getElementById('edit_user_id').value;
const body = { name: document.getElementById('edit_name').value, email: document.getElementById('edit_email').value, role: document.getElementById('edit_role').value };
const res = await fetch(`{{ url('/api/admin/users') }}/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: JSON.stringify(body) });
const json = await res.json();
if (json.success) { document.getElementById('modal_edit_user').close(); showToast('Data pengguna diperbarui', 'success'); await loadUsers(); }
else showToast(json.message || 'Gagal memperbarui data', 'error');
}
function openResetModal(id) {
const u = usersData.find(u => u.id === id); if (!u) return;
document.getElementById('reset_user_id').value = u.id;
document.getElementById('reset_user_label').textContent = `Reset password untuk: ${u.name} (${u.email})`;
document.getElementById('reset_password').value = '';
document.getElementById('reset_password_confirmation').value = '';
document.getElementById('modal_reset_password').showModal();
}
async function saveResetPassword() {
const id = document.getElementById('reset_user_id').value;
const body = { password: document.getElementById('reset_password').value, password_confirmation: document.getElementById('reset_password_confirmation').value };
const res = await fetch(`{{ url('/api/admin/users') }}/${id}/password`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: JSON.stringify(body) });
const json = await res.json();
if (json.success) { document.getElementById('modal_reset_password').close(); showToast('Password berhasil direset', 'success'); }
else showToast(json.message || 'Gagal reset password', 'error');
}
function openDeleteModal(id) {
const u = usersData.find(u => u.id === id); if (!u) return;
document.getElementById('delete_user_id').value = u.id;
document.getElementById('delete_user_label').textContent = `Pengguna "${u.name}" (${u.email}) akan dihapus secara permanen.`;
document.getElementById('modal_delete_user').showModal();
}
async function confirmDeleteUser() {
const id = document.getElementById('delete_user_id').value;
const res = await fetch(`{{ url('/api/admin/users') }}/${id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken } });
const json = await res.json();
if (json.success) { document.getElementById('modal_delete_user').close(); showToast('Pengguna dihapus', 'success'); await loadUsers(); }
else showToast(json.message || 'Gagal menghapus pengguna', 'error');
}
function showToast(msg, type = 'info') {
const t = document.createElement('div');
const cls = type === 'success' ? 'alert-success' : type === 'error' ? 'alert-error' : 'alert-info';
t.className = `alert ${cls} shadow-lg py-3 text-xs font-bold`;
t.innerHTML = `<span><i class="fa-solid fa-circle-info mr-2"></i>${msg}</span>`;
document.getElementById('toastBox').appendChild(t);
setTimeout(() => t.remove(), 3000);
}
</script>
</body>
</html>
```
- [ ] **Step 2: Commit**
```bash
git add resources/views/admin/users.blade.php
git commit -m "feat: add admin users management view"
```
---
## Task 4: Tambah Link Admin ke Navbar
**Files:**
- Modify: `resources/views/map.blade.php`
- Modify: `resources/views/data.blade.php`
- [ ] **Step 1: Tambah di map.blade.php**
Cari `<ul class="menu menu-horizontal px-1 gap-1">` di `map.blade.php`, tambahkan setelah item "Data Tabular":
```html
@if(auth()->user()->hasRole('administrator'))
<li>
<a href="{{ url('/admin/users') }}" class="font-medium">
<i class="fa-solid fa-users-gear"></i> Admin
</a>
</li>
@endif
```
- [ ] **Step 2: Tambah di data.blade.php**
Sama persis, setelah item "Data Tabular" di `data.blade.php`.
- [ ] **Step 3: Commit**
```bash
git add resources/views/map.blade.php resources/views/data.blade.php
git commit -m "feat: add Admin nav link for administrator role in map and data views"
```
---
## Task 5: Tulis Tests AdminUserController
**Files:**
- Create: `tests/Feature/AdminUserTest.php`
- [ ] **Step 1: Tulis test file**
```php
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AdminUserTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
foreach (['administrator', 'pengurus_ibadah', 'petugas_pendataan', 'pemangku_kebijakan'] as $role) {
\Spatie\Permission\Models\Role::create(['name' => $role, 'guard_name' => 'web']);
}
}
private function createUser(string $role): User
{
$user = User::factory()->create();
$user->assignRole($role);
return $user;
}
public function test_admin_users_page_is_accessible(): void
{
$admin = $this->createUser('administrator');
$this->actingAs($admin);
$this->get('/admin/users')->assertOk();
}
public function test_non_admin_cannot_access_users_page(): void
{
$pengurus = $this->createUser('pengurus_ibadah');
$this->actingAs($pengurus);
$this->get('/admin/users')->assertStatus(403);
}
public function test_admin_can_list_users(): void
{
$admin = $this->createUser('administrator');
$this->actingAs($admin);
$this->getJson('/api/admin/users')->assertOk()->assertJsonFragment(['email' => $admin->email]);
}
public function test_non_admin_cannot_list_users(): void
{
$pengurus = $this->createUser('pengurus_ibadah');
$this->actingAs($pengurus);
$this->getJson('/api/admin/users')->assertStatus(403);
}
public function test_admin_can_create_user(): void
{
$admin = $this->createUser('administrator');
$this->actingAs($admin);
$this->postJson('/api/admin/users', [
'name' => 'User Baru', 'email' => 'userbaru@webgis.local',
'password' => 'password123', 'role' => 'petugas_pendataan',
])->assertOk()->assertJsonFragment(['email' => 'userbaru@webgis.local']);
$this->assertDatabaseHas('users', ['email' => 'userbaru@webgis.local']);
}
public function test_admin_can_update_user(): void
{
$admin = $this->createUser('administrator');
$target = $this->createUser('pengurus_ibadah');
$this->actingAs($admin);
$this->putJson("/api/admin/users/{$target->id}", [
'name' => 'Nama Diubah', 'email' => $target->email, 'role' => 'petugas_pendataan',
])->assertOk()->assertJsonFragment(['name' => 'Nama Diubah']);
$this->assertTrue($target->fresh()->hasRole('petugas_pendataan'));
}
public function test_admin_can_delete_other_user(): void
{
$admin = $this->createUser('administrator');
$target = $this->createUser('pengurus_ibadah');
$this->actingAs($admin);
$this->deleteJson("/api/admin/users/{$target->id}")->assertOk();
$this->assertNull(User::find($target->id));
}
public function test_admin_cannot_delete_self(): void
{
$admin = $this->createUser('administrator');
$this->actingAs($admin);
$this->deleteJson("/api/admin/users/{$admin->id}")->assertStatus(403);
$this->assertNotNull(User::find($admin->id));
}
public function test_admin_can_reset_password(): void
{
$admin = $this->createUser('administrator');
$target = $this->createUser('pengurus_ibadah');
$this->actingAs($admin);
$this->putJson("/api/admin/users/{$target->id}/password", [
'password' => 'newpassword123', 'password_confirmation' => 'newpassword123',
])->assertOk();
$this->assertTrue(\Illuminate\Support\Facades\Hash::check('newpassword123', $target->fresh()->password));
}
}
```
- [ ] **Step 2: Jalankan tests**
```powershell
docker compose exec app php artisan test tests/Feature/AdminUserTest.php --no-coverage
```
Expected: Semua 8 tests **PASS**.
- [ ] **Step 3: Full test suite**
```powershell
docker compose exec app php artisan test --no-coverage
```
Expected: 28 tests **PASS**.
- [ ] **Step 4: Commit**
```bash
git add tests/Feature/AdminUserTest.php
git commit -m "test: add AdminUserController tests (CRUD + delete-self guard + password reset)"
```
---
## Task 6: Update Docs
- [ ] **Step 1: Update missing_features.md**
- "Terakhir diperbarui" → `2026-06-03 — setelah implementasi Manajemen Pengguna (Plan 3).`
- `A-01 Manajemen Pengguna``✅ Selesai`
- Ringkasan: Fitur Admin `✅ 1/8`, Total sisa `~13 item`
- Plan order: `~~Plan 3~~ | ~~Manajemen Pengguna~~ | ✅ Selesai`
- [ ] **Step 2: Update README.md**
Di bagian "Daftar Halaman", tambahkan:
```markdown
4. **Admin Pengguna (`/admin/users`)**: Manajemen pengguna sistem — tambah, edit, hapus akun, assign role, dan reset password. Hanya dapat diakses oleh Administrator.
```
- [ ] **Step 3: Commit**
```bash
git add missing_features.md README.md
git commit -m "docs: mark Plan 3 complete — admin user management implemented, update README"
```
---
## Verification Checklist
1. Login `pengurus@webgis.local``/admin/users` → 403
2. Login `admin@webgis.local``/admin/users` → halaman tampil
3. Tambah pengguna → user muncul di tabel
4. Edit pengguna → nama/role ter-update
5. Reset password → bisa login dengan password baru
6. Hapus pengguna lain → hilang dari tabel
7. Hapus akun sendiri → toast error
8. Navbar: link "Admin" hanya muncul untuk administrator
```powershell
docker compose exec app php artisan test --no-coverage
# Expected: 28 tests PASS
```
---
> **Plans 46:**
> - Plan 4: Dashboard Statistik + Gap Analysis
> - Plan 5: Ekspor PDF + per-radius export (`barryvdh/laravel-dompdf`)
> - Plan 6: Bulk delete, CSV import, log aktivitas, backup
+6 -6
View File
@@ -2,7 +2,7 @@
> Berdasarkan perbandingan dokumen SKPL (`D1041231009_RadityaIndraPutranto_SKPL_SIG.docx`) dengan implementasi aktual project.
>
> **Terakhir diperbarui:** 2026-06-02 — setelah implementasi Form Edit & Ownership (Plan 2).
> **Terakhir diperbarui:** 2026-06-03 — setelah implementasi Manajemen Pengguna (Plan 3).
---
@@ -21,7 +21,7 @@
| # | Role | Kode SKPL | Status |
|---|---|---|---|
| 1 | **Administrator Sistem** | A-01 s/d A-05 | ⚠️ Role ada, fitur admin belum (lihat bagian 2) |
| 1 | **Administrator Sistem** | A-01 s/d A-05 | ⚠️ A-01 selesai, A-02A-05 belum (lihat bagian 2) |
| 2 | **Pengurus Rumah Ibadah** | I-01 s/d I-06 | ⚠️ Bisa CRUD ibadah, tapi belum ada ownership |
| 3 | **Petugas Pendataan (Operator)** | P-01 s/d P-05 | ⚠️ Bisa CRUD miskin, belum ada form edit & verifikasi |
| 4 | **Pemangku Kebijakan** | K-01 s/d K-04 | ⚠️ View-only sudah, dashboard & ekspor lanjutan belum |
@@ -36,7 +36,7 @@
| Kode | Fitur | Status |
|---|---|---|
| A-01 | **Manajemen Pengguna** — CRUD user, assign role, reset password | ❌ Belum ada |
| A-01 | **Manajemen Pengguna** — CRUD user, assign role, reset password | ✅ Selesai |
| A-02 | **Bulk Delete** — Hapus data massal | ❌ Belum ada |
| A-02 | **Import CSV** — Import data dari file CSV | ❌ Belum ada |
| A-03 | **Konfigurasi Sistem** — Batas wilayah, radius min/max, legenda peta | ❌ Belum ada |
@@ -85,11 +85,11 @@ Data bisa ditambah dan dihapus. Untuk lokasi: drag marker (ibadah & miskin) dan
| Autentikasi & RBAC | ✅ 6/6 komponen | — |
| Role Pengguna | ✅ 4/4 role | — |
| Ownership pengurus_ibadah | ✅ 1/1 | — |
| Fitur Admin | | ❌ 8 fitur |
| Fitur Admin | ✅ 1/8 | ❌ 7 fitur |
| Fitur Edit Data | ✅ 2/2 | — |
| Fitur Analisis/Dashboard | — | ❌ 3 fitur |
| Fitur Cetak/Ekspor | — | ❌ 3 fitur |
| **Total sisa** | | **~14 item** |
| **Total sisa** | | **~13 item** |
---
@@ -99,7 +99,7 @@ Data bisa ditambah dan dihapus. Untuk lokasi: drag marker (ibadah & miskin) dan
|---|---|---|
| ~~Plan 1~~ | ~~Auth & RBAC~~ | ✅ Selesai |
| ~~Plan 2~~ | ~~Form Edit Rumah Ibadah & Keluarga Miskin + Ownership~~ | ✅ Selesai |
| Plan 3 | Manajemen Pengguna (Admin: CRUD user, assign role) | Tinggi |
| ~~Plan 3~~ | ~~Manajemen Pengguna (Admin: CRUD user, assign role)~~ | ✅ Selesai |
| Plan 4 | Dashboard Statistik + Gap Analysis (Pemangku Kebijakan) | Sedang |
| Plan 5 | Ekspor PDF + Ekspor per Radius + Cetak Laporan | Sedang |
| Plan 6 | Fitur Admin lanjutan (Bulk delete, CSV import, Log, Backup) | Rendah |
+443
View File
@@ -0,0 +1,443 @@
<!DOCTYPE html>
<html lang="id" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>Manajemen Pengguna - WebGIS</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap" rel="stylesheet">
<style>
* { font-family: 'Plus Jakarta Sans', sans-serif; }
body { font-weight: 500; background-color: #f8fafc; }
h1, h2, h3, h4, h5, h6, .btn, .card-title, .stat-value { font-weight: 700 !important; letter-spacing: -0.01em; }
</style>
</head>
<body class="min-h-screen">
<!-- ═══ NAVBAR ═══════════════════════════════════════════════════════════ -->
<div class="navbar bg-base-100 border-b border-base-200 shadow-sm sticky top-0 z-[1000]">
<div class="flex-1">
<a href="{{ url('/') }}" class="btn btn-ghost text-lg font-bold gap-2 text-primary">
<i class="fa-solid fa-house-chimney-crack"></i>
WebGIS Pemetaan Kemiskinan
</a>
</div>
<div class="flex-none">
<ul class="menu menu-horizontal px-1 gap-1">
<li>
<a href="{{ url('/map') }}" class="font-medium">
<i class="fa-solid fa-map"></i> Peta Interaktif
</a>
</li>
<li>
<a href="{{ url('/data') }}" class="font-medium">
<i class="fa-solid fa-table-list"></i> Data Tabular
</a>
</li>
<li>
<a href="{{ url('/admin/users') }}" class="font-semibold bg-primary/10 text-primary rounded-lg">
<i class="fa-solid fa-users-gear"></i> Admin
</a>
</li>
</ul>
<!-- User dropdown -->
<div class="dropdown dropdown-end ml-2">
<div tabindex="0" role="button" class="btn btn-ghost gap-2">
<div class="avatar placeholder">
<div class="bg-primary text-primary-content rounded-full w-8">
<span class="text-xs font-bold">{{ substr(auth()->user()->name, 0, 1) }}</span>
</div>
</div>
<span class="hidden sm:inline text-sm font-semibold">{{ auth()->user()->name }}</span>
<i class="fa-solid fa-chevron-down text-xs opacity-50"></i>
</div>
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2 shadow-lg border border-base-200 mt-2">
<li class="menu-title">
<span class="text-xs text-base-content/50 capitalize">{{ str_replace('_', ' ', auth()->user()->getRoleNames()->first() ?? 'user') }}</span>
</li>
<li>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="text-error w-full text-left flex items-center gap-2">
<i class="fa-solid fa-right-from-bracket"></i>
Keluar
</button>
</form>
</li>
</ul>
</div>
</div>
</div>
<!-- ═══ MAIN CONTENT ════════════════════════════════════════════════════ -->
<main class="max-w-screen-xl mx-auto px-4 py-8">
<!-- Header -->
<div class="flex flex-wrap items-center justify-between gap-4 mb-8">
<div>
<h1 class="text-3xl font-bold flex items-center gap-3">
<i class="fa-solid fa-users-gear text-primary"></i>
Manajemen Pengguna
</h1>
<p class="text-base-content/50 mt-1">Kelola akun pengguna dan hak akses sistem.</p>
</div>
<button onclick="openCreateModal()" class="btn btn-primary shadow-lg gap-2">
<i class="fa-solid fa-user-plus"></i> Tambah Pengguna
</button>
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8" id="statsGrid">
<!-- Diisi oleh JS -->
</div>
<!-- Table -->
<div class="bg-white rounded-2xl shadow-sm border border-base-200 overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr class="text-xs font-bold uppercase opacity-50">
<th>#</th>
<th>Nama</th>
<th>Email</th>
<th>Role</th>
<th class="text-center">Aksi</th>
</tr>
</thead>
<tbody id="bodyUsers"></tbody>
</table>
</div>
</main>
<!-- ═══ MODALS ═══════════════════════════════════════════════════════════ -->
<!-- Modal: Tambah Pengguna -->
<dialog id="modal_create_user" class="modal">
<div class="modal-box w-11/12 max-w-md">
<h3 class="font-bold text-lg mb-4">
<i class="fa-solid fa-user-plus mr-2 text-primary"></i>Tambah Pengguna Baru
</h3>
<div class="flex flex-col gap-3">
<div class="form-control">
<label class="label"><span class="label-text font-bold">Nama Lengkap *</span></label>
<input id="create_name" type="text" class="input input-bordered w-full" placeholder="Nama pengguna">
</div>
<div class="form-control">
<label class="label"><span class="label-text font-bold">Email *</span></label>
<input id="create_email" type="email" class="input input-bordered w-full" placeholder="email@domain.com">
</div>
<div class="form-control">
<label class="label"><span class="label-text font-bold">Password *</span></label>
<input id="create_password" type="password" class="input input-bordered w-full" placeholder="Min. 8 karakter">
</div>
<div class="form-control">
<label class="label"><span class="label-text font-bold">Role *</span></label>
<select id="create_role" class="select select-bordered w-full">
<option value="administrator">Administrator</option>
<option value="pengurus_ibadah">Pengurus Ibadah</option>
<option value="petugas_pendataan" selected>Petugas Pendataan</option>
<option value="pemangku_kebijakan">Pemangku Kebijakan</option>
</select>
</div>
</div>
<div class="modal-action">
<button onclick="document.getElementById('modal_create_user').close()" class="btn btn-ghost">Batal</button>
<button onclick="saveCreateUser()" class="btn btn-primary">
<i class="fa-solid fa-floppy-disk mr-1"></i>Simpan
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- Modal: Edit Pengguna -->
<dialog id="modal_edit_user" class="modal">
<div class="modal-box w-11/12 max-w-md">
<h3 class="font-bold text-lg mb-4">
<i class="fa-solid fa-user-pen mr-2 text-warning"></i>Edit Pengguna
</h3>
<input type="hidden" id="edit_user_id">
<div class="flex flex-col gap-3">
<div class="form-control">
<label class="label"><span class="label-text font-bold">Nama Lengkap *</span></label>
<input id="edit_name" type="text" class="input input-bordered w-full" placeholder="Nama pengguna">
</div>
<div class="form-control">
<label class="label"><span class="label-text font-bold">Email *</span></label>
<input id="edit_email" type="email" class="input input-bordered w-full" placeholder="email@domain.com">
</div>
<div class="form-control">
<label class="label"><span class="label-text font-bold">Role *</span></label>
<select id="edit_role" class="select select-bordered w-full">
<option value="administrator">Administrator</option>
<option value="pengurus_ibadah">Pengurus Ibadah</option>
<option value="petugas_pendataan">Petugas Pendataan</option>
<option value="pemangku_kebijakan">Pemangku Kebijakan</option>
</select>
</div>
</div>
<div class="modal-action">
<button onclick="document.getElementById('modal_edit_user').close()" class="btn btn-ghost">Batal</button>
<button onclick="saveEditUser()" class="btn btn-warning">
<i class="fa-solid fa-floppy-disk mr-1"></i>Simpan Perubahan
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- Modal: Reset Password -->
<dialog id="modal_reset_password" class="modal">
<div class="modal-box w-11/12 max-w-md">
<h3 class="font-bold text-lg mb-4">
<i class="fa-solid fa-key mr-2 text-info"></i>Reset Password
</h3>
<input type="hidden" id="reset_user_id">
<p class="text-sm text-base-content/60 mb-4" id="reset_user_label"></p>
<div class="flex flex-col gap-3">
<div class="form-control">
<label class="label"><span class="label-text font-bold">Password Baru *</span></label>
<input id="reset_password" type="password" class="input input-bordered w-full" placeholder="Min. 8 karakter">
</div>
<div class="form-control">
<label class="label"><span class="label-text font-bold">Konfirmasi Password *</span></label>
<input id="reset_password_confirmation" type="password" class="input input-bordered w-full" placeholder="Ulangi password baru">
</div>
</div>
<div class="modal-action">
<button onclick="document.getElementById('modal_reset_password').close()" class="btn btn-ghost">Batal</button>
<button onclick="saveResetPassword()" class="btn btn-info text-white">
<i class="fa-solid fa-key mr-1"></i>Reset Password
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
<!-- Modal: Confirm Delete -->
<dialog id="modal_delete_user" class="modal">
<div class="modal-box max-w-sm text-center">
<div class="text-5xl mb-4 text-error"><i class="fa-solid fa-trash-can"></i></div>
<h3 class="font-bold text-xl mb-2">Hapus Pengguna?</h3>
<p class="text-sm text-base-content/60 mb-6" id="delete_user_label"></p>
<input type="hidden" id="delete_user_id">
<div class="flex gap-2 justify-center">
<button class="btn btn-ghost px-8" onclick="document.getElementById('modal_delete_user').close()">Batal</button>
<button class="btn btn-error px-8 text-white" onclick="confirmDeleteUser()">Ya, Hapus</button>
</div>
</div>
</dialog>
<!-- Toast Container -->
<div class="toast toast-end toast-bottom z-[9999]" id="toastBox"></div>
<!-- ═══ JAVASCRIPT ════════════════════════════════════════════════════════ -->
<script>
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
let usersData = [];
document.addEventListener('DOMContentLoaded', loadUsers);
async function loadUsers() {
try {
const res = await fetch('{{ url('/api/admin/users') }}', {
headers: { 'X-CSRF-TOKEN': csrfToken }
});
usersData = await res.json();
renderUsers();
} catch (e) {
showToast('Gagal memuat data pengguna', 'error');
}
}
const roleLabels = {
administrator: 'Administrator',
pengurus_ibadah: 'Pengurus Ibadah',
petugas_pendataan: 'Petugas Pendataan',
pemangku_kebijakan:'Pemangku Kebijakan',
};
const roleBadge = {
administrator: 'badge-error',
pengurus_ibadah: 'badge-success',
petugas_pendataan: 'badge-warning',
pemangku_kebijakan:'badge-info',
};
function renderUsers() {
// Stats
const counts = { administrator: 0, pengurus_ibadah: 0, petugas_pendataan: 0, pemangku_kebijakan: 0 };
usersData.forEach(u => { if (counts[u.role] !== undefined) counts[u.role]++; });
document.getElementById('statsGrid').innerHTML = Object.entries(roleLabels).map(([key, label]) => `
<div class="stat bg-white shadow rounded-2xl border border-base-200">
<div class="stat-figure text-3xl opacity-20"><i class="fa-solid fa-user"></i></div>
<div class="stat-title text-xs uppercase font-bold opacity-50">${label}</div>
<div class="stat-value text-2xl">${counts[key]}</div>
</div>
`).join('');
// Table
const tbody = document.getElementById('bodyUsers');
tbody.innerHTML = usersData.length ? usersData.map((u, i) => `
<tr>
<td class="text-xs font-bold opacity-30">${i + 1}</td>
<td><div class="font-bold">${u.name}</div></td>
<td class="text-sm opacity-70">${u.email}</td>
<td>
<span class="badge badge-sm ${roleBadge[u.role] || 'badge-neutral'} badge-outline capitalize">
${roleLabels[u.role] || u.role}
</span>
</td>
<td class="text-center">
<div class="flex gap-1 justify-center">
<button onclick="openEditModal(${u.id})" class="btn btn-xs btn-warning btn-outline" title="Edit">
<i class="fa-solid fa-pen"></i>
</button>
<button onclick="openResetModal(${u.id})" class="btn btn-xs btn-info btn-outline" title="Reset Password">
<i class="fa-solid fa-key"></i>
</button>
<button onclick="openDeleteModal(${u.id})" class="btn btn-xs btn-error btn-outline" title="Hapus">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</td>
</tr>
`).join('') : '<tr><td colspan="5" class="text-center py-10 opacity-30 italic">Belum ada pengguna</td></tr>';
}
// ── CREATE ──────────────────────────────────────────────────
function openCreateModal() {
document.getElementById('create_name').value = '';
document.getElementById('create_email').value = '';
document.getElementById('create_password').value = '';
document.getElementById('create_role').value = 'petugas_pendataan';
document.getElementById('modal_create_user').showModal();
}
async function saveCreateUser() {
const body = {
name: document.getElementById('create_name').value,
email: document.getElementById('create_email').value,
password: document.getElementById('create_password').value,
role: document.getElementById('create_role').value,
};
const res = await fetch('{{ url('/api/admin/users') }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: JSON.stringify(body),
});
const json = await res.json();
if (json.success) {
document.getElementById('modal_create_user').close();
showToast('Pengguna berhasil ditambahkan', 'success');
await loadUsers();
} else {
showToast(json.message || 'Gagal menambahkan pengguna', 'error');
}
}
// ── EDIT ────────────────────────────────────────────────────
function openEditModal(id) {
const u = usersData.find(u => u.id === id);
if (!u) return;
document.getElementById('edit_user_id').value = u.id;
document.getElementById('edit_name').value = u.name;
document.getElementById('edit_email').value = u.email;
document.getElementById('edit_role').value = u.role || 'petugas_pendataan';
document.getElementById('modal_edit_user').showModal();
}
async function saveEditUser() {
const id = document.getElementById('edit_user_id').value;
const body = {
name: document.getElementById('edit_name').value,
email: document.getElementById('edit_email').value,
role: document.getElementById('edit_role').value,
};
const res = await fetch(`{{ url('/api/admin/users') }}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: JSON.stringify(body),
});
const json = await res.json();
if (json.success) {
document.getElementById('modal_edit_user').close();
showToast('Data pengguna diperbarui', 'success');
await loadUsers();
} else {
showToast(json.message || 'Gagal memperbarui data', 'error');
}
}
// ── RESET PASSWORD ──────────────────────────────────────────
function openResetModal(id) {
const u = usersData.find(u => u.id === id);
if (!u) return;
document.getElementById('reset_user_id').value = u.id;
document.getElementById('reset_user_label').textContent = `Reset password untuk: ${u.name} (${u.email})`;
document.getElementById('reset_password').value = '';
document.getElementById('reset_password_confirmation').value = '';
document.getElementById('modal_reset_password').showModal();
}
async function saveResetPassword() {
const id = document.getElementById('reset_user_id').value;
const body = {
password: document.getElementById('reset_password').value,
password_confirmation: document.getElementById('reset_password_confirmation').value,
};
const res = await fetch(`{{ url('/api/admin/users') }}/${id}/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: JSON.stringify(body),
});
const json = await res.json();
if (json.success) {
document.getElementById('modal_reset_password').close();
showToast('Password berhasil direset', 'success');
} else {
showToast(json.message || 'Gagal reset password', 'error');
}
}
// ── DELETE ──────────────────────────────────────────────────
function openDeleteModal(id) {
const u = usersData.find(u => u.id === id);
if (!u) return;
document.getElementById('delete_user_id').value = u.id;
document.getElementById('delete_user_label').textContent = `Pengguna "${u.name}" (${u.email}) akan dihapus secara permanen.`;
document.getElementById('modal_delete_user').showModal();
}
async function confirmDeleteUser() {
const id = document.getElementById('delete_user_id').value;
const res = await fetch(`{{ url('/api/admin/users') }}/${id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
});
const json = await res.json();
if (json.success) {
document.getElementById('modal_delete_user').close();
showToast('Pengguna dihapus', 'success');
await loadUsers();
} else {
showToast(json.message || 'Gagal menghapus pengguna', 'error');
}
}
// ── TOAST ──────────────────────────────────────────────────
function showToast(msg, type = 'info') {
const t = document.createElement('div');
const cls = type === 'success' ? 'alert-success' : type === 'error' ? 'alert-error' : 'alert-info';
t.className = `alert ${cls} shadow-lg py-3 text-xs font-bold`;
t.innerHTML = `<span><i class="fa-solid fa-circle-info mr-2"></i>${msg}</span>`;
document.getElementById('toastBox').appendChild(t);
setTimeout(() => t.remove(), 3000);
}
</script>
</body>
</html>
+7
View File
@@ -36,6 +36,13 @@
<ul class="menu menu-horizontal px-1 gap-1">
<li><a href="{{ url('/map') }}" class="font-medium"><i class="fa-solid fa-map"></i> Peta</a></li>
<li><a href="{{ url('/data') }}" class="font-semibold bg-primary/10 text-primary rounded-lg"><i class="fa-solid fa-table-list"></i> Data Tabular</a></li>
@if(auth()->user()->hasRole('administrator'))
<li>
<a href="{{ url('/admin/users') }}" class="font-medium">
<i class="fa-solid fa-users-gear"></i> Admin
</a>
</li>
@endif
</ul>
<!-- User dropdown -->
<div class="dropdown dropdown-end ml-2">
+7
View File
@@ -88,6 +88,13 @@
<i class="fa-solid fa-table-list"></i> Data Tabular
</a>
</li>
@if(auth()->user()->hasRole('administrator'))
<li>
<a href="{{ url('/admin/users') }}" class="font-medium">
<i class="fa-solid fa-users-gear"></i> Admin
</a>
</li>
@endif
</ul>
<!-- User dropdown -->
<div class="dropdown dropdown-end ml-2">
+14
View File
@@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\AdminUserController;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\IbadahController;
use App\Http\Controllers\MiskinController;
@@ -37,4 +38,17 @@ Route::middleware('auth')->group(function () {
Route::delete('/miskin/{id}', [MiskinController::class, 'destroy']);
});
});
// Admin routes — hanya administrator
Route::middleware('role:administrator')->group(function () {
Route::get('/admin/users', fn() => view('admin.users'))->name('admin.users');
Route::prefix('api/admin')->group(function () {
Route::get('/users', [AdminUserController::class, 'index']);
Route::post('/users', [AdminUserController::class, 'store']);
Route::put('/users/{id}', [AdminUserController::class, 'update']);
Route::delete('/users/{id}', [AdminUserController::class, 'destroy']);
Route::put('/users/{id}/password', [AdminUserController::class, 'resetPassword']);
});
});
});
+127
View File
@@ -0,0 +1,127 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AdminUserTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
foreach (['administrator', 'pengurus_ibadah', 'petugas_pendataan', 'pemangku_kebijakan'] as $role) {
\Spatie\Permission\Models\Role::create(['name' => $role, 'guard_name' => 'web']);
}
}
private function createUser(string $role): User
{
$user = User::factory()->create();
$user->assignRole($role);
return $user;
}
public function test_admin_users_page_is_accessible(): void
{
$admin = $this->createUser('administrator');
$this->actingAs($admin);
$this->get('/admin/users')->assertOk();
}
public function test_non_admin_cannot_access_users_page(): void
{
$pengurus = $this->createUser('pengurus_ibadah');
$this->actingAs($pengurus);
$this->get('/admin/users')->assertStatus(403);
}
public function test_admin_can_list_users(): void
{
$admin = $this->createUser('administrator');
$this->actingAs($admin);
$this->getJson('/api/admin/users')
->assertOk()
->assertJsonFragment(['email' => $admin->email]);
}
public function test_non_admin_cannot_list_users(): void
{
$pengurus = $this->createUser('pengurus_ibadah');
$this->actingAs($pengurus);
$this->getJson('/api/admin/users')->assertStatus(403);
}
public function test_admin_can_create_user(): void
{
$admin = $this->createUser('administrator');
$this->actingAs($admin);
$this->postJson('/api/admin/users', [
'name' => 'User Baru',
'email' => 'userbaru@webgis.local',
'password' => 'password123',
'role' => 'petugas_pendataan',
])->assertOk()->assertJsonFragment(['email' => 'userbaru@webgis.local']);
$this->assertDatabaseHas('users', ['email' => 'userbaru@webgis.local']);
}
public function test_admin_can_update_user(): void
{
$admin = $this->createUser('administrator');
$target = $this->createUser('pengurus_ibadah');
$this->actingAs($admin);
$this->putJson("/api/admin/users/{$target->id}", [
'name' => 'Nama Diubah',
'email' => $target->email,
'role' => 'petugas_pendataan',
])->assertOk()->assertJsonFragment(['name' => 'Nama Diubah']);
$this->assertTrue($target->fresh()->hasRole('petugas_pendataan'));
}
public function test_admin_can_delete_other_user(): void
{
$admin = $this->createUser('administrator');
$target = $this->createUser('pengurus_ibadah');
$this->actingAs($admin);
$this->deleteJson("/api/admin/users/{$target->id}")->assertOk();
$this->assertNull(User::find($target->id));
}
public function test_admin_cannot_delete_self(): void
{
$admin = $this->createUser('administrator');
$this->actingAs($admin);
$this->deleteJson("/api/admin/users/{$admin->id}")->assertStatus(403);
$this->assertNotNull(User::find($admin->id));
}
public function test_admin_can_reset_password(): void
{
$admin = $this->createUser('administrator');
$target = $this->createUser('pengurus_ibadah');
$this->actingAs($admin);
$this->putJson("/api/admin/users/{$target->id}/password", [
'password' => 'newpassword123',
'password_confirmation' => 'newpassword123',
])->assertOk();
$this->assertTrue(
\Illuminate\Support\Facades\Hash::check('newpassword123', $target->fresh()->password)
);
}
}