feat(auth): nuevo rol 'admin' (programador, modo Dios oculto)

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.
This commit is contained in:
Soporte ZH Consultores
2026-06-05 00:27:54 +00:00
parent 25f3d7461b
commit 510304872c
6 changed files with 80 additions and 23 deletions
+27 -7
View File
@@ -10,7 +10,7 @@ header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; } 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 { function ok($data = null): void {
echo json_encode(['ok' => true, 'data' => $data, 'user' => Auth::user()], JSON_UNESCAPED_UNICODE); echo json_encode(['ok' => true, 'data' => $data, 'user' => Auth::user()], JSON_UNESCAPED_UNICODE);
@@ -32,7 +32,8 @@ $pdo = Db::pdo();
try { try {
if ($method === 'GET' && $action === 'list_users') { 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') { if ($method === 'POST' && $action === 'create_user') {
@@ -46,6 +47,11 @@ try {
$activo = (int)($b['activo'] ?? 1) === 1 ? 1 : 0; $activo = (int)($b['activo'] ?? 1) === 1 ? 1 : 0;
$orden = (int)($b['orden'] ?? 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 = $pdo->prepare('SELECT id FROM users WHERE email = :e');
$exists->execute([':e' => $email]); $exists->execute([':e' => $email]);
if ($exists->fetch()) err('Ya existe un usuario con ese email', 409); 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']; $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']; $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 ((int)$existing['id'] === $me) {
if ($newRole !== 'socio') err('No puedes quitarte el rol de Socio a ti mismo', 400); if (!in_array($newRole, ['admin','socio'], true)) {
if ($newActivo === 0) err('No puedes desactivarte a ti mismo', 400); 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']; $newEmail = isset($b['email']) ? strtolower(trim((string)$b['email'])) : $existing['email'];
@@ -128,8 +145,11 @@ try {
$sel->execute([':id' => $id]); $sel->execute([':id' => $id]);
$u = $sel->fetch(); $u = $sel->fetch();
if (!$u) err('Usuario no encontrado', 404); if (!$u) err('Usuario no encontrado', 404);
if ($u['role'] === 'socio') { if ($u['role'] === 'admin' && !Auth::isAdmin()) {
err('No puedes eliminar a otro Socio. Cambia su rol a Abogado o Usuario primero.', 400); 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. // CASCADE de tramites_involucrados limpia los vínculos automáticamente.
+16
View File
@@ -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';
+5 -4
View File
@@ -68,10 +68,11 @@ final class Auth {
public static function role(): string { return (string)(self::userRow()['role'] ?? 'usuario'); } public static function role(): string { return (string)(self::userRow()['role'] ?? 'usuario'); }
public static function userName(): string { return (string)(self::userRow()['name'] ?? ''); } public static function userName(): string { return (string)(self::userRow()['name'] ?? ''); }
public static function isSocio(): bool { return self::role() === 'socio'; } public static function isAdmin(): bool { return self::role() === 'admin'; }
public static function isAbogado(): bool { return in_array(self::role(), ['socio','abogado'], true); } public static function isSocio(): bool { return in_array(self::role(), ['admin','socio'], true); }
public static function canCreate(): bool { return self::isAbogado(); } // socio + abogado pueden crear trámites public static function isAbogado(): bool { return in_array(self::role(), ['admin','socio','abogado'], true); }
public static function canDelete(): bool { return self::isAbogado(); } // socio + abogado pueden eliminar 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 { public static function requireSocio(): void {
self::requireAuth(); self::requireAuth();
+1
View File
@@ -16,6 +16,7 @@ final class Bootstrap {
'me' => $user['email'], 'me' => $user['email'],
'name' => $user['name'], 'name' => $user['name'],
'role' => Auth::role(), 'role' => Auth::role(),
'isAdmin' => Auth::isAdmin(),
'isSocio' => Auth::isSocio(), 'isSocio' => Auth::isSocio(),
'isAbogado' => Auth::isAbogado(), 'isAbogado' => Auth::isAbogado(),
'canCreate' => Auth::canCreate(), 'canCreate' => Auth::canCreate(),
+17 -10
View File
@@ -8,7 +8,11 @@ require_once __DIR__ . '/Db.php';
* dropdowns y resolver visibilidad. * dropdowns y resolver visibilidad.
*/ */
final class Lookups { 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 { public static function abogadoNames(): array {
$rows = Db::pdo()->query( $rows = Db::pdo()->query(
"SELECT DISTINCT name FROM users "SELECT DISTINCT name FROM users
@@ -20,12 +24,13 @@ final class Lookups {
return array_map(fn($r) => $r['name'], $rows); 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 { public static function involucrablesUsers(): array {
$rows = Db::pdo()->query( $rows = Db::pdo()->query(
"SELECT id, email, name, role "SELECT id, email, name, role
FROM users FROM users
WHERE activo = 1 WHERE activo = 1
AND role != 'admin'
AND email NOT LIKE 'placeholder-%' AND email NOT LIKE 'placeholder-%'
ORDER BY name ASC, email ASC" ORDER BY name ASC, email ASC"
)->fetchAll(); )->fetchAll();
@@ -33,14 +38,16 @@ final class Lookups {
return $rows; return $rows;
} }
/** Todos los users (incluye inactivos y placeholders) para el Panel de Control. */ /**
public static function allUsers(): array { * Todos los users para el Panel de Control.
$rows = Db::pdo()->query( * Si $includeAdmins es false, oculta el rol admin (uso para socios).
"SELECT id, email, name, role, activo, orden, */
first_seen_at, last_login_at public static function allUsers(bool $includeAdmins = true): array {
FROM users $sql = "SELECT id, email, name, role, activo, orden, first_seen_at, last_login_at
ORDER BY role DESC, name ASC, email ASC" FROM users";
)->fetchAll(); 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) { foreach ($rows as &$r) {
$r['id'] = (int)$r['id']; $r['id'] = (int)$r['id'];
$r['activo'] = (int)$r['activo']; $r['activo'] = (int)$r['activo'];
+14 -2
View File
@@ -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; .role-pill { display:inline-block; padding:2px 9px; border-radius:14px; font-size:10.5px; font-weight:700;
letter-spacing:.04em; text-transform:uppercase; } letter-spacing:.04em; text-transform:uppercase; }
.role-admin { background:#1B3A6B; color:#fff; }
.role-socio { background:#FCE4EC; color:#880E4F; } .role-socio { background:#FCE4EC; color:#880E4F; }
.role-abogado { background:#E1F5EE; color:#085041; } .role-abogado { background:#E1F5EE; color:#085041; }
.role-usuario { background:#EEEDE9; color:#5F5E5A; } .role-usuario { background:#EEEDE9; color:#5F5E5A; }
@@ -167,6 +168,7 @@ tr.is-me td { background:#FFFBEB; font-weight:500; }
<input type="search" id="search" placeholder="Buscar por email o nombre…"> <input type="search" id="search" placeholder="Buscar por email o nombre…">
<select id="f-role"> <select id="f-role">
<option value="">— Todos los roles —</option> <option value="">— Todos los roles —</option>
<option value="admin">Admin</option>
<option value="socio">Socio</option> <option value="socio">Socio</option>
<option value="abogado">Abogado</option> <option value="abogado">Abogado</option>
<option value="usuario">Usuario</option> <option value="usuario">Usuario</option>
@@ -223,6 +225,7 @@ tr.is-me td { background:#FFFBEB; font-weight:500; }
<option value="usuario">Usuario (pasante / asistente)</option> <option value="usuario">Usuario (pasante / asistente)</option>
<option value="abogado">Abogado</option> <option value="abogado">Abogado</option>
<option value="socio">Socio (modo Dios)</option> <option value="socio">Socio (modo Dios)</option>
<option value="admin" id="opt-admin" style="display:none;">Admin (programador, modo Dios oculto)</option>
</select> </select>
</div> </div>
</div> </div>
@@ -251,9 +254,18 @@ tr.is-me td { background:#FFFBEB; font-weight:500; }
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script> <script>
const ROLES = ['socio','abogado','usuario']; const ROLES = ['admin','socio','abogado','usuario'];
const ROLE_LABEL = { socio:'Socio', abogado:'Abogado', usuario:'Usuario' }; const ROLE_LABEL = { admin:'Admin', socio:'Socio', abogado:'Abogado', usuario:'Usuario' };
const MY_EMAIL = (window.DONNA && window.DONNA.me) || ''; const MY_EMAIL = (window.DONNA && window.DONNA.me) || '';
const I_AM_ADMIN = !!(window.DONNA && window.DONNA.isAdmin);
// Mostrar la opción "admin" en el dropdown solo si el usuario actual es admin
if (I_AM_ADMIN) {
document.addEventListener('DOMContentLoaded', () => {
const opt = document.getElementById('opt-admin');
if (opt) opt.style.display = '';
});
}
let users = []; let users = [];
let editingId = null; let editingId = null;