|
@@ -0,0 +1,136 @@
|
|
|
|
|
+<?php
|
|
|
|
|
+
|
|
|
|
|
+declare(strict_types=1);
|
|
|
|
|
+
|
|
|
|
|
+namespace App\Controllers;
|
|
|
|
|
+
|
|
|
|
|
+use App\Auth\SessionGuard;
|
|
|
|
|
+use App\Http\Request;
|
|
|
|
|
+use App\Http\Response;
|
|
|
|
|
+use App\Http\View;
|
|
|
|
|
+use App\Repositories\UserRepository;
|
|
|
|
|
+use App\Services\AuditLogger;
|
|
|
|
|
+use PDO;
|
|
|
|
|
+use Throwable;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * /users — admin-only page for promoting / demoting admin status.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Guardrails (server-side, both surface as ?error= codes):
|
|
|
|
|
+ * - You cannot demote yourself.
|
|
|
|
|
+ * - You cannot demote the last remaining admin.
|
|
|
|
|
+ *
|
|
|
|
|
+ * No delete — users are never deleted; they'd orphan audit rows that
|
|
|
|
|
+ * reference them by id.
|
|
|
|
|
+ */
|
|
|
|
|
+final class UserController
|
|
|
|
|
+{
|
|
|
|
|
+ public function __construct(
|
|
|
|
|
+ private readonly PDO $pdo,
|
|
|
|
|
+ private readonly UserRepository $users,
|
|
|
|
|
+ private readonly AuditLogger $audit,
|
|
|
|
|
+ private readonly View $view,
|
|
|
|
|
+ ) {
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** GET /users */
|
|
|
|
|
+ public function index(Request $req): Response
|
|
|
|
|
+ {
|
|
|
|
|
+ $actor = SessionGuard::requireAdmin($this->users);
|
|
|
|
|
+ if ($actor instanceof Response) {
|
|
|
|
|
+ return $actor;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return Response::html($this->view->render('users/index', [
|
|
|
|
|
+ 'title' => 'Users',
|
|
|
|
|
+ 'currentUser' => $actor,
|
|
|
|
|
+ 'csrfToken' => SessionGuard::csrfToken(),
|
|
|
|
|
+ 'users' => $this->users->all(),
|
|
|
|
|
+ 'flash' => $req->queryString('flash'),
|
|
|
|
|
+ 'error' => $req->queryString('error'),
|
|
|
|
|
+ ]));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** POST /users/{id} — form — toggle is_admin. */
|
|
|
|
|
+ public function update(Request $req, array $params): Response
|
|
|
|
|
+ {
|
|
|
|
|
+ $actor = SessionGuard::requireAdmin($this->users);
|
|
|
|
|
+ if ($actor instanceof Response) {
|
|
|
|
|
+ return $actor;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!SessionGuard::verifyCsrf($req)) {
|
|
|
|
|
+ return Response::text('CSRF token invalid', 403);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $id = (int) $params['id'];
|
|
|
|
|
+ $target = $this->users->find($id);
|
|
|
|
|
+ if ($target === null) {
|
|
|
|
|
+ return Response::redirect('/users?error=not_found');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Checkbox is absent from $_POST when unchecked.
|
|
|
|
|
+ $targetIsAdmin = isset($req->post['is_admin'])
|
|
|
|
|
+ && (string) $req->post['is_admin'] !== '0';
|
|
|
|
|
+
|
|
|
|
|
+ if ($targetIsAdmin === $target->isAdmin) {
|
|
|
|
|
+ return Response::redirect('/users?flash=noop');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $error = self::demoteGuardrail(
|
|
|
|
|
+ actorId: $actor->id,
|
|
|
|
|
+ targetId: $target->id,
|
|
|
|
|
+ targetWasAdmin: $target->isAdmin,
|
|
|
|
|
+ targetWillBeAdmin: $targetIsAdmin,
|
|
|
|
|
+ totalAdmins: $this->users->countAdmins(),
|
|
|
|
|
+ );
|
|
|
|
|
+ if ($error !== null) {
|
|
|
|
|
+ return Response::redirect('/users?error=' . $error);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $this->pdo->beginTransaction();
|
|
|
|
|
+ try {
|
|
|
|
|
+ $result = $this->users->setAdmin($id, $targetIsAdmin);
|
|
|
|
|
+ $this->audit->recordForRequest(
|
|
|
|
|
+ 'UPDATE', 'user', $id,
|
|
|
|
|
+ $result['before']->toAuditSnapshot(),
|
|
|
|
|
+ $result['after']->toAuditSnapshot(),
|
|
|
|
|
+ $req, $actor,
|
|
|
|
|
+ );
|
|
|
|
|
+ $this->pdo->commit();
|
|
|
|
|
+ } catch (Throwable) {
|
|
|
|
|
+ $this->pdo->rollBack();
|
|
|
|
|
+ return Response::redirect('/users?error=db_error');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return Response::redirect('/users?flash=' . ($targetIsAdmin ? 'promoted' : 'demoted'));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Pure guardrail check for admin demotions. Returns null when the action
|
|
|
|
|
+ * is allowed, or a short error code for the redirect query string.
|
|
|
|
|
+ *
|
|
|
|
|
+ * self_demote — the actor is trying to demote themselves
|
|
|
|
|
+ * last_admin — demoting the only remaining admin
|
|
|
|
|
+ *
|
|
|
|
|
+ * Extracted so tests can hit the rules without SessionGuard/PDO setup.
|
|
|
|
|
+ */
|
|
|
|
|
+ public static function demoteGuardrail(
|
|
|
|
|
+ int $actorId,
|
|
|
|
|
+ int $targetId,
|
|
|
|
|
+ bool $targetWasAdmin,
|
|
|
|
|
+ bool $targetWillBeAdmin,
|
|
|
|
|
+ int $totalAdmins,
|
|
|
|
|
+ ): ?string {
|
|
|
|
|
+ // Only enforce when the change is an admin demotion.
|
|
|
|
|
+ if (!$targetWasAdmin || $targetWillBeAdmin) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($targetId === $actorId) {
|
|
|
|
|
+ return 'self_demote';
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($totalAdmins <= 1) {
|
|
|
|
|
+ return 'last_admin';
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|