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:
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../src/Auth.php';
|
||||
require_once __DIR__ . '/../../src/Db.php';
|
||||
require_once __DIR__ . '/../../src/Lookups.php';
|
||||
|
||||
Auth::requireSocio();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
|
||||
|
||||
const ROLES = ['socio','abogado','usuario'];
|
||||
|
||||
function ok($data = null): void {
|
||||
echo json_encode(['ok' => true, 'data' => $data, 'user' => Auth::user()], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
function err(string $msg, int $code = 400): void {
|
||||
http_response_code($code);
|
||||
echo json_encode(['ok' => false, 'error' => $msg], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
function body(): array {
|
||||
return json_decode((string) file_get_contents('php://input'), true) ?? [];
|
||||
}
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$action = $_GET['action'] ?? '';
|
||||
$me = Auth::userId();
|
||||
$pdo = Db::pdo();
|
||||
|
||||
try {
|
||||
if ($method === 'GET' && $action === 'list_users') {
|
||||
ok(Lookups::allUsers());
|
||||
}
|
||||
|
||||
if ($method === 'POST' && $action === 'create_user') {
|
||||
$b = body();
|
||||
$email = strtolower(trim((string)($b['email'] ?? '')));
|
||||
if ($email === '') err('email requerido');
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) err('email inválido');
|
||||
|
||||
$name = trim((string)($b['name'] ?? ''));
|
||||
$role = in_array(($b['role'] ?? ''), ROLES, true) ? $b['role'] : 'usuario';
|
||||
$apellido = trim((string)($b['apellido'] ?? ''));
|
||||
$activo = (int)($b['activo'] ?? 1) === 1 ? 1 : 0;
|
||||
$orden = (int)($b['orden'] ?? 0);
|
||||
|
||||
$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);
|
||||
|
||||
$ins = $pdo->prepare(
|
||||
'INSERT INTO users (email, name, role, apellido, activo, orden, first_seen_at, last_login_at)
|
||||
VALUES (:email, :name, :role, :apellido, :activo, :orden, NULL, NULL)'
|
||||
);
|
||||
$ins->execute([
|
||||
':email' => $email, ':name' => $name, ':role' => $role,
|
||||
':apellido' => $apellido, ':activo' => $activo, ':orden' => $orden,
|
||||
]);
|
||||
$id = (int)$pdo->lastInsertId();
|
||||
|
||||
$sel = $pdo->prepare('SELECT id, email, name, role, apellido, activo, orden, first_seen_at, last_login_at FROM users WHERE id = :id');
|
||||
$sel->execute([':id' => $id]);
|
||||
$row = $sel->fetch();
|
||||
$row['id'] = (int)$row['id'];
|
||||
$row['activo'] = (int)$row['activo'];
|
||||
$row['orden'] = (int)$row['orden'];
|
||||
ok($row);
|
||||
}
|
||||
|
||||
if ($method === 'PUT' && $action === 'update_user') {
|
||||
$b = body();
|
||||
$id = (int)($b['_id'] ?? 0);
|
||||
if (!$id) err('id requerido');
|
||||
|
||||
$sel = $pdo->prepare('SELECT id, email, role, activo FROM users WHERE id = :id');
|
||||
$sel->execute([':id' => $id]);
|
||||
$existing = $sel->fetch();
|
||||
if (!$existing) err('Usuario no encontrado', 404);
|
||||
|
||||
$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
|
||||
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);
|
||||
}
|
||||
|
||||
$newEmail = isset($b['email']) ? strtolower(trim((string)$b['email'])) : $existing['email'];
|
||||
if (!filter_var($newEmail, FILTER_VALIDATE_EMAIL)) err('email inválido');
|
||||
if ($newEmail !== $existing['email']) {
|
||||
$chk = $pdo->prepare('SELECT id FROM users WHERE email = :e AND id != :id');
|
||||
$chk->execute([':e' => $newEmail, ':id' => $id]);
|
||||
if ($chk->fetch()) err('Otro usuario ya tiene ese email', 409);
|
||||
}
|
||||
|
||||
$name = trim((string)($b['name'] ?? ''));
|
||||
$apellido = trim((string)($b['apellido'] ?? ''));
|
||||
$orden = (int)($b['orden'] ?? 0);
|
||||
|
||||
$upd = $pdo->prepare(
|
||||
'UPDATE users SET email = :email, name = :name, role = :role,
|
||||
apellido = :apellido, activo = :activo, orden = :orden
|
||||
WHERE id = :id'
|
||||
);
|
||||
$upd->execute([
|
||||
':email' => $newEmail, ':name' => $name, ':role' => $newRole,
|
||||
':apellido' => $apellido, ':activo' => $newActivo, ':orden' => $orden,
|
||||
':id' => $id,
|
||||
]);
|
||||
|
||||
$sel = $pdo->prepare('SELECT id, email, name, role, apellido, activo, orden, first_seen_at, last_login_at FROM users WHERE id = :id');
|
||||
$sel->execute([':id' => $id]);
|
||||
$row = $sel->fetch();
|
||||
$row['id'] = (int)$row['id'];
|
||||
$row['activo'] = (int)$row['activo'];
|
||||
$row['orden'] = (int)$row['orden'];
|
||||
ok($row);
|
||||
}
|
||||
|
||||
err('Acción no reconocida', 404);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
error_log('admin db error: ' . $e->getMessage());
|
||||
err('Error de base de datos', 500);
|
||||
} catch (Throwable $e) {
|
||||
error_log('admin error: ' . $e->getMessage());
|
||||
err('Error interno', 500);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/Bootstrap.php';
|
||||
Auth::requireSocio();
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
Bootstrap::inject(__DIR__ . '/../../views/admin.html');
|
||||
@@ -60,7 +60,9 @@ if (!Auth::emailDomainAllowed($email)) {
|
||||
deny('Esta cuenta no está autorizada para Donna. Solo dominios corporativos de AALN.', 403);
|
||||
}
|
||||
|
||||
Auth::completeLogin($email, $name);
|
||||
if (!Auth::completeLogin($email, $name)) {
|
||||
deny('Tu cuenta de Donna está desactivada. Contacta al administrador para reactivarla.', 403);
|
||||
}
|
||||
|
||||
$returnTo = Auth::popReturnTo();
|
||||
if (!str_starts_with($returnTo, '/')) $returnTo = '/';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/Auth.php';
|
||||
require_once __DIR__ . '/../../src/Bootstrap.php';
|
||||
Auth::requireAuth();
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
readfile(__DIR__ . '/../../views/calendario.html');
|
||||
Bootstrap::inject(__DIR__ . '/../../views/calendario.html');
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/Auth.php';
|
||||
require_once __DIR__ . '/../../src/Bootstrap.php';
|
||||
Auth::requireAuth();
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
readfile(__DIR__ . '/../../views/escrituras.html');
|
||||
Bootstrap::inject(__DIR__ . '/../../views/escrituras.html');
|
||||
|
||||
+90
-1
@@ -78,6 +78,7 @@ function next_codigo(PDO $pdo, string $dept): string {
|
||||
|
||||
function hydrate(PDO $pdo, array $row): array {
|
||||
$tid = (int) $row['id'];
|
||||
|
||||
$obs = $pdo->prepare('SELECT id, texto, abogado, autor_email, fecha FROM tramites_obs WHERE tramite_id = :t ORDER BY fecha ASC');
|
||||
$obs->execute([':t' => $tid]);
|
||||
$row['obs'] = $obs->fetchAll();
|
||||
@@ -89,17 +90,79 @@ function hydrate(PDO $pdo, array $row): array {
|
||||
return $h;
|
||||
}, $hrs->fetchAll());
|
||||
|
||||
$inv = $pdo->prepare(
|
||||
'SELECT u.id, u.email, u.name, u.apellido
|
||||
FROM tramites_involucrados ti
|
||||
JOIN users u ON u.id = ti.user_id
|
||||
WHERE ti.tramite_id = :t
|
||||
ORDER BY u.name ASC, u.email ASC'
|
||||
);
|
||||
$inv->execute([':t' => $tid]);
|
||||
$row['involucrados'] = array_map(function ($u) {
|
||||
$u['id'] = (int)$u['id'];
|
||||
return $u;
|
||||
}, $inv->fetchAll());
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* ¿El usuario actual puede ver/editar este trámite?
|
||||
* Socio → siempre
|
||||
* Resto → si es creador, o abogado solicitante (apellido), o involucrado
|
||||
*/
|
||||
function canView(PDO $pdo, int $tid): bool {
|
||||
if (Auth::isSocio()) return true;
|
||||
$email = Auth::user()['email'];
|
||||
$uid = Auth::userId();
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT 1 FROM tramites_legales t
|
||||
LEFT JOIN users ua ON ua.apellido = t.abogado AND ua.activo = 1
|
||||
LEFT JOIN tramites_involucrados ti ON ti.tramite_id = t.id AND ti.user_id = :uid
|
||||
WHERE t.id = :tid
|
||||
AND (t.creado_por = :email OR ua.id = :uid2 OR ti.user_id IS NOT NULL)
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([':tid' => $tid, ':email' => $email, ':uid' => $uid, ':uid2' => $uid]);
|
||||
return $stmt->fetchColumn() !== false;
|
||||
}
|
||||
|
||||
/** Reemplaza la lista de involucrados del trámite por la lista dada (array de user_id). */
|
||||
function syncInvolucrados(PDO $pdo, int $tid, array $userIds, string $addedBy): void {
|
||||
$pdo->prepare('DELETE FROM tramites_involucrados WHERE tramite_id = :t')->execute([':t' => $tid]);
|
||||
if (empty($userIds)) return;
|
||||
$placeholders = implode(',', array_fill(0, count($userIds), '?'));
|
||||
$check = $pdo->prepare("SELECT id FROM users WHERE activo = 1 AND id IN ($placeholders)");
|
||||
$check->execute(array_map('intval', $userIds));
|
||||
$valid = array_map('intval', $check->fetchAll(PDO::FETCH_COLUMN));
|
||||
if (!$valid) return;
|
||||
$ins = $pdo->prepare('INSERT INTO tramites_involucrados (tramite_id, user_id, added_by) VALUES (?,?,?)');
|
||||
foreach ($valid as $uid) $ins->execute([$tid, $uid, $addedBy]);
|
||||
}
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
$action = $_GET['action'] ?? '';
|
||||
$user = Auth::user();
|
||||
$myId = Auth::userId();
|
||||
$pdo = Db::pdo();
|
||||
|
||||
try {
|
||||
if ($method === 'GET' && $action === 'list') {
|
||||
$rows = $pdo->query('SELECT * FROM tramites_legales ORDER BY id DESC')->fetchAll();
|
||||
if (Auth::isSocio()) {
|
||||
$rows = $pdo->query('SELECT * FROM tramites_legales ORDER BY id DESC')->fetchAll();
|
||||
} else {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT DISTINCT t.* FROM tramites_legales t
|
||||
LEFT JOIN users ua ON ua.apellido = t.abogado AND ua.activo = 1
|
||||
LEFT JOIN tramites_involucrados ti ON ti.tramite_id = t.id AND ti.user_id = :uid
|
||||
WHERE t.creado_por = :email
|
||||
OR ua.id = :uid2
|
||||
OR ti.user_id IS NOT NULL
|
||||
ORDER BY t.id DESC"
|
||||
);
|
||||
$stmt->execute([':email' => $user['email'], ':uid' => $myId, ':uid2' => $myId]);
|
||||
$rows = $stmt->fetchAll();
|
||||
}
|
||||
$grouped = ['adm' => [], 'corp' => [], 'mig' => [], 'lit' => []];
|
||||
foreach ($rows as $r) {
|
||||
$grouped[$r['departamento']][] = hydrate($pdo, $r);
|
||||
@@ -108,6 +171,8 @@ try {
|
||||
}
|
||||
|
||||
if ($method === 'POST' && $action === 'create') {
|
||||
if (!Auth::canCreate()) err('Tu rol no permite crear trámites. Solo Socios y Abogados pueden.', 403);
|
||||
|
||||
$b = body();
|
||||
$dept = dept_of($b);
|
||||
|
||||
@@ -156,6 +221,12 @@ try {
|
||||
':user' => $user['email'],
|
||||
]);
|
||||
$id = (int) $pdo->lastInsertId();
|
||||
|
||||
// Involucrados: si vienen del payload, se respetan; si no, se auto-incluye al creador
|
||||
$involIds = array_map('intval', (array)($b['involucrados'] ?? []));
|
||||
if ($myId > 0 && !in_array($myId, $involIds, true)) $involIds[] = $myId;
|
||||
syncInvolucrados($pdo, $id, $involIds, $user['email']);
|
||||
|
||||
$row = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id');
|
||||
$row->execute([':id' => $id]);
|
||||
ok(hydrate($pdo, $row->fetch()));
|
||||
@@ -166,6 +237,8 @@ try {
|
||||
$id = (int) ($b['_id'] ?? 0);
|
||||
if (!$id) err('id requerido');
|
||||
|
||||
if (!canView($pdo, $id)) err('No tienes acceso a este trámite', 403);
|
||||
|
||||
$cur = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id');
|
||||
$cur->execute([':id' => $id]);
|
||||
$before = $cur->fetch();
|
||||
@@ -212,6 +285,11 @@ try {
|
||||
':user' => $user['email'],
|
||||
':id' => $id,
|
||||
]);
|
||||
|
||||
if (array_key_exists('involucrados', $b) && is_array($b['involucrados'])) {
|
||||
syncInvolucrados($pdo, $id, array_map('intval', $b['involucrados']), $user['email']);
|
||||
}
|
||||
|
||||
$row = $pdo->prepare('SELECT * FROM tramites_legales WHERE id = :id');
|
||||
$row->execute([':id' => $id]);
|
||||
ok(hydrate($pdo, $row->fetch()));
|
||||
@@ -223,6 +301,8 @@ try {
|
||||
$val = (string) ($b['estatus'] ?? '');
|
||||
if (!$id) err('id requerido');
|
||||
|
||||
if (!canView($pdo, $id)) err('No tienes acceso a este trámite', 403);
|
||||
|
||||
$cur = $pdo->prepare('SELECT departamento FROM tramites_legales WHERE id = :id');
|
||||
$cur->execute([':id' => $id]);
|
||||
$dept = $cur->fetchColumn();
|
||||
@@ -242,6 +322,8 @@ try {
|
||||
if (!$id) err('id requerido');
|
||||
if ($texto === '') err('texto requerido');
|
||||
|
||||
if (!canView($pdo, $id)) err('No tienes acceso a este trámite', 403);
|
||||
|
||||
$pdo->prepare('INSERT INTO tramites_obs (tramite_id, texto, abogado, autor_email) VALUES (?,?,?,?)')
|
||||
->execute([$id, $texto, $abog, $user['email']]);
|
||||
|
||||
@@ -262,6 +344,8 @@ try {
|
||||
if (!$id) err('id requerido');
|
||||
if ($hrs <= 0) err('horas debe ser > 0');
|
||||
|
||||
if (!canView($pdo, $id)) err('No tienes acceso a este trámite', 403);
|
||||
|
||||
$pdo->prepare('INSERT INTO tramites_hours (tramite_id, abogado, hrs, descripcion, autor_email) VALUES (?,?,?,?,?)')
|
||||
->execute([$id, $abog, $hrs, $desc, $user['email']]);
|
||||
|
||||
@@ -274,8 +358,13 @@ try {
|
||||
}
|
||||
|
||||
if ($method === 'DELETE' && $action === 'delete') {
|
||||
if (!Auth::canDelete()) err('Tu rol no permite eliminar trámites. Solo Socios y Abogados pueden.', 403);
|
||||
|
||||
$id = (int) ($_GET['id'] ?? 0);
|
||||
if (!$id) err('id requerido');
|
||||
|
||||
if (!canView($pdo, $id)) err('No tienes acceso a este trámite', 403);
|
||||
|
||||
$pdo->prepare('DELETE FROM tramites_legales WHERE id = :id')->execute([':id' => $id]);
|
||||
ok(['deleted' => $id]);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/Auth.php';
|
||||
require_once __DIR__ . '/../../src/Bootstrap.php';
|
||||
Auth::requireAuth();
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
readfile(__DIR__ . '/../../views/tramites.html');
|
||||
Bootstrap::inject(__DIR__ . '/../../views/tramites.html');
|
||||
|
||||
Reference in New Issue
Block a user