feat(perms): permisología completa con 3 roles (Socio/Abogado/Usuario) + Panel de Control

ESQUEMA (migraciones 005 + 006)
* users: agrega role ENUM('socio','abogado','usuario'), apellido, activo, orden
* Seed: cabrego@aal-law.com como socio + 15 placeholders por apellido para
  poblar el dropdown "Abogado Solicitante" hasta que los reales se logueen.
* tramites_involucrados: tabla N:M (tramite_id, user_id) con CASCADE delete.

AUTH (src/Auth.php)
* userRow() cachea el row del usuario actual por request.
* role(), isSocio(), isAbogado(), canCreate(), canDelete(), requireSocio().
* completeLogin() retorna bool: false si user.activo=0 → callback muestra
  pantalla "cuenta deshabilitada" en lugar de loguear.

BOOTSTRAP (src/Bootstrap.php + src/Lookups.php)
* Bootstrap::inject(view) reemplaza readfile en los wrappers; inyecta
  <script>window.DONNA={me,name,role,isSocio,isAbogado,canCreate,canDelete,
  abogados,users}</script> antes de </head> para que el frontend tenga
  contexto sin requests extra.

TRAMITES — visibilidad + involucrados
* api.php: list filtra por (socio | creador | abogado solicitante mapped via
  users.apellido | involucrado) excepto socios que ven todo. Todas las
  mutaciones validan canView(). create/delete validan rol. Persiste
  involucrados vía syncInvolucrados().
* tramites.html: nueva columna "Involucrados" después de "Abogado", chip
  picker multi-select en el modal, gear icon (oculto a non-socios), oculta
  botones "+ Nuevo" y ✕ para usuarios sin permisos. Auto-incluye al
  creador como involucrado por defecto.
* Calendario y demás apps consumen el API filtrado → heredan la
  visibilidad sin código nuevo.

PANEL DE CONTROL (/admin/)
* index.php: requireSocio() + Bootstrap injection.
* api.php: list_users, create_user, update_user. Salvaguardas: el socio
  no puede demoverse ni desactivarse a sí mismo.
* admin.html: tabla unificada de usuarios con filtros, modal CRUD,
  resaltado de fila propia, placeholders identificados visualmente.

GEAR ICON
* Agregado en escrituras.html, tramites.html, calendario.html con
  display:none por defecto; JS lo revela si window.DONNA.isSocio.
* Link a /admin/.
This commit is contained in:
Soporte ZH Consultores
2026-06-04 23:14:07 +00:00
parent cd9dae98bb
commit 2db8f23ba5
16 changed files with 1088 additions and 37 deletions
+99 -13
View File
@@ -5,6 +5,9 @@ require_once __DIR__ . '/Config.php';
require_once __DIR__ . '/Db.php';
final class Auth {
/** Cache por request del row de users del usuario autenticado */
private static ?array $userRowCache = null;
public static function startSession(): void {
if (session_status() === PHP_SESSION_ACTIVE) return;
@@ -37,6 +40,65 @@ final class Auth {
];
}
/** Fetch del row de users del usuario actual (cached per request). */
public static function userRow(): array {
if (self::$userRowCache !== null) return self::$userRowCache;
$email = $_SESSION['user_email'] ?? '';
if (!$email) return self::$userRowCache = self::defaultRow();
try {
$stmt = Db::pdo()->prepare(
'SELECT id, email, name, role, apellido, activo
FROM users WHERE email = :e LIMIT 1'
);
$stmt->execute([':e' => $email]);
$row = $stmt->fetch();
self::$userRowCache = $row ?: self::defaultRow();
} catch (Throwable $e) {
error_log('Auth::userRow fetch failed: ' . $e->getMessage());
self::$userRowCache = self::defaultRow();
}
return self::$userRowCache;
}
private static function defaultRow(): array {
return ['id' => 0, 'email' => '', 'name' => '', 'role' => 'usuario', 'apellido' => '', 'activo' => 1];
}
public static function userId(): int { return (int)(self::userRow()['id'] ?? 0); }
public static function role(): string { return (string)(self::userRow()['role'] ?? 'usuario'); }
public static function apellido(): string { return (string)(self::userRow()['apellido'] ?? ''); }
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 requireSocio(): void {
self::requireAuth();
if (!self::isSocio()) {
$accepts = $_SERVER['HTTP_ACCEPT'] ?? '';
$isAjax = !empty($_SERVER['HTTP_X_REQUESTED_WITH'])
|| str_contains($accepts, 'application/json');
http_response_code(403);
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => 'Acceso restringido a Socios']);
} else {
header('Content-Type: text/html; charset=utf-8');
echo '<!DOCTYPE html><html lang="es"><head><meta charset="UTF-8"><title>403 — Restringido</title>'
. '<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#F7F6F3;color:#1A1916;}'
. '.box{text-align:center;max-width:420px;padding:40px;background:#fff;border-radius:12px;box-shadow:0 4px 20px rgba(0,0,0,.08);}'
. 'h1{color:#c0392b;margin:0 0 12px;font-size:24px;}p{color:#666;margin:0 0 20px;line-height:1.5;}'
. 'a{display:inline-block;padding:10px 24px;background:#1B3A6B;color:#fff;border-radius:8px;text-decoration:none;}'
. 'a:hover{background:#112446;}</style></head><body><div class="box">'
. '<h1>403 — Acceso restringido</h1>'
. '<p>El Panel de Control es exclusivo para Socios. Si crees que deberías tener acceso, contacta al administrador.</p>'
. '<a href="/">← Volver al portal</a></div></body></html>';
}
exit;
}
}
public static function buildLoginUrl(?string $returnTo = null): string {
self::startSession();
$state = bin2hex(random_bytes(16));
@@ -77,25 +139,49 @@ final class Auth {
return in_array($parts[1], array_map('strtolower', $allowed), true);
}
public static function completeLogin(string $email, string $name): void {
/**
* Procesa el login. Retorna false si el usuario existe pero está desactivado
* (activo=0), en cuyo caso el caller debe mostrar un mensaje "cuenta
* deshabilitada" y NO crear sesión.
*/
public static function completeLogin(string $email, string $name): bool {
try {
$pdo = Db::pdo();
$sel = $pdo->prepare('SELECT id, activo FROM users WHERE email = :e LIMIT 1');
$sel->execute([':e' => $email]);
$existing = $sel->fetch();
if ($existing && (int)$existing['activo'] === 0) {
return false;
}
if ($existing) {
$upd = $pdo->prepare(
'UPDATE users SET name = :name, last_login_at = NOW() WHERE id = :id'
);
$upd->execute([':name' => $name, ':id' => (int)$existing['id']]);
} else {
$ins = $pdo->prepare(
'INSERT INTO users (email, name, role, activo, first_seen_at, last_login_at)
VALUES (:email, :name, :role, 1, NOW(), NOW())'
);
// Nuevos usuarios entran como "usuario" (sin permisos de crear) por defecto.
// El Socio los promueve a abogado vía el Panel de Control si corresponde.
$ins->execute([':email' => $email, ':name' => $name, ':role' => 'usuario']);
}
} catch (Throwable $e) {
error_log('Auth::completeLogin upsert failed: ' . $e->getMessage());
// No bloqueamos el login por un fallo de DB no crítico
}
self::startSession();
session_regenerate_id(true);
$_SESSION['authenticated'] = true;
$_SESSION['user_email'] = $email;
$_SESSION['user_name'] = $name ?: $email;
$_SESSION['auth_time'] = time();
try {
$pdo = Db::pdo();
$stmt = $pdo->prepare(
'INSERT INTO users (email, name, first_seen_at, last_login_at)
VALUES (:email, :name, NOW(), NOW())
ON DUPLICATE KEY UPDATE name = VALUES(name), last_login_at = NOW()'
);
$stmt->execute([':email' => $email, ':name' => $name]);
} catch (Throwable $e) {
error_log('users upsert failed: ' . $e->getMessage());
}
self::$userRowCache = null; // invalidate cache so next userRow() call re-fetches
return true;
}
public static function logout(): void {
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/Auth.php';
require_once __DIR__ . '/Lookups.php';
/**
* Bootstrap — sirve una vista HTML estática con `window.DONNA` inyectado
* en <head> para que el JS tenga rol, lista de abogados, lista de
* usuarios involucrables, etc. sin hacer un fetch extra al cargar.
*/
final class Bootstrap {
public static function inject(string $viewPath): void {
$user = Auth::user();
$payload = [
'me' => $user['email'],
'name' => $user['name'],
'role' => Auth::role(),
'isSocio' => Auth::isSocio(),
'isAbogado' => Auth::isAbogado(),
'canCreate' => Auth::canCreate(),
'canDelete' => Auth::canDelete(),
'abogados' => Lookups::abogadoApellidos(),
'users' => Lookups::involucrablesUsers(),
];
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP);
$inject = '<script>window.DONNA = ' . $json . ';</script>';
$html = file_get_contents($viewPath);
if ($html === false) {
http_response_code(500);
exit('Vista no disponible.');
}
$out = str_replace('</head>', $inject . "\n</head>", $html, $count);
if ($count === 0) {
$out = $inject . "\n" . $html;
}
echo $out;
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/Db.php';
/**
* Lookups — vistas de solo lectura sobre la tabla users para poblar
* dropdowns y resolver mapeo email ↔ apellido.
*/
final class Lookups {
/** Apellidos para el dropdown "Abogado Solicitante" (socios + abogados activos). */
public static function abogadoApellidos(): array {
$rows = Db::pdo()->query(
"SELECT DISTINCT apellido FROM users
WHERE activo = 1
AND role IN ('socio','abogado')
AND apellido != ''
ORDER BY orden ASC, apellido ASC"
)->fetchAll();
return array_map(fn($r) => $r['apellido'], $rows);
}
/** Todos los usuarios activos para el picker de Involucrados. */
public static function involucrablesUsers(): array {
$rows = Db::pdo()->query(
"SELECT id, email, name, role, apellido
FROM users
WHERE activo = 1
AND email NOT LIKE 'placeholder-%'
ORDER BY name ASC, email ASC"
)->fetchAll();
foreach ($rows as &$r) { $r['id'] = (int)$r['id']; }
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, apellido, activo, orden,
first_seen_at, last_login_at
FROM users
ORDER BY role DESC, apellido ASC, name ASC, email ASC"
)->fetchAll();
foreach ($rows as &$r) {
$r['id'] = (int)$r['id'];
$r['activo'] = (int)$r['activo'];
$r['orden'] = (int)$r['orden'];
}
return $rows;
}
/** id del usuario por email; null si no existe. */
public static function userIdByEmail(string $email): ?int {
$stmt = Db::pdo()->prepare('SELECT id FROM users WHERE email = :e');
$stmt->execute([':e' => $email]);
$id = $stmt->fetchColumn();
return $id === false ? null : (int)$id;
}
}