Initial commit — Donna portal v1
Setup completo del portal interno donna.aal-law.com: * Landing público con OAuth login + grid de apps autenticado * Flujo OAuth2 contra Mailcow (mail.aal-law.com) * Sesiones SSO compartidas en el dominio (cookie donna_session, 8h) * Whitelist de dominios aal-law.com / aalnlaw.com * App "Control de Escrituras" migrada desde ZH-Consultores/control-escrituras (mismo schema, mismo frontend, ahora con shared Auth/Db/Config libs) * Migraciones SQL para users, escrituras_tramites, escrituras_historial * Caddy on :80 con rewrite extless → .php * Backup diario MariaDB (systemd timer + script) con retención 14d * Documentación de arquitectura en README + infra files en docs/infra
This commit is contained in:
+33
@@ -0,0 +1,33 @@
|
||||
# Secretos — NUNCA hacer commit
|
||||
*.env
|
||||
*.env.*
|
||||
!*.env.example
|
||||
config.local.php
|
||||
*.local.php
|
||||
|
||||
# Backups
|
||||
*.bak
|
||||
*.orig
|
||||
*~
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs (los logs viven en /var/log/donna/, no en el repo)
|
||||
*.log
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# PHP
|
||||
vendor/
|
||||
composer.lock
|
||||
|
||||
# Node (por si en el futuro hay tooling JS)
|
||||
node_modules/
|
||||
|
||||
# Datos efímeros
|
||||
/var/
|
||||
/tmp/
|
||||
@@ -0,0 +1,206 @@
|
||||
# Donna — Portal interno AALN Law
|
||||
|
||||
Portal interno de la firma **Arias, Abrego, López & Noriega** (aalnlaw.com) que aloja herramientas internas para el equipo. Primer app embarcada: **Control de Escrituras Públicas**.
|
||||
|
||||
> Producción: <https://donna.aal-law.com>
|
||||
|
||||
---
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
Navegador
|
||||
│ HTTPS
|
||||
▼
|
||||
Cloudflare → Caddy infra (SSL termination)
|
||||
│ HTTP :80
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ Caddy (este server) │ /opt/donna/public
|
||||
│ rewrite *.php │
|
||||
└──────┬───────────────┘
|
||||
│ unix socket
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ PHP-FPM 8.4 │
|
||||
└──────┬───────────────┘
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ MariaDB 11.8 │ DB: donna
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
**Stack:**
|
||||
- PHP 8.4-fpm (`/run/php/php8.4-fpm.sock`)
|
||||
- Caddy 2.6 en `:80`
|
||||
- MariaDB 11.8 (`127.0.0.1:3306`)
|
||||
- Autenticación OAuth2 contra Mailcow (`mail.aal-law.com`)
|
||||
- Backups diarios automáticos vía systemd timer
|
||||
|
||||
---
|
||||
|
||||
## Estructura del repo
|
||||
|
||||
```
|
||||
donna/
|
||||
├── public/ ← DocumentRoot
|
||||
│ ├── index.php ← Landing + grid de apps
|
||||
│ ├── auth/
|
||||
│ │ ├── login.php ← Inicia OAuth flow
|
||||
│ │ ├── callback.php ← Recibe code de Mailcow
|
||||
│ │ └── logout.php
|
||||
│ └── escrituras/
|
||||
│ ├── index.php ← Auth-guard + sirve view
|
||||
│ └── api.php ← CRUD trámites + historial
|
||||
├── src/ ← Librerías PHP compartidas (fuera de DocumentRoot)
|
||||
│ ├── Config.php ← Lee /etc/donna/donna.env
|
||||
│ ├── Db.php ← PDO singleton
|
||||
│ ├── Auth.php ← Sesión + OAuth guard + whitelist
|
||||
│ ├── Oauth.php ← Cliente HTTP a Mailcow
|
||||
│ └── Apps.php ← Catálogo de apps del menú
|
||||
├── views/ ← Templates HTML servidos por PHP tras auth
|
||||
│ └── escrituras.html
|
||||
├── sql/ ← Migraciones (aplicar manualmente)
|
||||
│ ├── 001_core.sql ← users
|
||||
│ └── 002_escrituras.sql ← escrituras_tramites + escrituras_historial
|
||||
├── bin/
|
||||
│ └── donna-backup.sh ← mysqldump diario, retención 14 días
|
||||
├── docs/ ← Diseños y archivos de infra de referencia
|
||||
├── donna.env.example ← Plantilla del archivo de secrets
|
||||
└── README.md
|
||||
```
|
||||
|
||||
**Fuera del repo (en el servidor):**
|
||||
- `/etc/donna/donna.env` — secrets reales (`640 root:www-data`)
|
||||
- `/etc/caddy/Caddyfile` — config del Caddy local
|
||||
- `/etc/systemd/system/donna-backup.{service,timer}` — backups
|
||||
- `/var/log/donna/` — access logs
|
||||
- `/var/backups/donna/` — dumps SQL
|
||||
|
||||
---
|
||||
|
||||
## Setup en un servidor nuevo
|
||||
|
||||
```bash
|
||||
# 1. Paquetes del SO (Debian 13)
|
||||
apt install -y caddy php8.4-fpm php8.4-mysql php8.4-curl \
|
||||
php8.4-mbstring php8.4-intl php8.4-zip mariadb-server
|
||||
|
||||
# 2. Base de datos
|
||||
mariadb -e "CREATE DATABASE donna CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
mariadb -e "CREATE USER 'donna_app'@'localhost' IDENTIFIED BY 'XXX';
|
||||
GRANT ALL ON donna.* TO 'donna_app'@'localhost';"
|
||||
|
||||
# 3. Código
|
||||
git clone https://git.zhconsultores.com/ZH-Consultores/donna.git /opt/donna
|
||||
chown -R www-data:www-data /opt/donna
|
||||
|
||||
# 4. Migraciones
|
||||
mariadb donna < /opt/donna/sql/001_core.sql
|
||||
mariadb donna < /opt/donna/sql/002_escrituras.sql
|
||||
|
||||
# 5. Secrets
|
||||
mkdir -p /etc/donna
|
||||
cp /opt/donna/donna.env.example /etc/donna/donna.env
|
||||
# Editar /etc/donna/donna.env con valores reales (OAuth, DB_PASS, APP_SECRET)
|
||||
chown root:www-data /etc/donna/donna.env
|
||||
chmod 640 /etc/donna/donna.env
|
||||
|
||||
# 6. Caddy y backups: ver docs/infra/ para Caddyfile y systemd units
|
||||
systemctl reload caddy
|
||||
systemctl enable --now donna-backup.timer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## OAuth2 con Mailcow
|
||||
|
||||
1. En `https://mail.aal-law.com/admin` → **Access → OAuth2 Apps**
|
||||
2. Crear app con `Redirect URI = https://donna.aal-law.com/auth/callback`
|
||||
3. Copiar `Client ID` y `Client Secret` a `/etc/donna/donna.env`
|
||||
|
||||
El flujo:
|
||||
```
|
||||
/ ──login──▶ /auth/login.php ──redirect──▶ mail.aal-law.com/oauth/authorize
|
||||
│
|
||||
▼ ?code=...&state=...
|
||||
/auth/callback.php ◀── redirect ◀── usuario autorizado
|
||||
│
|
||||
├── valida state (anti-CSRF)
|
||||
├── intercambia code → token
|
||||
├── lee profile (email, nombre)
|
||||
├── valida dominio @aal-law.com o @aalnlaw.com
|
||||
├── inserta/actualiza fila en `users`
|
||||
└── redirige a /
|
||||
```
|
||||
|
||||
Sesión: cookie `donna_session`, `path=/`, `Secure`, `HttpOnly`, `SameSite=Lax`, lifetime 8h. SSO automático entre todas las apps del portal.
|
||||
|
||||
---
|
||||
|
||||
## Apps
|
||||
|
||||
Las apps se registran en `src/Apps.php::catalog()`. El landing las renderiza automáticamente como cards.
|
||||
|
||||
| Slug | Estado | Ruta | Tablas DB |
|
||||
|---|---|---|---|
|
||||
| `escrituras` | Live | `/escrituras/` | `escrituras_tramites`, `escrituras_historial` |
|
||||
| `casos` | Coming soon | — | — |
|
||||
| `agenda` | Coming soon | — | — |
|
||||
| `clientes` | Coming soon | — | — |
|
||||
|
||||
**Para agregar una app nueva:**
|
||||
1. Crear `/opt/donna/public/<slug>/index.php` con `Auth::requireAuth()` arriba.
|
||||
2. Crear `/opt/donna/public/<slug>/api.php` si necesita backend.
|
||||
3. Prefijar todas las tablas DB con `<slug>_`.
|
||||
4. Crear migración en `sql/00N_<slug>.sql`.
|
||||
5. Registrar en `src/Apps.php::catalog()`.
|
||||
6. Commit + push.
|
||||
|
||||
---
|
||||
|
||||
## Identidad visual
|
||||
|
||||
| Token | Valor |
|
||||
|---|---|
|
||||
| `--navy` | `#1B3A6B` |
|
||||
| `--navy-d` | `#112446` |
|
||||
| `--navy-l` | `#2D5090` |
|
||||
| `--gold` | `#C9A84C` |
|
||||
| `--gold-l` | `#E8C96A` |
|
||||
| `--gold-d` | `#9E7C2E` |
|
||||
| `--off` | `#F7F6F3` |
|
||||
| Fuente headlines | DM Serif Display |
|
||||
| Fuente UI | DM Sans |
|
||||
|
||||
Gradient hero del landing: `#0E1F3F → #1B3A6B → #2D5090`.
|
||||
|
||||
---
|
||||
|
||||
## Backups
|
||||
|
||||
`donna-backup.timer` ejecuta diariamente a las 03:00 (con jitter de ±5min) y vuelca `donna` comprimido a `/var/backups/donna/donna-YYYY-MM-DD_HHMMSS.sql.gz`. Retención 14 días.
|
||||
|
||||
Restaurar:
|
||||
```bash
|
||||
gunzip -c /var/backups/donna/donna-2026-MM-DD_HHMMSS.sql.gz | mariadb donna
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Operación
|
||||
|
||||
| Acción | Comando |
|
||||
|---|---|
|
||||
| Reload Caddy | `systemctl reload caddy` |
|
||||
| Reload PHP-FPM | `systemctl reload php8.4-fpm` |
|
||||
| Ver logs Caddy | `tail -f /var/log/donna/access.log` |
|
||||
| Ver errores PHP | `journalctl -u php8.4-fpm -f` |
|
||||
| Backup manual | `/opt/donna/bin/donna-backup.sh` |
|
||||
| Próximo backup | `systemctl list-timers donna-backup.timer` |
|
||||
|
||||
---
|
||||
|
||||
## Licencia
|
||||
|
||||
Software interno — sin licencia pública. Propiedad de **Arias, Abrego, López & Noriega**.
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Donna — daily MariaDB backup with 14-day retention
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_DIR="/var/backups/donna"
|
||||
ENV_FILE="/etc/donna/donna.env"
|
||||
RETENTION_DAYS=14
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
. <(grep -E '^(DB_NAME|DB_USER|DB_PASS|DB_HOST|DB_PORT)=' "$ENV_FILE")
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
chmod 750 "$BACKUP_DIR"
|
||||
|
||||
STAMP=$(date +%F_%H%M%S)
|
||||
OUT="$BACKUP_DIR/donna-$STAMP.sql.gz"
|
||||
|
||||
MYSQL_PWD="$DB_PASS" mariadb-dump \
|
||||
--host="${DB_HOST:-127.0.0.1}" \
|
||||
--port="${DB_PORT:-3306}" \
|
||||
--user="$DB_USER" \
|
||||
--single-transaction \
|
||||
--quick \
|
||||
--routines \
|
||||
--triggers \
|
||||
--events \
|
||||
--default-character-set=utf8mb4 \
|
||||
"$DB_NAME" \
|
||||
| gzip -9 > "$OUT"
|
||||
|
||||
chmod 640 "$OUT"
|
||||
|
||||
find "$BACKUP_DIR" -type f -name 'donna-*.sql.gz' -mtime "+$RETENTION_DAYS" -delete
|
||||
|
||||
echo "[donna-backup] $(date -Is) wrote $OUT ($(du -h "$OUT" | cut -f1))"
|
||||
@@ -0,0 +1,38 @@
|
||||
# Donna Portal — HTTP only on :80
|
||||
# El Caddy upstream hace terminación SSL y nos pasa X-Forwarded-Proto: https.
|
||||
|
||||
:80 {
|
||||
root * /opt/donna/public
|
||||
|
||||
encode gzip zstd
|
||||
|
||||
# Bloquear acceso directo a vistas privadas, código fuente, secretos, SQL, etc.
|
||||
@hidden {
|
||||
path /src/* /sql/* /bin/* /views/* /.env* /.git*
|
||||
}
|
||||
respond @hidden 404
|
||||
|
||||
# Rewrite /foo → /foo.php cuando el .php existe (para URLs sin extensión: /auth/callback)
|
||||
@extlessphp {
|
||||
not path *.php
|
||||
not path */
|
||||
file {path}.php
|
||||
}
|
||||
rewrite @extlessphp {path}.php
|
||||
|
||||
php_fastcgi unix//run/php/php8.4-fpm.sock {
|
||||
env HTTPS on
|
||||
}
|
||||
|
||||
file_server {
|
||||
index index.php
|
||||
}
|
||||
|
||||
log {
|
||||
output file /var/log/donna/access.log {
|
||||
roll_size 50mb
|
||||
roll_keep 5
|
||||
}
|
||||
format json
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Donna MariaDB daily backup
|
||||
After=mariadb.service
|
||||
Wants=mariadb.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
ExecStart=/opt/donna/bin/donna-backup.sh
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=Run Donna backup daily at 03:00
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 03:00:00
|
||||
Persistent=true
|
||||
RandomizedDelaySec=300
|
||||
Unit=donna-backup.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,31 @@
|
||||
# Plantilla de /etc/donna/donna.env
|
||||
# Copia este archivo a /etc/donna/donna.env y rellena con valores reales:
|
||||
# sudo cp donna.env.example /etc/donna/donna.env
|
||||
# sudo chown root:www-data /etc/donna/donna.env
|
||||
# sudo chmod 640 /etc/donna/donna.env
|
||||
# Nunca commitear el .env real.
|
||||
|
||||
# --- OAuth2 Mailcow ---
|
||||
OAUTH_CLIENT_ID=pega_aqui
|
||||
OAUTH_CLIENT_SECRET=pega_aqui
|
||||
OAUTH_REDIRECT_URI=https://donna.aal-law.com/auth/callback
|
||||
OAUTH_AUTH_URL=https://mail.aal-law.com/oauth/authorize
|
||||
OAUTH_TOKEN_URL=https://mail.aal-law.com/oauth/token
|
||||
OAUTH_PROFILE_URL=https://mail.aal-law.com/oauth/profile
|
||||
OAUTH_SCOPE=profile
|
||||
|
||||
# --- App ---
|
||||
APP_SECRET=genera_con_openssl_rand_hex_32
|
||||
APP_BASE_URL=https://donna.aal-law.com
|
||||
SESSION_LIFETIME=28800
|
||||
|
||||
# --- Base de datos (MariaDB local) ---
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_NAME=donna
|
||||
DB_USER=donna_app
|
||||
DB_PASS=password_local_seguro
|
||||
|
||||
# --- Autorización ---
|
||||
# Dominios de correo permitidos para entrar (CSV)
|
||||
ALLOWED_EMAIL_DOMAINS=aal-law.com,aalnlaw.com
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/Auth.php';
|
||||
require_once __DIR__ . '/../../src/Oauth.php';
|
||||
|
||||
function deny(string $msg, int $code = 401): void {
|
||||
http_response_code($code);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
$safe = htmlspecialchars($msg, ENT_QUOTES, 'UTF-8');
|
||||
echo <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="es"><head><meta charset="UTF-8"><title>Acceso denegado — Donna</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body{font-family:'DM Sans',sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#F7F6F3;}
|
||||
.box{background:#fff;border:1px solid #eee;border-radius:14px;padding:44px 52px;text-align:center;max-width:440px;box-shadow:0 8px 32px rgba(0,0,0,0.06);}
|
||||
.logo{font-family:'DM Serif Display',serif;font-size:24px;color:#1B3A6B;margin-bottom:6px;}
|
||||
.tag{font-size:11px;color:#C9A84C;letter-spacing:.18em;text-transform:uppercase;margin-bottom:24px;font-weight:600;}
|
||||
h2{color:#1B3A6B;margin:0 0 12px;font-family:'DM Serif Display',serif;font-weight:400;}
|
||||
p{color:#666;font-size:14px;margin:0 0 24px;line-height:1.55;}
|
||||
a{display:inline-block;padding:10px 24px;background:#1B3A6B;color:#fff;border-radius:8px;text-decoration:none;font-size:13px;font-weight:500;}
|
||||
a:hover{background:#112446;}
|
||||
</style></head><body>
|
||||
<div class="box">
|
||||
<div class="logo">Donna</div>
|
||||
<div class="tag">AALN · Portal interno</div>
|
||||
<h2>Acceso denegado</h2>
|
||||
<p>{$safe}</p>
|
||||
<a href="/">Volver al inicio</a>
|
||||
</div></body></html>
|
||||
HTML;
|
||||
exit;
|
||||
}
|
||||
|
||||
$state = $_GET['state'] ?? '';
|
||||
if (!is_string($state) || !Auth::checkState($state)) {
|
||||
deny('Token de seguridad inválido o expirado. Intenta iniciar sesión de nuevo.');
|
||||
}
|
||||
|
||||
$code = $_GET['code'] ?? '';
|
||||
if (!is_string($code) || $code === '') {
|
||||
$err = $_GET['error_description'] ?? $_GET['error'] ?? 'Autorización cancelada.';
|
||||
deny((string)$err);
|
||||
}
|
||||
|
||||
$tokenResp = Oauth::exchangeCodeForToken($code);
|
||||
if (empty($tokenResp['access_token'])) {
|
||||
deny('No se pudo obtener el token de acceso. Reintenta más tarde.');
|
||||
}
|
||||
|
||||
$profile = Oauth::fetchProfile($tokenResp['access_token']);
|
||||
$email = $profile['email'] ?? $profile['username'] ?? '';
|
||||
$name = $profile['full_name'] ?? $profile['name'] ?? $email;
|
||||
|
||||
if ($email === '') {
|
||||
deny('No se pudo leer el perfil del usuario desde el servidor de correo.');
|
||||
}
|
||||
|
||||
if (!Auth::emailDomainAllowed($email)) {
|
||||
deny('Esta cuenta no está autorizada para Donna. Solo dominios corporativos de AALN.', 403);
|
||||
}
|
||||
|
||||
Auth::completeLogin($email, $name);
|
||||
|
||||
$returnTo = Auth::popReturnTo();
|
||||
if (!str_starts_with($returnTo, '/')) $returnTo = '/';
|
||||
|
||||
header('Location: ' . $returnTo);
|
||||
exit;
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/Auth.php';
|
||||
|
||||
$returnTo = $_GET['return_to'] ?? '/';
|
||||
if (!is_string($returnTo) || !str_starts_with($returnTo, '/')) {
|
||||
$returnTo = '/';
|
||||
}
|
||||
|
||||
header('Location: ' . Auth::buildLoginUrl($returnTo));
|
||||
exit;
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/Auth.php';
|
||||
|
||||
Auth::logout();
|
||||
header('Location: /');
|
||||
exit;
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../src/Auth.php';
|
||||
require_once __DIR__ . '/../../src/Db.php';
|
||||
|
||||
if (($_GET['action'] ?? '') === 'logout') {
|
||||
header('Location: /auth/logout.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
Auth::requireAuth();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
|
||||
|
||||
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'] ?? '';
|
||||
$user = Auth::user();
|
||||
$pdo = Db::pdo();
|
||||
|
||||
try {
|
||||
if ($method === 'GET' && $action === 'list') {
|
||||
$rows = $pdo->query('SELECT * FROM escrituras_tramites ORDER BY id DESC')->fetchAll();
|
||||
ok($rows);
|
||||
}
|
||||
|
||||
if ($method === 'POST' && $action === 'create') {
|
||||
$b = body();
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO escrituras_tramites
|
||||
(tramite, cliente, abogado, notaria, inscribe, altera_turno, instrucciones,
|
||||
fecha_solicitud, estatus, observaciones, creado_por)
|
||||
VALUES
|
||||
(:tramite, :cliente, :abogado, :notaria, :inscribe, :altera, :instrucciones,
|
||||
NOW(), :estatus, :obs, :user)'
|
||||
);
|
||||
$stmt->execute([
|
||||
':tramite' => $b['tramite'] ?? '',
|
||||
':cliente' => $b['cliente'] ?? '',
|
||||
':abogado' => $b['abogado'] ?? '',
|
||||
':notaria' => $b['notaria'] ?? '',
|
||||
':inscribe' => $b['inscribe'] ?? 'Sí',
|
||||
':altera' => $b['altera_turno'] ?? 'No',
|
||||
':instrucciones' => $b['instrucciones'] ?? '',
|
||||
':estatus' => $b['estatus'] ?? 'Solicitado',
|
||||
':obs' => $b['observaciones'] ?? '',
|
||||
':user' => $user['email'],
|
||||
]);
|
||||
$id = (int) $pdo->lastInsertId();
|
||||
|
||||
$pdo->prepare(
|
||||
"INSERT INTO escrituras_historial
|
||||
(tramite_id, modificado_por, campo, valor_anterior, valor_nuevo)
|
||||
VALUES (?, ?, 'Trámite', '', 'Creado')"
|
||||
)->execute([$id, $user['email']]);
|
||||
|
||||
$row = $pdo->prepare('SELECT * FROM escrituras_tramites WHERE id = :id');
|
||||
$row->execute([':id' => $id]);
|
||||
ok($row->fetch());
|
||||
}
|
||||
|
||||
if ($method === 'PUT' && $action === 'update') {
|
||||
$b = body();
|
||||
$id = (int) ($b['id'] ?? 0);
|
||||
if (!$id) err('ID requerido');
|
||||
|
||||
$beforeStmt = $pdo->prepare('SELECT * FROM escrituras_tramites WHERE id = :id');
|
||||
$beforeStmt->execute([':id' => $id]);
|
||||
$before = $beforeStmt->fetch();
|
||||
if (!$before) err('Trámite no encontrado', 404);
|
||||
|
||||
$fields = [
|
||||
'tramite' => $b['tramite'] ?? '',
|
||||
'cliente' => $b['cliente'] ?? '',
|
||||
'abogado' => $b['abogado'] ?? '',
|
||||
'notaria' => $b['notaria'] ?? '',
|
||||
'inscribe' => $b['inscribe'] ?? 'Sí',
|
||||
'altera_turno' => $b['altera_turno'] ?? 'No',
|
||||
'instrucciones' => $b['instrucciones'] ?? '',
|
||||
'estatus' => $b['estatus'] ?? 'Solicitado',
|
||||
'observaciones' => $b['observaciones'] ?? '',
|
||||
];
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE escrituras_tramites SET
|
||||
tramite=:tramite, cliente=:cliente, abogado=:abogado, notaria=:notaria,
|
||||
inscribe=:inscribe, altera_turno=:altera_turno, instrucciones=:instrucciones,
|
||||
estatus=:estatus, observaciones=:observaciones,
|
||||
actualizado_por=:actualizado_por
|
||||
WHERE id=:id'
|
||||
);
|
||||
$stmt->execute(array_merge(
|
||||
array_combine(
|
||||
array_map(fn($k) => ':' . $k, array_keys($fields)),
|
||||
array_values($fields)
|
||||
),
|
||||
[':actualizado_por' => $user['email'], ':id' => $id]
|
||||
));
|
||||
|
||||
$labels = [
|
||||
'tramite' => 'Nombre del Trámite',
|
||||
'cliente' => 'Cliente',
|
||||
'abogado' => 'Abogado Solicitante',
|
||||
'notaria' => 'Notaría',
|
||||
'inscribe' => 'Se Inscribe',
|
||||
'altera_turno' => 'Alt. de Turno',
|
||||
'instrucciones' => 'Instrucciones Especiales',
|
||||
'estatus' => 'Estatus',
|
||||
'observaciones' => 'Observaciones',
|
||||
];
|
||||
|
||||
$hist = $pdo->prepare(
|
||||
'INSERT INTO escrituras_historial
|
||||
(tramite_id, modificado_por, campo, valor_anterior, valor_nuevo)
|
||||
VALUES (:tid, :por, :campo, :ant, :nvo)'
|
||||
);
|
||||
foreach ($fields as $campo => $nuevo) {
|
||||
$anterior = $before[$campo] ?? '';
|
||||
if ((string) $anterior !== (string) $nuevo) {
|
||||
$hist->execute([
|
||||
':tid' => $id,
|
||||
':por' => $user['email'],
|
||||
':campo' => $labels[$campo] ?? $campo,
|
||||
':ant' => $anterior,
|
||||
':nvo' => $nuevo,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$row = $pdo->prepare('SELECT * FROM escrituras_tramites WHERE id = :id');
|
||||
$row->execute([':id' => $id]);
|
||||
ok($row->fetch());
|
||||
}
|
||||
|
||||
if ($method === 'DELETE' && $action === 'delete') {
|
||||
$id = (int) ($_GET['id'] ?? 0);
|
||||
if (!$id) err('ID requerido');
|
||||
$pdo->prepare('DELETE FROM escrituras_tramites WHERE id = :id')->execute([':id' => $id]);
|
||||
ok(['deleted' => $id]);
|
||||
}
|
||||
|
||||
if ($method === 'GET' && $action === 'historial') {
|
||||
$tid = (int) ($_GET['id'] ?? 0);
|
||||
if (!$tid) err('ID requerido');
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT * FROM escrituras_historial
|
||||
WHERE tramite_id = :tid
|
||||
ORDER BY modificado_en DESC
|
||||
LIMIT 200'
|
||||
);
|
||||
$stmt->execute([':tid' => $tid]);
|
||||
ok($stmt->fetchAll());
|
||||
}
|
||||
|
||||
err('Acción no reconocida', 404);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
error_log('escrituras db error: ' . $e->getMessage());
|
||||
err('Error de base de datos', 500);
|
||||
} catch (Throwable $e) {
|
||||
error_log('escrituras error: ' . $e->getMessage());
|
||||
err('Error interno', 500);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/Auth.php';
|
||||
Auth::requireAuth();
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
readfile(__DIR__ . '/../../views/escrituras.html');
|
||||
@@ -0,0 +1,245 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../src/Auth.php';
|
||||
require_once __DIR__ . '/../src/Apps.php';
|
||||
|
||||
$authed = Auth::isAuthenticated();
|
||||
$user = $authed ? Auth::user() : ['email' => '', 'name' => ''];
|
||||
$apps = $authed ? Apps::catalog() : [];
|
||||
|
||||
function initials(string $name): string {
|
||||
$parts = preg_split('/\s+/u', trim($name));
|
||||
$a = mb_substr($parts[0] ?? '', 0, 1);
|
||||
$b = mb_substr($parts[1] ?? '', 0, 1);
|
||||
return strtoupper($a . $b) ?: 'AA';
|
||||
}
|
||||
|
||||
function icon(string $name): string {
|
||||
$svgs = [
|
||||
'document' => '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="15" y2="17"/>',
|
||||
'briefcase' => '<rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/>',
|
||||
'calendar' => '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
|
||||
'users' => '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
];
|
||||
return $svgs[$name] ?? $svgs['document'];
|
||||
}
|
||||
?><!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= $authed ? 'Donna · AALN Portal' : 'Donna · AALN Law' ?></title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
||||
:root{
|
||||
--navy:#1B3A6B;
|
||||
--navy-d:#112446;
|
||||
--navy-l:#2D5090;
|
||||
--gold:#C9A84C;
|
||||
--gold-l:#E8C96A;
|
||||
--gold-d:#9E7C2E;
|
||||
--off:#F7F6F3;
|
||||
--gray-100:#EEEDE9;
|
||||
--gray-200:#D6D4CC;
|
||||
--gray-400:#9B9A96;
|
||||
--gray-700:#4A4946;
|
||||
--gray-900:#1A1916;
|
||||
--shadow:0 1px 3px rgba(0,0,0,0.06),0 4px 16px rgba(0,0,0,0.06);
|
||||
--shadow-lg:0 12px 40px rgba(17,36,70,0.18);
|
||||
}
|
||||
html,body{min-height:100%;}
|
||||
body{font-family:'DM Sans',system-ui,sans-serif;color:var(--gray-900);background:var(--off);font-size:15px;-webkit-font-smoothing:antialiased;}
|
||||
a{color:inherit;text-decoration:none;}
|
||||
|
||||
/* ───────── LANDING (anonymous) ───────── */
|
||||
.landing{min-height:100vh;display:flex;flex-direction:column;background:linear-gradient(135deg,#0E1F3F 0%,#1B3A6B 55%,#2D5090 100%);color:#fff;position:relative;overflow:hidden;}
|
||||
.landing::before{content:'';position:absolute;inset:0;background:radial-gradient(ellipse 60% 50% at 80% 10%,rgba(201,168,76,0.22),transparent 60%),radial-gradient(ellipse 40% 40% at 10% 90%,rgba(201,168,76,0.12),transparent 60%);pointer-events:none;}
|
||||
.landing-nav{padding:28px 48px;display:flex;align-items:center;justify-content:space-between;position:relative;z-index:2;}
|
||||
.brand{display:flex;align-items:center;gap:14px;}
|
||||
.brand-mark{width:42px;height:42px;background:var(--gold);border-radius:8px;display:flex;align-items:center;justify-content:center;font-family:'DM Serif Display',serif;font-size:20px;color:var(--navy);}
|
||||
.brand-text{display:flex;flex-direction:column;line-height:1.15;}
|
||||
.brand-firm{font-size:11px;font-weight:600;color:var(--gold);letter-spacing:.18em;text-transform:uppercase;}
|
||||
.brand-firm a{color:inherit;border-bottom:1px dotted transparent;transition:border-color .2s;}
|
||||
.brand-firm a:hover{border-color:var(--gold);}
|
||||
.brand-name{font-family:'DM Serif Display',serif;font-size:22px;color:#fff;}
|
||||
.landing-foot-link{font-size:12px;color:rgba(255,255,255,0.55);letter-spacing:.06em;}
|
||||
.landing-foot-link a{color:rgba(255,255,255,0.8);border-bottom:1px solid rgba(255,255,255,0.2);padding-bottom:1px;}
|
||||
.landing-foot-link a:hover{color:var(--gold);border-color:var(--gold);}
|
||||
|
||||
.hero{flex:1;display:grid;grid-template-columns:1.1fr 1fr;gap:48px;padding:40px 80px 80px;align-items:center;position:relative;z-index:2;}
|
||||
@media (max-width:980px){.hero{grid-template-columns:1fr;padding:24px 32px 48px;}}
|
||||
.hero-text{max-width:560px;}
|
||||
.hero-eyebrow{font-size:12px;font-weight:600;color:var(--gold);letter-spacing:.22em;text-transform:uppercase;margin-bottom:24px;display:flex;align-items:center;gap:12px;}
|
||||
.hero-eyebrow::before{content:'';width:32px;height:1px;background:var(--gold);display:inline-block;}
|
||||
.hero-title{font-family:'DM Serif Display',serif;font-size:clamp(40px,5.6vw,68px);line-height:1.05;color:#fff;margin-bottom:24px;font-weight:400;}
|
||||
.hero-title em{font-style:italic;color:var(--gold-l);}
|
||||
.hero-sub{font-size:17px;line-height:1.6;color:rgba(255,255,255,0.78);margin-bottom:36px;font-weight:300;max-width:520px;}
|
||||
.btn-login{display:inline-flex;align-items:center;gap:12px;background:var(--gold);color:var(--navy-d);padding:16px 32px;border-radius:10px;font-weight:600;font-size:15px;letter-spacing:.02em;border:none;cursor:pointer;transition:transform .15s,box-shadow .15s,background .15s;box-shadow:0 6px 18px rgba(201,168,76,0.35);}
|
||||
.btn-login:hover{background:var(--gold-l);transform:translateY(-1px);box-shadow:0 10px 24px rgba(201,168,76,0.42);}
|
||||
.btn-login:active{transform:translateY(0);}
|
||||
.btn-login svg{width:18px;height:18px;}
|
||||
.login-meta{margin-top:18px;font-size:12px;color:rgba(255,255,255,0.5);letter-spacing:.04em;}
|
||||
|
||||
.hero-art{position:relative;height:420px;display:flex;align-items:center;justify-content:center;}
|
||||
@media (max-width:980px){.hero-art{display:none;}}
|
||||
.card-stack{position:relative;width:100%;max-width:420px;}
|
||||
.art-card{position:absolute;background:rgba(255,255,255,0.06);border:1px solid rgba(201,168,76,0.25);backdrop-filter:blur(14px);border-radius:14px;padding:22px;color:#fff;box-shadow:var(--shadow-lg);}
|
||||
.art-card-1{top:0;left:0;width:280px;transform:rotate(-3deg);}
|
||||
.art-card-2{top:90px;right:0;width:300px;transform:rotate(2deg);}
|
||||
.art-card-3{bottom:0;left:40px;width:280px;transform:rotate(-1deg);}
|
||||
.art-card-eyebrow{font-size:9.5px;color:var(--gold);letter-spacing:.16em;text-transform:uppercase;font-weight:600;margin-bottom:10px;}
|
||||
.art-card h4{font-family:'DM Serif Display',serif;font-size:16px;font-weight:400;line-height:1.3;margin-bottom:8px;color:#fff;}
|
||||
.art-card p{font-size:12px;color:rgba(255,255,255,0.65);line-height:1.5;}
|
||||
.art-card .pill{display:inline-block;font-size:10px;font-weight:600;padding:2px 8px;border-radius:20px;background:rgba(201,168,76,0.2);color:var(--gold-l);margin-top:8px;letter-spacing:.04em;}
|
||||
|
||||
/* ───────── DASHBOARD (authenticated) ───────── */
|
||||
.dash-header{background:var(--navy);border-bottom:3px solid var(--gold);padding:0 32px;display:flex;align-items:center;justify-content:space-between;height:64px;position:sticky;top:0;z-index:200;}
|
||||
.dash-header .brand-mark{width:36px;height:36px;font-size:16px;}
|
||||
.dash-header .brand-name{font-size:18px;}
|
||||
.user-chip{display:flex;align-items:center;gap:10px;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.12);border-radius:24px;padding:5px 6px 5px 6px;color:#fff;}
|
||||
.user-avatar{width:30px;height:30px;border-radius:50%;background:var(--gold);color:var(--navy-d);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;flex-shrink:0;}
|
||||
.user-info{display:flex;flex-direction:column;line-height:1.15;padding-right:6px;}
|
||||
.user-name{font-size:12.5px;font-weight:500;color:#fff;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.user-email{font-size:10.5px;color:rgba(255,255,255,0.55);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.btn-logout{font-size:11px;padding:6px 12px;border:1px solid rgba(255,255,255,0.18);border-radius:18px;background:transparent;color:rgba(255,255,255,0.7);cursor:pointer;font-family:'DM Sans',sans-serif;transition:all .15s;}
|
||||
.btn-logout:hover{background:rgba(255,255,255,0.12);color:#fff;border-color:rgba(255,255,255,0.3);}
|
||||
|
||||
.dash-main{max-width:1180px;margin:0 auto;padding:48px 32px 80px;}
|
||||
.dash-hero{margin-bottom:40px;}
|
||||
.dash-greeting{font-family:'DM Serif Display',serif;font-size:36px;color:var(--navy);font-weight:400;line-height:1.15;margin-bottom:8px;}
|
||||
.dash-greeting em{color:var(--gold-d);font-style:italic;}
|
||||
.dash-sub{font-size:15px;color:var(--gray-700);max-width:560px;line-height:1.55;}
|
||||
|
||||
.section-label{font-size:11px;font-weight:600;color:var(--gray-400);letter-spacing:.18em;text-transform:uppercase;margin-bottom:18px;display:flex;align-items:center;gap:14px;}
|
||||
.section-label::after{content:'';flex:1;height:1px;background:var(--gray-200);}
|
||||
|
||||
.app-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:20px;}
|
||||
.app-card{background:#fff;border:1px solid var(--gray-100);border-radius:14px;padding:24px;display:flex;flex-direction:column;gap:14px;transition:all .2s;position:relative;box-shadow:var(--shadow);cursor:pointer;color:inherit;min-height:200px;}
|
||||
.app-card.live:hover{transform:translateY(-3px);box-shadow:0 12px 32px rgba(17,36,70,0.10);border-color:var(--navy-l);}
|
||||
.app-card.coming-soon{cursor:default;opacity:0.7;}
|
||||
.app-card.coming-soon:hover{transform:none;}
|
||||
.app-icon{width:48px;height:48px;border-radius:10px;background:rgba(27,58,107,0.08);color:var(--navy);display:flex;align-items:center;justify-content:center;}
|
||||
.app-card.live .app-icon{background:linear-gradient(135deg,var(--navy) 0%,var(--navy-l) 100%);color:var(--gold-l);}
|
||||
.app-icon svg{width:24px;height:24px;}
|
||||
.app-title{font-family:'DM Serif Display',serif;font-size:22px;color:var(--navy);font-weight:400;line-height:1.2;}
|
||||
.app-desc{font-size:13.5px;color:var(--gray-700);line-height:1.55;flex:1;}
|
||||
.app-action{font-size:12px;font-weight:600;color:var(--gold-d);letter-spacing:.06em;text-transform:uppercase;display:flex;align-items:center;gap:6px;}
|
||||
.app-action svg{width:14px;height:14px;transition:transform .2s;}
|
||||
.app-card.live:hover .app-action svg{transform:translateX(4px);}
|
||||
.badge-soon{position:absolute;top:18px;right:18px;font-size:10px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;padding:3px 10px;border-radius:20px;background:var(--gray-100);color:var(--gray-700);}
|
||||
|
||||
.dash-foot{text-align:center;padding:24px;font-size:11px;color:var(--gray-400);}
|
||||
.dash-foot a{color:var(--navy);border-bottom:1px dotted var(--gray-400);}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<?php if (!$authed): ?>
|
||||
<div class="landing">
|
||||
<nav class="landing-nav">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">D</div>
|
||||
<div class="brand-text">
|
||||
<span class="brand-firm"><a href="https://www.aalnlaw.com" target="_blank" rel="noopener">AALN · Arias, Abrego, López & Noriega</a></span>
|
||||
<span class="brand-name">Donna</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="landing-foot-link">
|
||||
Portal interno · <a href="https://www.aalnlaw.com" target="_blank" rel="noopener">aalnlaw.com</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<section class="hero">
|
||||
<div class="hero-text">
|
||||
<div class="hero-eyebrow">Plataforma interna AALN</div>
|
||||
<h1 class="hero-title">Tu equipo de trabajo, <em>siempre un paso adelante</em>.</h1>
|
||||
<p class="hero-sub">Donna es el portal interno de AALN: control de escrituras, expedientes, agenda y herramientas internas en un solo lugar. Inicia sesión con tu correo corporativo.</p>
|
||||
<form method="get" action="/auth/login.php">
|
||||
<button class="btn-login" type="submit">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||
Iniciar sesión con correo corporativo
|
||||
</button>
|
||||
</form>
|
||||
<div class="login-meta">Autenticación segura vía Mailcow · mail.aal-law.com</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-art" aria-hidden="true">
|
||||
<div class="card-stack">
|
||||
<div class="art-card art-card-1">
|
||||
<div class="art-card-eyebrow">Trámite #1284</div>
|
||||
<h4>Constitución sociedad anónima</h4>
|
||||
<p>Notaría Quinta · Lic. R. Abrego</p>
|
||||
<span class="pill">En Registro</span>
|
||||
</div>
|
||||
<div class="art-card art-card-2">
|
||||
<div class="art-card-eyebrow">Hoy · 14:30</div>
|
||||
<h4>Audiencia preliminar</h4>
|
||||
<p>Juzgado Primero Civil · Sala 3</p>
|
||||
<span class="pill">Confirmada</span>
|
||||
</div>
|
||||
<div class="art-card art-card-3">
|
||||
<div class="art-card-eyebrow">Cliente nuevo</div>
|
||||
<h4>Tech Holdings, S.A.</h4>
|
||||
<p>KYC pendiente · Asignado a M. Noriega</p>
|
||||
<span class="pill">Onboarding</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<?php else: ?>
|
||||
<header class="dash-header">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">D</div>
|
||||
<div class="brand-text">
|
||||
<span class="brand-firm" style="color:var(--gold);">AALN · Donna</span>
|
||||
<span class="brand-name" style="font-size:14px;color:rgba(255,255,255,0.7);">Portal interno</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-chip">
|
||||
<div class="user-avatar"><?= htmlspecialchars(initials($user['name'] ?: $user['email'])) ?></div>
|
||||
<div class="user-info">
|
||||
<span class="user-name"><?= htmlspecialchars($user['name'] ?: $user['email']) ?></span>
|
||||
<?php if ($user['name']): ?><span class="user-email"><?= htmlspecialchars($user['email']) ?></span><?php endif; ?>
|
||||
</div>
|
||||
<a href="/auth/logout.php" class="btn-logout">Cerrar sesión</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="dash-main">
|
||||
<div class="dash-hero">
|
||||
<h1 class="dash-greeting">Hola, <em><?= htmlspecialchars(explode(' ', $user['name'] ?: $user['email'])[0]) ?></em>.</h1>
|
||||
<p class="dash-sub">Bienvenido a Donna. Elige una herramienta para empezar.</p>
|
||||
</div>
|
||||
|
||||
<div class="section-label">Aplicaciones disponibles</div>
|
||||
<div class="app-grid">
|
||||
<?php foreach ($apps as $app): ?>
|
||||
<?php $isLive = $app['status'] === 'live'; ?>
|
||||
<a class="app-card <?= $isLive ? 'live' : 'coming-soon' ?>" href="<?= htmlspecialchars($app['href']) ?>"<?= $isLive ? '' : ' onclick="return false"' ?>>
|
||||
<?php if (!$isLive): ?><span class="badge-soon">Próximamente</span><?php endif; ?>
|
||||
<div class="app-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><?= icon($app['icon']) ?></svg>
|
||||
</div>
|
||||
<div class="app-title"><?= htmlspecialchars($app['title']) ?></div>
|
||||
<div class="app-desc"><?= htmlspecialchars($app['description']) ?></div>
|
||||
<?php if ($isLive): ?>
|
||||
<div class="app-action">
|
||||
Abrir
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="dash-foot">
|
||||
Donna · AALN Law — <a href="https://www.aalnlaw.com" target="_blank" rel="noopener">aalnlaw.com</a>
|
||||
</footer>
|
||||
<?php endif; ?>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
-- ============================================================
|
||||
-- Donna Portal — Schema 001: Core
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_email (email)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,51 @@
|
||||
-- ============================================================
|
||||
-- Donna Portal — Schema 002: Control de Escrituras
|
||||
-- (migrado del repo ZH-Consultores/control-escrituras)
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS escrituras_tramites (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tramite VARCHAR(255) NOT NULL DEFAULT '',
|
||||
cliente VARCHAR(255) NOT NULL DEFAULT '',
|
||||
abogado VARCHAR(100) NOT NULL DEFAULT '',
|
||||
notaria VARCHAR(100) NOT NULL DEFAULT '',
|
||||
inscribe ENUM('Sí','No') NOT NULL DEFAULT 'Sí',
|
||||
altera_turno ENUM('Sí','No') NOT NULL DEFAULT 'No',
|
||||
instrucciones TEXT NOT NULL,
|
||||
fecha_solicitud DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
estatus ENUM(
|
||||
'Solicitado',
|
||||
'Recibido',
|
||||
'En Proceso',
|
||||
'Protocolizado',
|
||||
'En Registro',
|
||||
'Inscrito'
|
||||
) NOT NULL DEFAULT 'Solicitado',
|
||||
ultima_actualizacion DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
ON UPDATE CURRENT_TIMESTAMP,
|
||||
observaciones TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
creado_por VARCHAR(255) NOT NULL DEFAULT '',
|
||||
actualizado_por VARCHAR(255) NOT NULL DEFAULT '',
|
||||
|
||||
INDEX idx_estatus (estatus),
|
||||
INDEX idx_abogado (abogado),
|
||||
INDEX idx_inscribe (inscribe),
|
||||
INDEX idx_altera (altera_turno),
|
||||
INDEX idx_fecha (fecha_solicitud DESC)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS escrituras_historial (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tramite_id INT NOT NULL,
|
||||
modificado_por VARCHAR(255) NOT NULL DEFAULT '',
|
||||
modificado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
campo VARCHAR(100) NOT NULL DEFAULT '',
|
||||
valor_anterior TEXT NOT NULL,
|
||||
valor_nuevo TEXT NOT NULL,
|
||||
|
||||
INDEX idx_tramite (tramite_id),
|
||||
INDEX idx_fecha (modificado_en DESC),
|
||||
CONSTRAINT fk_escrituras_historial_tramite
|
||||
FOREIGN KEY (tramite_id) REFERENCES escrituras_tramites(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class Apps {
|
||||
public static function catalog(): array {
|
||||
return [
|
||||
[
|
||||
'slug' => 'escrituras',
|
||||
'title' => 'Control de Escrituras',
|
||||
'description' => 'Seguimiento de trámites notariales: estado, abogado, notaría, historial y auditoría.',
|
||||
'icon' => 'document',
|
||||
'href' => '/escrituras/',
|
||||
'status' => 'live',
|
||||
],
|
||||
[
|
||||
'slug' => 'casos',
|
||||
'title' => 'Gestión de Casos',
|
||||
'description' => 'Expedientes, plazos procesales y notas de cada asunto.',
|
||||
'icon' => 'briefcase',
|
||||
'href' => '#',
|
||||
'status' => 'coming-soon',
|
||||
],
|
||||
[
|
||||
'slug' => 'agenda',
|
||||
'title' => 'Agenda',
|
||||
'description' => 'Calendario compartido de audiencias, reuniones y vencimientos.',
|
||||
'icon' => 'calendar',
|
||||
'href' => '#',
|
||||
'status' => 'coming-soon',
|
||||
],
|
||||
[
|
||||
'slug' => 'clientes',
|
||||
'title' => 'Directorio de Clientes',
|
||||
'description' => 'Datos de contacto, sociedades vinculadas y matriz de KYC.',
|
||||
'icon' => 'users',
|
||||
'href' => '#',
|
||||
'status' => 'coming-soon',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/Config.php';
|
||||
require_once __DIR__ . '/Db.php';
|
||||
|
||||
final class Auth {
|
||||
public static function startSession(): void {
|
||||
if (session_status() === PHP_SESSION_ACTIVE) return;
|
||||
|
||||
$secure = ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'
|
||||
|| (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||||
|
||||
session_name('donna_session');
|
||||
session_set_cookie_params([
|
||||
'lifetime' => Config::int('SESSION_LIFETIME', 28800),
|
||||
'path' => '/',
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
session_start();
|
||||
}
|
||||
|
||||
public static function isAuthenticated(): bool {
|
||||
self::startSession();
|
||||
return !empty($_SESSION['authenticated'])
|
||||
&& !empty($_SESSION['auth_time'])
|
||||
&& (time() - (int)$_SESSION['auth_time']) < Config::int('SESSION_LIFETIME', 28800);
|
||||
}
|
||||
|
||||
public static function user(): array {
|
||||
self::startSession();
|
||||
return [
|
||||
'email' => $_SESSION['user_email'] ?? '',
|
||||
'name' => $_SESSION['user_name'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
public static function buildLoginUrl(?string $returnTo = null): string {
|
||||
self::startSession();
|
||||
$state = bin2hex(random_bytes(16));
|
||||
$_SESSION['oauth_state'] = $state;
|
||||
$_SESSION['oauth_return_to'] = $returnTo ?: ($_SERVER['REQUEST_URI'] ?? '/');
|
||||
|
||||
$params = http_build_query([
|
||||
'response_type' => 'code',
|
||||
'client_id' => Config::get('OAUTH_CLIENT_ID'),
|
||||
'redirect_uri' => Config::get('OAUTH_REDIRECT_URI'),
|
||||
'scope' => Config::get('OAUTH_SCOPE', 'profile'),
|
||||
'state' => $state,
|
||||
]);
|
||||
return Config::get('OAUTH_AUTH_URL') . '?' . $params;
|
||||
}
|
||||
|
||||
public static function requireAuth(): void {
|
||||
if (self::isAuthenticated()) return;
|
||||
$loginUrl = self::buildLoginUrl();
|
||||
$accepts = $_SERVER['HTTP_ACCEPT'] ?? '';
|
||||
$isAjax = !empty($_SERVER['HTTP_X_REQUESTED_WITH'])
|
||||
|| str_contains($accepts, 'application/json');
|
||||
if ($isAjax) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
http_response_code(401);
|
||||
echo json_encode(['ok' => false, 'auth_required' => true, 'login_url' => $loginUrl]);
|
||||
exit;
|
||||
}
|
||||
header('Location: ' . $loginUrl);
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function emailDomainAllowed(string $email): bool {
|
||||
$allowed = Config::list('ALLOWED_EMAIL_DOMAINS');
|
||||
if (!$allowed) return true;
|
||||
$parts = explode('@', strtolower(trim($email)));
|
||||
if (count($parts) !== 2) return false;
|
||||
return in_array($parts[1], array_map('strtolower', $allowed), true);
|
||||
}
|
||||
|
||||
public static function completeLogin(string $email, string $name): void {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
public static function logout(): void {
|
||||
self::startSession();
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$p = session_get_cookie_params();
|
||||
setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'] ?? '', $p['secure'], $p['httponly']);
|
||||
}
|
||||
session_destroy();
|
||||
}
|
||||
|
||||
public static function popReturnTo(): string {
|
||||
self::startSession();
|
||||
$to = $_SESSION['oauth_return_to'] ?? '/';
|
||||
unset($_SESSION['oauth_return_to']);
|
||||
return $to;
|
||||
}
|
||||
|
||||
public static function checkState(string $state): bool {
|
||||
self::startSession();
|
||||
$expected = $_SESSION['oauth_state'] ?? '';
|
||||
unset($_SESSION['oauth_state']);
|
||||
return $state !== '' && hash_equals((string)$expected, $state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class Config {
|
||||
private static ?array $env = null;
|
||||
|
||||
public static function load(string $path = '/etc/donna/donna.env'): void {
|
||||
if (self::$env !== null) return;
|
||||
if (!is_readable($path)) {
|
||||
http_response_code(500);
|
||||
exit('Config no disponible.');
|
||||
}
|
||||
$vars = parse_ini_file($path, false, INI_SCANNER_RAW);
|
||||
if ($vars === false) {
|
||||
http_response_code(500);
|
||||
exit('Config inválida.');
|
||||
}
|
||||
self::$env = $vars;
|
||||
}
|
||||
|
||||
public static function get(string $key, ?string $default = null): string {
|
||||
self::load();
|
||||
return self::$env[$key] ?? $default ?? '';
|
||||
}
|
||||
|
||||
public static function int(string $key, int $default = 0): int {
|
||||
return (int) self::get($key, (string) $default);
|
||||
}
|
||||
|
||||
public static function list(string $key): array {
|
||||
$v = self::get($key);
|
||||
return $v === '' ? [] : array_map('trim', explode(',', $v));
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/Config.php';
|
||||
|
||||
final class Db {
|
||||
private static ?PDO $pdo = null;
|
||||
|
||||
public static function pdo(): PDO {
|
||||
if (self::$pdo !== null) return self::$pdo;
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
|
||||
Config::get('DB_HOST', '127.0.0.1'),
|
||||
Config::get('DB_PORT', '3306'),
|
||||
Config::get('DB_NAME')
|
||||
);
|
||||
self::$pdo = new PDO($dsn, Config::get('DB_USER'), Config::get('DB_PASS'), [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
return self::$pdo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/Config.php';
|
||||
|
||||
final class Oauth {
|
||||
public static function exchangeCodeForToken(string $code): array {
|
||||
return self::httpPost(Config::get('OAUTH_TOKEN_URL'), [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => Config::get('OAUTH_REDIRECT_URI'),
|
||||
'client_id' => Config::get('OAUTH_CLIENT_ID'),
|
||||
'client_secret' => Config::get('OAUTH_CLIENT_SECRET'),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function fetchProfile(string $accessToken): array {
|
||||
return self::httpGetBearer(Config::get('OAUTH_PROFILE_URL'), $accessToken);
|
||||
}
|
||||
|
||||
private static function httpPost(string $url, array $form): array {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query($form),
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
if ($err || $body === false) {
|
||||
error_log('oauth POST failed: ' . $err);
|
||||
return [];
|
||||
}
|
||||
return json_decode((string)$body, true) ?? [];
|
||||
}
|
||||
|
||||
private static function httpGetBearer(string $url, string $token): array {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer ' . $token,
|
||||
'Accept: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
if ($err || $body === false) {
|
||||
error_log('oauth GET failed: ' . $err);
|
||||
return [];
|
||||
}
|
||||
return json_decode((string)$body, true) ?? [];
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user