Skip to content

Commit 9e7570f

Browse files
committed
v0.8.5 enhance admin user management with edit and safe disable
1 parent 00df645 commit 9e7570f

10 files changed

Lines changed: 407 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## v0.8.5 - 2026-04-02
2+
- enhanced Admin Users management with Edit page and safe Disable/Enable actions
3+
- added password reset fields on admin edit page without changing database config
4+
- protected against disabling your own account or removing the last active admin account
5+
16
## v0.8.4
27
- UI polish pass for Inquiry Management and Inquiry Detail.
38
- Compressed the filter/export area and grouped less-used export fields.

VERSION.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v0.8.4
1+
v0.8.5

app/Controllers/AdminController.php

Lines changed: 203 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use App\Core\Auth;
88
use App\Core\Controller;
99
use App\Core\Csrf;
10+
use App\Core\Session;
1011
use App\Models\Admin;
1112
use App\Models\InquiryLog;
1213

@@ -29,6 +30,29 @@ public function index(): void
2930
]);
3031
}
3132

33+
public function edit(): void
34+
{
35+
if (!Auth::can('tools.view')) {
36+
flash('error', 'You do not have permission to access user management.');
37+
redirect('dashboard');
38+
}
39+
40+
$id = (int) ($_GET['id'] ?? 0);
41+
$adminModel = new Admin();
42+
$admin = $adminModel->findById($id);
43+
44+
if (!$admin) {
45+
flash('error', 'Admin user not found.');
46+
redirect('admins');
47+
}
48+
49+
$this->view('dashboard/admin-user-edit', [
50+
'pageTitle' => 'Edit Admin User',
51+
'adminUser' => $admin,
52+
'csrfToken' => Csrf::token(),
53+
]);
54+
}
55+
3256
public function create(): void
3357
{
3458
if (!Auth::can('tools.view') || !Csrf::verify($_POST['_csrf'] ?? null)) {
@@ -39,8 +63,8 @@ public function create(): void
3963
$username = trim((string) ($_POST['username'] ?? ''));
4064
$nickname = trim((string) ($_POST['nickname'] ?? ''));
4165
$email = trim((string) ($_POST['email'] ?? ''));
42-
$role = trim((string) ($_POST['role'] ?? 'agent'));
43-
$status = trim((string) ($_POST['status'] ?? 'active'));
66+
$role = $this->sanitizeRole((string) ($_POST['role'] ?? 'agent'));
67+
$status = $this->sanitizeStatus((string) ($_POST['status'] ?? 'active'));
4468
$password = (string) ($_POST['password'] ?? '');
4569

4670
if ($username === '' || $email === '' || $password === '') {
@@ -51,11 +75,9 @@ public function create(): void
5175
flash('error', 'Please enter a valid email address.');
5276
redirect('admins');
5377
}
54-
if (!in_array($role, ['admin', 'manager', 'agent', 'viewer'], true)) {
55-
$role = 'agent';
56-
}
57-
if (!in_array($status, ['active', 'disabled'], true)) {
58-
$status = 'active';
78+
if (strlen($password) < 8) {
79+
flash('error', 'Password must be at least 8 characters long.');
80+
redirect('admins');
5981
}
6082

6183
$created = (new Admin())->create([
@@ -88,27 +110,194 @@ public function updateMeta(): void
88110
}
89111

90112
$id = (int) ($_POST['id'] ?? 0);
91-
$role = trim((string) ($_POST['role'] ?? 'agent'));
92-
$status = trim((string) ($_POST['status'] ?? 'active'));
113+
$role = $this->sanitizeRole((string) ($_POST['role'] ?? 'agent'));
114+
$status = $this->sanitizeStatus((string) ($_POST['status'] ?? 'active'));
93115

94116
if ($id <= 0) {
95117
flash('error', 'Invalid admin id.');
96118
redirect('admins');
97119
}
98-
if (!in_array($role, ['admin', 'manager', 'agent', 'viewer'], true)) {
99-
$role = 'agent';
120+
121+
$adminModel = new Admin();
122+
$target = $adminModel->findById($id);
123+
if (!$target) {
124+
flash('error', 'Admin user not found.');
125+
redirect('admins');
100126
}
101-
if (!in_array($status, ['active', 'disabled'], true)) {
102-
$status = 'active';
127+
128+
$guardMessage = $this->guardAdminChange($target, $role, $status);
129+
if ($guardMessage !== null) {
130+
flash('error', $guardMessage);
131+
redirect('admins');
103132
}
104133

105-
$updated = (new Admin())->updateRoleAndStatus($id, $role, $status);
134+
$updated = $adminModel->updateRoleAndStatus($id, $role, $status);
106135
if ($updated) {
136+
$this->refreshAuthUserIfCurrent($id);
107137
(new InquiryLog())->create(null, Auth::id(), 'admin_user_updated', 'Updated admin #' . $id . ' to ' . $role . '/' . $status);
108138
flash('success', 'Admin user updated successfully.');
109139
} else {
110140
flash('error', 'Unable to update admin user.');
111141
}
112142
redirect('admins');
113143
}
144+
145+
public function update(): void
146+
{
147+
if (!Auth::can('tools.view') || !Csrf::verify($_POST['_csrf'] ?? null)) {
148+
flash('error', 'Invalid request.');
149+
redirect('admins');
150+
}
151+
152+
$id = (int) ($_POST['id'] ?? 0);
153+
$nickname = trim((string) ($_POST['nickname'] ?? ''));
154+
$email = trim((string) ($_POST['email'] ?? ''));
155+
$role = $this->sanitizeRole((string) ($_POST['role'] ?? 'agent'));
156+
$status = $this->sanitizeStatus((string) ($_POST['status'] ?? 'active'));
157+
$password = (string) ($_POST['password'] ?? '');
158+
$passwordConfirm = (string) ($_POST['password_confirm'] ?? '');
159+
160+
if ($id <= 0) {
161+
flash('error', 'Invalid admin id.');
162+
redirect('admins');
163+
}
164+
if ($nickname === '' || $email === '') {
165+
flash('error', 'Nickname and email are required.');
166+
redirect('admins/edit?id=' . $id);
167+
}
168+
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
169+
flash('error', 'Please enter a valid email address.');
170+
redirect('admins/edit?id=' . $id);
171+
}
172+
173+
$adminModel = new Admin();
174+
$target = $adminModel->findById($id);
175+
if (!$target) {
176+
flash('error', 'Admin user not found.');
177+
redirect('admins');
178+
}
179+
180+
$guardMessage = $this->guardAdminChange($target, $role, $status);
181+
if ($guardMessage !== null) {
182+
flash('error', $guardMessage);
183+
redirect('admins/edit?id=' . $id);
184+
}
185+
186+
$adminModel->updateManagedUser($id, [
187+
'nickname' => $nickname,
188+
'email' => $email,
189+
'role' => $role,
190+
'status' => $status,
191+
]);
192+
193+
if ($password !== '' || $passwordConfirm !== '') {
194+
if ($password !== $passwordConfirm) {
195+
flash('error', 'The two passwords do not match.');
196+
redirect('admins/edit?id=' . $id);
197+
}
198+
if (strlen($password) < 8) {
199+
flash('error', 'Password must be at least 8 characters long.');
200+
redirect('admins/edit?id=' . $id);
201+
}
202+
$adminModel->updatePassword($id, password_hash($password, PASSWORD_DEFAULT));
203+
}
204+
205+
$this->refreshAuthUserIfCurrent($id);
206+
(new InquiryLog())->create(null, Auth::id(), 'admin_user_profile_updated', 'Updated admin profile #' . $id);
207+
flash('success', 'Admin user saved successfully.');
208+
redirect('admins/edit?id=' . $id);
209+
}
210+
211+
public function toggleStatus(): void
212+
{
213+
if (!Auth::can('tools.view') || !Csrf::verify($_POST['_csrf'] ?? null)) {
214+
flash('error', 'Invalid request.');
215+
redirect('admins');
216+
}
217+
218+
$id = (int) ($_POST['id'] ?? 0);
219+
$targetStatus = $this->sanitizeStatus((string) ($_POST['target_status'] ?? 'disabled'));
220+
221+
$adminModel = new Admin();
222+
$target = $adminModel->findById($id);
223+
if (!$target) {
224+
flash('error', 'Admin user not found.');
225+
redirect('admins');
226+
}
227+
228+
$guardMessage = $this->guardAdminChange($target, (string) ($target['role'] ?? 'agent'), $targetStatus);
229+
if ($guardMessage !== null) {
230+
flash('error', $guardMessage);
231+
redirect('admins');
232+
}
233+
234+
$updated = $adminModel->updateStatus($id, $targetStatus);
235+
if ($updated) {
236+
$this->refreshAuthUserIfCurrent($id);
237+
$action = $targetStatus === 'disabled' ? 'admin_user_disabled' : 'admin_user_enabled';
238+
(new InquiryLog())->create(null, Auth::id(), $action, ucfirst($targetStatus) . ' admin #' . $id);
239+
flash('success', 'Admin user status updated successfully.');
240+
} else {
241+
flash('error', 'Unable to update admin status.');
242+
}
243+
redirect('admins');
244+
}
245+
246+
private function sanitizeRole(string $role): string
247+
{
248+
return in_array($role, ['admin', 'manager', 'agent', 'viewer'], true) ? $role : 'agent';
249+
}
250+
251+
private function sanitizeStatus(string $status): string
252+
{
253+
return in_array($status, ['active', 'disabled'], true) ? $status : 'active';
254+
}
255+
256+
private function guardAdminChange(array $target, string $newRole, string $newStatus): ?string
257+
{
258+
$id = (int) ($target['id'] ?? 0);
259+
$currentRole = (string) ($target['role'] ?? 'agent');
260+
$currentStatus = (string) ($target['status'] ?? 'active');
261+
$adminModel = new Admin();
262+
263+
if ($id === (int) Auth::id() && $newStatus === 'disabled') {
264+
return 'You cannot disable your own account.';
265+
}
266+
267+
if ($currentStatus === 'active' && $newStatus === 'disabled' && $adminModel->countActiveAdmins(null, $id) < 1) {
268+
return 'At least one active administrator account must remain.';
269+
}
270+
271+
$isRemovingLastActiveAdmin = $currentRole === 'admin' && $currentStatus === 'active'
272+
&& ($newRole !== 'admin' || $newStatus !== 'active')
273+
&& $adminModel->countActiveAdmins('admin', $id) < 1;
274+
275+
if ($isRemovingLastActiveAdmin) {
276+
return 'At least one active admin role account must remain.';
277+
}
278+
279+
return null;
280+
}
281+
282+
private function refreshAuthUserIfCurrent(int $id): void
283+
{
284+
if ($id !== (int) Auth::id()) {
285+
return;
286+
}
287+
288+
$updated = (new Admin())->findById($id);
289+
if (!$updated) {
290+
return;
291+
}
292+
293+
Session::set('auth_user', [
294+
'id' => (int) $updated['id'],
295+
'username' => $updated['username'],
296+
'nickname' => $updated['nickname'] ?: $updated['username'],
297+
'email' => $updated['email'],
298+
'role' => $updated['role'] ?? 'admin',
299+
'status' => $updated['status'] ?? 'active',
300+
'page_size' => (int) ($updated['page_size'] ?? 20),
301+
]);
302+
}
114303
}

app/Models/Admin.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,25 @@ public function updateProfile(int $id, array $data): bool
9898
]);
9999
}
100100

101+
public function updateManagedUser(int $id, array $data): bool
102+
{
103+
$sql = 'UPDATE admins
104+
SET nickname = :nickname,
105+
email = :email,
106+
role = :role,
107+
status = :status,
108+
updated_at = NOW()
109+
WHERE id = :id';
110+
$stmt = Database::connection()->prepare($sql);
111+
return $stmt->execute([
112+
'nickname' => $data['nickname'],
113+
'email' => $data['email'],
114+
'role' => $data['role'],
115+
'status' => $data['status'],
116+
'id' => $id,
117+
]);
118+
}
119+
101120
public function updateRoleAndStatus(int $id, string $role, string $status): bool
102121
{
103122
$sql = 'UPDATE admins SET role = :role, status = :status, updated_at = NOW() WHERE id = :id';
@@ -109,6 +128,16 @@ public function updateRoleAndStatus(int $id, string $role, string $status): bool
109128
]);
110129
}
111130

131+
public function updateStatus(int $id, string $status): bool
132+
{
133+
$sql = 'UPDATE admins SET status = :status, updated_at = NOW() WHERE id = :id';
134+
$stmt = Database::connection()->prepare($sql);
135+
return $stmt->execute([
136+
'status' => $status,
137+
'id' => $id,
138+
]);
139+
}
140+
112141
public function updatePassword(int $id, string $passwordHash): bool
113142
{
114143
$sql = 'UPDATE admins SET password_hash = :password_hash, updated_at = NOW() WHERE id = :id';
@@ -119,6 +148,26 @@ public function updatePassword(int $id, string $passwordHash): bool
119148
]);
120149
}
121150

151+
public function countActiveAdmins(?string $role = null, ?int $excludeId = null): int
152+
{
153+
$sql = 'SELECT COUNT(*) FROM admins WHERE status = :status';
154+
$params = ['status' => 'active'];
155+
156+
if ($role !== null) {
157+
$sql .= ' AND role = :role';
158+
$params['role'] = $role;
159+
}
160+
161+
if ($excludeId !== null) {
162+
$sql .= ' AND id <> :exclude_id';
163+
$params['exclude_id'] = $excludeId;
164+
}
165+
166+
$stmt = Database::connection()->prepare($sql);
167+
$stmt->execute($params);
168+
return (int) $stmt->fetchColumn();
169+
}
170+
122171
public function touchLastLogin(int $id): bool
123172
{
124173
$stmt = Database::connection()->prepare('UPDATE admins SET last_login_at = NOW(), updated_at = NOW() WHERE id = :id');

database/upgrade-v0.8.5.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- v0.8.5
2+
-- No database structure changes required for this release.

public/assets/css/app.css

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1658,3 +1658,47 @@ textarea.form-input {
16581658
gap: 4px;
16591659
}
16601660
}
1661+
1662+
.admin-user-actions {
1663+
display: flex;
1664+
flex-wrap: wrap;
1665+
gap: 8px;
1666+
align-items: center;
1667+
}
1668+
1669+
.admin-inline-meta-form {
1670+
display: contents;
1671+
}
1672+
1673+
.admin-edit-meta {
1674+
display: grid;
1675+
grid-template-columns: repeat(3, minmax(0, 1fr));
1676+
gap: 12px;
1677+
}
1678+
1679+
.admin-danger-zone {
1680+
border: 1px dashed var(--border-strong);
1681+
border-radius: 16px;
1682+
padding: 18px;
1683+
background: linear-gradient(180deg, #ffffff 0%, #fafcff 100%);
1684+
display: flex;
1685+
gap: 16px;
1686+
justify-content: space-between;
1687+
align-items: center;
1688+
}
1689+
1690+
@media (max-width: 920px) {
1691+
.admin-edit-meta {
1692+
grid-template-columns: 1fr;
1693+
}
1694+
1695+
.admin-danger-zone {
1696+
flex-direction: column;
1697+
align-items: stretch;
1698+
}
1699+
1700+
.admin-user-actions {
1701+
flex-direction: column;
1702+
align-items: stretch;
1703+
}
1704+
}

0 commit comments

Comments
 (0)