Files
D1041231009-WebGIS-PovertyMap/docs/plan3-admin-user-management.md
T
GuavaPopper 8476c5d643 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>
2026-06-03 13:51:08 +07:00

747 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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