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:
+99
-13
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user