From 510304872cc13cef1ad5bf1e3b9f17b98a0f79f4 Mon Sep 17 00:00:00 2001 From: Soporte ZH Consultores Date: Fri, 5 Jun 2026 00:27:54 +0000 Subject: [PATCH] feat(auth): nuevo rol 'admin' (programador, modo Dios oculto) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JERARQUÍA DE ROLES admin = programador. Modo Dios. Oculto de dropdowns y del Panel de Control para non-admins. Acceso total. socio = modo Dios visible. Acceso al Panel, ve y edita todo. abogado = crea casos, ve los suyos + donde es involucrado. usuario = solo ve/edita donde es involucrado. * Migración 008: agrega 'admin' al ENUM users.role. Promueve a ozerpa@aal-law.com como admin. * Auth.php: isAdmin() nuevo. isSocio() y isAbogado() ahora incluyen admin (admin hereda todos los poderes de socio). canCreate/canDelete también heredan. * Lookups.php: - abogadoNames() ya filtraba a role IN (socio, abogado) → admins no aparecen en el dropdown "Abogado Solicitante". - involucrablesUsers() agrega filtro role != 'admin' → admins no aparecen en el picker de Involucrados. - allUsers($includeAdmins) parametrizada: socios reciben la lista sin admins, admins reciben todo. * Bootstrap.php: window.DONNA.isAdmin disponible al frontend. * admin/api.php: - list_users pasa Auth::isAdmin() para filtrar. - create_user / update_user: rechazan role='admin' si quien llama no es admin. Solo admins pueden editar a otros admins. - delete_user: protege admins (extends al check existente de socio). - Self-protection extendida: admin no puede demoverse por debajo de socio (queda en god-mode tier). * admin.html: opción 'admin' en role dropdown solo visible si el usuario actual es admin. Role pill navy/blanca para admin. --- public/admin/api.php | 34 +++++++++++++++++++++++++++------- sql/008_admin_role.sql | 16 ++++++++++++++++ src/Auth.php | 9 +++++---- src/Bootstrap.php | 1 + src/Lookups.php | 27 +++++++++++++++++---------- views/admin.html | 16 ++++++++++++++-- 6 files changed, 80 insertions(+), 23 deletions(-) create mode 100644 sql/008_admin_role.sql diff --git a/public/admin/api.php b/public/admin/api.php index 9276348..6f21cb5 100644 --- a/public/admin/api.php +++ b/public/admin/api.php @@ -10,7 +10,7 @@ header('Content-Type: application/json; charset=utf-8'); if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; } -const ROLES = ['socio','abogado','usuario']; +const ROLES = ['admin','socio','abogado','usuario']; function ok($data = null): void { echo json_encode(['ok' => true, 'data' => $data, 'user' => Auth::user()], JSON_UNESCAPED_UNICODE); @@ -32,7 +32,8 @@ $pdo = Db::pdo(); try { if ($method === 'GET' && $action === 'list_users') { - ok(Lookups::allUsers()); + // Los socios no ven a los admins; los admins ven todo + ok(Lookups::allUsers(Auth::isAdmin())); } if ($method === 'POST' && $action === 'create_user') { @@ -46,6 +47,11 @@ try { $activo = (int)($b['activo'] ?? 1) === 1 ? 1 : 0; $orden = (int)($b['orden'] ?? 0); + // Solo admins pueden crear nuevos admins + if ($role === 'admin' && !Auth::isAdmin()) { + err('Solo los admins pueden crear nuevos admins', 403); + } + $exists = $pdo->prepare('SELECT id FROM users WHERE email = :e'); $exists->execute([':e' => $email]); if ($exists->fetch()) err('Ya existe un usuario con ese email', 409); @@ -82,10 +88,21 @@ try { $newRole = in_array(($b['role'] ?? $existing['role']), ROLES, true) ? $b['role'] : $existing['role']; $newActivo = isset($b['activo']) ? ((int)$b['activo'] === 1 ? 1 : 0) : (int)$existing['activo']; - // Salvaguardas: el socio no puede demoverse ni desactivarse a sí mismo + // Solo admins pueden editar a otros admins + if ($existing['role'] === 'admin' && !Auth::isAdmin()) { + err('Solo los admins pueden editar a otros admins', 403); + } + // Solo admins pueden asignar el rol admin + if ($newRole === 'admin' && !Auth::isAdmin()) { + err('Solo los admins pueden asignar el rol admin', 403); + } + + // Salvaguardas: no puedes quitarte tu modo Dios ni desactivarte if ((int)$existing['id'] === $me) { - if ($newRole !== 'socio') err('No puedes quitarte el rol de Socio a ti mismo', 400); - if ($newActivo === 0) err('No puedes desactivarte a ti mismo', 400); + if (!in_array($newRole, ['admin','socio'], true)) { + err('No puedes quitarte el modo Dios a ti mismo (debe quedar Admin o Socio)', 400); + } + if ($newActivo === 0) err('No puedes desactivarte a ti mismo', 400); } $newEmail = isset($b['email']) ? strtolower(trim((string)$b['email'])) : $existing['email']; @@ -128,8 +145,11 @@ try { $sel->execute([':id' => $id]); $u = $sel->fetch(); if (!$u) err('Usuario no encontrado', 404); - if ($u['role'] === 'socio') { - err('No puedes eliminar a otro Socio. Cambia su rol a Abogado o Usuario primero.', 400); + if ($u['role'] === 'admin' && !Auth::isAdmin()) { + err('Solo los admins pueden eliminar a otros admins', 403); + } + if (in_array($u['role'], ['admin','socio'], true)) { + err('No puedes eliminar a un Admin o Socio. Cambia su rol a Abogado o Usuario primero.', 400); } // CASCADE de tramites_involucrados limpia los vínculos automáticamente. diff --git a/sql/008_admin_role.sql b/sql/008_admin_role.sql new file mode 100644 index 0000000..ae91475 --- /dev/null +++ b/sql/008_admin_role.sql @@ -0,0 +1,16 @@ +-- ============================================================ +-- Donna — Schema 008: Rol 'admin' (programador / modo Dios oculto) +-- ============================================================ +-- Jerarquía de roles (de mayor a menor poder): +-- admin → programador. Modo Dios. Oculto de dropdowns y del +-- Panel de Control para non-admins. Acceso total. +-- socio → modo Dios visible. Acceso al Panel, ve y edita todo. +-- abogado → crea casos, ve los suyos + involucrados. +-- usuario → solo ve/edita donde es involucrado. + +ALTER TABLE users + MODIFY COLUMN role ENUM('admin','socio','abogado','usuario') + NOT NULL DEFAULT 'usuario'; + +-- Promover al programador +UPDATE users SET role='admin' WHERE email='ozerpa@aal-law.com'; diff --git a/src/Auth.php b/src/Auth.php index 75048c4..2333651 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -68,10 +68,11 @@ final class Auth { public static function role(): string { return (string)(self::userRow()['role'] ?? 'usuario'); } public static function userName(): string { return (string)(self::userRow()['name'] ?? ''); } - public static function isSocio(): bool { return self::role() === 'socio'; } - public static function isAbogado(): bool { return in_array(self::role(), ['socio','abogado'], true); } - public static function canCreate(): bool { return self::isAbogado(); } // socio + abogado pueden crear trámites - public static function canDelete(): bool { return self::isAbogado(); } // socio + abogado pueden eliminar + public static function isAdmin(): bool { return self::role() === 'admin'; } + public static function isSocio(): bool { return in_array(self::role(), ['admin','socio'], true); } + public static function isAbogado(): bool { return in_array(self::role(), ['admin','socio','abogado'], true); } + public static function canCreate(): bool { return self::isAbogado(); } // admin + socio + abogado pueden crear trámites + public static function canDelete(): bool { return self::isAbogado(); } // admin + socio + abogado pueden eliminar public static function requireSocio(): void { self::requireAuth(); diff --git a/src/Bootstrap.php b/src/Bootstrap.php index f20531a..b3f016b 100644 --- a/src/Bootstrap.php +++ b/src/Bootstrap.php @@ -16,6 +16,7 @@ final class Bootstrap { 'me' => $user['email'], 'name' => $user['name'], 'role' => Auth::role(), + 'isAdmin' => Auth::isAdmin(), 'isSocio' => Auth::isSocio(), 'isAbogado' => Auth::isAbogado(), 'canCreate' => Auth::canCreate(), diff --git a/src/Lookups.php b/src/Lookups.php index 11c7f6a..71b3fc5 100644 --- a/src/Lookups.php +++ b/src/Lookups.php @@ -8,7 +8,11 @@ require_once __DIR__ . '/Db.php'; * dropdowns y resolver visibilidad. */ final class Lookups { - /** Nombres completos para el dropdown "Abogado Solicitante" (socios + abogados activos). */ + /** + * Nombres completos para el dropdown "Abogado Solicitante". + * Solo socios y abogados; admins quedan ocultos (el programador no debe + * aparecer en listas de abogados del firme). + */ public static function abogadoNames(): array { $rows = Db::pdo()->query( "SELECT DISTINCT name FROM users @@ -20,12 +24,13 @@ final class Lookups { return array_map(fn($r) => $r['name'], $rows); } - /** Todos los usuarios activos para el picker de Involucrados. */ + /** Usuarios activos para el picker de Involucrados (sin admins ni placeholders). */ public static function involucrablesUsers(): array { $rows = Db::pdo()->query( "SELECT id, email, name, role FROM users WHERE activo = 1 + AND role != 'admin' AND email NOT LIKE 'placeholder-%' ORDER BY name ASC, email ASC" )->fetchAll(); @@ -33,14 +38,16 @@ final class Lookups { return $rows; } - /** Todos los users (incluye inactivos y placeholders) para el Panel de Control. */ - public static function allUsers(): array { - $rows = Db::pdo()->query( - "SELECT id, email, name, role, activo, orden, - first_seen_at, last_login_at - FROM users - ORDER BY role DESC, name ASC, email ASC" - )->fetchAll(); + /** + * Todos los users para el Panel de Control. + * Si $includeAdmins es false, oculta el rol admin (uso para socios). + */ + public static function allUsers(bool $includeAdmins = true): array { + $sql = "SELECT id, email, name, role, activo, orden, first_seen_at, last_login_at + FROM users"; + if (!$includeAdmins) $sql .= " WHERE role != 'admin'"; + $sql .= " ORDER BY FIELD(role,'admin','socio','abogado','usuario'), name ASC, email ASC"; + $rows = Db::pdo()->query($sql)->fetchAll(); foreach ($rows as &$r) { $r['id'] = (int)$r['id']; $r['activo'] = (int)$r['activo']; diff --git a/views/admin.html b/views/admin.html index 85abce7..559d7ba 100644 --- a/views/admin.html +++ b/views/admin.html @@ -81,6 +81,7 @@ tr.is-me td { background:#FFFBEB; font-weight:500; } .role-pill { display:inline-block; padding:2px 9px; border-radius:14px; font-size:10.5px; font-weight:700; letter-spacing:.04em; text-transform:uppercase; } +.role-admin { background:#1B3A6B; color:#fff; } .role-socio { background:#FCE4EC; color:#880E4F; } .role-abogado { background:#E1F5EE; color:#085041; } .role-usuario { background:#EEEDE9; color:#5F5E5A; } @@ -167,6 +168,7 @@ tr.is-me td { background:#FFFBEB; font-weight:500; } @@ -251,9 +254,18 @@ tr.is-me td { background:#FFFBEB; font-weight:500; }