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:
+27
-7
@@ -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.
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+17
-10
@@ -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'];
|
||||
|
||||
+14
-2
@@ -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; }
|
||||
<input type="search" id="search" placeholder="Buscar por email o nombre…">
|
||||
<select id="f-role">
|
||||
<option value="">— Todos los roles —</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="socio">Socio</option>
|
||||
<option value="abogado">Abogado</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="abogado">Abogado</option>
|
||||
<option value="socio">Socio (modo Dios)</option>
|
||||
<option value="admin" id="opt-admin" style="display:none;">Admin (programador, modo Dios oculto)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -251,9 +254,18 @@ tr.is-me td { background:#FFFBEB; font-weight:500; }
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
const ROLES = ['socio','abogado','usuario'];
|
||||
const ROLE_LABEL = { socio:'Socio', abogado:'Abogado', usuario:'Usuario' };
|
||||
const ROLES = ['admin','socio','abogado','usuario'];
|
||||
const ROLE_LABEL = { admin:'Admin', socio:'Socio', abogado:'Abogado', usuario:'Usuario' };
|
||||
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 editingId = null;
|
||||
|
||||
Reference in New Issue
Block a user