diff --git a/README.md b/README.md index 5abd0229..82953300 100644 --- a/README.md +++ b/README.md @@ -24,15 +24,51 @@ Full documentation lives in [`docs/`](docs/general/README.md): - [Blacklist](docs/blacklist/README.md) - [Conciliation](docs/conciliation/README.md) -## Local development +## Try it + +A public demo runs on the project site: **Try our live demo** on the login +page signs you in as an administrator, with fictitious data that resets every +day. User management and settings are locked there. To see everything, run it +locally. + +## Run it locally + +You need Node ≥ 22.16, pnpm, Python 3.11+ (for the scheduling solver) and +MySQL 8, either installed or through Docker. `frontend/` and `backend/` are +independent pnpm projects, each with its own lockfile. ```bash -cd backend && pnpm install && pnpm dev:aiven # API against Aiven; frontend looks for it on :4000 -cd frontend && pnpm install && pnpm dev # http://localhost:3000 +# 1. Settings: copy and fill in (LOCAL_DB_USER=root, a LOCAL_DB_PASSWORD of +# your choice, a long random SECRET_JWT_KEY) +cp backend/.env.example backend/.env +cp frontend/.env.example frontend/.env.local + +# 2. MySQL in Docker (skip it if you already run MySQL 8; if that one uses +# port 3306, set LOCAL_DB_PORT=3307 for Docker) +docker compose --env-file backend/.env up -d + +# 3. Database: tables, departments, the local admin and fictitious data. +# It creates hotel_db and refuses to touch one that already has tables +# (add -- --force to replace it) +cd backend && pnpm install && pnpm setup:local + +# 4. Scheduling solver (once) +cd scheduling-solver && python -m venv venv +venv/Scripts/pip install -r requirements.txt # Linux/macOS: venv/bin/pip +cd .. + +# 5. Run +pnpm dev:local # API on :4000 +cd ../frontend && pnpm install && pnpm dev # http://localhost:3000 ``` -Node ≥ 22.16, pnpm. `frontend/` and `backend/` are independent pnpm projects, -each with its own lockfile. +Sign in as **`admin`** / **`fourpoints-local`**: a full administrator, +users, roles and settings included. The password is public on purpose: +`setup:local` only runs against a local database, so this account never +exists anywhere else. Change it in Profile if you like. + +Files (photos, PDFs) need a Cloudinary account in `backend/.env`; without it +everything else works. ## License diff --git a/backend/.env.example b/backend/.env.example index 4fd0135e..41aac9c0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -11,6 +11,8 @@ NODE_ENV=development LOG_LEVEL= # Extra allowed CORS origin, on top of the ones in index.ts (optional) FRONTEND_URL= +# Public demo: "true" enables POST /api/auth/demo (entry without password) +DEMO_MODE= # ============================= # Security @@ -20,9 +22,9 @@ SECRET_JWT_KEY=change-me-to-a-random-string-of-32-or-more-characters SALT_ROUNDS=10 # ============================= -# MySQL: which preset config/db.ts uses (local | aiven, default: local) +# MySQL: which preset config/db-config.ts uses (local | aiven, default: local) # ============================= -DB_ENVIRONMENT=aiven +DB_ENVIRONMENT=local # Local MySQL LOCAL_DB_HOST=localhost diff --git a/backend/config/cors.ts b/backend/config/cors.ts new file mode 100644 index 00000000..612a034f --- /dev/null +++ b/backend/config/cors.ts @@ -0,0 +1,55 @@ +// config/cors.ts +/** + * CORS, which is also the CSRF defence: a request whose Origin is not one of + * ours is answered 403 before any route runs, including simple requests that + * a browser sends without a preflight (a form POST, a multipart upload). + * Requests without Origin pass: browsers always send it on cross-site writes, + * and server-to-server calls or curl do not carry cookies from a victim. + */ + +import cors from 'cors' +import type { Request, Response, NextFunction, RequestHandler } from 'express' +import { logger } from './logger.js' + +export function allowedOrigins( + nodeEnv = process.env.NODE_ENV, + frontendUrl = process.env.FRONTEND_URL +): string[] { + return [ + ...(nodeEnv === 'production' ? [] : ['http://localhost:3000']), + 'https://four-points.stackbp.es', + 'https://four-points.vercel.app', + 'https://api.four-points.stackbp.es', + 'https://four-points.onrender.com', + frontendUrl, // URL adicional si es necesario + ].filter(Boolean) as string[] +} + +export function corsMiddleware(origins = allowedOrigins()): RequestHandler { + const handler = cors({ + origin: (origin, callback) => { + // Permitir requests sin origin (Postman, server-to-server, mobile apps) + if (!origin || origins.includes(origin)) { + callback(null, true) + return + } + logger.warn({ origin }, '[CORS] Blocked origin') + // Must stay an error: callback(null, false) would let simple requests reach the routes + callback(new Error('Origen no permitido')) + }, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'Cookie'], + exposedHeaders: ['Set-Cookie'], + }) + + return (req: Request, res: Response, next: NextFunction) => { + handler(req, res, (err?: unknown) => { + if (err) { + res.status(403).json({ error: 'Origen no permitido' }) + return + } + next() + }) + } +} diff --git a/backend/config/db-config.ts b/backend/config/db-config.ts new file mode 100644 index 00000000..51e4fd91 --- /dev/null +++ b/backend/config/db-config.ts @@ -0,0 +1,85 @@ +// config/db-config.ts +// Connection settings only, without opening anything: scripts that create the +// database (scripts/setup-local.ts) read them before it exists. config/db.ts +// opens the pool with them. + +import dotenv from 'dotenv' +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' + +dotenv.config() + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// Detectar entorno +const isDevelopment = process.env.NODE_ENV === 'development' + +// ======================================== +// 🔧 INTERFAZ DE CONFIGURACIÓN +// ======================================== +interface DBConfig { + // ← AÑADIDO + host: string + port: number + user: string + password: string | undefined + database: string + ssl?: any +} + +// ======================================== +// 🎯 PRESETS DE CONFIGURACIÓN +// ======================================== +const presets: Record = { + // ← MODIFICADO (añadido tipo) + local: { + host: process.env.LOCAL_DB_HOST || 'localhost', + port: parseInt(process.env.LOCAL_DB_PORT || '3306'), // ← MODIFICADO (añadido fallback) + user: process.env.LOCAL_DB_USER || 'root', + password: process.env.LOCAL_DB_PASSWORD, + database: process.env.LOCAL_DB_NAME || 'hotel_db', + }, + aiven: { + host: process.env.AIVEN_DB_HOST || '', // ← MODIFICADO (añadido fallback) + port: parseInt(process.env.AIVEN_DB_PORT || ''), + user: process.env.AIVEN_DB_USER || '', + password: process.env.AIVEN_PASSWORD, + database: process.env.AIVEN_DB_NAME || 'hotel_db', + ssl: isDevelopment + ? { rejectUnauthorized: false } + : { + ca: fs.readFileSync(path.join(__dirname, 'certs', 'ca-certificate.pem')), + rejectUnauthorized: true, + }, + }, +} + +// ======================================== +// 🔍 VALIDACIÓN Y SELECCIÓN DE ENTORNO +// ======================================== +export const environment = process.env.DB_ENVIRONMENT || 'local' +const config = presets[environment] + +// Validar que el entorno existe +if (!config) { + throw new Error(`❌ Entorno no válido: ${environment}`) +} + +// Validar credenciales críticas +if (!config.password) { + throw new Error( + `❌ Falta ${environment === 'local' ? 'LOCAL_DB_PASSWORD' : 'AIVEN_PASSWORD'} en .env` + ) +} + +if (environment === 'aiven' && !config.host) { + throw new Error('❌ Falta AIVEN_DB_HOST en .env') +} + +if (environment === 'aiven' && (!config.user || !Number.isInteger(config.port))) { + throw new Error('❌ Faltan AIVEN_DB_USER o AIVEN_DB_PORT en .env') +} + +export const dbConfig = config diff --git a/backend/config/db.ts b/backend/config/db.ts index 1d3979d0..fa1c0cff 100644 --- a/backend/config/db.ts +++ b/backend/config/db.ts @@ -3,86 +3,7 @@ import mysql from 'mysql2/promise' import type { Pool } from 'mysql2/promise' // ← AÑADIDO import { logger } from './logger.js' -import dotenv from 'dotenv' -import fs from 'fs' -import path from 'path' -import { fileURLToPath } from 'url' - -// Cargar variables de entorno -dotenv.config() - -// Para usar __dirname en ES modules -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -// Detectar entorno -const isDevelopment = process.env.NODE_ENV === 'development' - -// ======================================== -// 🔧 INTERFAZ DE CONFIGURACIÓN -// ======================================== -interface DBConfig { - // ← AÑADIDO - host: string - port: number - user: string - password: string | undefined - database: string - ssl?: any -} - -// ======================================== -// 🎯 PRESETS DE CONFIGURACIÓN -// ======================================== -const presets: Record = { - // ← MODIFICADO (añadido tipo) - local: { - host: process.env.LOCAL_DB_HOST || 'localhost', - port: parseInt(process.env.LOCAL_DB_PORT || '3306'), // ← MODIFICADO (añadido fallback) - user: process.env.LOCAL_DB_USER || 'root', - password: process.env.LOCAL_DB_PASSWORD, - database: process.env.LOCAL_DB_NAME || 'hotel_db', - }, - aiven: { - host: process.env.AIVEN_DB_HOST || '', // ← MODIFICADO (añadido fallback) - port: parseInt(process.env.AIVEN_DB_PORT || ''), - user: process.env.AIVEN_DB_USER || '', - password: process.env.AIVEN_PASSWORD, - database: process.env.AIVEN_DB_NAME || 'hotel_db', - ssl: isDevelopment - ? { rejectUnauthorized: false } - : { - ca: fs.readFileSync(path.join(__dirname, 'certs', 'ca-certificate.pem')), - rejectUnauthorized: true, - }, - }, -} - -// ======================================== -// 🔍 VALIDACIÓN Y SELECCIÓN DE ENTORNO -// ======================================== -const environment = process.env.DB_ENVIRONMENT || 'local' -const config = presets[environment] - -// Validar que el entorno existe -if (!config) { - throw new Error(`❌ Entorno no válido: ${environment}`) -} - -// Validar credenciales críticas -if (!config.password) { - throw new Error( - `❌ Falta ${environment === 'local' ? 'LOCAL_DB_PASSWORD' : 'AIVEN_PASSWORD'} en .env` - ) -} - -if (environment === 'aiven' && !config.host) { - throw new Error('❌ Falta AIVEN_DB_HOST en .env') -} - -if (environment === 'aiven' && (!config.user || !Number.isInteger(config.port))) { - throw new Error('❌ Faltan AIVEN_DB_USER o AIVEN_DB_PORT en .env') -} +import { dbConfig as config, environment } from './db-config.js' // ======================================== // 📊 LOG DE CONFIGURACIÓN (sin passwords) @@ -122,4 +43,7 @@ async function connectWithRetry(retries = 3, delayMs = 3000): Promise { connectWithRetry() +// For one-off connections with other options (services/demo: multipleStatements) +export { dbConfig } from './db-config.js' + export default pool diff --git a/backend/config/error-codes.ts b/backend/config/error-codes.ts index 901872bf..39d90afe 100644 --- a/backend/config/error-codes.ts +++ b/backend/config/error-codes.ts @@ -26,6 +26,7 @@ export const ERROR_CODES = { // ============================================================================ AUTH_CREDENTIALS_REQUIRED: 'AUTH_CREDENTIALS_REQUIRED', AUTH_INVALID_CREDENTIALS: 'AUTH_INVALID_CREDENTIALS', + AUTH_DEMO_UNAVAILABLE: 'AUTH_DEMO_UNAVAILABLE', AUTH_REFRESH_TOKEN_MISSING: 'AUTH_REFRESH_TOKEN_MISSING', AUTH_REFRESH_TOKEN_INVALID: 'AUTH_REFRESH_TOKEN_INVALID', AUTH_USER_NOT_FOUND: 'AUTH_USER_NOT_FOUND', diff --git a/backend/controllers/auth/auth-controllers.ts b/backend/controllers/auth/auth-controllers.ts index 1915b684..7a18d208 100644 --- a/backend/controllers/auth/auth-controllers.ts +++ b/backend/controllers/auth/auth-controllers.ts @@ -13,6 +13,7 @@ import { generateRefreshToken, verifyToken, } from '../../services/auth/tokenService.js' +import { resetIfStale } from '../../services/demo/demo-reset.service.js' import { CloudinaryService } from '../../services/blacklist/cloudinary-service.js' import { ERROR_CODES, SUCCESS_CODES } from '../../config/error-codes.js' import { logger } from '../../config/logger.js' @@ -86,6 +87,7 @@ export const login = async (req: Request, res: Response): Promise => { id: user.id, username: user.username, role: user.role, + demo: Boolean(user.is_demo), type: 'access', } @@ -121,6 +123,55 @@ export const login = async (req: Request, res: Response): Promise => { } } +/** + * Entrada a la demo pública, sin contraseña (ADR-038) + * Solo existe con DEMO_MODE=true; la cuenta demo la limita demoRestriction + */ +export const demoLogin = async (_req: Request, res: Response): Promise => { + if (process.env.DEMO_MODE !== 'true') { + res.status(404).json({ error: 'Ruta no encontrada' }) + return + } + + try { + const user = await UserRepository.getDemoUser() + if (!user) { + logger.error({ event: 'demo_login_failed' }, '[AUTH] no active demo account') + res.status(503).json({ + error: ERROR_CODES.AUTH_DEMO_UNAVAILABLE, + code: ERROR_CODES.AUTH_DEMO_UNAVAILABLE, + }) + return + } + + // First entry of the day: back to the mock data and the scheduling base + await resetIfStale() + + const tokenPayload: TokenPayload = { + id: user.id, + username: user.username, + role: user.role, + demo: true, + type: 'access', + } + + res.cookie('access_token', generateAccessToken(tokenPayload), { + ...cookieOptions, + maxAge: 15 * 60 * 1000, + }) + res.cookie('refresh_token', generateRefreshToken({ ...tokenPayload, type: 'refresh' }), { + ...cookieOptions, + maxAge: 7 * 24 * 60 * 60 * 1000, + }) + + logger.info({ event: 'demo_login' }, '[AUTH] demo login') + res.status(200).json({ success: true, user }) + } catch (error) { + logger.error({ err: error }, '[AUTH] demo login error') + res.status(500).json({ error: ERROR_CODES.INTERNAL_ERROR, code: ERROR_CODES.INTERNAL_ERROR }) + } +} + /** * Registro de nuevo usuario */ @@ -213,6 +264,7 @@ export const refreshToken = async (req: Request, res: Response): Promise = id: user.id, username: user.username, role: user.role, + demo: Boolean(user.is_demo), type: 'access', } diff --git a/backend/controllers/auth/user-controllers.ts b/backend/controllers/auth/user-controllers.ts index 6ddd17db..d790ab28 100644 --- a/backend/controllers/auth/user-controllers.ts +++ b/backend/controllers/auth/user-controllers.ts @@ -64,7 +64,7 @@ export const getUsersByRole = async (req: Request, res: Response): Promise /** * Actualiza un usuario por ID - * Solo administradores (isRealAdmin en la ruta) + * Solo administradores (isAdmin en la ruta) */ export const updateUser = async (req: Request, res: Response): Promise => { const validation = validateUpdateUser(req.body) diff --git a/backend/controllers/demo/demo-reset-controller.ts b/backend/controllers/demo/demo-reset-controller.ts new file mode 100644 index 00000000..feeed28d --- /dev/null +++ b/backend/controllers/demo/demo-reset-controller.ts @@ -0,0 +1,45 @@ +// controllers/demo/demo-reset-controller.ts +/** + * Configuración → Demo (ADR-038): estado, reinicio a mano y guardar la base de + * horarios. Solo existe con DEMO_MODE=true y solo para admins que no son la + * cuenta demo (demo-reset-routes.ts). + */ + +import type { Request, Response } from 'express' +import { + getDemoStatus, + resetDemoData, + saveSchedulingSnapshot, + type DemoResetResult, +} from '../../services/demo/demo-reset.service.js' +import { ERROR_CODES } from '../../config/error-codes.js' +import { logger } from '../../config/logger.js' + +function answer(res: Response, result: DemoResetResult): void { + if (result.status === 'busy') { + res.status(409).json({ success: false, error: 'Ya hay un reinicio de la demo en marcha' }) + return + } + if (result.status === 'failed') { + res.status(500).json({ success: false, error: ERROR_CODES.INTERNAL_ERROR }) + return + } + res.status(200).json({ success: true, data: result }) +} + +export async function getStatus(_req: Request, res: Response): Promise { + try { + res.status(200).json({ success: true, data: await getDemoStatus() }) + } catch (error) { + logger.error({ err: error }, '[DEMO] status error') + res.status(500).json({ success: false, error: ERROR_CODES.INTERNAL_ERROR }) + } +} + +export async function resetNow(req: Request, res: Response): Promise { + answer(res, await resetDemoData('manual', req.user!.id)) +} + +export async function saveSnapshot(req: Request, res: Response): Promise { + answer(res, await saveSchedulingSnapshot(req.user!.id)) +} diff --git a/backend/db-mysql/AGENTS.md b/backend/db-mysql/AGENTS.md index 545dc3bf..2eda1fe0 100644 --- a/backend/db-mysql/AGENTS.md +++ b/backend/db-mysql/AGENTS.md @@ -136,7 +136,6 @@ See `aiven/aiven-conexion.md` for connection details. SSL required. | 2 | `admin` | Full admin | | 3 | `mantenimiento` | Maintenance staff | | 6 | `group-admin` | Group administrator | -| 7 | `demo-admin` | Demo user (disabled since 2026-05-12) | ## Backups diff --git a/backend/db-mysql/INDEX.md b/backend/db-mysql/INDEX.md index 4afcee8e..e0de4188 100644 --- a/backend/db-mysql/INDEX.md +++ b/backend/db-mysql/INDEX.md @@ -104,6 +104,11 @@ db-mysql/ | 2026-09-29 | `20260929_fix_cashier_daily_trigger.sql` | Recrea `trg_cashier_shift_update_daily`: separa el cálculo de `income` (suma directa de `cashier_shifts`) del de pagos electrónicos (subquery agregado), eliminando la inflación por LEFT JOIN. No toca esquema; solo DDL del trigger. Aplicado manualmente en Aiven + recálculo de las 10 filas de `cashier_daily` desde origen. | ✅ local · ✅ Aiven | | 2026-10-04 | `20261004_add_messages_to_notifications_module.sql` | Añade `messages` al ENUM `notifications.module`. Desde `9be16b1` el aviso de mensaje urgente se guarda con ese módulo y el INSERT fallaba en silencio («Data truncated»). Solo cambia la columna si tiene la definición anterior exacta. En local ya estaba (BD montada desde el baseline); en Aiven se aplicó el 2026-10-04. | ✅ local · ✅ Aiven | | 2026-10-04 | `20261004_fix_parking_availability_triggers.sql` | Recrea `trg_free_availability_on_status_change` (solo libera los días de la propia reserva) y `trg_update_availability_on_date_change` (solo ocupa días libres o propios). Antes completar, cancelar o mover una reserva podía liberar u ocupar días de otra. La comprobación de solapes está en `bookings.repository.ts`. Local tenía otro cuerpo de `date_change`; se parte del de Aiven. | ⏳ local · ✅ Aiven | +| 2026-10-05 | `20261005_add_is_demo_to_users.sql` | Añade `users.is_demo` (0 por defecto): marca la cuenta demo pública, que el backend lleva en el token y `demoRestriction` limita (ADR-038). Hay que aplicarla **antes** de desplegar el código: el login lee la columna. Solo añade la columna si no existe. En Aiven se aplicó el 2026-10-05, antes del código. | ✅ local · ✅ Aiven | +| 2026-10-05 | `20261005_add_demo_reset_log.sql` | Crea `demo_reset_log`: un registro por cada reinicio diario de la demo y por cada base de horarios guardada (ADR-038). La base vive en tablas `demo_snapshot_scheduling_*` que crea el backend al guardarla. Hay que aplicarla antes de desplegar el código. `CREATE TABLE IF NOT EXISTS`. | ✅ local · ✅ Aiven | +| 2026-10-05 | `20261005_complete_fresh_install.sql` | Completa una instalación desde cero: el baseline congelado no crea `cashier_history` (nombre de clave duplicado), `scheduling_assignments` (referencia a una tabla creada después) ni `demo_activity_log`, y deja `roles.name` en `varchar(50)`. Definiciones copiadas de Aiven. En Aiven no cambia nada; en local creó `demo_activity_log`, que faltaba. `pnpm setup:local` además corrige esos dos fallos del baseline en memoria. | ✅ local · ✅ Aiven (sin cambios) | +| 2026-10-05 | `20261005_fix_bo_monthly_summary_view.sql` | Recrea `v_bo_monthly_summary` con `period` en el `GROUP BY`. Con `ONLY_FULL_GROUP_BY` (activo en Aiven y local) toda consulta a la vista fallaba con el error 1055 y `GET /api/backoffice/stats/monthly` respondía siempre 500. Mismas columnas y mismos grupos; comprobada contra un cálculo directo sobre `bo_invoices`. | ✅ local · ✅ Aiven | +| 2026-10-05 | `20261005_drop_demo_admin_role.sql` | Borra el rol `demo-admin` si ningún usuario lo tiene (ADR-038): la cuenta demo es un admin con `is_demo` y el código ya no conoce ese rol. En local no existía. | ✅ local (sin cambios) · ✅ Aiven | ### Verificación (99) diff --git a/backend/db-mysql/mock-data.sql b/backend/db-mysql/mock-data.sql index 412ab6a2..a3e4ee76 100644 --- a/backend/db-mysql/mock-data.sql +++ b/backend/db-mysql/mock-data.sql @@ -264,7 +264,7 @@ INSERT INTO cashier_shifts (id, shift_date, shift_type, status, initial_fund, in (5, CURDATE(), 'night', 'closed', 200.00, 120.00, 300.00, 300.00, 0.00, 150.00, 270.00, @recep2, @recep2, NOW() - INTERVAL 3 HOUR), (6, CURDATE(), 'morning', 'open', 200.00, 0.00, 0.00, 180.00, 0.00, 0.00, 0.00, @recep1, NULL, NULL); -INSERT INTO cashier_shift_users (shift_id, user_id, is_primary) VALUES +INSERT IGNORE INTO cashier_shift_users (shift_id, user_id, is_primary) VALUES (1, @recep2, 1), (2, @recep1, 1), (2, @recep2, 0), (3, @recep2, 1), (4, @recep1, 1), (5, @recep2, 1), (6, @recep1, 1); diff --git a/backend/db-mysql/scripts/20261005_add_demo_reset_log.sql b/backend/db-mysql/scripts/20261005_add_demo_reset_log.sql new file mode 100644 index 00000000..f1a35e87 --- /dev/null +++ b/backend/db-mysql/scripts/20261005_add_demo_reset_log.sql @@ -0,0 +1,29 @@ +-- ============================================================================= +-- 20261005_add_demo_reset_log.sql +-- ============================================================================= +-- Adds `demo_reset_log`: one row per daily reset of the public demo and per +-- saved scheduling base (ADR-038). The backend reads it to reset only once a +-- day, on the first demo entry, and shows it in Settings → Demo. +-- +-- The scheduling base itself lives in `demo_snapshot_scheduling_*` tables, +-- created by the backend when an admin saves it (services/demo). +-- +-- Idempotent: CREATE TABLE IF NOT EXISTS. +-- +-- Applies to: LOCAL + AIVEN. +-- Rollback: DROP TABLE demo_reset_log; +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS demo_reset_log ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + action ENUM('reset', 'snapshot') NOT NULL COMMENT 'reset: mock + scheduling base; snapshot: base saved', + trigger_type ENUM('auto', 'manual') NOT NULL COMMENT 'auto: first demo entry of the day', + user_id VARCHAR(36) NULL COMMENT 'Admin who ran it (manual)', + started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at DATETIME NULL, + ok TINYINT(1) NOT NULL DEFAULT 0, + details VARCHAR(500) NULL COMMENT 'Summary or error code, never SQL text', + PRIMARY KEY (id), + KEY idx_demo_reset_log_action_started (action, started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='Public demo: daily resets and saved scheduling bases (ADR-038)'; diff --git a/backend/db-mysql/scripts/20261005_add_is_demo_to_users.sql b/backend/db-mysql/scripts/20261005_add_is_demo_to_users.sql new file mode 100644 index 00000000..cb21cbae --- /dev/null +++ b/backend/db-mysql/scripts/20261005_add_is_demo_to_users.sql @@ -0,0 +1,34 @@ +-- ============================================================================= +-- 20261005_add_is_demo_to_users.sql +-- ============================================================================= +-- Adds `users.is_demo`: marks the public demo account. The backend puts it in +-- the token, and middlewares/demoRestriction.ts blocks that account from user +-- management, its own profile, departments, file uploads, the demo log and the +-- scheduling configuration. Every existing user keeps 0. +-- +-- Must run BEFORE deploying the code that reads it: login selects u.is_demo. +-- +-- Idempotent: adds the column only when it does not exist. +-- +-- Applies to: LOCAL + AIVEN. +-- Rollback: ALTER TABLE users DROP COLUMN is_demo; +-- (only after the code that reads it is gone) +-- ============================================================================= + +SET @has_col := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'users' + AND COLUMN_NAME = 'is_demo' +); + +SET @ddl := IF( + @has_col = 0, + 'ALTER TABLE users ADD COLUMN is_demo TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''Public demo account: restricted by demoRestriction'' AFTER is_active', + 'SELECT ''users.is_demo already exists, skipping'' AS info' +); + +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/backend/db-mysql/scripts/20261005_complete_fresh_install.sql b/backend/db-mysql/scripts/20261005_complete_fresh_install.sql new file mode 100644 index 00000000..fb6ba071 --- /dev/null +++ b/backend/db-mysql/scripts/20261005_complete_fresh_install.sql @@ -0,0 +1,99 @@ +-- ============================================================================= +-- 20261005_complete_fresh_install.sql +-- ============================================================================= +-- Completes a database installed from scratch with MASTER_INSTALL.sql, so it +-- ends up like Aiven (compared column by column on 2026-10-05, ADR-038). The +-- frozen baseline (aiven/NN, ADR-021) leaves out three tables: +-- +-- - cashier_history: aiven/11_cashier.sql names its foreign key +-- fk_history_user, already used by logbook_history, and the CREATE fails. +-- - scheduling_assignments: aiven/19_scheduling.sql creates it before +-- scheduling_constraints, which it references, and the CREATE fails. +-- - demo_activity_log: MASTER_INSTALL.sql skips aiven/15_demo_user.sql and its +-- migration predates the baseline. +-- +-- And roles.name is varchar(50) there, varchar(255) on Aiven. +-- +-- Definitions copied from Aiven (SHOW CREATE TABLE). On Aiven and on any +-- database that already has them it does nothing. +-- +-- Idempotent: CREATE TABLE IF NOT EXISTS; the ALTER runs only on varchar(50). +-- +-- Applies to: LOCAL + AIVEN (no-op on both today) + fresh installs. +-- Rollback: not needed; it only adds what a fresh install lacks. +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS `cashier_history` ( + `id` int NOT NULL AUTO_INCREMENT, + `shift_id` int NOT NULL COMMENT 'Turno afectado', + `action` enum('created','updated','deleted','status_changed','adjustment','voucher_created','voucher_repaid','daily_closed','daily_reopened') NOT NULL, + `table_affected` varchar(100) DEFAULT NULL COMMENT 'Tabla modificada', + `record_id` int DEFAULT NULL COMMENT 'ID del registro modificado', + `field_changed` varchar(100) DEFAULT NULL COMMENT 'Campo modificado', + `old_value` text COMMENT 'Valor anterior', + `new_value` text COMMENT 'Valor nuevo', + `changed_by` char(36) DEFAULT NULL COMMENT 'Usuario que realizó el cambio', + `changed_at` datetime DEFAULT CURRENT_TIMESTAMP, + `notes` text COMMENT 'Notas adicionales', + PRIMARY KEY (`id`), + KEY `fk_cashier_history_user` (`changed_by`), + KEY `idx_history_shift` (`shift_id`), + KEY `idx_history_action` (`action`), + KEY `idx_history_changed_at` (`changed_at`), + CONSTRAINT `fk_cashier_history_shift` FOREIGN KEY (`shift_id`) REFERENCES `cashier_shifts` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_cashier_history_user` FOREIGN KEY (`changed_by`) REFERENCES `users` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `scheduling_assignments` ( + `id` int NOT NULL AUTO_INCREMENT, + `month_id` int NOT NULL COMMENT 'ID del planning mensual', + `day_id` int NOT NULL COMMENT 'ID del día', + `employee_id` char(36) NOT NULL COMMENT 'ID del empleado', + `shift_code` varchar(5) NOT NULL COMMENT 'Código del turno asignado', + `source_constraint_id` int DEFAULT NULL COMMENT 'Constraint origen (si la celda está precargada/bloqueada)', + `notes` varchar(255) DEFAULT NULL COMMENT 'Notas de la asignación', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `libre_number` int DEFAULT NULL COMMENT 'Par de libre semanal al que pertenece esta asignación (1-45)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_day_employee` (`day_id`,`employee_id`), + KEY `idx_month_id` (`month_id`), + KEY `idx_employee_id` (`employee_id`), + KEY `idx_shift_code` (`shift_code`), + KEY `idx_source_constraint_id` (`source_constraint_id`), + CONSTRAINT `fk_sched_assign_day` FOREIGN KEY (`day_id`) REFERENCES `scheduling_days` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_sched_assign_employee` FOREIGN KEY (`employee_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_sched_assign_month` FOREIGN KEY (`month_id`) REFERENCES `scheduling_months` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_sched_assign_shift` FOREIGN KEY (`shift_code`) REFERENCES `scheduling_shifts` (`code`) ON DELETE RESTRICT, + CONSTRAINT `fk_sched_assign_source_constraint` FOREIGN KEY (`source_constraint_id`) REFERENCES `scheduling_constraints` (`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS `demo_activity_log` ( + `id` int NOT NULL AUTO_INCREMENT, + `timestamp` datetime DEFAULT CURRENT_TIMESTAMP, + `user_id` varchar(36) DEFAULT NULL, + `username` varchar(100) DEFAULT NULL, + `method` varchar(10) NOT NULL, + `route` varchar(255) NOT NULL, + `body_preview` text, + `ip_address` varchar(45) DEFAULT NULL, + `user_agent` text, + `blocked` tinyint(1) DEFAULT '1', + PRIMARY KEY (`id`), + KEY `idx_demo_log_timestamp` (`timestamp`), + KEY `idx_demo_log_username` (`username`), + KEY `idx_demo_log_route` (`route`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +SET @roles_name := ( + SELECT COLUMN_TYPE FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'roles' AND COLUMN_NAME = 'name' +); +SET @ddl := IF( + @roles_name = 'varchar(50)', + 'ALTER TABLE roles MODIFY COLUMN name VARCHAR(255) NOT NULL', + 'SELECT ''roles.name already wider than varchar(50), skipping'' AS info' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/backend/db-mysql/scripts/20261005_drop_demo_admin_role.sql b/backend/db-mysql/scripts/20261005_drop_demo_admin_role.sql new file mode 100644 index 00000000..9fdf8191 --- /dev/null +++ b/backend/db-mysql/scripts/20261005_drop_demo_admin_role.sql @@ -0,0 +1,22 @@ +-- ============================================================================= +-- 20261005_drop_demo_admin_role.sql +-- ============================================================================= +-- Removes the `demo-admin` role (ADR-038). The public demo account is now an +-- admin marked with users.is_demo, restricted by demoRestriction; the code no +-- longer knows the old role. Its only user, `demo`, was converted by +-- scripts/create-demo-user.ts. +-- +-- Deletes the role only when no user has it: if one still did, that user +-- would be left without a role. +-- +-- Idempotent: deletes nothing the second time. +-- +-- Applies to: AIVEN (local never had the role). +-- Rollback: INSERT INTO roles (id, name) VALUES (7, 'demo-admin'); +-- ============================================================================= + +DELETE r FROM roles r +WHERE r.name = 'demo-admin' + AND NOT EXISTS (SELECT 1 FROM users u WHERE u.role_id = r.id); + +SELECT CONCAT('demo-admin roles left: ', COUNT(*)) AS info FROM roles WHERE name = 'demo-admin'; diff --git a/backend/db-mysql/scripts/20261005_fix_bo_monthly_summary_view.sql b/backend/db-mysql/scripts/20261005_fix_bo_monthly_summary_view.sql new file mode 100644 index 00000000..c6d4f8de --- /dev/null +++ b/backend/db-mysql/scripts/20261005_fix_bo_monthly_summary_view.sql @@ -0,0 +1,35 @@ +-- ============================================================================= +-- 20261005_fix_bo_monthly_summary_view.sql +-- ============================================================================= +-- Recreates v_bo_monthly_summary with `period` in its GROUP BY. The view +-- returned DATE_FORMAT(invoice_date, '%Y-%m') without grouping by it, and +-- with ONLY_FULL_GROUP_BY (on in Aiven and local) every SELECT on it failed +-- with error 1055: GET /api/backoffice/stats/monthly always answered 500. +-- Found on 2026-10-05 while comparing a fresh install with Aiven. +-- +-- Same columns and the same groups as before: year + month and '%Y-%m' split +-- the invoices the same way. +-- +-- Idempotent: CREATE OR REPLACE VIEW. +-- +-- Applies to: LOCAL + AIVEN. +-- Rollback: the previous body is in aiven/16_backoffice.sql (it fails while +-- ONLY_FULL_GROUP_BY is on). +-- ============================================================================= + +CREATE OR REPLACE VIEW v_bo_monthly_summary AS +SELECT + YEAR(i.invoice_date) AS year, + MONTH(i.invoice_date) AS month, + DATE_FORMAT(i.invoice_date, '%Y-%m') AS period, + COUNT(*) AS total_invoices, + COUNT(CASE WHEN i.status = 'pending' THEN 1 END) AS pending_count, + COUNT(CASE WHEN i.status = 'paid' THEN 1 END) AS paid_count, + SUM(i.amount_without_vat) AS total_without_vat, + SUM(i.amount_with_vat) AS total_with_vat, + SUM(CASE WHEN i.status = 'pending' THEN i.amount_with_vat ELSE 0 END) AS pending_total, + SUM(CASE WHEN i.status = 'paid' THEN i.amount_with_vat ELSE 0 END) AS paid_total +FROM bo_invoices i +WHERE i.is_deleted = 0 +GROUP BY YEAR(i.invoice_date), MONTH(i.invoice_date), DATE_FORMAT(i.invoice_date, '%Y-%m') +ORDER BY year DESC, month DESC; diff --git a/backend/index.ts b/backend/index.ts index 91bd9491..0dafd4e9 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -2,13 +2,13 @@ import express, { Request, Response, NextFunction } from 'express' import cookieParser from 'cookie-parser' -import cors from 'cors' import helmet from 'helmet' import { PORT } from './config/config.js' import { logServerInfo } from './config/startup-logger.js' import { logger } from './config/logger.js' import { apiLimiter } from './middlewares/rateLimiter.js' import { noStore } from './middlewares/noStore.js' +import { corsMiddleware } from './config/cors.js' import { CronService } from './services/cron/cron-service.js' import { warmupSolver } from './services/scheduling/solver-client.js' @@ -31,6 +31,7 @@ import backofficeRoutes from './routes/backoffice/backoffice-routes.js' import schedulingRoutes from './routes/scheduling/scheduling-routes.js' import searchRoutes from './routes/search/search-routes.js' import demoActivityRoutes from './routes/demo/demo-activity-routes.js' +import demoResetRoutes from './routes/demo/demo-reset-routes.js' import checklistRoutes from './routes/checklist/checklist-routes.js' import fnbRoutes from './routes/fnb/fnb-routes.js' @@ -54,39 +55,8 @@ app.disable('x-powered-by') // CSP deshabilitado — API pura JSON, no sirve HTML ni assets app.use(helmet({ contentSecurityPolicy: false })) -// CORS (antes de todo). Also the CSRF defence: a request carrying any other Origin -// is rejected before the routes, including simple (non-preflighted) requests. -const allowedOrigins = [ - ...(process.env.NODE_ENV === 'production' ? [] : ['http://localhost:3000']), - 'https://four-points.stackbp.es', - 'https://four-points.vercel.app', - 'https://api.four-points.stackbp.es', - 'https://four-points.onrender.com', - process.env.FRONTEND_URL, // URL adicional si es necesario -].filter(Boolean) as string[] - -app.use( - cors({ - origin: (origin, callback) => { - // Permitir requests sin origin (Postman, server-to-server, mobile apps) - if (!origin) { - callback(null, true) - return - } - if (allowedOrigins.includes(origin)) { - callback(null, true) - } else { - logger.warn({ origin }, '[CORS] Blocked origin') - // Must stay an error: callback(null, false) would let simple requests reach the routes - callback(Object.assign(new Error('Origen no permitido'), { status: 403 })) - } - }, - credentials: true, - methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization', 'Cookie'], - exposedHeaders: ['Set-Cookie'], - }) -) +// CORS (antes de todo); also the CSRF defence (config/cors.ts) +app.use(corsMiddleware()) // ======================================== // MIDDLEWARES GLOBALES @@ -166,6 +136,8 @@ app.use('/api/search', searchRoutes) // Rutas de actividad demo (solo admin) app.use('/api/demo-activity', demoActivityRoutes) +// Demo reset and scheduling base (only with DEMO_MODE=true) +app.use('/api/demo', demoResetRoutes) app.use('/api/checklists', checklistRoutes) app.use('/api/fnb', fnbRoutes) diff --git a/backend/middlewares/authenticateToken.ts b/backend/middlewares/authenticateToken.ts index cf990bb6..c9ffb5de 100644 --- a/backend/middlewares/authenticateToken.ts +++ b/backend/middlewares/authenticateToken.ts @@ -3,6 +3,7 @@ import { Request, Response, NextFunction } from 'express' import { verifyToken } from '../services/auth/tokenService.js' import { demoRestriction } from './demoRestriction.js' +import { demoWriteLimiter } from './rateLimiter.js' import { logger } from '../config/logger.js' /** @@ -56,11 +57,8 @@ export function authenticateToken(req: Request, res: Response, next: NextFunctio username: decoded.username, email: '', // No está en el token, se puede obtener de la BD si es necesario role: decoded.role, + isDemo: decoded.demo === true, } - - // ✅ DEMO: Verificar restricciones de usuario demo - // Para eliminar: quitar esta línea y el import de demoRestriction - demoRestriction(req, res, next) } catch (error) { // Diferencia entre token expirado y token inválido let message = 'Token inválido' @@ -74,5 +72,9 @@ export function authenticateToken(req: Request, res: Response, next: NextFunctio } res.status(401).json({ error: message }) + return } + + // Demo account restrictions, outside the try: an error here is not a bad token + demoRestriction(req, res, () => demoWriteLimiter(req, res, next)) } diff --git a/backend/middlewares/demoRestriction.ts b/backend/middlewares/demoRestriction.ts index 1c8783d1..b0e702ae 100644 --- a/backend/middlewares/demoRestriction.ts +++ b/backend/middlewares/demoRestriction.ts @@ -1,51 +1,17 @@ // middlewares/demoRestriction.ts /** - * Middleware para restringir acciones de escritura a usuarios demo. + * Restricciones de la cuenta demo pública (users.is_demo, ADR-038). * - * El usuario demo puede VER todo (igual que admin), pero solo puede - * hacer escrituras específicas (whitelist). - * - * DISEÑO NO INVASIVO: Fácil de eliminar - solo quitar este archivo - * y la línea en index.ts que lo importa. + * La cuenta demo es un admin: ve y cambia todo lo que se reinicia cada día. + * Se le bloquea lo que podría romper la app o tocar otras cuentas: + * - en todas las rutas: subir ficheros (demoRestriction) + * - en cada ruta sensible: denyDemo o denyDemoWrites */ import { Request, Response, NextFunction } from 'express' import { DemoActivityRepository } from '../repositories/demo/demo-activity-repository.js' import { logger } from '../config/logger.js' -// Rol del usuario demo -const DEMO_ROLE = 'demo-admin' - -/** - * Rutas que el usuario demo SÍ puede usar (whitelist). - * Formato: { method: 'POST|PATCH|PUT|DELETE', pattern: RegExp } - */ -const DEMO_ALLOWED_ROUTES: Array<{ method: string; pattern: RegExp }> = [ - // Auth - puede hacer logout - { method: 'POST', pattern: /^\/api\/auth\/logout$/ }, - - // Parking - puede crear reservas de prueba - { method: 'POST', pattern: /^\/api\/parking\/bookings$/ }, - - // Logbooks - puede agregar comentarios - { method: 'POST', pattern: /^\/api\/logbooks\/\d+\/comments$/ }, - - // Maintenance - puede crear reportes de prueba - { method: 'POST', pattern: /^\/api\/maintenance$/ }, -] - -/** - * Verifica si una ruta está en la whitelist para demo - * Usa originalUrl para obtener la ruta completa (incluyendo /api/...) - */ -function isAllowedForDemo(method: string, originalUrl: string): boolean { - // Quitar query string si existe - const pathOnly = originalUrl.split('?')[0] - return DEMO_ALLOWED_ROUTES.some( - (route) => route.method === method && route.pattern.test(pathOnly) - ) -} - /** * Registra intentos bloqueados en la base de datos * Ejecuta de forma asíncrona sin bloquear la respuesta @@ -53,7 +19,9 @@ function isAllowedForDemo(method: string, originalUrl: string): boolean { const SENSITIVE_FIELDS = new Set(['password', 'currentPassword', 'newPassword', 'confirmPassword']) function sanitizeBodyForLog(body: unknown): string { - if (!body || typeof body !== 'object') return JSON.stringify(body).substring(0, 500) + // Express 5 leaves req.body undefined without a JSON body (DELETE, uploads) + if (!body || typeof body !== 'object') + return String(JSON.stringify(body ?? null)).substring(0, 500) const cleaned: Record = {} for (const [k, v] of Object.entries(body as Record)) { cleaned[k] = SENSITIVE_FIELDS.has(k) ? '[REDACTED]' : v @@ -83,44 +51,58 @@ function logBlockedAttempt( }) } +function block(req: Request, res: Response): void { + logBlockedAttempt(req, req.user?.id, req.user?.username || 'demo-user', req.originalUrl) + + res.status(403).json({ + success: false, + error: + 'Acción no disponible en modo demo. Esta es una cuenta de demostración con funcionalidad limitada.', + demo: true, + }) +} + +// Uploads go to Cloudinary and the daily reset does not clean them; checking the +// header blocks every upload route before multer reads the body +function isMultipart(req: Request): boolean { + return String(req.headers['content-type'] || '') + .toLowerCase() + .includes('multipart') +} + /** - * Middleware que restringe escrituras para usuarios demo. - * - * - GET requests: siempre permitidos - * - POST/PUT/PATCH/DELETE: solo si están en whitelist + * Runs inside authenticateToken, on every authenticated request. + * The demo account (users.is_demo) cannot upload files; the rest is + * limited per route with denyDemo and denyDemoWrites. */ export function demoRestriction(req: Request, res: Response, next: NextFunction): void { - // Si no hay usuario autenticado, dejar pasar (authenticateToken ya lo manejará) - if (!req.user) { - next() + if (req.user?.isDemo && isMultipart(req)) { + block(req, res) return } - // Si no es usuario demo, dejar pasar sin restricciones - if (req.user.role !== DEMO_ROLE) { - next() - return - } + next() +} - // GET siempre permitido para demo (puede ver todo) - if (req.method === 'GET') { - next() +/** + * Blocks the demo account from a route entirely (reads too). Declared on the + * route itself, so it does not depend on how the URL is written. + */ +export function denyDemo(req: Request, res: Response, next: NextFunction): void { + if (req.user?.isDemo) { + block(req, res) return } + next() +} - // Verificar si la ruta está en whitelist (usar originalUrl para ruta completa) - if (isAllowedForDemo(req.method, req.originalUrl)) { - next() +/** + * Lets the demo account read a route but not change anything through it. + */ +export function denyDemoWrites(req: Request, res: Response, next: NextFunction): void { + if (req.user?.isDemo && !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { + block(req, res) return } - - // Bloquear y registrar en BD - logBlockedAttempt(req, req.user.id, req.user.username || 'demo-user', req.originalUrl) - - res.status(403).json({ - success: false, - error: - 'Acción no disponible en modo demo. Esta es una cuenta de demostración con funcionalidad limitada.', - demo: true, - }) + next() } diff --git a/backend/middlewares/rateLimiter.ts b/backend/middlewares/rateLimiter.ts index e4aea7bb..4c60a83c 100644 --- a/backend/middlewares/rateLimiter.ts +++ b/backend/middlewares/rateLimiter.ts @@ -75,6 +75,88 @@ export const loginIpLimiter = rateLimit({ }, }) +/** + * Rate limiter for the demo entry (POST /api/auth/demo), per IP + */ +export const demoLoginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req: Request) => `demo-login-${getIpKey(req)}`, + handler: (req: Request, res: Response) => { + logger.warn( + { event: 'rate_limit_exceeded', kind: 'demo_login', ip: getIpKey(req) }, + '[SECURITY] demo login rate limit exceeded' + ) + res.status(429).json({ + error: 'Demasiadas entradas a la demo. Intenta de nuevo en 15 minutos.', + }) + }, +}) + +// The demo account is shared by every visitor, so its limits go by IP, plus a +// total cap where one Render instance is at stake (ADR-038) +const READ_METHODS = ['GET', 'HEAD', 'OPTIONS'] + +function demoLimitHandler(kind: string, error: string) { + return (req: Request, res: Response) => { + logger.warn( + { event: 'rate_limit_exceeded', kind, ip: getIpKey(req) }, + '[SECURITY] demo rate limit exceeded' + ) + res.status(429).json({ error }) + } +} + +/** + * Demo account writes, per IP + */ +export const demoWriteLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 60, + standardHeaders: true, + legacyHeaders: false, + skip: (req: Request) => !req.user?.isDemo || READ_METHODS.includes(req.method), + keyGenerator: (req: Request) => `demo-write-${getIpKey(req)}`, + handler: demoLimitHandler( + 'demo_write', + 'Demasiados cambios en la demo. Intenta de nuevo en 15 minutos.' + ), +}) + +/** + * Schedule generation by the demo account: the solver loads OR-Tools on a + * 512 MB instance and runs one at a time. Per IP, and in total + */ +const demoGeneratePerIp = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 5, + standardHeaders: true, + legacyHeaders: false, + skip: (req: Request) => !req.user?.isDemo, + keyGenerator: (req: Request) => `demo-generate-${getIpKey(req)}`, + handler: demoLimitHandler( + 'demo_generate', + 'Has generado demasiados horarios en la demo. Intenta de nuevo en una hora.' + ), +}) + +const demoGenerateTotal = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 30, + standardHeaders: false, + legacyHeaders: false, + skip: (req: Request) => !req.user?.isDemo, + keyGenerator: () => 'demo-generate-total', + handler: demoLimitHandler( + 'demo_generate_total', + 'La demo ha generado demasiados horarios esta hora. Intenta de nuevo más tarde.' + ), +}) + +export const demoGenerateLimiter = [demoGeneratePerIp, demoGenerateTotal] + /** * Rate limiter for password change attempts * Stricter limits to prevent brute force on password verification diff --git a/backend/middlewares/roleCheck.ts b/backend/middlewares/roleCheck.ts index c0d7219d..96230a2c 100644 --- a/backend/middlewares/roleCheck.ts +++ b/backend/middlewares/roleCheck.ts @@ -7,10 +7,9 @@ type RoleCheckMiddleware = (req: Request, res: Response, next: NextFunction) => /** * Verifica que el usuario tenga rol de administrador * Uso: Para rutas que SOLO admins pueden acceder - * Nota: demo-admin tiene acceso visual pero sus escrituras están limitadas por demoRestriction */ export const isAdmin: RoleCheckMiddleware = (req, res, next) => { - const allowedRoles = ['admin', 'demo-admin'] + const allowedRoles = ['admin'] if (!req.user?.role) { res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) @@ -29,15 +28,14 @@ export const isAdmin: RoleCheckMiddleware = (req, res, next) => { /** * Verifica que el usuario sea el dueño del recurso O sea administrador * Uso: Para rutas donde un usuario puede ver/editar SU propio perfil - * Nota: demo-admin tiene acceso visual como admin */ export const isOwnerOrAdmin: RoleCheckMiddleware = (req, res, next) => { const resourceUserId = req.params.id // ID del usuario en la URL const requestingUserId = req.user?.id // ID del usuario autenticado const userRole = req.user?.role?.toLowerCase() - // Si es admin o demo-admin, tiene acceso total - if (userRole === 'admin' || userRole === 'demo-admin') { + // Si es admin, tiene acceso total + if (userRole === 'admin') { next() return } @@ -57,10 +55,9 @@ export const isOwnerOrAdmin: RoleCheckMiddleware = (req, res, next) => { /** * Verifica que el usuario pueda gestionar grupos * Uso: Para rutas de creación/edición/eliminación de grupos - * Nota: demo-admin incluido para acceso visual */ export const canManageGroups: RoleCheckMiddleware = (req, res, next) => { - const allowedRoles = ['admin', 'group-admin', 'demo-admin'] + const allowedRoles = ['admin', 'group-admin'] if (!req.user?.role) { res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) @@ -82,7 +79,7 @@ export const canManageGroups: RoleCheckMiddleware = (req, res, next) => { * Nota: mantenimiento incluido para acceso a notificaciones del profile */ export const canViewGroups: RoleCheckMiddleware = (req, res, next) => { - const allowedRoles = ['admin', 'recepcionista', 'group-admin', 'mantenimiento', 'demo-admin'] + const allowedRoles = ['admin', 'recepcionista', 'group-admin', 'mantenimiento'] if (!req.user?.role) { res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) @@ -101,10 +98,9 @@ export const canViewGroups: RoleCheckMiddleware = (req, res, next) => { /** * Verifica que el usuario pueda gestionar caja * Uso: Para crear, editar, cerrar turnos, vales, etc. - * Nota: demo-admin incluido para acceso visual */ export const canManageCashier: RoleCheckMiddleware = (req, res, next) => { - const allowedRoles = ['admin', 'recepcionista', 'group-admin', 'demo-admin'] + const allowedRoles = ['admin', 'recepcionista', 'group-admin'] if (!req.user?.role) { res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) @@ -123,10 +119,9 @@ export const canManageCashier: RoleCheckMiddleware = (req, res, next) => { /** * Verifica que el usuario pueda ver reportes de caja * Uso: Para reportes, estadísticas, históricos completos - * Nota: demo-admin incluido para acceso visual */ export const canViewReports: RoleCheckMiddleware = (req, res, next) => { - const allowedRoles = ['admin', 'demo-admin'] + const allowedRoles = ['admin'] if (!req.user?.role) { res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) @@ -145,10 +140,9 @@ export const canViewReports: RoleCheckMiddleware = (req, res, next) => { /** * Verifica que el usuario pueda acceder al módulo de mantenimiento * Uso: Para rutas de creación/edición/consulta de reportes de mantenimiento - * Nota: TODOS los roles pueden acceder a mantenimiento (incluido demo-admin) */ export const canAccessMaintenance: RoleCheckMiddleware = (req, res, next) => { - const allowedRoles = ['admin', 'recepcionista', 'group-admin', 'mantenimiento', 'demo-admin'] + const allowedRoles = ['admin', 'recepcionista', 'group-admin', 'mantenimiento'] if (!req.user?.role) { res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) @@ -189,10 +183,10 @@ export const excludeMantenimiento: RoleCheckMiddleware = (req, res, next) => { /** * Verifica que el usuario pueda acceder al módulo de backoffice * Uso: Para rutas de facturas, proveedores, etc. - * SOLO admin y demo-admin pueden acceder + * SOLO admin puede acceder */ export const canAccessBackoffice: RoleCheckMiddleware = (req, res, next) => { - const allowedRoles = ['admin', 'demo-admin'] + const allowedRoles = ['admin'] if (!req.user?.role) { res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) @@ -211,10 +205,10 @@ export const canAccessBackoffice: RoleCheckMiddleware = (req, res, next) => { /** * Verifica que el usuario pueda resetear checklists * Uso: Botón de reset en el módulo Check List - * Roles permitidos: admin, recepcionista, demo-admin + * Roles permitidos: admin, recepcionista */ export const canResetChecklist: RoleCheckMiddleware = (req, res, next) => { - const allowedRoles = ['admin', 'recepcionista', 'demo-admin'] + const allowedRoles = ['admin', 'recepcionista'] if (!req.user?.role) { res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) @@ -232,10 +226,10 @@ export const canResetChecklist: RoleCheckMiddleware = (req, res, next) => { /** * Verifica acceso al módulo F&B Revenue - * Roles permitidos: admin, recepcionista, demo-admin, group-admin + * Roles permitidos: admin, recepcionista, group-admin */ export const canAccessFnb: RoleCheckMiddleware = (req, res, next) => { - const allowedRoles = ['admin', 'recepcionista', 'demo-admin', 'group-admin'] + const allowedRoles = ['admin', 'recepcionista', 'group-admin'] if (!req.user?.role) { res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) @@ -253,7 +247,7 @@ export const canAccessFnb: RoleCheckMiddleware = (req, res, next) => { /** * Lanzar a mano la generación de notificaciones pendientes - * Roles permitidos: admin, group-admin (demo-admin está deshabilitado) + * Roles permitidos: admin, group-admin */ export const canRunNotificationCheck: RoleCheckMiddleware = (req, res, next) => { const allowedRoles = ['admin', 'group-admin'] @@ -269,27 +263,3 @@ export const canRunNotificationCheck: RoleCheckMiddleware = (req, res, next) => res.status(403).json({ error: 'Acceso denegado. Se requiere rol de admin o group-admin' }) } } - -/** - * Verifica que el usuario sea admin real (NO demo-admin) - * Uso: Para operaciones de escritura en backoffice - * demo-admin tiene acceso de solo lectura - */ -export const isRealAdmin: RoleCheckMiddleware = (req, res, next) => { - if (!req.user?.role) { - res.status(403).json({ error: 'No se pudo verificar el rol del usuario' }) - return - } - - if (req.user.role.toLowerCase() === 'admin') { - next() - } else if (req.user.role.toLowerCase() === 'demo-admin') { - res.status(403).json({ - error: 'Acceso de solo lectura. El rol demo-admin no puede realizar modificaciones', - }) - } else { - res.status(403).json({ - error: 'Acceso denegado. Se requiere rol de administrador', - }) - } -} diff --git a/backend/models/auth/index.ts b/backend/models/auth/index.ts index 9ecc1237..c7b0db09 100644 --- a/backend/models/auth/index.ts +++ b/backend/models/auth/index.ts @@ -13,6 +13,7 @@ export interface UserWithRole extends RowDataPacket { password?: string role: string is_active: number | boolean + is_demo?: number | boolean created_at: Date updated_at?: Date | null avatar_url?: string | null @@ -34,6 +35,7 @@ export interface User { email: string role: string is_active: number | boolean + is_demo?: number | boolean created_at: Date updated_at?: Date | null avatar_url?: string | null @@ -79,6 +81,8 @@ export interface TokenPayload { id: string username: string role: string + // Public demo account (users.is_demo); absent for everyone else + demo?: boolean type: 'access' | 'refresh' iat?: number exp?: number diff --git a/backend/package.json b/backend/package.json index 25103760..0ad318a7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,6 +8,9 @@ "dev": "tsx watch index.ts", "dev:local": "cross-env DB_ENVIRONMENT=local tsx watch index.ts", "dev:aiven": "cross-env DB_ENVIRONMENT=aiven tsx watch index.ts", + "setup:local": "cross-env DB_ENVIRONMENT=local tsx scripts/setup-local.ts", + "demo:user": "cross-env DB_ENVIRONMENT=local tsx scripts/create-demo-user.ts", + "demo:user:aiven": "cross-env DB_ENVIRONMENT=aiven tsx scripts/create-demo-user.ts", "build": "python3 -m venv scheduling-solver/venv && scheduling-solver/venv/bin/pip install --no-cache-dir -r scheduling-solver/requirements.txt", "start": "tsx index.ts", "typecheck": "tsc --noEmit", diff --git a/backend/repositories/auth/user-repository.ts b/backend/repositories/auth/user-repository.ts index 813f53a4..1ca7ad7d 100644 --- a/backend/repositories/auth/user-repository.ts +++ b/backend/repositories/auth/user-repository.ts @@ -141,6 +141,7 @@ export class UserRepository { u.email, u.password, u.is_active, + u.is_demo, u.created_at, u.avatar_url, r.name AS role @@ -176,6 +177,15 @@ export class UserRepository { throw new Error('Usuario inactivo') } + // The demo account has no usable password: it is entered only through POST /api/auth/demo + if (user.is_demo) { + logger.warn( + { event: 'login_failed', reason: 'demo_account', username }, + '[SECURITY] login failed' + ) + throw new Error('Credenciales inválidas') + } + if (!isPasswordValid) { logger.warn( { event: 'login_failed', reason: 'invalid_password', username }, @@ -228,6 +238,7 @@ export class UserRepository { u.email, u.created_at, u.is_active, + u.is_demo, u.updated_at, u.avatar_url, r.name AS role @@ -243,6 +254,29 @@ export class UserRepository { } } + /** + * The public demo account (users.is_demo), if there is an active one + */ + static async getDemoUser(): Promise { + const [rows] = await db.query( + `SELECT + u.id, + u.username, + u.email, + u.created_at, + u.is_active, + u.is_demo, + u.avatar_url, + r.name AS role + FROM users u + INNER JOIN roles r ON r.id = u.role_id + WHERE u.is_demo = 1 AND u.is_active = 1 + ORDER BY u.created_at, u.id + LIMIT 1` + ) + return (rows[0] as User) || null + } + /** * Obtener por username con JOIN directo */ diff --git a/backend/routes/auth/auth-routes.ts b/backend/routes/auth/auth-routes.ts index 9cedae24..58625d92 100644 --- a/backend/routes/auth/auth-routes.ts +++ b/backend/routes/auth/auth-routes.ts @@ -3,6 +3,7 @@ import express, { Router } from 'express' import { login, + demoLogin, register, refreshToken, logout, @@ -13,15 +14,17 @@ import { deleteAvatar, } from '../../controllers/auth/auth-controllers.js' import { authenticateToken } from '../../middlewares/authenticateToken.js' +import { denyDemo } from '../../middlewares/demoRestriction.js' import { singleImage } from '../../middlewares/imageUpload.js' import { loginLimiter, + demoLoginLimiter, loginIpLimiter, refreshLimiter, passwordChangeLimiter, profileUpdateLimiter, } from '../../middlewares/rateLimiter.js' -import { isRealAdmin } from '../../middlewares/roleCheck.js' +import { isAdmin } from '../../middlewares/roleCheck.js' const router: Router = express.Router() @@ -33,13 +36,15 @@ router.post('/login', loginIpLimiter, loginLimiter, login) router.post('/refresh-token', refreshLimiter, refreshToken) // Public: only clears the HttpOnly cookies, which the browser cannot do itself once the access token expired router.post('/logout', logout) +// Demo entry without password; answers 404 unless DEMO_MODE=true (ADR-038) +router.post('/demo', demoLoginLimiter, demoLogin) // ======================================== // ADMIN ONLY ROUTES // ======================================== -// Solo admins reales pueden crear usuarios (no demo-admin) -router.post('/register', authenticateToken, isRealAdmin, register) +// Solo admins pueden crear usuarios (nunca la cuenta demo) +router.post('/register', authenticateToken, denyDemo, isAdmin, register) // ======================================== // PROTECTED ROUTES (require authentication) @@ -48,11 +53,18 @@ router.post('/register', authenticateToken, isRealAdmin, register) router.get('/me', authenticateToken, me) // Profile management (with rate limiting) -router.patch('/me/profile', authenticateToken, profileUpdateLimiter, updateProfile) -router.patch('/me/password', authenticateToken, passwordChangeLimiter, updatePassword) +// The demo account cannot change its username, password or avatar (it is shared) +router.patch('/me/profile', authenticateToken, denyDemo, profileUpdateLimiter, updateProfile) +router.patch('/me/password', authenticateToken, denyDemo, passwordChangeLimiter, updatePassword) // Avatar management -router.post('/me/avatar', authenticateToken, singleImage('avatar', 2 * 1024 * 1024), uploadAvatar) -router.delete('/me/avatar', authenticateToken, deleteAvatar) +router.post( + '/me/avatar', + authenticateToken, + denyDemo, + singleImage('avatar', 2 * 1024 * 1024), + uploadAvatar +) +router.delete('/me/avatar', authenticateToken, denyDemo, deleteAvatar) export default router diff --git a/backend/routes/auth/user-routes.ts b/backend/routes/auth/user-routes.ts index 1979f357..3a1dce84 100644 --- a/backend/routes/auth/user-routes.ts +++ b/backend/routes/auth/user-routes.ts @@ -11,7 +11,8 @@ import { } from '../../controllers/auth/user-controllers.js' import { authenticateToken } from '../../middlewares/authenticateToken.js' -import { isAdmin, isOwnerOrAdmin, isRealAdmin } from '../../middlewares/roleCheck.js' +import { isAdmin, isOwnerOrAdmin } from '../../middlewares/roleCheck.js' +import { denyDemoWrites } from '../../middlewares/demoRestriction.js' const router: Router = express.Router() @@ -19,6 +20,8 @@ const router: Router = express.Router() // ALL routes require authentication // ======================================== router.use(authenticateToken) +// The demo account sees users but cannot create, edit, delete or reset them +router.use(denyDemoWrites) // ======================================== // ADMIN ONLY ROUTES @@ -29,7 +32,7 @@ router.get('/role/:role', isAdmin, getUsersByRole) router.delete('/:id', isAdmin, deleteUser) router.post('/:id/reset-password', isAdmin, resetUserPassword) // Users edit their own username via PATCH /api/auth/me/profile (requires current password) -router.put('/:id', isRealAdmin, updateUser) +router.put('/:id', isAdmin, updateUser) // ======================================== // OWNER OR ADMIN ROUTES diff --git a/backend/routes/backoffice/backoffice-routes.ts b/backend/routes/backoffice/backoffice-routes.ts index 660e81af..481823d2 100644 --- a/backend/routes/backoffice/backoffice-routes.ts +++ b/backend/routes/backoffice/backoffice-routes.ts @@ -5,14 +5,15 @@ * * Permisos: * - admin: CRUD completo - * - demo-admin: solo lectura (GET) + * - cuenta demo (admin): todo salvo subir ficheros y crear categorías */ import { Router } from 'express' import multer from 'multer' import { BackofficeController } from '../../controllers/backoffice/backoffice-controller.js' import { authenticateToken } from '../../middlewares/authenticateToken.js' -import { canAccessBackoffice, isRealAdmin } from '../../middlewares/roleCheck.js' +import { canAccessBackoffice, isAdmin } from '../../middlewares/roleCheck.js' +import { denyDemo } from '../../middlewares/demoRestriction.js' const router = Router() @@ -35,14 +36,14 @@ router.use(canAccessBackoffice) /** * @route GET /api/backoffice/stats * @desc Obtener estadísticas generales - * @access Private (admin, demo-admin) + * @access Private (admin) */ router.get('/stats', BackofficeController.getStats) /** * @route GET /api/backoffice/stats/monthly * @desc Obtener resumen mensual - * @access Private (admin, demo-admin) + * @access Private (admin) * @query year (opcional) */ router.get('/stats/monthly', BackofficeController.getMonthlySummary) @@ -54,14 +55,14 @@ router.get('/stats/monthly', BackofficeController.getMonthlySummary) /** * @route GET /api/backoffice/categories * @desc Obtener todas las categorías - * @access Private (admin, demo-admin) + * @access Private (admin) */ router.get('/categories', BackofficeController.getCategories) /** * @route GET /api/backoffice/categories/:id * @desc Obtener categoría por ID - * @access Private (admin, demo-admin) + * @access Private (admin) */ router.get('/categories/:id', BackofficeController.getCategoryById) @@ -70,7 +71,8 @@ router.get('/categories/:id', BackofficeController.getCategoryById) * @desc Crear nueva categoría * @access Private (admin only) */ -router.post('/categories', isRealAdmin, BackofficeController.createCategory) +// Categories are a catalog the daily reset keeps: the demo account cannot add them +router.post('/categories', isAdmin, denyDemo, BackofficeController.createCategory) // ======================================== // RUTAS DE PROVEEDORES @@ -79,7 +81,7 @@ router.post('/categories', isRealAdmin, BackofficeController.createCategory) /** * @route GET /api/backoffice/suppliers * @desc Obtener todos los proveedores con filtros - * @access Private (admin, demo-admin) + * @access Private (admin) * @query category_id, periodicity, payment_method, is_active, search */ router.get('/suppliers', BackofficeController.getSuppliers) @@ -87,7 +89,7 @@ router.get('/suppliers', BackofficeController.getSuppliers) /** * @route GET /api/backoffice/suppliers/:id * @desc Obtener proveedor por ID con estadísticas y facturas - * @access Private (admin, demo-admin) + * @access Private (admin) */ router.get('/suppliers/:id', BackofficeController.getSupplierById) @@ -96,35 +98,35 @@ router.get('/suppliers/:id', BackofficeController.getSupplierById) * @desc Crear nuevo proveedor * @access Private (admin only) */ -router.post('/suppliers', isRealAdmin, BackofficeController.createSupplier) +router.post('/suppliers', isAdmin, BackofficeController.createSupplier) /** * @route PATCH /api/backoffice/suppliers/:id * @desc Actualizar proveedor * @access Private (admin only) */ -router.patch('/suppliers/:id', isRealAdmin, BackofficeController.updateSupplier) +router.patch('/suppliers/:id', isAdmin, BackofficeController.updateSupplier) /** * @route DELETE /api/backoffice/suppliers/:id * @desc Eliminar proveedor permanentemente (solo si no tiene facturas) * @access Private (admin only) */ -router.delete('/suppliers/:id', isRealAdmin, BackofficeController.deleteSupplier) +router.delete('/suppliers/:id', isAdmin, BackofficeController.deleteSupplier) /** * @route POST /api/backoffice/suppliers/:id/inactivate * @desc Marcar proveedor como inactivo (preserva histórico) * @access Private (admin only) */ -router.post('/suppliers/:id/inactivate', isRealAdmin, BackofficeController.inactivateSupplier) +router.post('/suppliers/:id/inactivate', isAdmin, BackofficeController.inactivateSupplier) /** * @route POST /api/backoffice/suppliers/:id/activate * @desc Reactivar proveedor inactivo * @access Private (admin only) */ -router.post('/suppliers/:id/activate', isRealAdmin, BackofficeController.activateSupplier) +router.post('/suppliers/:id/activate', isAdmin, BackofficeController.activateSupplier) // ======================================== // RUTAS DE FACTURAS @@ -133,7 +135,7 @@ router.post('/suppliers/:id/activate', isRealAdmin, BackofficeController.activat /** * @route GET /api/backoffice/invoices * @desc Obtener todas las facturas con filtros y paginación - * @access Private (admin, demo-admin) + * @access Private (admin) * @query status, supplier_id, category_id, payment_method, date_from, date_to, search, include_deleted, page, limit */ router.get('/invoices', BackofficeController.getInvoices) @@ -141,7 +143,7 @@ router.get('/invoices', BackofficeController.getInvoices) /** * @route POST /api/backoffice/invoices/download-zip * @desc Descargar múltiples facturas validadas como ZIP - * @access Private (admin, demo-admin) + * @access Private (admin) * @body invoice_ids: number[] (max 100) */ router.post('/invoices/download-zip', BackofficeController.downloadValidatedInvoicesZip) @@ -149,7 +151,7 @@ router.post('/invoices/download-zip', BackofficeController.downloadValidatedInvo /** * @route GET /api/backoffice/invoices/batch-pay/preview * @desc Preview batch payment - muestra cuántas facturas se marcarían como pagadas - * @access Private (admin, demo-admin) + * @access Private (admin) * @query year, month (opcional, default: mes anterior) */ router.get('/invoices/batch-pay/preview', BackofficeController.previewBatchPayment) @@ -160,12 +162,12 @@ router.get('/invoices/batch-pay/preview', BackofficeController.previewBatchPayme * @access Private (admin only) * @body { year?: number, month?: number } (opcional, default: mes anterior) */ -router.post('/invoices/batch-pay', isRealAdmin, BackofficeController.executeBatchPayment) +router.post('/invoices/batch-pay', isAdmin, BackofficeController.executeBatchPayment) /** * @route GET /api/backoffice/invoices/batch-pay/revert/preview * @desc Preview revert batch payment - muestra cuántas facturas se revertirían a validated - * @access Private (admin, demo-admin) + * @access Private (admin) * @query year, month (requeridos) */ router.get('/invoices/batch-pay/revert/preview', BackofficeController.previewRevertBatchPayment) @@ -176,19 +178,19 @@ router.get('/invoices/batch-pay/revert/preview', BackofficeController.previewRev * @access Private (admin only) * @body { year: number, month: number } (requeridos) */ -router.post('/invoices/batch-pay/revert', isRealAdmin, BackofficeController.revertBatchPayment) +router.post('/invoices/batch-pay/revert', isAdmin, BackofficeController.revertBatchPayment) /** * @route GET /api/backoffice/invoices/:id * @desc Obtener factura por ID con historial - * @access Private (admin, demo-admin) + * @access Private (admin) */ router.get('/invoices/:id', BackofficeController.getInvoiceById) /** * @route GET /api/backoffice/invoices/:id/history * @desc Obtener historial de una factura - * @access Private (admin, demo-admin) + * @access Private (admin) */ router.get('/invoices/:id/history', BackofficeController.getInvoiceHistory) @@ -198,14 +200,14 @@ router.get('/invoices/:id/history', BackofficeController.getInvoiceHistory) * @access Private (admin only) * @body invoice_number, supplier_id, amount_without_vat, amount_with_vat, invoice_date, payment_method, etc. */ -router.post('/invoices', isRealAdmin, BackofficeController.createInvoice) +router.post('/invoices', isAdmin, BackofficeController.createInvoice) /** * @route PATCH /api/backoffice/invoices/:id * @desc Actualizar factura * @access Private (admin only) */ -router.patch('/invoices/:id', isRealAdmin, BackofficeController.updateInvoice) +router.patch('/invoices/:id', isAdmin, BackofficeController.updateInvoice) /** * @route POST /api/backoffice/invoices/:id/validate @@ -213,7 +215,7 @@ router.patch('/invoices/:id', isRealAdmin, BackofficeController.updateInvoice) * @access Private (admin only) * @body validated_pdf_url, validated_pdf_public_id, validation_notes (opcional) */ -router.post('/invoices/:id/validate', isRealAdmin, BackofficeController.validateInvoice) +router.post('/invoices/:id/validate', isAdmin, BackofficeController.validateInvoice) /** * @route POST /api/backoffice/invoices/:id/reject @@ -221,7 +223,7 @@ router.post('/invoices/:id/validate', isRealAdmin, BackofficeController.validate * @access Private (admin only) * @body notes (requerido) */ -router.post('/invoices/:id/reject', isRealAdmin, BackofficeController.rejectInvoice) +router.post('/invoices/:id/reject', isAdmin, BackofficeController.rejectInvoice) /** * @route POST /api/backoffice/invoices/:id/unvalidate @@ -229,7 +231,7 @@ router.post('/invoices/:id/reject', isRealAdmin, BackofficeController.rejectInvo * @access Private (admin only) * @body notes (opcional) */ -router.post('/invoices/:id/unvalidate', isRealAdmin, BackofficeController.unvalidateInvoice) +router.post('/invoices/:id/unvalidate', isAdmin, BackofficeController.unvalidateInvoice) /** * @route POST /api/backoffice/invoices/:id/pay @@ -237,21 +239,21 @@ router.post('/invoices/:id/unvalidate', isRealAdmin, BackofficeController.unvali * @access Private (admin only) * @body paid_date (requerido) */ -router.post('/invoices/:id/pay', isRealAdmin, BackofficeController.markAsPaid) +router.post('/invoices/:id/pay', isAdmin, BackofficeController.markAsPaid) /** * @route POST /api/backoffice/invoices/:id/unpay * @desc Revertir el pago de una factura (paid -> validated) * @access Private (admin only) */ -router.post('/invoices/:id/unpay', isRealAdmin, BackofficeController.revertPayment) +router.post('/invoices/:id/unpay', isAdmin, BackofficeController.revertPayment) /** * @route DELETE /api/backoffice/invoices/:id * @desc Eliminar factura (hard delete - eliminación permanente) * @access Private (admin only) */ -router.delete('/invoices/:id', isRealAdmin, BackofficeController.deleteInvoice) +router.delete('/invoices/:id', isAdmin, BackofficeController.deleteInvoice) /** * @route POST /api/backoffice/invoices/:id/pdf @@ -262,7 +264,7 @@ router.delete('/invoices/:id', isRealAdmin, BackofficeController.deleteInvoice) */ router.post( '/invoices/:id/pdf', - isRealAdmin, + isAdmin, upload.single('pdf'), BackofficeController.uploadInvoicePdf ) @@ -271,7 +273,7 @@ router.post( * @route GET /api/backoffice/invoices/:id/pdf-download * @desc PDF de la factura. Los ficheros son privados en Cloudinary: la API * los descarga y los sirve; su URL firmada no sale nunca del servidor - * @access Private (admin, demo-admin) + * @access Private (admin) * @query type (original/validated) */ router.get('/invoices/:id/pdf-download', BackofficeController.downloadInvoicePdf) @@ -283,7 +285,7 @@ router.get('/invoices/:id/pdf-download', BackofficeController.downloadInvoicePdf /** * @route GET /api/backoffice/assets * @desc Obtener todos los assets - * @access Private (admin, demo-admin) + * @access Private (admin) * @query type (stamp/signature) opcional */ router.get('/assets', BackofficeController.getAssets) @@ -291,7 +293,7 @@ router.get('/assets', BackofficeController.getAssets) /** * @route GET /api/backoffice/assets/:id/file * @desc Imagen del sello o la firma (fichero privado servido por la API) - * @access Private (admin, demo-admin) + * @access Private (admin) */ router.get('/assets/:id/file', BackofficeController.getAssetFile) @@ -302,20 +304,20 @@ router.get('/assets/:id/file', BackofficeController.getAssetFile) * @body type (stamp/signature), name, is_default (opcional) * @file image (multipart/form-data) - PNG o WebP */ -router.post('/assets', isRealAdmin, upload.single('image'), BackofficeController.createAsset) +router.post('/assets', isAdmin, upload.single('image'), BackofficeController.createAsset) /** * @route DELETE /api/backoffice/assets/:id * @desc Eliminar asset * @access Private (admin only) */ -router.delete('/assets/:id', isRealAdmin, BackofficeController.deleteAsset) +router.delete('/assets/:id', isAdmin, BackofficeController.deleteAsset) /** * @route PATCH /api/backoffice/assets/:id/default * @desc Establecer asset como predeterminado * @access Private (admin only) */ -router.patch('/assets/:id/default', isRealAdmin, BackofficeController.setDefaultAsset) +router.patch('/assets/:id/default', isAdmin, BackofficeController.setDefaultAsset) export default router diff --git a/backend/routes/demo/demo-activity-routes.ts b/backend/routes/demo/demo-activity-routes.ts index 66628238..8771aae4 100644 --- a/backend/routes/demo/demo-activity-routes.ts +++ b/backend/routes/demo/demo-activity-routes.ts @@ -1,19 +1,22 @@ // backend/routes/demo/demo-activity-routes.ts /** * Rutas para consultar y gestionar logs de actividad demo - * Solo accesible para administradores reales (no demo-admin) + * Solo accesible para administradores (nunca la cuenta demo) */ import { Router } from 'express' import { DemoActivityController } from '../../controllers/demo/demo-activity-controller.js' import { authenticateToken } from '../../middlewares/authenticateToken.js' -import { isRealAdmin } from '../../middlewares/roleCheck.js' +import { isAdmin } from '../../middlewares/roleCheck.js' +import { denyDemo } from '../../middlewares/demoRestriction.js' const router = Router() -// Aplicar autenticación y verificar que es admin real +// Aplicar autenticación y verificar que es admin router.use(authenticateToken) -router.use(isRealAdmin) +// The log holds visitors' IPs: never for the demo account, which is an admin +router.use(denyDemo) +router.use(isAdmin) // ======================================== // RUTAS DE LOGS @@ -22,7 +25,7 @@ router.use(isRealAdmin) /** * @route GET /api/demo-activity/logs * @desc Obtener logs de actividad demo con paginación y filtros - * @access Private (admin only, not demo-admin) + * @access Private (admin, not the demo account) * @query limit, offset, username, method, route, startDate, endDate, blocked */ router.get('/logs', DemoActivityController.getLogs) @@ -30,7 +33,7 @@ router.get('/logs', DemoActivityController.getLogs) /** * @route GET /api/demo-activity/stats * @desc Obtener estadísticas de actividad demo - * @access Private (admin only, not demo-admin) + * @access Private (admin, not the demo account) * @query days (default 30, max 365) */ router.get('/stats', DemoActivityController.getStats) @@ -38,7 +41,7 @@ router.get('/stats', DemoActivityController.getStats) /** * @route GET /api/demo-activity/export * @desc Exportar logs a formato markdown (descargar registrosDemo.md) - * @access Private (admin only, not demo-admin) + * @access Private (admin, not the demo account) * @query limit (default 100, max 500) */ router.get('/export', DemoActivityController.exportToMarkdown) @@ -46,7 +49,7 @@ router.get('/export', DemoActivityController.exportToMarkdown) /** * @route DELETE /api/demo-activity/cleanup * @desc Limpiar logs antiguos - * @access Private (admin only, not demo-admin) + * @access Private (admin, not the demo account) * @query daysToKeep (default 90, min 30) */ router.delete('/cleanup', DemoActivityController.cleanupOldLogs) diff --git a/backend/routes/demo/demo-reset-routes.ts b/backend/routes/demo/demo-reset-routes.ts new file mode 100644 index 00000000..c8edd186 --- /dev/null +++ b/backend/routes/demo/demo-reset-routes.ts @@ -0,0 +1,33 @@ +// routes/demo/demo-reset-routes.ts +/** + * Configuración → Demo (ADR-038). Solo con DEMO_MODE=true: el reinicio borra + * los datos de todos los módulos, así que en cualquier otra instalación la + * ruta no existe. + */ + +import { Router, type Request, type Response, type NextFunction } from 'express' +import { getStatus, resetNow, saveSnapshot } from '../../controllers/demo/demo-reset-controller.js' +import { authenticateToken } from '../../middlewares/authenticateToken.js' +import { denyDemo } from '../../middlewares/demoRestriction.js' +import { isAdmin } from '../../middlewares/roleCheck.js' +import { isDemoMode } from '../../services/demo/demo-reset.service.js' + +const router = Router() + +router.use((_req: Request, res: Response, next: NextFunction) => { + if (!isDemoMode()) { + res.status(404).json({ error: 'Ruta no encontrada' }) + return + } + next() +}) +router.use(authenticateToken) +// The demo account is an admin: it must not reset the demo for everyone +router.use(denyDemo) +router.use(isAdmin) + +router.get('/status', getStatus) +router.post('/reset', resetNow) +router.post('/snapshot', saveSnapshot) + +export default router diff --git a/backend/routes/departments/departments-routes.ts b/backend/routes/departments/departments-routes.ts index c8a722d9..7e130628 100644 --- a/backend/routes/departments/departments-routes.ts +++ b/backend/routes/departments/departments-routes.ts @@ -11,6 +11,7 @@ import { import { authenticateToken } from '../../middlewares/authenticateToken.js' import { isAdmin, excludeMantenimiento } from '../../middlewares/roleCheck.js' +import { denyDemoWrites } from '../../middlewares/demoRestriction.js' const router = Router() @@ -20,6 +21,8 @@ const router = Router() // ======================================== router.use(authenticateToken) router.use(excludeMantenimiento) +// Departments are configuration: the demo account only reads them +router.use(denyDemoWrites) // ======================================== // RUTAS diff --git a/backend/routes/scheduling/scheduling-routes.ts b/backend/routes/scheduling/scheduling-routes.ts index 364d43aa..b51eb592 100644 --- a/backend/routes/scheduling/scheduling-routes.ts +++ b/backend/routes/scheduling/scheduling-routes.ts @@ -73,6 +73,8 @@ import { generateSchedule } from '../../controllers/scheduling/schedule-generate // Middlewares import { authenticateToken } from '../../middlewares/authenticateToken.js' import { isAdmin } from '../../middlewares/roleCheck.js' +import { denyDemoWrites } from '../../middlewares/demoRestriction.js' +import { demoGenerateLimiter } from '../../middlewares/rateLimiter.js' const router: Router = express.Router() @@ -87,6 +89,10 @@ const router: Router = express.Router() router.use(authenticateToken) router.use(isAdmin) +// The demo account cannot change the configuration; months, assignments, +// requests and generating stay open, and are reset daily (ADR-038) +router.use(['/config', '/shifts', '/rules', '/employees', '/contracts'], denyDemoWrites) + // ======================================== // CONFIG ROUTES // ======================================== @@ -149,7 +155,7 @@ router.delete('/months/:id', isAdmin, deleteMonth) router.post('/months/:id/validate', validateSchedule) // POST - Generate schedule with CP-SAT solver (admin only) -router.post('/months/:id/generate', isAdmin, generateSchedule) +router.post('/months/:id/generate', isAdmin, demoGenerateLimiter, generateSchedule) // POST - Reset month (clear assignments, re-load approved constraints) router.post('/months/:id/reset', isAdmin, resetMonth) diff --git a/backend/scripts/create-demo-user.ts b/backend/scripts/create-demo-user.ts new file mode 100644 index 00000000..dc5da065 --- /dev/null +++ b/backend/scripts/create-demo-user.ts @@ -0,0 +1,67 @@ +/** + * create-demo-user.ts + * + * Crea o actualiza la cuenta demo pública (ADR-038): usuario `demo`, rol admin, + * `is_demo = 1`. Su contraseña es aleatoria y no se guarda en ningún sitio: a + * la cuenta solo se entra por POST /api/auth/demo, y el login con contraseña la + * rechaza. Si ya existe un usuario `demo` (el antiguo, con rol demo-admin), lo + * convierte. + * + * Necesita la columna users.is_demo (db-mysql/scripts/20261005_add_is_demo_to_users.sql). + * + * Uso: + * pnpm demo:user (BD local) + * pnpm demo:user:aiven (Aiven) + */ + +import bcrypt from 'bcrypt' +import crypto from 'node:crypto' +import type { RowDataPacket } from 'mysql2' +import db from '../config/db.js' +import { SALT_ROUNDS } from '../config/config.js' + +const USERNAME = 'demo' +const EMAIL = 'demo@example.com' + +async function main(): Promise { + const [roles] = await db.query("SELECT id FROM roles WHERE name = 'admin'") + if (roles.length === 0) throw new Error("No existe el rol 'admin'") + const roleId = roles[0].id as number + + const password = await bcrypt.hash(crypto.randomBytes(32).toString('base64url'), SALT_ROUNDS) + + const [existing] = await db.query('SELECT id FROM users WHERE username = ?', [ + USERNAME, + ]) + + if (existing.length > 0) { + await db.query( + `UPDATE users + SET role_id = ?, password = ?, email = ?, is_active = 1, is_demo = 1, avatar_url = NULL, + avatar_public_id = NULL + WHERE id = ?`, + [roleId, password, EMAIL, existing[0].id] + ) + console.log(`Cuenta demo actualizada: ${USERNAME} (${existing[0].id})`) + } else { + const id = crypto.randomUUID() + await db.query( + `INSERT INTO users (id, username, email, password, role_id, created_at, is_active, is_demo) + VALUES (?, ?, ?, ?, ?, NOW(), 1, 1)`, + [id, USERNAME, EMAIL, password, roleId] + ) + console.log(`Cuenta demo creada: ${USERNAME} (${id})`) + } + + const [demo] = await db.query( + 'SELECT COUNT(*) AS n FROM users WHERE is_demo = 1 AND is_active = 1' + ) + console.log(`Cuentas demo activas: ${demo[0].n}`) +} + +main() + .catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + }) + .finally(() => db.end()) diff --git a/backend/scripts/setup-local.ts b/backend/scripts/setup-local.ts new file mode 100644 index 00000000..9a0092db --- /dev/null +++ b/backend/scripts/setup-local.ts @@ -0,0 +1,173 @@ +/** + * setup-local.ts — `pnpm setup:local` + * + * Monta una BD local desde cero para probar la aplicación con un admin + * completo (ADR-038): + * 1. tablas: MASTER_INSTALL.sql y las migraciones posteriores a su baseline; + * 2. departamentos que usan los módulos y el mock; + * 3. el admin local `admin` / `fourpoints-local`, público y documentado en el + * README: solo puede existir en una BD local; + * 4. datos ficticios (mock-data.sql). + * + * MASTER_INSTALL.sql borra y crea `hotel_db`: el script se niega a seguir si + * la BD no es local, si no se llama hotel_db o si ya tiene tablas (salvo + * --force). + * + * Uso: + * pnpm setup:local # admin `admin` / `fourpoints-local` + * pnpm setup:local -- --admin maria # otro nombre + * pnpm setup:local -- --force # borra una hotel_db que ya exista + */ + +import bcrypt from 'bcrypt' +import crypto from 'node:crypto' +import { readdir } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import mysql from 'mysql2/promise' +import type { RowDataPacket } from 'mysql2/promise' +import { dbConfig } from '../config/db-config.js' +import { SALT_ROUNDS } from '../config/config.js' +import { readSqlScript } from '../services/db/sql-script.js' + +const DB_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'db-mysql') +const DATABASE = 'hotel_db' // fixed in MASTER_INSTALL.sql + +// MASTER_INSTALL.sql is the baseline frozen on 2026-05-20; later changes are +// the incremental scripts from this date on (db-mysql/INDEX.md) +const FIRST_SCRIPT_AFTER_BASELINE = '20260521' + +// Used by the modules and by mock-data.sql, which looks them up by name +const DEPARTMENTS = [ + 'pisos', + 'mantenimiento', + 'reservas', + 'parking', + 'clientes', + 'backoffice', + 'blacklist', + 'grupos', +] + +const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1']) + +export const LOCAL_ADMIN_PASSWORD = 'fourpoints-local' + +// Bugs of the frozen baseline (aiven/NN, ADR-021) that stop a fresh install; +// on Aiven those tables were created another way. Fixed here, in memory, as +// exact replacements: if the text no longer matches, the setup stops instead +// of guessing. scripts/20261005_complete_fresh_install.sql completes the same +// tables for an install made by hand with the mysql client. +const BASELINE_FIXES: Array<{ file: string; from: string; to: string; why: string }> = [ + { + file: '11_cashier.sql', + from: 'CONSTRAINT fk_history_user FOREIGN KEY (changed_by)', + to: 'CONSTRAINT fk_cashier_history_user FOREIGN KEY (changed_by)', + why: 'fk_history_user already belongs to logbook_history; Aiven uses this name', + }, + { + file: '11_cashier.sql', + from: 'CONSTRAINT fk_history_shift FOREIGN KEY (shift_id)', + to: 'CONSTRAINT fk_cashier_history_shift FOREIGN KEY (shift_id)', + why: 'same name as on Aiven', + }, + { + file: '19_scheduling.sql', + from: 'DROP TABLE IF EXISTS scheduling_config;\n\nSET FOREIGN_KEY_CHECKS = 1;', + to: 'DROP TABLE IF EXISTS scheduling_config;\n\nSET FOREIGN_KEY_CHECKS = 0;', + why: 'scheduling_assignments references scheduling_constraints, created after it', + }, +] + +function fixBaseline(path: string, text: string): string { + for (const fix of BASELINE_FIXES.filter((f) => path.endsWith(f.file))) { + if (text.split(fix.from).length !== 2) { + throw new Error(`El baseline ${fix.file} ha cambiado; revisa BASELINE_FIXES (${fix.why})`) + } + text = text.replace(fix.from, fix.to) + } + return text +} + +function arg(name: string): string | undefined { + const i = process.argv.indexOf(`--${name}`) + return i === -1 ? undefined : process.argv[i + 1] +} + +async function main(): Promise { + if ((process.env.DB_ENVIRONMENT || 'local') !== 'local' || !LOCAL_HOSTS.has(dbConfig.host)) { + throw new Error('Solo para una BD local (DB_ENVIRONMENT=local y LOCAL_DB_HOST=localhost)') + } + if (dbConfig.database !== DATABASE) { + throw new Error(`MASTER_INSTALL.sql crea "${DATABASE}": pon LOCAL_DB_NAME=${DATABASE}`) + } + + const adminName = arg('admin') ?? 'admin' + // Public on purpose (README): this script only runs against a local database + const password = LOCAL_ADMIN_PASSWORD + const { database: _db, ...server } = dbConfig + const conn = await mysql.createConnection({ ...server, multipleStatements: true }) + + try { + const [existing] = await conn.query( + 'SELECT COUNT(*) AS n FROM information_schema.tables WHERE table_schema = ?', + [DATABASE] + ) + if (Number(existing[0].n) > 0 && !process.argv.includes('--force')) { + throw new Error( + `"${DATABASE}" ya tiene tablas y MASTER_INSTALL.sql la borraría. ` + + 'Si es lo que quieres: pnpm setup:local -- --force' + ) + } + + console.log('1/4 Tablas (MASTER_INSTALL.sql)') + await conn.query(await readSqlScript(join(DB_DIR, 'MASTER_INSTALL.sql'), DB_DIR, fixBaseline)) + await conn.query(`USE \`${DATABASE}\``) + + const scripts = (await readdir(join(DB_DIR, 'scripts'))) + .filter((f) => f.endsWith('.sql') && f.slice(0, 8) >= FIRST_SCRIPT_AFTER_BASELINE) + .sort() + for (const file of scripts) { + console.log(` + scripts/${file}`) + await conn.query(await readSqlScript(join(DB_DIR, 'scripts', file))) + } + + console.log('2/4 Departamentos') + await conn.query('INSERT IGNORE INTO departments (name) VALUES ?', [ + DEPARTMENTS.map((name) => [name]), + ]) + + console.log('3/4 Admin') + const [roles] = await conn.query("SELECT id FROM roles WHERE name = 'admin'") + await conn.query( + `INSERT INTO users (id, username, email, password, role_id, created_at, is_active) + VALUES (?, ?, ?, ?, ?, NOW(), 1)`, + [ + crypto.randomUUID(), + adminName, + `${adminName}@example.com`, + await bcrypt.hash(password, SALT_ROUNDS), + roles[0].id, + ] + ) + + console.log('4/4 Datos ficticios (mock-data.sql)') + // MASTER_INSTALL.sql leaves safe updates on for this session; the mock empties whole tables + await conn.query('SET SQL_SAFE_UPDATES = 0') + await conn.query(await readSqlScript(join(DB_DIR, 'mock-data.sql'))) + + console.log('\nListo. Entra en http://localhost:3000 con:') + console.log(` usuario: ${adminName}`) + console.log(` contraseña: ${password}`) + console.log('Es la contraseña pública del README: cámbiala en Perfil si quieres.') + } finally { + await conn.end() + } +} + +main() + .then(() => process.exit(0)) + .catch((error: unknown) => { + console.error(`\n✗ ${error instanceof Error ? error.message : error}`) + process.exit(1) + }) diff --git a/backend/services/auth/module-access.ts b/backend/services/auth/module-access.ts index b9fb9979..13fff2fa 100644 --- a/backend/services/auth/module-access.ts +++ b/backend/services/auth/module-access.ts @@ -8,7 +8,7 @@ export type AppModule = 'parking' | 'maintenance' | 'groups' | 'blacklist' | 'cashier' | 'logbook' const allButMaintenanceRole = (role: string) => role !== 'mantenimiento' -const staffRoles = ['admin', 'recepcionista', 'group-admin', 'mantenimiento', 'demo-admin'] +const staffRoles = ['admin', 'recepcionista', 'group-admin', 'mantenimiento'] const MODULE_ACCESS: Record boolean> = { parking: allButMaintenanceRole, // excludeMantenimiento @@ -31,7 +31,7 @@ export function openableModules( return new Set(modules.filter((m) => canOpenModule(role, m))) } -// Only admin: the demo-admin user is disabled, so new rules leave that role out +// Only admin (the public demo account is an admin too, limited by demoRestriction) export function isAdminRole(role: string | undefined): boolean { return role?.toLowerCase() === 'admin' } diff --git a/backend/services/auth/tokenService.ts b/backend/services/auth/tokenService.ts index 5ee8ee61..d0561a27 100644 --- a/backend/services/auth/tokenService.ts +++ b/backend/services/auth/tokenService.ts @@ -14,6 +14,7 @@ interface UserForToken { id: string username?: string role?: string + demo?: boolean type?: 'access' | 'refresh' } @@ -29,6 +30,7 @@ export function generateAccessToken(user: UserForToken): string { id: user.id, username: user.username || '', role: user.role || 'user', + ...(user.demo ? { demo: true } : {}), type: 'access', } @@ -50,6 +52,7 @@ export function generateRefreshToken(user: UserForToken): string { id: user.id, username: user.username || '', role: user.role || 'user', + ...(user.demo ? { demo: true } : {}), type: 'refresh', } diff --git a/backend/services/backoffice/AGENTS.md b/backend/services/backoffice/AGENTS.md index 5b01b386..9967091b 100644 --- a/backend/services/backoffice/AGENTS.md +++ b/backend/services/backoffice/AGENTS.md @@ -111,13 +111,14 @@ A `revertBatchPayment` endpoint exists for undo (also with a preview). ## Role boundaries -- **Read anything** (stats, categories, suppliers, invoices, assets) — `admin`, - `demo-admin` (`canAccessBackoffice`) +- **Read anything** (stats, categories, suppliers, invoices, assets) — `admin` + (`canAccessBackoffice`) - **Create/update/delete suppliers, invoices, assets; validate/reject/pay - invoices; batch operations** — `admin` only (`isRealAdmin`) + invoices; batch operations** — `admin` only (`isAdmin`) -`canAccessBackoffice` = `['admin', 'demo-admin']`. `isRealAdmin` = `['admin']` -only. +`canAccessBackoffice` = `isAdmin` = `['admin']`. The public demo account is an +admin: it cannot create categories or upload PDFs/assets (`denyDemo`, +`demoRestriction`). ## Frontend layout diff --git a/backend/services/cashier/AGENTS.md b/backend/services/cashier/AGENTS.md index 693410de..dffd9711 100644 --- a/backend/services/cashier/AGENTS.md +++ b/backend/services/cashier/AGENTS.md @@ -113,9 +113,9 @@ patches. - **Read daily, shifts, vouchers** — all authenticated (excl. `mantenimiento`) - **Create/update/close shifts, payments, denominations, vouchers** — `admin`, - `recepcionista`, `group-admin`, `demo-admin` (`canManageCashier`) + `recepcionista`, `group-admin` (`canManageCashier`) - **Reopen shifts or days, delete any resource** — `admin` only (`isAdmin`) -- **Reports, history, stats** — `admin`, `demo-admin` (`canViewReports`) +- **Reports, history, stats** — `admin` (`canViewReports`) `mantenimiento` role is excluded at the router level (`excludeMantenimiento`). diff --git a/backend/services/db/sql-script.ts b/backend/services/db/sql-script.ts new file mode 100644 index 00000000..cffa77cf --- /dev/null +++ b/backend/services/db/sql-script.ts @@ -0,0 +1,50 @@ +// services/db/sql-script.ts +/** + * Ficheros .sql del repositorio para enviarlos en una sola petición + * multi-statement desde Node, sin el cliente mysql: SOURCE y DELIMITER son + * comandos de ese cliente, no SQL. Solo para ficheros del repositorio, nunca + * para texto que venga de una petición. + */ + +import { readFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' + +/** + * Removes DELIMITER lines; the server already parses CREATE PROCEDURE ... END + * as one statement, so the custom delimiter goes back to ';' + */ +export function toMultiStatement(sql: string): string { + let delimiter = ';' + const out: string[] = [] + for (const line of sql.split(/\r?\n/)) { + const change = /^\s*DELIMITER\s+(\S+)\s*$/i.exec(line) + if (change) { + delimiter = change[1] + continue + } + out.push( + delimiter !== ';' && line.trimEnd().endsWith(delimiter) + ? line.trimEnd().slice(0, -delimiter.length) + ';' + : line + ) + } + return out.join('\n') +} + +/** + * Reads a .sql file and inlines its `SOURCE path;` lines (relative to root, as + * MASTER_INSTALL.sql expects). `transform` may adjust each file's text first + */ +export async function readSqlScript( + file: string, + root = dirname(file), + transform: (path: string, text: string) => string = (_path, text) => text +): Promise { + const text = transform(file, (await readFile(file, 'utf8')).replace(/\r\n/g, '\n')) + const parts: string[] = [] + for (const line of text.split('\n')) { + const source = /^\s*SOURCE\s+(.+?);?\s*$/i.exec(line) + parts.push(source ? await readSqlScript(resolve(root, source[1]), root, transform) : line) + } + return toMultiStatement(parts.join('\n')) +} diff --git a/backend/services/demo/demo-reset.service.ts b/backend/services/demo/demo-reset.service.ts new file mode 100644 index 00000000..a03ca6c4 --- /dev/null +++ b/backend/services/demo/demo-reset.service.ts @@ -0,0 +1,254 @@ +// services/demo/demo-reset.service.ts +/** + * Reinicio diario de la demo pública (ADR-038). + * + * Un reinicio: + * 1. vuelve a cargar db-mysql/mock-data.sql (datos de los módulos, con fechas + * relativas a hoy; no toca usuarios ni horarios); + * 2. restaura la base de horarios guardada en las tablas demo_snapshot_*. + * + * Se lanza solo con la primera entrada a la demo de cada día (Render gratuito + * se duerme, así que un cron a hora fija podría no ejecutarse) o a mano desde + * Configuración → Demo. La base de horarios la guarda un admin, también desde + * ahí, después de generar los meses con el solver. + * + * Todo corre en una conexión propia: GET_LOCK evita dos reinicios a la vez y + * multipleStatements permite enviar el mock entero, que es un fichero del + * repositorio y nunca lleva datos de la petición. + */ + +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import mysql from 'mysql2/promise' +import type { Connection, RowDataPacket } from 'mysql2/promise' +import db, { dbConfig } from '../../config/db.js' +import { formatDateMadrid, getTodayMadrid } from '../../config/date-utils.js' +import { logger } from '../../config/logger.js' +import { readSqlScript } from '../db/sql-script.js' + +const MOCK_FILE = join( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + 'db-mysql', + 'mock-data.sql' +) + +const LOCK_NAME = 'four_points_demo_reset' + +// Every scheduling table: the base is the whole module, configuration included +export const SNAPSHOT_TABLES = [ + 'scheduling_config', + 'scheduling_shifts', + 'scheduling_employees', + 'scheduling_employee_contracts', + 'scheduling_employee_rules', + 'scheduling_employee_requests', + 'scheduling_months', + 'scheduling_days', + 'scheduling_constraints', + 'scheduling_assignments', + 'scheduling_history', + 'scheduling_solver_runs', +] as const + +const snapshotName = (table: string) => `demo_snapshot_${table}` + +export type DemoTrigger = 'auto' | 'manual' + +export interface DemoResetResult { + status: 'done' | 'busy' | 'failed' + scheduling?: 'restored' | 'no-snapshot' +} + +export const isDemoMode = (): boolean => process.env.DEMO_MODE === 'true' + +async function openConnection(): Promise { + return mysql.createConnection({ ...dbConfig, multipleStatements: true }) +} + +async function startLog( + action: 'reset' | 'snapshot', + trigger: DemoTrigger, + userId?: string +): Promise { + const [result] = await db.query( + 'INSERT INTO demo_reset_log (action, trigger_type, user_id) VALUES (?, ?, ?)', + [action, trigger, userId ?? null] + ) + return result.insertId +} + +async function finishLog(id: number, ok: boolean, details: string): Promise { + await db.query( + 'UPDATE demo_reset_log SET finished_at = NOW(), ok = ?, details = ? WHERE id = ?', + [ok ? 1 : 0, details.slice(0, 500), id] + ) +} + +async function snapshotExists(conn: Connection): Promise { + const [rows] = await conn.query( + `SELECT COUNT(*) AS n FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name IN (?)`, + [SNAPSHOT_TABLES.map(snapshotName)] + ) + return Number(rows[0].n) === SNAPSHOT_TABLES.length +} + +async function restoreScheduling(conn: Connection): Promise { + await conn.query('START TRANSACTION') + try { + // Foreign keys off: the tables are emptied and refilled as a whole + await conn.query('SET FOREIGN_KEY_CHECKS = 0') + for (const table of SNAPSHOT_TABLES) { + await conn.query(`DELETE FROM \`${table}\``) + await conn.query(`INSERT INTO \`${table}\` SELECT * FROM \`${snapshotName(table)}\``) + } + await conn.query('SET FOREIGN_KEY_CHECKS = 1') + await conn.query('COMMIT') + } catch (error) { + await conn.query('ROLLBACK') + await conn.query('SET FOREIGN_KEY_CHECKS = 1') + throw error + } +} + +async function withLock( + work: (conn: Connection) => Promise +): Promise<{ busy: true } | { busy: false; value: T }> { + const conn = await openConnection() + try { + const [rows] = await conn.query('SELECT GET_LOCK(?, 0) AS got', [LOCK_NAME]) + if (Number(rows[0].got) !== 1) return { busy: true } + try { + return { busy: false, value: await work(conn) } + } finally { + await conn.query('SELECT RELEASE_LOCK(?)', [LOCK_NAME]) + } + } finally { + await conn.end() + } +} + +/** + * Reloads the mock data and restores the scheduling base, if one was saved + */ +export async function resetDemoData( + trigger: DemoTrigger, + userId?: string +): Promise { + const startedAt = Date.now() + // Logged only once the lock is held: a reset that finds another running leaves no row + let logId: number | undefined + + try { + const result = await withLock(async (conn) => { + logId = await startLog('reset', trigger, userId) + const mock = await readSqlScript(MOCK_FILE) + try { + await conn.query(mock) + } catch (error) { + // The mock opens its own transaction: undo whatever it left half done + await conn.query('ROLLBACK').catch(() => undefined) + throw error + } + + if (!(await snapshotExists(conn))) return 'no-snapshot' as const + await restoreScheduling(conn) + return 'restored' as const + }) + + if (result.busy) return { status: 'busy' } + + const ms = Date.now() - startedAt + await finishLog(logId!, true, `scheduling=${result.value}; ${ms} ms`) + logger.info({ event: 'demo_reset', trigger, scheduling: result.value, ms }, '[DEMO] reset') + return { status: 'done', scheduling: result.value } + } catch (error) { + logger.error({ err: error, trigger }, '[DEMO] reset failed') + // A code, never the SQL text (it names tables and columns) + const code = (error as { code?: string }).code ?? 'ERROR' + if (logId) await finishLog(logId, false, `failed: ${code}`).catch(() => undefined) + return { status: 'failed' } + } +} + +/** + * Saves the current scheduling tables as the base the daily reset restores + */ +export async function saveSchedulingSnapshot(userId: string): Promise { + let logId: number | undefined + try { + const result = await withLock(async (conn) => { + logId = await startLog('snapshot', 'manual', userId) + for (const table of SNAPSHOT_TABLES) { + const snapshot = snapshotName(table) + await conn.query(`DROP TABLE IF EXISTS \`${snapshot}\``) + await conn.query(`CREATE TABLE \`${snapshot}\` LIKE \`${table}\``) + await conn.query(`INSERT INTO \`${snapshot}\` SELECT * FROM \`${table}\``) + } + const [rows] = await conn.query( + `SELECT COUNT(*) AS n FROM \`${snapshotName('scheduling_months')}\`` + ) + return Number(rows[0].n) + }) + + if (result.busy) return { status: 'busy' } + await finishLog(logId!, true, `months=${result.value}`) + logger.info({ event: 'demo_snapshot', months: result.value }, '[DEMO] scheduling base saved') + return { status: 'done' } + } catch (error) { + logger.error({ err: error }, '[DEMO] saving the scheduling base failed') + const code = (error as { code?: string }).code ?? 'ERROR' + if (logId) await finishLog(logId, false, `failed: ${code}`).catch(() => undefined) + return { status: 'failed' } + } +} + +/** + * Resets once a day: only when there is no successful reset since midnight + * (Madrid). Called on each demo entry; never throws, so the entry goes on. + */ +export async function resetIfStale(): Promise { + try { + const [rows] = await db.query( + `SELECT started_at FROM demo_reset_log + WHERE action = 'reset' AND ok = 1 + ORDER BY started_at DESC LIMIT 1` + ) + const last = rows[0]?.started_at as Date | undefined + if (last && formatDateMadrid(last) === getTodayMadrid()) return + await resetDemoData('auto') + } catch (error) { + logger.error({ err: error }, '[DEMO] daily reset check failed') + } +} + +export interface DemoStatus { + lastReset: { startedAt: Date; ok: boolean; trigger: DemoTrigger; details: string | null } | null + snapshot: { savedAt: Date; details: string | null } | null +} + +export async function getDemoStatus(): Promise { + const [resets] = await db.query( + `SELECT started_at, ok, trigger_type, details FROM demo_reset_log + WHERE action = 'reset' ORDER BY started_at DESC, id DESC LIMIT 1` + ) + const [snapshots] = await db.query( + `SELECT started_at, details FROM demo_reset_log + WHERE action = 'snapshot' AND ok = 1 ORDER BY started_at DESC, id DESC LIMIT 1` + ) + const reset = resets[0] + const snapshot = snapshots[0] + return { + lastReset: reset + ? { + startedAt: reset.started_at, + ok: Boolean(reset.ok), + trigger: reset.trigger_type, + details: reset.details, + } + : null, + snapshot: snapshot ? { savedAt: snapshot.started_at, details: snapshot.details } : null, + } +} diff --git a/backend/services/fnb/AGENTS.md b/backend/services/fnb/AGENTS.md index c0fa0511..00cac415 100644 --- a/backend/services/fnb/AGENTS.md +++ b/backend/services/fnb/AGENTS.md @@ -128,7 +128,7 @@ it might become viable again. Don't upgrade without measuring. - **`DELETE /api/fnb/day/:date`**: delete all records for a day The whole subroute sits behind `authenticateToken` + `canAccessFnb`. Allowed -roles: `admin`, `recepcionista`, `demo-admin`, **`group-admin`** (the last one +roles: `admin`, `recepcionista`, **`group-admin`** (the last one added in `00bab83`). **About `/entries` (manual):** the manual path exists for when Opera doesn't @@ -188,7 +188,7 @@ monthlyExpenses) is also hardcoded — part of the mocked phase. ## Auth -`canAccessFnb` allows: `admin`, `group-admin`, `demo-admin`, `recepcionista`. +`canAccessFnb` allows: `admin`, `group-admin`, `recepcionista`. **Mantenimiento blocked.** No admin-only restrictions on endpoints — reception can upload PDFs and edit manually. diff --git a/backend/services/group/AGENTS.md b/backend/services/group/AGENTS.md index a78cadad..bf91c63b 100644 --- a/backend/services/group/AGENTS.md +++ b/backend/services/group/AGENTS.md @@ -108,7 +108,7 @@ the notification is saved but the email is skipped silently. - **Read groups, contacts, rooms, payments, status, history**: all authenticated (`canViewGroups` — includes `mantenimiento`) - **Create/update groups, contacts, rooms, payments, status**: `admin`, - `group-admin`, `demo-admin` (`canManageGroups`) + `group-admin` (`canManageGroups`) - **Delete group**: `admin` only (`canManageGroups` + `isAdmin`) `mantenimiento` can **view** groups (needed for notification access from the diff --git a/backend/tests/activity/activity-access.test.ts b/backend/tests/activity/activity-access.test.ts index 27268023..8920a6f0 100644 --- a/backend/tests/activity/activity-access.test.ts +++ b/backend/tests/activity/activity-access.test.ts @@ -12,7 +12,7 @@ describe('readableSources', () => { }) it('gives the other roles every source', () => { - for (const role of ['admin', 'recepcionista', 'group-admin', 'demo-admin']) { + for (const role of ['admin', 'recepcionista', 'group-admin']) { expect(readableSources(role).size).toBe(4) } }) diff --git a/backend/tests/auth/demo-limits.test.ts b/backend/tests/auth/demo-limits.test.ts new file mode 100644 index 00000000..fb8483ed --- /dev/null +++ b/backend/tests/auth/demo-limits.test.ts @@ -0,0 +1,100 @@ +// tests/auth/demo-limits.test.ts +// Limits for the shared demo account (ADR-038): schedule generation per IP and +// in total (one 512 MB Render instance runs the solver), and writes per IP. +// Other accounts are never limited by them. + +import { describe, it, expect, vi } from 'vitest' +import type { Request, Response, NextFunction } from 'express' + +const { demoGenerateLimiter, demoWriteLimiter } = await import('../../middlewares/rateLimiter.js') + +const DEMO = { id: 'd1', username: 'demo', role: 'admin', isDemo: true } +const OWNER = { id: 'a1', username: 'admin', role: 'admin' } + +function fakeReq(ip: string, user: object, method = 'POST'): Request { + return { + ip, + method, + user, + headers: {}, + socket: { remoteAddress: ip }, + app: { get: () => 3 }, + get: () => undefined, + } as unknown as Request +} + +function fakeRes() { + const res = { + statusCode: 200, + headersSent: false, + setHeader: vi.fn(), + getHeader: vi.fn(), + append: vi.fn(), + status: vi.fn((code: number) => { + res.statusCode = code + return res + }), + json: vi.fn(() => res), + send: vi.fn(() => res), + on: vi.fn(), + } + return res +} + +// Runs a middleware chain; returns the status (200 when every step called next) +async function run( + chain: Array<(req: Request, res: Response, next: NextFunction) => unknown>, + req: Request +) { + const res = fakeRes() + for (const mw of chain) { + let passed = false + await mw(req, res as unknown as Response, () => { + passed = true + }) + if (!passed) return res.statusCode + } + return 200 +} + +describe('demo schedule generation limit', () => { + it('allows 5 per hour per IP, then answers 429', async () => { + const codes = [] + for (let i = 0; i < 6; i++) + codes.push(await run(demoGenerateLimiter, fakeReq('198.51.100.1', DEMO))) + expect(codes).toEqual([200, 200, 200, 200, 200, 429]) + }) + + it('caps all visitors together at 30 per hour', async () => { + const codes = [] + // 25 more, from other IPs, after the 5 above + for (let i = 0; i < 26; i++) + codes.push(await run(demoGenerateLimiter, fakeReq(`198.51.100.${10 + i}`, DEMO))) + expect(codes.slice(0, 25).every((c) => c === 200)).toBe(true) + expect(codes[25]).toBe(429) + }) + + it('never limits other accounts', async () => { + for (let i = 0; i < 40; i++) { + expect(await run(demoGenerateLimiter, fakeReq('198.51.100.1', OWNER))).toBe(200) + } + }) +}) + +describe('demo write limit', () => { + it('allows 60 writes per 15 minutes per IP, then answers 429', async () => { + const codes = [] + for (let i = 0; i < 61; i++) + codes.push(await run([demoWriteLimiter], fakeReq('203.0.113.1', DEMO))) + expect(codes.filter((c) => c === 200)).toHaveLength(60) + expect(codes[60]).toBe(429) + }) + + it('does not count reads', async () => { + expect(await run([demoWriteLimiter], fakeReq('203.0.113.1', DEMO, 'GET'))).toBe(200) + }) + + it('never limits other accounts', async () => { + expect(await run([demoWriteLimiter], fakeReq('203.0.113.1', OWNER))).toBe(200) + }) +}) diff --git a/backend/tests/auth/demo-login.test.ts b/backend/tests/auth/demo-login.test.ts new file mode 100644 index 00000000..3cd26324 --- /dev/null +++ b/backend/tests/auth/demo-login.test.ts @@ -0,0 +1,86 @@ +// tests/auth/demo-login.test.ts +// POST /api/auth/demo (ADR-038): exists only with DEMO_MODE=true, enters the +// active demo account and marks its tokens as demo, so demoRestriction limits it. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { Request, Response } from 'express' + +process.env.SECRET_JWT_KEY ??= 'test-secret-key-for-vitest-only-not-prod' + +vi.mock('../../repositories/auth/user-repository.js', () => ({ + UserRepository: { getDemoUser: vi.fn() }, +})) +vi.mock('../../services/demo/demo-reset.service.js', () => ({ resetIfStale: vi.fn() })) + +const { UserRepository } = await import('../../repositories/auth/user-repository.js') +const { demoLogin } = await import('../../controllers/auth/auth-controllers.js') +const { verifyToken } = await import('../../services/auth/tokenService.js') +const { resetIfStale } = await import('../../services/demo/demo-reset.service.js') + +function fakeRes() { + const res = { + statusCode: 0, + cookies: {} as Record, + status: vi.fn((code: number) => { + res.statusCode = code + return res + }), + json: vi.fn(() => res), + cookie: vi.fn((name: string, value: string) => { + res.cookies[name] = value + return res + }), + } + return res +} + +const req = {} as Request +const DEMO = { id: 'd1', username: 'demo', role: 'admin', is_demo: 1, is_active: 1 } + +beforeEach(() => vi.clearAllMocks()) +afterEach(() => { + delete process.env.DEMO_MODE +}) + +describe('demoLogin', () => { + it.each([undefined, 'false', '1', 'TRUE'])('answers 404 with DEMO_MODE=%s', async (mode) => { + if (mode !== undefined) process.env.DEMO_MODE = mode + const res = fakeRes() + await demoLogin(req, res as unknown as Response) + expect(res.statusCode).toBe(404) + expect(res.cookie).not.toHaveBeenCalled() + expect(UserRepository.getDemoUser).not.toHaveBeenCalled() + }) + + it('answers 503 without an active demo account', async () => { + process.env.DEMO_MODE = 'true' + vi.mocked(UserRepository.getDemoUser).mockResolvedValueOnce(null) + const res = fakeRes() + await demoLogin(req, res as unknown as Response) + expect(res.statusCode).toBe(503) + expect(res.cookie).not.toHaveBeenCalled() + }) + + it('enters the demo account with tokens marked as demo', async () => { + process.env.DEMO_MODE = 'true' + vi.mocked(UserRepository.getDemoUser).mockResolvedValueOnce(DEMO as never) + const res = fakeRes() + await demoLogin(req, res as unknown as Response) + expect(res.statusCode).toBe(200) + + const access = verifyToken(res.cookies.access_token) + const refresh = verifyToken(res.cookies.refresh_token) + expect(access).toMatchObject({ id: 'd1', role: 'admin', demo: true, type: 'access' }) + expect(refresh).toMatchObject({ id: 'd1', demo: true, type: 'refresh' }) + expect(resetIfStale).toHaveBeenCalledOnce() + }) + + it('answers 500 without the error text when the database fails', async () => { + process.env.DEMO_MODE = 'true' + vi.mocked(UserRepository.getDemoUser).mockRejectedValueOnce(new Error('ER_SECRET_TABLE')) + const res = fakeRes() + await demoLogin(req, res as unknown as Response) + expect(res.statusCode).toBe(500) + expect(JSON.stringify(res.json.mock.calls)).not.toContain('ER_SECRET_TABLE') + }) +}) diff --git a/backend/tests/auth/demo-password-login.test.ts b/backend/tests/auth/demo-password-login.test.ts new file mode 100644 index 00000000..dac9686e --- /dev/null +++ b/backend/tests/auth/demo-password-login.test.ts @@ -0,0 +1,34 @@ +// tests/auth/demo-password-login.test.ts +// The demo account is entered only through POST /api/auth/demo: the password +// login rejects it even with the right password (ADR-038). + +import { describe, it, expect, vi } from 'vitest' +import bcrypt from 'bcrypt' + +const PASSWORD = 'CorrectPassword123!' +const hash = await bcrypt.hash(PASSWORD, 4) + +const query = vi.fn() +vi.mock('../../config/db.js', () => ({ default: { query } })) + +const { UserRepository } = await import('../../repositories/auth/user-repository.js') + +const row = (is_demo: number) => [ + [{ id: 'u1', username: 'someone', password: hash, is_active: 1, is_demo, role: 'admin' }], +] + +describe('password login and the demo account', () => { + it('rejects the demo account with the right password', async () => { + query.mockResolvedValueOnce(row(1)) + await expect(UserRepository.login({ username: 'someone', password: PASSWORD })).rejects.toThrow( + /credenciales/i + ) + }) + + it('still accepts any other account with the right password', async () => { + query.mockResolvedValueOnce(row(0)) + await expect( + UserRepository.login({ username: 'someone', password: PASSWORD }) + ).resolves.toMatchObject({ id: 'u1' }) + }) +}) diff --git a/backend/tests/auth/demo-restriction.test.ts b/backend/tests/auth/demo-restriction.test.ts index c1ae01a3..f440ea6d 100644 --- a/backend/tests/auth/demo-restriction.test.ts +++ b/backend/tests/auth/demo-restriction.test.ts @@ -1,6 +1,6 @@ // tests/auth/demo-restriction.test.ts -// Regression tests for demoRestriction middleware — pure logic against mocked req/res. -// Locks the positive-list of routes a demo-admin can mutate. +// Regression tests for the demo account guards (users.is_demo, ADR-038) — +// pure logic against mocked req/res. import { describe, it, expect, vi, beforeEach } from 'vitest' import type { Request, Response, NextFunction } from 'express' @@ -13,7 +13,8 @@ vi.mock('../../repositories/demo/demo-activity-repository.js', () => ({ }, })) -const { demoRestriction } = await import('../../middlewares/demoRestriction.js') +const { demoRestriction, denyDemo, denyDemoWrites } = + await import('../../middlewares/demoRestriction.js') const { DemoActivityRepository } = await import('../../repositories/demo/demo-activity-repository.js') @@ -21,6 +22,7 @@ function buildReq(overrides: Partial = {}): Request { return { method: 'GET', originalUrl: '/', + headers: {}, body: {}, ip: '127.0.0.1', socket: { remoteAddress: '127.0.0.1' } as any, @@ -43,6 +45,11 @@ beforeEach(() => { vi.clearAllMocks() }) +// The public demo account (users.is_demo) is a real admin: only what is +// declared here or on the route is blocked +const DEMO_USER = { id: 'd1', username: 'demo', role: 'admin', isDemo: true } as any +const OWNER = { id: 'a1', username: 'admin', role: 'admin' } as any + describe('demoRestriction — pass-through cases', () => { it('lets through requests without an authenticated user (handled upstream)', () => { const req = buildReq() @@ -62,46 +69,69 @@ describe('demoRestriction — pass-through cases', () => { demoRestriction(req, res, next) expect(next).toHaveBeenCalledOnce() }) +}) - it('lets demo-admin perform any GET', () => { +describe('demoRestriction — blocked-attempt log', () => { + it('redacts password fields from the logged body preview', () => { const req = buildReq({ - user: { id: 'demo', role: 'demo-admin' } as any, - method: 'GET', - originalUrl: '/api/scheduling/months/77', + user: DEMO_USER, + method: 'PATCH', + originalUrl: '/api/auth/me/password', + body: { + currentPassword: 'old-secret-1', + newPassword: 'new-secret-2', + confirmPassword: 'new-secret-2', + note: 'keep', + }, + }) + denyDemo(req, buildRes(), next) + const logged = (DemoActivityRepository.logActivity as any).mock.calls[0][0] + .body_preview as string + expect(logged).not.toMatch(/secret/) + expect(JSON.parse(logged)).toEqual({ + currentPassword: '[REDACTED]', + newPassword: '[REDACTED]', + confirmPassword: '[REDACTED]', + note: 'keep', }) - const res = buildRes() - demoRestriction(req, res, next) - expect(next).toHaveBeenCalledOnce() }) }) -describe('demoRestriction — demo whitelist (POSTs allowed)', () => { - const allowed: Array<[string, string]> = [ - ['POST', '/api/auth/logout'], - ['POST', '/api/parking/bookings'], - ['POST', '/api/logbooks/42/comments'], - ['POST', '/api/maintenance'], - ] - - for (const [method, url] of allowed) { - it(`allows demo-admin: ${method} ${url}`, () => { +describe('demoRestriction — demo account (is_demo)', () => { + it.each(['multipart/form-data; boundary=x', 'Multipart/Form-Data', 'multipart/mixed'])( + 'blocks any upload (content-type %s)', + (contentType) => { const req = buildReq({ - user: { id: 'demo', role: 'demo-admin' } as any, - method, - originalUrl: url, + user: DEMO_USER, + method: 'POST', + originalUrl: '/api/maintenance/1/images', + headers: { 'content-type': contentType } as any, }) const res = buildRes() demoRestriction(req, res, next) - expect(next).toHaveBeenCalledOnce() - expect(res.status).not.toHaveBeenCalled() + expect(next).not.toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith(403) + expect(DemoActivityRepository.logActivity).toHaveBeenCalledOnce() + } + ) + + it('lets JSON writes through (the route decides)', () => { + const req = buildReq({ + user: DEMO_USER, + method: 'DELETE', + originalUrl: '/api/parking/bookings/ABC', + headers: { 'content-type': 'application/json' } as any, }) - } + const res = buildRes() + demoRestriction(req, res, next) + expect(next).toHaveBeenCalledOnce() + }) - it('also matches the whitelist with a query string appended', () => { + it('lets the owner upload', () => { const req = buildReq({ - user: { id: 'demo', role: 'demo-admin' } as any, + user: OWNER, method: 'POST', - originalUrl: '/api/parking/bookings?foo=bar', + headers: { 'content-type': 'multipart/form-data; boundary=x' } as any, }) const res = buildRes() demoRestriction(req, res, next) @@ -109,56 +139,61 @@ describe('demoRestriction — demo whitelist (POSTs allowed)', () => { }) }) -describe('demoRestriction — demo writes blocked (deny-by-default)', () => { - const blocked: Array<[string, string]> = [ - ['POST', '/api/scheduling/months'], - ['POST', '/api/scheduling/months/77/generate'], - ['DELETE', '/api/parking/bookings/123'], - ['PATCH', '/api/maintenance/abc'], - ['PUT', '/api/users/123'], - ['POST', '/api/auth/register'], - ['POST', '/api/logbooks'], // logbook root, not /:id/comments - ] - - for (const [method, url] of blocked) { - it(`blocks demo-admin: ${method} ${url}`, () => { - const req = buildReq({ - user: { id: 'demo', role: 'demo-admin' } as any, - method, - originalUrl: url, - }) - const res = buildRes() - demoRestriction(req, res, next) - expect(next).not.toHaveBeenCalled() - expect(res.status).toHaveBeenCalledWith(403) - const payload = (res.json as any).mock.calls[0][0] - expect(payload.demo).toBe(true) - }) - } +describe('denyDemo', () => { + it.each(['GET', 'POST', 'DELETE'])('blocks the demo account on %s', (method) => { + const res = buildRes() + denyDemo(buildReq({ user: DEMO_USER, method }), res, next) + expect(next).not.toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith(403) + }) + + it('lets the owner through', () => { + const res = buildRes() + denyDemo(buildReq({ user: OWNER, method: 'POST' }), res, next) + expect(next).toHaveBeenCalledOnce() + }) }) -describe('demoRestriction — blocked-attempt log', () => { - it('redacts password fields from the logged body preview', () => { +describe('denyDemoWrites', () => { + it.each(['GET', 'HEAD', 'OPTIONS'])('lets the demo account %s', (method) => { + const res = buildRes() + denyDemoWrites(buildReq({ user: DEMO_USER, method }), res, next) + expect(next).toHaveBeenCalledOnce() + }) + + it.each(['POST', 'PUT', 'PATCH', 'DELETE'])('blocks the demo account on %s', (method) => { + const res = buildRes() + denyDemoWrites(buildReq({ user: DEMO_USER, method }), res, next) + expect(next).not.toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith(403) + }) + + it('lets the owner write', () => { + const res = buildRes() + denyDemoWrites(buildReq({ user: OWNER, method: 'DELETE' }), res, next) + expect(next).toHaveBeenCalledOnce() + }) +}) + +describe('blocking a request without a JSON body', () => { + it.each(['DELETE', 'POST'])('answers 403 on %s with req.body undefined', (method) => { + const res = buildRes() + denyDemo(buildReq({ user: DEMO_USER, method, body: undefined }), res, next) + expect(res.status).toHaveBeenCalledWith(403) + expect(DemoActivityRepository.logActivity).toHaveBeenCalledWith( + expect.objectContaining({ body_preview: 'null' }) + ) + }) + + it('answers 403 on an upload before multer reads the body', () => { const req = buildReq({ - user: { id: 'demo', role: 'demo-admin' } as any, - method: 'PATCH', - originalUrl: '/api/auth/me/password', - body: { - currentPassword: 'old-secret-1', - newPassword: 'new-secret-2', - confirmPassword: 'new-secret-2', - note: 'keep', - }, - }) - demoRestriction(req, buildRes(), next) - const logged = (DemoActivityRepository.logActivity as any).mock.calls[0][0] - .body_preview as string - expect(logged).not.toMatch(/secret/) - expect(JSON.parse(logged)).toEqual({ - currentPassword: '[REDACTED]', - newPassword: '[REDACTED]', - confirmPassword: '[REDACTED]', - note: 'keep', + user: DEMO_USER, + method: 'POST', + body: undefined, + headers: { 'content-type': 'multipart/form-data; boundary=x' } as any, }) + const res = buildRes() + demoRestriction(req, res, next) + expect(res.status).toHaveBeenCalledWith(403) }) }) diff --git a/backend/tests/auth/login-timing.test.ts b/backend/tests/auth/login-timing.test.ts index cf1da2c0..6c0c54e1 100644 --- a/backend/tests/auth/login-timing.test.ts +++ b/backend/tests/auth/login-timing.test.ts @@ -9,6 +9,8 @@ import type { Request, Response } from 'express' vi.mock('../../repositories/auth/user-repository.js', () => ({ UserRepository: { login: vi.fn() }, })) +// The demo reset opens its own database connection: not part of this test +vi.mock('../../services/demo/demo-reset.service.js', () => ({ resetIfStale: vi.fn() })) const { UserRepository } = await import('../../repositories/auth/user-repository.js') const { login, LOGIN_FAILURE_MIN_MS } = await import('../../controllers/auth/auth-controllers.js') diff --git a/backend/tests/auth/session-cookies.test.ts b/backend/tests/auth/session-cookies.test.ts new file mode 100644 index 00000000..cc04a3cf --- /dev/null +++ b/backend/tests/auth/session-cookies.test.ts @@ -0,0 +1,66 @@ +// tests/auth/session-cookies.test.ts +// The session cookies a sign-in sets in production: not readable by scripts +// (XSS cannot steal them), only over HTTPS, and SameSite=Lax so a POST from +// another site does not carry them (CSRF). What res.cookie actually receives. + +import { describe, it, expect, vi, afterAll } from 'vitest' +import type { Request, Response } from 'express' + +const original = { NODE_ENV: process.env.NODE_ENV, DEMO_MODE: process.env.DEMO_MODE } +process.env.NODE_ENV = 'production' +process.env.SECRET_JWT_KEY ??= 'test-secret-key-for-vitest-only-not-prod' +process.env.DEMO_MODE = 'true' + +vi.mock('../../repositories/auth/user-repository.js', () => ({ + UserRepository: { + login: vi.fn().mockResolvedValue({ id: 'u1', username: 'someone', role: 'admin' }), + getDemoUser: vi.fn().mockResolvedValue({ id: 'd1', username: 'demo', role: 'admin' }), + }, +})) +vi.mock('../../services/demo/demo-reset.service.js', () => ({ resetIfStale: vi.fn() })) + +const { login, demoLogin } = await import('../../controllers/auth/auth-controllers.js') +// The cookie options are read at import; put the environment back for other files +process.env.NODE_ENV = original.NODE_ENV +afterAll(() => { + if (original.DEMO_MODE === undefined) delete process.env.DEMO_MODE + else process.env.DEMO_MODE = original.DEMO_MODE +}) + +function fakeRes() { + const cookies: Array<{ name: string; options: Record }> = [] + const res = { + cookies, + status: vi.fn(() => res), + json: vi.fn(() => res), + cookie: vi.fn((name: string, _value: string, options: Record) => { + cookies.push({ name, options }) + return res + }), + } + return res +} + +describe.each([ + [ + 'password login', + (res: Response) => + login({ body: { username: 'someone', password: 'CorrectPassword123!' } } as Request, res), + ], + ['demo entry', (res: Response) => demoLogin({} as Request, res)], +])('%s in production', (_name, signIn) => { + it('sets both session cookies HttpOnly, Secure, SameSite=Lax on the shared domain', async () => { + const res = fakeRes() + await signIn(res as unknown as Response) + expect(res.cookies.map((c) => c.name).sort()).toEqual(['access_token', 'refresh_token']) + for (const { options } of res.cookies) { + expect(options).toMatchObject({ + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + domain: '.four-points.stackbp.es', + }) + } + }) +}) diff --git a/backend/tests/auth/token-service.test.ts b/backend/tests/auth/token-service.test.ts index 1ab2cf23..1d835977 100644 --- a/backend/tests/auth/token-service.test.ts +++ b/backend/tests/auth/token-service.test.ts @@ -73,3 +73,17 @@ describe('tokenService — error cases', () => { expect(() => verifyToken(expired)).toThrow(/expirado/i) }) }) + +describe('tokenService — demo claim', () => { + it('marks the demo account in both tokens', () => { + const user = { ...VALID_USER, demo: true } + expect(verifyToken(generateAccessToken(user)).demo).toBe(true) + expect(verifyToken(generateRefreshToken(user)).demo).toBe(true) + }) + + it('leaves the claim out for everyone else', () => { + for (const user of [VALID_USER, { ...VALID_USER, demo: false }]) { + expect(verifyToken(generateAccessToken(user))).not.toHaveProperty('demo') + } + }) +}) diff --git a/backend/tests/backoffice/invoice-status.test.ts b/backend/tests/backoffice/invoice-status.test.ts index ec592e89..6119d0bc 100644 --- a/backend/tests/backoffice/invoice-status.test.ts +++ b/backend/tests/backoffice/invoice-status.test.ts @@ -69,7 +69,7 @@ describe('controller', () => { it('unpay is admin only', () => { expect(routes).toContain( - "router.post('/invoices/:id/unpay', isRealAdmin, BackofficeController.revertPayment)" + "router.post('/invoices/:id/unpay', isAdmin, BackofficeController.revertPayment)" ) }) }) diff --git a/backend/tests/frontend/safe-href.test.ts b/backend/tests/frontend/safe-href.test.ts new file mode 100644 index 00000000..0be113f2 --- /dev/null +++ b/backend/tests/frontend/safe-href.test.ts @@ -0,0 +1,46 @@ +// tests/frontend/safe-href.test.ts +// Links written in checklist content are rendered through safeHref: a +// javascript: or data: URL must never become a clickable href, in any of the +// spellings browsers still execute. Runs the real helper (the frontend has +// no test runner). + +import { describe, it, expect } from 'vitest' +import { safeHref } from '../../../frontend/app/lib/helpers/safeHref.js' + +describe('safeHref refuses executable URLs', () => { + it.each([ + 'javascript:alert(1)', + 'JavaScript:alert(1)', + ' javascript:alert(1)', + 'java\nscript:alert(1)', + 'java\tscript:alert(1)', + 'java\rscript:alert(1)', + '\u0000javascript:alert(1)', + 'javascript:alert(1)'.replace(':', ':'), + 'data:text/html,', + 'vbscript:msgbox(1)', + 'file:///etc/passwd', + ])('%j', (href) => { + expect(safeHref(href)).toBeUndefined() + }) +}) + +describe('safeHref keeps ordinary links', () => { + it.each([ + '/dashboard/checklist', + 'checklist/night-audit', + '#paso-3', + 'https://www.example.com/manual', + 'http://intranet.example.com', + 'mailto:recepcion@example.com', + 'tel:+34900000000', + ])('%j', (href) => { + expect(safeHref(href)).toBe(href) + }) + + it('returns undefined for empty values', () => { + expect(safeHref('')).toBeUndefined() + expect(safeHref(undefined)).toBeUndefined() + expect(safeHref(null)).toBeUndefined() + }) +}) diff --git a/backend/tests/maintenance/deleted-reports.test.ts b/backend/tests/maintenance/deleted-reports.test.ts index 883bec15..8bd98410 100644 --- a/backend/tests/maintenance/deleted-reports.test.ts +++ b/backend/tests/maintenance/deleted-reports.test.ts @@ -24,7 +24,7 @@ function method(source: string, name: string): string { } describe('isAdminRole', () => { - it('is admin only: the demo-admin user is disabled', () => { + it('is admin only (demo-admin is a retired role)', () => { for (const role of ['admin', 'ADMIN']) expect(isAdminRole(role), role).toBe(true) for (const role of [ 'demo-admin', diff --git a/backend/tests/search/search-access.test.ts b/backend/tests/search/search-access.test.ts index 8a527861..1f236a6f 100644 --- a/backend/tests/search/search-access.test.ts +++ b/backend/tests/search/search-access.test.ts @@ -12,7 +12,7 @@ describe('searchableModules', () => { }) it('gives the other roles every module', () => { - for (const role of ['admin', 'recepcionista', 'group-admin', 'demo-admin', 'ADMIN']) { + for (const role of ['admin', 'recepcionista', 'group-admin', 'ADMIN']) { expect([...searchableModules(role)].sort()).toEqual([ 'blacklist', 'groups', diff --git a/backend/tests/security/csrf-origin.test.ts b/backend/tests/security/csrf-origin.test.ts new file mode 100644 index 00000000..ba721c95 --- /dev/null +++ b/backend/tests/security/csrf-origin.test.ts @@ -0,0 +1,95 @@ +// tests/security/csrf-origin.test.ts +// The CSRF defence is the Origin check in config/cors.ts: a write from another +// site must be refused before the route runs. A real Express server with the +// real middleware; requests as a browser would send them. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import express from 'express' +import type { Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { allowedOrigins, corsMiddleware } from '../../config/cors.js' + +let server: Server +let base: string +let ran = 0 + +beforeAll(async () => { + const app = express() + app.use(corsMiddleware(allowedOrigins('production', undefined))) + app.post('/api/write', (_req, res) => { + ran++ + res.json({ ok: true }) + }) + server = app.listen(0) + await new Promise((resolve) => server.once('listening', resolve)) + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}` +}) + +afterAll(() => new Promise((resolve) => server.close(resolve))) + +async function post(headers: Record, body = '{}') { + const before = ran + const res = await fetch(`${base}/api/write`, { method: 'POST', headers, body }) + return { + status: res.status, + ran: ran > before, + allow: res.headers.get('access-control-allow-origin'), + } +} + +describe('a write from another site', () => { + it.each([ + ['a JSON request', { 'Content-Type': 'application/json' }], + // What an HTML form on another site sends: no preflight in the browser + ['a form post', { 'Content-Type': 'application/x-www-form-urlencoded' }], + ['a multipart upload', { 'Content-Type': 'multipart/form-data; boundary=x' }], + ['a text/plain post', { 'Content-Type': 'text/plain' }], + ])('is refused before the route runs: %s', async (_name, headers) => { + const res = await post({ ...headers, Origin: 'https://evil.example' }) + expect(res.status).toBe(403) + expect(res.ran).toBe(false) + expect(res.allow).toBeNull() + }) + + it.each([ + 'https://four-points.stackbp.es.evil.example', + 'https://evil-four-points.stackbp.es', + 'null', + ])('is refused for a look-alike origin: %s', async (origin) => { + const res = await post({ 'Content-Type': 'application/json', Origin: origin }) + expect(res.status).toBe(403) + expect(res.ran).toBe(false) + }) + + it('is refused from localhost in production', async () => { + const res = await post({ 'Content-Type': 'application/json', Origin: 'http://localhost:3000' }) + expect(res.status).toBe(403) + expect(res.ran).toBe(false) + }) +}) + +describe('a write from our own site', () => { + it('reaches the route and may read the answer with cookies', async () => { + const res = await fetch(`${base}/api/write`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: 'https://four-points.stackbp.es' }, + body: '{}', + }) + expect(res.status).toBe(200) + expect(res.headers.get('access-control-allow-origin')).toBe('https://four-points.stackbp.es') + expect(res.headers.get('access-control-allow-credentials')).toBe('true') + }) +}) + +describe('allowed origins', () => { + it('include localhost only outside production', () => { + expect(allowedOrigins('development', undefined)).toContain('http://localhost:3000') + expect(allowedOrigins('production', undefined)).not.toContain('http://localhost:3000') + }) + + it('add FRONTEND_URL when set', () => { + expect(allowedOrigins('production', 'https://preview.four-points.stackbp.es')).toContain( + 'https://preview.four-points.stackbp.es' + ) + }) +}) diff --git a/backend/tests/security/demo-guards.test.ts b/backend/tests/security/demo-guards.test.ts new file mode 100644 index 00000000..eb927af8 --- /dev/null +++ b/backend/tests/security/demo-guards.test.ts @@ -0,0 +1,68 @@ +// tests/security/demo-guards.test.ts +// The public demo account is an admin (ADR-038): what it must not touch is +// guarded on each route with denyDemo or denyDemoWrites. Locks those guards so +// a refactor cannot drop one silently. Reads the sources; no database needed. + +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const routes = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'routes') +const read = (file: string) => readFileSync(join(routes, file), 'utf8') + +// Whole routers: the guard runs on every route right after authentication +const routerGuards: Array<[string, string]> = [ + ['auth/user-routes.ts', 'denyDemoWrites'], + ['departments/departments-routes.ts', 'denyDemoWrites'], + ['demo/demo-activity-routes.ts', 'denyDemo'], +] + +// Scheduling configuration: reads stay open, writes are blocked +const schedulingConfig = ['/config', '/shifts', '/rules', '/employees', '/contracts'] + +// Single routes, as method and path +const routeGuards: Array<[string, string, string]> = [ + ['auth/auth-routes.ts', 'post', '/register'], + ['auth/auth-routes.ts', 'patch', '/me/profile'], + ['auth/auth-routes.ts', 'patch', '/me/password'], + ['auth/auth-routes.ts', 'post', '/me/avatar'], + ['auth/auth-routes.ts', 'delete', '/me/avatar'], + ['backoffice/backoffice-routes.ts', 'post', '/categories'], +] + +// The whole `router.method('path', ...)` statement, also when Prettier splits it +function statement(source: string, method: string, path: string): string | undefined { + return source.split(/\n(?=router\.)/).find( + (s) => + s.startsWith(`router.${method}(`) && + s + .slice(method.length + 8) + .trimStart() + .startsWith(`'${path}'`) + ) +} + +describe('demo account guards', () => { + it.each(routerGuards)('%s runs %s after authenticateToken', (file, guard) => { + const source = read(file) + const auth = source.indexOf('router.use(authenticateToken)') + const guarded = source.indexOf(`router.use(${guard})`) + expect(auth).toBeGreaterThanOrEqual(0) + expect(guarded).toBeGreaterThan(auth) + }) + + it('scheduling blocks writes to its configuration after isAdmin', () => { + const source = read('scheduling/scheduling-routes.ts') + const line = source.split('\n').find((l) => l.includes('denyDemoWrites)')) + expect(line).toBeDefined() + for (const path of schedulingConfig) expect(line).toContain(`'${path}'`) + expect(source.indexOf(line!)).toBeGreaterThan(source.indexOf('router.use(isAdmin)')) + }) + + it.each(routeGuards)('%s: %s %s carries denyDemo', (file, method, path) => { + const found = statement(read(file), method, path) + expect(found, `${method} ${path} not found`).toBeDefined() + expect(found).toMatch(/\bdenyDemo\b/) + }) +}) diff --git a/backend/tests/security/demo-reset.test.ts b/backend/tests/security/demo-reset.test.ts new file mode 100644 index 00000000..508ee647 --- /dev/null +++ b/backend/tests/security/demo-reset.test.ts @@ -0,0 +1,67 @@ +// tests/security/demo-reset.test.ts +// The demo reset (ADR-038) wipes every module: its routes exist only with +// DEMO_MODE=true and only for admins that are not the demo account. The mock +// file is sent as one multi-statement query, so its DELIMITER blocks must turn +// into plain SQL. Reads the sources; no database needed. + +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { toMultiStatement } from '../../services/db/sql-script.js' + +const backend = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +const read = (...p: string[]) => readFileSync(join(backend, ...p), 'utf8') + +describe('toMultiStatement', () => { + it('drops DELIMITER lines and ends the block with ;', () => { + const sql = [ + 'SET @a := 1;', + 'DELIMITER $$', + 'CREATE PROCEDURE p()', + 'BEGIN', + ' SELECT 1;', + 'END$$', + 'DELIMITER ;', + 'CALL p();', + ].join('\r\n') + expect(toMultiStatement(sql)).toBe( + ['SET @a := 1;', 'CREATE PROCEDURE p()', 'BEGIN', ' SELECT 1;', 'END;', 'CALL p();'].join( + '\n' + ) + ) + }) + + it('leaves mock-data.sql without DELIMITER and with its procedure closed', () => { + const out = toMultiStatement(read('db-mysql', 'mock-data.sql')) + expect(out).not.toMatch(/^\s*DELIMITER/im) + expect(out).not.toContain('$$') + expect(out).toMatch(/END;\s*\nCALL mock_require_admin\(\);/) + }) +}) + +describe('demo reset routes', () => { + const routes = read('routes', 'demo', 'demo-reset-routes.ts') + + it('answer 404 unless DEMO_MODE is on, before anything else', () => { + const gate = routes.indexOf('isDemoMode()') + expect(gate).toBeGreaterThan(-1) + expect(gate).toBeLessThan(routes.indexOf('router.use(authenticateToken)')) + }) + + it('block the demo account before checking the admin role', () => { + const auth = routes.indexOf('router.use(authenticateToken)') + const deny = routes.indexOf('router.use(denyDemo)') + const admin = routes.indexOf('router.use(isAdmin)') + expect(auth).toBeGreaterThan(-1) + expect(deny).toBeGreaterThan(auth) + expect(admin).toBeGreaterThan(deny) + }) + + it('reset only with DEMO_MODE exactly "true"', () => { + expect(read('services', 'demo', 'demo-reset.service.ts')).toContain( + "process.env.DEMO_MODE === 'true'" + ) + }) +}) diff --git a/backend/tests/security/setup-local.test.ts b/backend/tests/security/setup-local.test.ts new file mode 100644 index 00000000..59be9096 --- /dev/null +++ b/backend/tests/security/setup-local.test.ts @@ -0,0 +1,56 @@ +// tests/security/setup-local.test.ts +// pnpm setup:local (ADR-038) installs from the frozen baseline and fixes, in +// memory, the bugs that stop a fresh install. Each fix is an exact +// replacement: these tests fail as soon as the baseline text changes, and keep +// the guards that stop MASTER_INSTALL.sql (it drops hotel_db) from running +// anywhere but a local, empty database. Reads the sources; no database needed. + +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const backend = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +const read = (...p: string[]) => readFileSync(join(backend, ...p), 'utf8').replace(/\r\n/g, '\n') +const setup = read('scripts', 'setup-local.ts') + +// The fixes, as written in the script (file, from) +const fixes = [...setup.matchAll(/file: '([^']+)',\s*from: '([^']+)'/g)].map((m) => ({ + file: m[1], + // The source writes newlines as the two characters \ and n + from: m[2].split(String.raw`\n`).join('\n'), +})) + +describe('setup:local baseline fixes', () => { + it('lists the known fixes', () => { + expect(fixes.map((f) => f.file)).toEqual([ + '11_cashier.sql', + '11_cashier.sql', + '19_scheduling.sql', + ]) + }) + + it.each(fixes)('$file still has the text it replaces, exactly once', ({ file, from }) => { + expect(read('db-mysql', 'aiven', file).split(from)).toHaveLength(2) + }) +}) + +describe('setup:local guards', () => { + it('runs only on a local database host', () => { + expect(setup).toContain("new Set(['localhost', '127.0.0.1', '::1'])") + expect(setup).toMatch(/DB_ENVIRONMENT \|\| 'local'\) !== 'local'/) + }) + + it('refuses a database that already has tables unless --force', () => { + expect(setup).toContain("process.argv.includes('--force')") + expect(setup.indexOf("includes('--force')")).toBeLessThan( + setup.indexOf("MASTER_INSTALL.sql'), DB_DIR") + ) + }) + + it('uses the public local admin password documented in the README', () => { + const readme = readFileSync(join(backend, '..', 'README.md'), 'utf8') + expect(setup).toContain("LOCAL_ADMIN_PASSWORD = 'fourpoints-local'") + expect(readme).toContain('fourpoints-local') + }) +}) diff --git a/backend/types/express.d.ts b/backend/types/express.d.ts index 879bc749..be23b0e9 100644 --- a/backend/types/express.d.ts +++ b/backend/types/express.d.ts @@ -8,6 +8,8 @@ declare global { username: string email: string role: string + // Public demo account, restricted by demoRestriction + isDemo?: boolean } } } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..8b74e3ff --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +# Local MySQL only (ADR-038). The backend and the frontend still run with pnpm. +# +# docker compose --env-file backend/.env up -d # start (port LOCAL_DB_PORT, default 3306) +# docker compose --env-file backend/.env down # stop; add -v to delete the data +# +# backend/.env must have LOCAL_DB_USER=root and the LOCAL_DB_PASSWORD you choose. +# If you already run MySQL on 3306, set LOCAL_DB_PORT=3307 in backend/.env. +services: + mysql: + image: mysql:8.0 + container_name: four-points-mysql + environment: + MYSQL_ROOT_PASSWORD: ${LOCAL_DB_PASSWORD:?Set LOCAL_DB_PASSWORD in backend/.env} + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_ai_ci + ports: + # Only this machine can reach it + - '127.0.0.1:${LOCAL_DB_PORT:-3306}:3306' + volumes: + - mysql-data:/var/lib/mysql + healthcheck: + test: ['CMD', 'mysqladmin', 'ping', '-h', '127.0.0.1', '--silent'] + interval: 5s + retries: 20 + +volumes: + mysql-data: diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index d35537ad..0711f117 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -244,7 +244,8 @@ ## ADR-015 — Una sola base de datos: Aiven, con datos de prueba -- **Estado:** ✅ aceptada (2026-09-28) +- **Estado:** ✅ aceptada (2026-09-28) · 📌 BD local revisada por + [ADR-039](#adr-039--la-bd-local-vuelve-como-instalación-documentada-aiven-sigue-siendo-la-única-compartida) - **Fecha:** 2026-09-28 - **Decisión:** el proyecto trabaja contra **una única BD, la de Aiven**, que solo contiene datos de ejemplo. No se mantiene una BD local de desarrollo ni @@ -708,6 +709,8 @@ - **Estado:** ✅ aceptada (2026-10-05) · 📌 orden revisado por [ADR-036](#adr-036--el-original-se-archiva-al-final-y-la-rotación-va-después-de-publicar) + · 📌 momento de publicar revisado por + [ADR-037](#adr-037--la-demo-va-en-la-propia-web-y-el-repositorio-se-publica-después) - **Fecha:** 2026-10-05 - **Decisión:** - Se limpian y se publican **solo `main` y @@ -753,3 +756,89 @@ prueba. El procedimiento describe paneles, equipos y claves concretas que no sirven a quien lea el repositorio. - **Revisa:** ADR-035 en el orden; ADR-007 en el momento de la rotación. + +## ADR-037 — La demo va en la propia web y el repositorio se publica después + +- **Estado:** ✅ aceptada (2026-10-05) · 📌 usuarios demo y acceso revisados + por + [ADR-038](#adr-038--un-solo-admin-demo-restringido-y-la-instalación-local-con-mysql-en-docker) +- **Fecha:** 2026-10-05 +- **Decisión:** + - `four-points.stackbp.es` es solo un escaparate: la demo pública va en esa + misma web y su BD, con usuarios demo por rol, datos ficticios + (`mock-data.sql`) y reinicio diario, también de los horarios. + - Los usuarios demo ven toda la app y pueden generar horarios, pero no tocan + la configuración: ni usuarios, ni roles, ni contraseñas. + - El repositorio se hace público cuando la demo esté lista. +- **Motivo:** decisión del propietario. Nadie usa la web para trabajar, y una + segunda web con su BD gastaría más horas del plan gratuito de Render. Sin + demo, quien llegue al repositorio no tiene cómo probar la aplicación: + `MASTER_INSTALL.sql` no crea ningún usuario. +- **Rechazado:** la demo aparte en `demo.four-points.stackbp.es` con la BD + `hotel_demo` (plan del 2026-10-04); publicar antes de la demo. +- **Consecuencias:** si la web llega a usarse de verdad, la demo se separa + entonces. Fase 5 antes de terminar la 4. +- **Revisa:** ADR-035 en el momento de hacer público el repositorio. + +## ADR-038 — Un solo admin demo restringido y la instalación local con MySQL en Docker + +- **Estado:** ✅ aceptada (2026-10-05) +- **Fecha:** 2026-10-05 +- **Decisión:** + - **Un solo usuario demo, con rol admin**, en lugar de uno por rol: como + admin ve todos los módulos. Se entra con un botón «Probar la demo» en el + login, sin contraseña: el usuario tiene una aleatoria que nadie conoce, y el + botón y su ruta solo existen con `DEMO_MODE=true` en el backend. + - **Marca `is_demo`** en el usuario, que viaja en el token. Un middleware + bloquea solo lo prohibido: gestión de usuarios y contraseñas, el propio + perfil, departamentos, toda subida de ficheros (cualquier `multipart`), el + registro de la demo y la configuración de horarios (turnos, parámetros, + empleados y contratos). Generar horarios, crear meses y peticiones sí. El + rol `demo-admin` se retira. + - **Reinicio diario** con la primera entrada a la demo del día: recarga + `mock-data.sql` y restaura una foto de las tablas de horarios guardada en la + BD. + - **En local, admin completo**, sin modo demo. MySQL en Docker, opcional y + **solo MySQL** (un `docker-compose.yml`); backend y frontend arrancan con + pnpm como ahora. `pnpm setup:local` crea las tablas, el primer admin y los + departamentos, y carga el mock. +- **Motivo:** simplicidad, decisión del propietario. Un admin demo prueba toda + la app de una vez, y un botón no obliga a escribir nada. Con roles reales, + los permisos funcionan igual que para cualquier usuario y la restricción vive + en un solo sitio. Render gratuito se duerme, así que un cron a hora fija + puede no ejecutarse. Docker ahorra instalar MySQL; meter en él backend, + frontend y solver sería un segundo sistema que producción no usa. +- **Rechazado:** usuarios demo por rol y contraseña pública en el login + (ADR-037); lista blanca de escrituras con el rol `demo-admin`; reinicio por + GitHub Actions (credenciales de Aiven en GitHub y tareas programadas que se + desactivan tras 60 días sin actividad); la app entera en Docker. +- **Consecuencias:** CSRF sigue cubierto por `SameSite=Lax` y el rechazo de + orígenes ajenos; se añade un test. Para XSS se comprueba que un enlace + `javascript:` en las notas del checklist no se ejecuta. Límites nuevos para + el solver y las escrituras del usuario demo. El README pasa a explicar la + instalación local. +- **Revisa:** ADR-037 en los usuarios demo y su acceso. + +## ADR-039 — La BD local vuelve como instalación documentada; Aiven sigue siendo la única compartida + +- **Estado:** ✅ aceptada (2026-10-05) +- **Fecha:** 2026-10-05 +- **Decisión:** + - **Aiven** sigue siendo la única BD del proyecto que se comparte: + producción, preview y la demo pública. + - **La BD local** vuelve a ser parte documentada del proyecto: quien clona el + repositorio la monta con `pnpm setup:local` (con MySQL propio o en Docker) + y el propietario puede seguir usando la suya con `pnpm dev:local`. Se + mantienen `DB_ENVIRONMENT`, el preset `local` y `LOCAL_DB_*`. +- **Motivo:** ADR-038: quien quiera ver la aplicación entera la instala en + local con un admin completo. Y lo que ADR-015 rechazaba ya existe: una + instalación desde los scripts del repositorio, comprobada el 2026-10-05 en + un MySQL limpio, deja la misma estructura que Aiven (1161 de 1161 columnas, + índices, claves, triggers y procedimientos) y los mismos catálogos. +- **Rechazado:** quitar el soporte de BD local (la tarea pendiente de + ADR-015). +- **Consecuencias:** las migraciones se siguen aplicando en local y en Aiven + (`INDEX.md`). Dos fallos del baseline congelado, que impedían instalar desde + cero, se corrigen en `setup:local` y con + `20261005_complete_fresh_install.sql` sin tocar `aiven/` (ADR-021). +- **Revisa:** ADR-015 en la BD local. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c6a45bd7..dadc5132 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -21,7 +21,7 @@ cosa va a `TODO.md`. | 1c | «Antes de publicar» de `TODO.md` resuelto y probado en producción | 1b | | 2 | Clon con el historial limpio (dos ramas) + `docs/GITCLEAN.md` | 1c | | 4 | Repositorio público nuevo con el historial limpio (dos ramas) | 2 | -| 5 | Demo pública en un entorno aparte, con datos ficticios y reinicio diario | 4 | +| 5 | Demo pública en la propia web, con datos ficticios y reinicio diario | 4 | El orden lo fija ADR-001, revisado por ADR-022 (fase 1b) y ADR-027 (fase 1c, que además absorbe la antigua fase 3 de tareas pendientes). ADR-033 limita la 1c a lo @@ -54,16 +54,18 @@ Historial de `main` y `claude/compassionate-planck-gh6aof` reescrito y auditado ## Fase 4 — Publicación ← **actual** -El historial limpio está en `bpstack/four-points`, todavía privado, y Vercel y -Render ya despliegan desde él. Falta archivar el repositorio original, pasar el -nuevo a público y activar secret scanning y push protection. La rotación de -credenciales (ADR-007) no bloquea: va en «Después de publicar» de `TODO.md`. +El historial limpio está en `bpstack/four-points`, todavía privado; Vercel y +Render despliegan desde él y el original está archivado. Pasar a público (con +secret scanning y push protection) espera a la demo de la fase 5 (ADR-037). La +rotación de credenciales (ADR-007) no bloquea: va en «Después de publicar» de +`TODO.md`. ## Fase 5 — Demo pública -Un entorno para que cualquiera pruebe la aplicación sin tocar producción: -`demo.four-points.stackbp.es` con su propia BD (`hotel_demo` en Aiven), datos -ficticios, secretos propios, usuarios demo públicos por rol y reinicio nocturno -de la BD. Reutiliza la infraestructura del preview y el rol `demo-admin`. -Diseño y decisiones pendientes en `TODO.md`, «Demo pública» (propuesta aceptada -por el propietario el 2026-10-04). +Que cualquiera pruebe la aplicación en la propia web, que es solo un escaparate +(ADR-037): un admin demo restringido que entra con un botón, datos ficticios, +generación de horarios y reinicio diario; y quien quiera verlo todo lo instala +en local con admin completo (ADR-038, ADR-039). Hecho en código y probado en +preview el 2026-10-05; lo que falta, en `TODO.md`, «Demo pública en la propia +web: lo que falta». El repositorio se hace público cuando la demo esté en +producción. diff --git a/docs/SESSION.md b/docs/SESSION.md index b7cf4489..cec3f38d 100644 --- a/docs/SESSION.md +++ b/docs/SESSION.md @@ -21,7 +21,13 @@ y auditado (`GITCLEAN.md`). El historial limpio está en `bpstack/four-points`, **todavía privado**. El original sigue privado como `four-points-archive`. Vercel y Render despliegan ya desde el nuevo, en producción y en preview (`VERIFY.md`, «Cambio al repositorio -nuevo»). Decisiones en `DECISIONS.md`, hasta ADR-036. +nuevo»). Decisiones en `DECISIONS.md`, hasta ADR-039. + +La demo pública (fase 5) está hecha en código y probada en preview el +2026-10-05: cuenta demo con botón, bloqueos, límites, reinicio diario, +Configuración → Demo, instalación local con `pnpm setup:local` y `demo-admin` +retirado. Base de horarios guardada (octubre a diciembre de 2026, generados +con el solver). `DEMO_MODE` y `NEXT_PUBLIC_DEMO_MODE` solo están en preview. ## ⚠️ Empieza por aquí @@ -38,13 +44,16 @@ nuevo»). Decisiones en `DECISIONS.md`, hasta ADR-036. ## Siguiente -1. Push de la rama y comprobar que Vercel y Render despliegan solos desde el - repositorio nuevo (queda sin comprobar en `VERIFY.md`). -2. Fusionar la rama en `main` por PR con merge commit (ADR-031). -3. Archivar `four-points-archive` (ADR-036). -4. Pasar `four-points` a público y activar secret scanning y push protection. -5. Después, «Después de publicar» de `TODO.md`, empezando por la rotación de - credenciales. +1. PR a `main`, poner `DEMO_MODE` y `NEXT_PUBLIC_DEMO_MODE` en producción y + probar el botón demo en la web (`TODO.md`, «Demo pública en la propia web: + lo que falta»). +2. Pasar `four-points` a público y activar secret scanning y push protection. +3. «Después de publicar» de `TODO.md`, empezando por la rotación de + credenciales (se puede adelantar). + +Hecho el 2026-10-05: Vercel y Render despliegan solos desde este repositorio +(producción y preview), `four-points-archive` está archivado y Aiven tiene +usuarios anonimizados y datos ficticios (`mock-data.sql`). ## Esperando decisión @@ -62,6 +71,10 @@ Nada ahora mismo. `SCHEDULING-CONSTRAINTS.md §7.5`** en un comentario: los scripts 01–19 no se tocan (ADR-021). La sección equivalente es `docs/scheduling/constraints.md §7.5`. +- **La cuenta demo es un admin** (`users.is_demo = 1`): lo que no deba tocar + se bloquea en la ruta con `denyDemo`/`denyDemoWrites` o en + `demoRestriction`. Una ruta nueva que escriba fuera de lo que reinicia el + mock (usuarios, catálogos, configuración) necesita su guarda. - **Enlaces rotos conocidos** tras archivar: `backend/README.md` (enlaces a `docs/backend/…`) y comentarios que citan `Global-Plan.md` o `SCHEDULING-SOLVER-PLAN.md`. Se arreglan al escribir cada módulo (ADR-010). diff --git a/docs/TODO.md b/docs/TODO.md index 93161940..b759f76c 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -136,15 +136,6 @@ sigue siendo donde se arregla este fichero y se prueba en el entorno preview. `PK-AAAAMMDD` del trigger, `CURRENT_TIMESTAMP` de las tablas) van 1–2 h por detrás de Madrid: entre las 00:00 y las 02:00 «hoy» sigue siendo ayer. _Comprobado el 2026-09-28._ -- [ ] **Quitar el soporte de BD local del código** (ADR-015) — `DB_ENVIRONMENT` - y el preset `local` de `backend/config/db.ts`, el script `dev:local`, las - variables `LOCAL_DB_*` y las menciones a «local primero» en - `backend/db-mysql/`. -- [ ] **Comprobar que los scripts del repo reproducen Aiven** — hoy Aiven es la - única BD y `backend/db-mysql/` la fuente de verdad (ADR-016), pero nadie - ha verificado que coincidan. Pista: la BD local, instalada desde esos - scripts, difería de Aiven en `demo_activity_log`, `notifications.module`, - `roles.name` y 7 claves foráneas (comparación del 2026-09-28). - [ ] **Logbook: papelera e historial solo ocultos en la pantalla** — el backend da `/trashed`, `include_trashed`, el historial y los comentarios de entradas borradas a cualquier rol con acceso. _Según la revisión @@ -288,6 +279,20 @@ sigue siendo donde se arregla este fichero y se prueba en el entorno preview. ### 🟢 Baja +- [ ] **`excludeMantenimiento` es una lista de exclusión** — parking, lista + negra, caja, partes, conciliación y departamentos dejan pasar cualquier + rol salvo `mantenimiento`, y `services/auth/module-access.ts` lo replica. + Hoy solo hay roles conocidos (el rol va en el token firmado y sale de + `roles`), pero un rol nuevo tendría acceso sin que nadie lo decida. Pasar + a lista de permitidos. _Comprobado el 2026-10-05 al retirar + `demo-admin`._ +- [ ] **`20260512_disable_demo_user.sql` desactivaría la cuenta demo** si se + volviera a ejecutar (`UPDATE users SET is_active = 0 WHERE username = + 'demo'`). `setup:local` no la ejecuta (es anterior al baseline); no + aplicarla a mano. _Comprobado el 2026-10-05._ +- [ ] **Configuración: `?tab=` de una pestaña sin permiso deja el contenido en + blanco** — p. ej. recepción con `?tab=users`; debería caer en la primera + pestaña disponible. _Visto el 2026-10-05._ - [ ] **Scheduling: vista de solo lectura para los demás roles** — hoy `/dashboard/scheduling` es solo de `admin` y el resto no ve el cuadrante. El dueño quiere que los demás (salvo `mantenimiento`) vean el cuadrante @@ -299,45 +304,19 @@ sigue siendo donde se arregla este fichero y se prueba en el entorno preview. pestaña muestra las rechazadas. Una factura incorrecta hoy se borra. El dueño decidió el 2026-10-04 no añadirlo porque casi nunca pasa; si se añade, hace falta también reabrir (`rejected` → `pending`). -- [ ] **Demo pública** (fase 5 de `ROADMAP.md`; propuesta aceptada por el dueño - el 2026-10-04) — un entorno aparte que la gente pueda probar sin tocar - producción: - - **BD propia**: base `hotel_demo` en el mismo servicio Aiven, con datos - inventados de un hotel ficticio. - - **Backend y frontend propios** en `demo.four-points.stackbp.es`, - reutilizando la infraestructura del preview al acabar la fase 1c (sin - más horas de Render). - - **Secretos propios**: otro `SECRET_JWT_KEY` y otras credenciales; un - token de la demo nunca vale en producción. - - **Reinicio nocturno**: un cron borra y vuelve a sembrar la BD demo. - - **Usuarios demo públicos** en la pantalla de login: `demo_admin`, - `demo_recepcion`, `demo_mantenimiento`. - - **Nivel de libertad** (recomendado: admin casi completo): crear, editar y - borrar de todo, porque el reinicio lo arregla. Bloqueado: cambiar - contraseñas, crear o borrar usuarios, subidas a Cloudinary (desactivadas - o en una carpeta `demo/` vaciada cada noche) y límites de peticiones más - estrictos. La alternativa es solo lectura con la lista blanca de - `demoRestriction` (más segura, demo pobre). - - **Nunca** sobre la BD de producción: cuando haya datos reales del hotel, - cualquier demo ahí queda descartada. -- [ ] **Reutilizar el rol `demo-admin` para la demo pública** (antes «retirarlo»; - ver «Demo pública» y la fase 5 de `ROADMAP.md`) — el único usuario con - ese rol, `demo`, está desactivado (`is_active = 0`, comprobado en Aiven el - 2026-10-04). El rol sigue en los middlewares de `roleCheck.ts`, en - `demoRestriction` (lista blanca de escrituras y registro de intentos - bloqueados), en las rutas `/demo` y en el frontend (`isAdminRole`). Las - reglas añadidas el 2026-10-02 ya no lo incluyen. En producción, mientras - exista, `demo-admin` entra en backoffice y lee los PDFs de facturas por - `/pdf-url`, `/pdf-download` y el ZIP (antes un punto 🔴 aparte; se movió - aquí el 2026-10-04 porque no hay ninguna cuenta activa): en la demo vale, - en producción no debe haber cuentas con ese rol. +- [ ] **Demo pública en la propia web: lo que falta** (fase 5 de `ROADMAP.md`, + ADR-037 y ADR-038) — el código está hecho y probado en preview el + 2026-10-05 (cuenta demo, bloqueos, límites, reinicio diario, pestaña + Configuración → Demo, `setup:local`) y la base de horarios guardada + (octubre a diciembre de 2026, generados con el solver; los meses que + venían de bajas reales se borraron). Queda: + 1. **Producción:** tras el merge, `DEMO_MODE=true` en el servicio de + producción de Render y `NEXT_PUBLIC_DEMO_MODE=true` en Production de + Vercel (hoy solo están en preview). Probar el botón en la web. - [ ] **Conciliation: `GET /api/conciliations` sin paginar ni filtrar** — devuelve todo el histórico a cualquier rol con acceso. _Según la revisión `security` L3 del 2026-09-28 (fichero y línea en el informe); no repasado por mí._ -- [ ] **Backoffice: `demo-admin` ve IBAN, CIF y datos de contacto completos** de - los proveedores. _Según la revisión `security` L3 del 2026-09-28 (fichero - y línea en el informe); no repasado por mí._ - [ ] **Checklist: `checklist_config` existe pero no se usa.** _Comprobado por mí el 2026-09-28._ - [ ] **Scheduling: reiniciar un mes no usa transacción** — si falla a mitad, el diff --git a/docs/VERIFY.md b/docs/VERIFY.md index e9401dba..98e13223 100644 --- a/docs/VERIFY.md +++ b/docs/VERIFY.md @@ -231,3 +231,24 @@ Vercel y Render se reconectaron de `four-points-archive` a `bpstack/four-points` | `d197ad6` | docs(todo): drop the local path of the harness repository | (documentación) | no aplica | — | | `5b216a0` | docs(roadmap): close phases 1c and 2 and make publication current | (documentación) | no aplica | — | | `0f237d0` | docs(decisions): record the publication order and when to rotate | (documentación) | no aplica | — | +| `2009f39` | docs(verify): record the switch to the new repository | (documentación) | no aplica | — | +| `8194031` | docs(session): move the session to the publication phase | (documentación) | no aplica | — | +| `0bb4c1f` | fix(db): require the Aiven user and port instead of defaulting them | Puerto y usuario de Aiven por defecto en el código | Producción arranca y conecta tras el merge (PR #2) | ✅ 2026-10-05 | +| `a474007` | chore(db): rewrite the mock data without users and for the current schema | Datos ficticios para Aiven | Cargado en local y en Aiven; horarios intactos | ✅ 2026-10-05 | +| `a889999` | docs: plan the demo on the showcase site before going public | (documentación) | no aplica | — | +| `e332b8a` | docs: settle the demo design and the local install | (documentación) | no aplica | — | +| `5789e83` | feat(auth): restrict the public demo account with an is_demo flag | Demo: marca `is_demo` y bloqueos | Preview, sesión demo real: 18 rutas, lecturas 200 y bloqueadas 403 | ✅ 2026-10-05 | +| `a306d36` | docs(db): mark the is_demo migration as applied on Aiven | (documentación) | no aplica | — | +| `8553963` | fix(auth): answer 403 to blocked demo requests without a JSON body | — | Local: DELETE y subidas bloqueadas dan 403, no 500/401 | ✅ 2026-10-05 | +| `7507d58` | feat(auth): enter the public demo with one button, without a password | Demo: entrada con botón | Preview: botón visible, entra como Demo · Admin; login con contraseña de `demo` da 401 | ✅ 2026-10-05 | +| `d8c23af` | feat(auth): rate limit the demo account's writes and schedule generation | Demo: límites | `demo-limits.test.ts` (5/h por IP, 30/h en total, 60 escrituras/15 min) | 🧪 | +| `db8b356` | feat(demo): reset the demo data daily and from Settings | Demo: reinicio diario | Preview: primer reinicio del día registrado en Aiven (734 ms); local: base de horarios restaurada, fallo deshecho; pestaña Demo solo para admins | ✅ 2026-10-05 | +| `a2395a7` | docs(db): mark the demo reset log migration as applied on Aiven | (documentación) | no aplica | — | +| `7e7ed39` | test(auth): mock the demo reset in the login timing test | — | CI en verde | ✅ 2026-10-05 | +| `ee9cbc0` | fix(db): load the mock data on a database with a single admin | — | `setup:local` en un MySQL limpio carga el mock | ✅ 2026-10-05 | +| `a8882c4` | feat(db): install a local database from scratch with pnpm setup:local | Demo: instalación local | MySQL 8.0 limpio en Docker: estructura igual que Aiven (1161 de 1161) y mismos catálogos; la app entra con el admin | ✅ 2026-10-05 | +| `9336716` | feat(db): give the local install a documented admin password | — | `setup-local.test.ts` | 🧪 | +| `e3a642c` | fix(db): group the backoffice monthly summary view by its period | — (hallazgo del 2026-10-05) | `GET /api/backoffice/stats/monthly` en preview da 200; la vista coincide con un cálculo directo | ✅ 2026-10-05 | +| `9991d79` | test(security): check the CSRF defence and the session cookies by behaviour | Demo: CSRF | `csrf-origin.test.ts` (servidor Express real), `session-cookies.test.ts`; preview: origen ajeno da 403 | ✅ 2026-10-05 | +| `4449b7a` | fix(checklist): render unsafe links in step notes as plain text | Demo: XSS | `safe-href.test.ts` (11 URL maliciosas) | 🧪 | +| `143882d` | refactor(auth): retire the demo-admin role | Retirar `demo-admin` | Preview tras el push: la entrada demo, los bloqueos y horarios funcionan; rol borrado en Aiven (no tenía usuarios) | ✅ 2026-10-05 | diff --git a/docs/backoffice/README.md b/docs/backoffice/README.md index 718d9a8d..5deba492 100644 --- a/docs/backoffice/README.md +++ b/docs/backoffice/README.md @@ -15,8 +15,7 @@ in one step. ## Who uses it -Only **`admin`**. `demo-admin` can see it but not change anything. No other role -has access. +Only **`admin`**. No other role has access. ## What it can do diff --git a/docs/cashier/README.md b/docs/cashier/README.md index cc9359e3..a03380b3 100644 --- a/docs/cashier/README.md +++ b/docs/cashier/README.md @@ -18,12 +18,12 @@ when every shift is closed. Every role **except `mantenimiento`**: - **Everyone with access** sees days, shifts and vouchers. -- **`admin`, `recepcionista`, `group-admin` and `demo-admin`** start a day, fill - in and close shifts, record payments and counts, create vouchers and close the +- **`admin`, `recepcionista` and `group-admin`** start a day, fill in and close + shifts, record payments and counts, create vouchers and close the day. - **Only `admin`** reopens a shift or a day, cancels or deletes a voucher, deletes a shift and changes who worked a shift. -- **Reports, history and statistics** are for `admin` (and `demo-admin`). +- **Reports, history and statistics** are for `admin`. - Closed shifts and closed days **stay editable** by those same roles. This is deliberate for now (ADR-032). diff --git a/docs/checklist/README.md b/docs/checklist/README.md index 020fe1ee..6300c9f3 100644 --- a/docs/checklist/README.md +++ b/docs/checklist/README.md @@ -16,7 +16,7 @@ away. Every morning the lists start fresh. Every role **except `mantenimiento`**. Anyone with access ticks and unticks steps and adds comments or images. Restarting a checklist by hand is for -`admin`, `recepcionista` and `demo-admin`. A comment or image can be deleted by +`admin` and `recepcionista`. A comment or image can be deleted by its author or by an `admin`. ## What it can do diff --git a/docs/fnb/README.md b/docs/fnb/README.md index 518adec7..e2e9d82f 100644 --- a/docs/fnb/README.md +++ b/docs/fnb/README.md @@ -20,8 +20,8 @@ phase (product inventory, supplier orders) that has not started. ## Who uses it -Every role **except `mantenimiento`**: `admin`, `recepcionista`, `group-admin` -and `demo-admin`. There are no admin-only actions — any of these roles can +Every role **except `mantenimiento`**: `admin`, `recepcionista` and +`group-admin`. There are no admin-only actions — any of these roles can upload a PDF, edit a day by hand, or delete a day's revenue, of any date. This is deliberate for now (ADR-032). diff --git a/docs/general/README.md b/docs/general/README.md index 19850391..04c49c3d 100644 --- a/docs/general/README.md +++ b/docs/general/README.md @@ -25,9 +25,11 @@ Hotel staff, each with **a role**: receives their notices. - **`mantenimiento`**: only the maintenance module, plus the common functions (profile, messages, notifications, search). -- **`demo-admin`**: sees everything as `admin`, but can only write in a - whitelist (log out, create a parking booking, comment in the logbook, open a - maintenance report). Each blocked attempt is recorded. +- **Public demo account** (`users.is_demo = 1`, only where `DEMO_MODE=true`): + an `admin` entered with the demo button, without a password. It can do + everything the daily reset undoes, but not manage users, departments or the + scheduling configuration, upload files, or reset the demo. Each blocked + attempt is recorded. The side menu only hides backoffice and scheduling links from non-admins. **The backend is what actually decides**: each route checks the role and returns `403` @@ -244,16 +246,22 @@ Hostinger. Delete or deactivate the `qa_*` users and the `QA-` test data, and ## Local development +Installing from scratch (MySQL in Docker or your own, `pnpm setup:local`, the +local admin `admin` / `fourpoints-local`) is in the root +[`README.md`](../../README.md#run-it-locally). Day to day: + ```bash -cd backend && pnpm install && pnpm dev:aiven # API against Aiven; frontend looks for it on :4000 -cd frontend && pnpm install && pnpm dev # http://localhost:3000 +cd backend && pnpm dev:local # API on :4000 against the local MySQL (dev:aiven: against Aiven) +cd frontend && pnpm dev # http://localhost:3000 ``` Backend variables: `PORT` (4000 locally), `NODE_ENV`, `SECRET_JWT_KEY`, -`SALT_ROUNDS`, `DB_ENVIRONMENT=aiven`, `AIVEN_DB_*` + `AIVEN_PASSWORD`, -`CLOUDINARY_*`, `LOG_LEVEL`, `FRONTEND_URL`. Frontend: `NEXT_PUBLIC_API_URL`, -`NEXT_SERVER_API_URL` (optional) and `NEXT_PUBLIC_APP_URL`, which the user -creation route uses to accept the request origin. +`SALT_ROUNDS`, `DB_ENVIRONMENT` (`local` or `aiven`), `LOCAL_DB_*` or +`AIVEN_DB_*` + `AIVEN_PASSWORD`, `CLOUDINARY_*`, `LOG_LEVEL`, `FRONTEND_URL` +and `DEMO_MODE` (`true` only where the public demo runs). Frontend: +`NEXT_PUBLIC_API_URL`, `NEXT_SERVER_API_URL` (optional), `NEXT_PUBLIC_APP_URL`, +which the user creation route uses to accept the request origin, and +`NEXT_PUBLIC_DEMO_MODE`, which shows the demo button on the login page. **Tests**: `pnpm test` in `backend/` (also `test:watch` and `test:coverage`). Some suites (login, checklist, F&B, scheduling requests) use the configured diff --git a/docs/general/database/README.md b/docs/general/database/README.md index 52ad22dc..338d5b85 100644 --- a/docs/general/database/README.md +++ b/docs/general/database/README.md @@ -31,10 +31,12 @@ Tables by module: **maintenance** (3), **messaging** (3), **notifications** (2), **backoffice** (5 tables + 3 views `v_bo_*`), **scheduling** (12), **checklist** (6), **F&B** (2). -- **Demo mode**: `demo_activity_log`. +- **Public demo**: `demo_activity_log` (blocked attempts), `demo_reset_log` + (daily resets and saved scheduling bases) and the `demo_snapshot_scheduling_*` + copies the reset restores, created when an admin saves the base. **Roles** (table `roles`): 1 `recepcionista`, 2 `admin`, 3 `mantenimiento`, 6 -`group-admin`, 7 `demo-admin` (the demo user is deactivated). +`group-admin`. The public demo account is an `admin` with `users.is_demo = 1`. **Logic that lives in the database itself** (not in the code): @@ -57,6 +59,11 @@ Tables by module: - **`aiven/` is the frozen base schema** as of 2026-05-20. The name is misleading: it is not "the Aiven database", but the initial installation that runs `MASTER_INSTALL.sql`. It is never run against a database with data. + Two of its files fail on an empty server (a duplicate foreign key name in + `11_cashier.sql`, a foreign key to a later table in `19_scheduling.sql`): + `pnpm setup:local` fixes both in memory, and + `20261005_complete_fresh_install.sql` adds what an install made by hand + misses. - **Character set**: `utf8mb4` with `utf8mb4_0900_ai_ci`, which does not distinguish case or accents when comparing texts. - **Time**: the Aiven server is in **UTC**. What the database calculates with diff --git a/docs/groups/README.md b/docs/groups/README.md index 9b69d8ea..945db347 100644 --- a/docs/groups/README.md +++ b/docs/groups/README.md @@ -16,7 +16,7 @@ lines in parallel, and sends in-app reminders before the important dates. - **Every role can view groups**, including `recepcionista` and `mantenimiento` (the latter needs it to open group notifications from the profile). -- **`admin`, `group-admin` and `demo-admin`** create and edit groups, payments, +- **`admin` and `group-admin`** create and edit groups, payments, contacts, rooms and statuses, and send manual notifications. - **Only `admin`** deletes a group. diff --git a/frontend/.env.example b/frontend/.env.example index 59e9a569..349d9253 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -5,6 +5,9 @@ NEXT_PUBLIC_API_URL=http://localhost:4000 # (optional) Different API URL for server-side requests only # NEXT_SERVER_API_URL=http://localhost:4000 +# Public demo: "true" shows the demo button on the login page (backend needs DEMO_MODE=true) +# NEXT_PUBLIC_DEMO_MODE=true + # Hotel name printed in the footer of printed checklists (default: Hotel PMS) # NEXT_PUBLIC_HOTEL_NAME=My Hotel diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx index 86b9c873..6d8d5ee8 100644 --- a/frontend/app/(auth)/login/page.tsx +++ b/frontend/app/(auth)/login/page.tsx @@ -15,6 +15,7 @@ import { Fa4 } from 'react-icons/fa6' import { TbTransformPointTopLeft } from 'react-icons/tb' import { SimpleThemeButton } from '@/app/components/theme/SetThemeButton' import { useAuth } from '@/app/lib/auth/useAuth' +import { DEMO_MODE } from '@/app/lib/env' import Link from 'next/link' export default function LoginPage() { @@ -25,7 +26,7 @@ export default function LoginPage() { const [error, setError] = useState(null) const [fieldErrors, setFieldErrors] = useState>({}) const [success, setSuccess] = useState(false) - const { login, loading } = useAuth() + const { login, loginDemo, loading } = useAuth() const validateUsername = (value: string) => { if (value.length === 0) return @@ -53,6 +54,16 @@ export default function LoginPage() { } } + const handleDemo = async () => { + setError(null) + setFieldErrors({}) + try { + await loginDemo() + } catch { + setError(t('errors.demoUnavailable')) + } + } + return (
@@ -195,6 +206,20 @@ export default function LoginPage() { {!loading && !success && } + + {DEMO_MODE && ( +
+ +

{t('login.demoHint')}

+
+ )}
diff --git a/frontend/app/components/checklist/ChecklistNoteBanner.tsx b/frontend/app/components/checklist/ChecklistNoteBanner.tsx index 2550a32e..c8bb5184 100644 --- a/frontend/app/components/checklist/ChecklistNoteBanner.tsx +++ b/frontend/app/components/checklist/ChecklistNoteBanner.tsx @@ -1,4 +1,5 @@ import Link from 'next/link' +import { safeHref } from '@/app/lib/helpers/safeHref' function parseNoteText(text: string) { const parts: React.ReactNode[] = [] @@ -10,10 +11,17 @@ function parseNoteText(text: string) { if (match.index > lastIndex) { parts.push(text.slice(lastIndex, match.index)) } + // A link that is not safe (javascript:, data:…) stays as plain text + const href = safeHref(match[2]) + if (!href) { + parts.push(match[1]) + lastIndex = regex.lastIndex + continue + } parts.push( {match[1]} diff --git a/frontend/app/components/profile/DemoTab.tsx b/frontend/app/components/profile/DemoTab.tsx new file mode 100644 index 00000000..ed9e77ab --- /dev/null +++ b/frontend/app/components/profile/DemoTab.tsx @@ -0,0 +1,136 @@ +// app/components/profile/DemoTab.tsx +'use client' + +/** + * Configuración → Demo (ADR-038): estado del reinicio diario, reinicio a mano y + * guardar los horarios actuales como base. Solo con NEXT_PUBLIC_DEMO_MODE=true + * y para admins que no son la cuenta demo; el backend lo comprueba igual. + */ + +import { useCallback, useEffect, useState } from 'react' +import { useTranslations } from 'next-intl' +import { toast } from 'react-hot-toast' +import { FiRefreshCw, FiSave, FiAlertTriangle } from 'react-icons/fi' +import { apiClient } from '@/app/lib/apiClient' +import { API_BASE_URL } from '@/app/lib/env' +import { CenterModal, CenterModalFooterButtons } from '@/app/ui/panels' + +interface DemoStatus { + lastReset: { + startedAt: string + ok: boolean + trigger: 'auto' | 'manual' + details: string | null + } | null + snapshot: { savedAt: string; details: string | null } | null +} + +type Action = 'reset' | 'snapshot' + +const API = `${API_BASE_URL}/api/demo` + +const formatDate = (iso: string) => + new Date(iso).toLocaleString('es-ES', { dateStyle: 'short', timeStyle: 'short' }) + +export function DemoTab() { + const t = useTranslations('profile.settings.demo') + const [status, setStatus] = useState(null) + const [confirm, setConfirm] = useState(null) + const [running, setRunning] = useState(false) + + const load = useCallback(async () => { + try { + const res = await apiClient.get<{ data: DemoStatus }>(`${API}/status`) + setStatus(res.data) + } catch { + toast.error(t('loadError')) + } + }, [t]) + + useEffect(() => { + load() + }, [load]) + + const run = async () => { + if (!confirm) return + setRunning(true) + try { + await apiClient.post(`${API}/${confirm}`) + toast.success(confirm === 'reset' ? t('resetDone') : t('snapshotDone')) + setConfirm(null) + await load() + } catch { + toast.error(t('actionError')) + } finally { + setRunning(false) + } + } + + return ( +
+
+

{t('title')}

+

{t('description')}

+ +
+
+
{t('lastReset')}
+
+ {status?.lastReset + ? `${formatDate(status.lastReset.startedAt)} · ${ + status.lastReset.trigger === 'auto' ? t('auto') : t('manual') + } · ${status.lastReset.ok ? t('ok') : t('failed')}` + : t('never')} +
+
+
+
{t('snapshot')}
+
+ {status?.snapshot ? formatDate(status.snapshot.savedAt) : t('noSnapshot')} +
+
+
+ +
+ + +
+
+ + !running && setConfirm(null)} + title={confirm === 'reset' ? t('resetButton') : t('snapshotButton')} + size="sm" + headerIcon={} + footer={ + setConfirm(null)} + onSubmit={run} + submitText={t('confirm')} + isSubmitting={running} + submitVariant="danger" + /> + } + > +

+ {confirm === 'reset' ? t('resetWarning') : t('snapshotWarning')} +

+
+
+ ) +} diff --git a/frontend/app/components/profile/SettingsPanel.tsx b/frontend/app/components/profile/SettingsPanel.tsx index 2cc86dee..1a46134e 100644 --- a/frontend/app/components/profile/SettingsPanel.tsx +++ b/frontend/app/components/profile/SettingsPanel.tsx @@ -6,7 +6,7 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { useSearchParams, useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' import { apiClient, isDemoError } from '@/app/lib/apiClient' -import { API_BASE_URL } from '@/app/lib/env' +import { API_BASE_URL, DEMO_MODE } from '@/app/lib/env' import { useAuth } from '@/app/lib/auth/useAuth' import NewUserModal from '@/app/components/auth/NewUserModal' import { UsersTableSkeleton } from '@/app/ui/skeletons' @@ -35,6 +35,7 @@ import { FiEyeOff, } from 'react-icons/fi' import { ReportsTab } from './reports' +import { DemoTab } from './DemoTab' import { CenterModal, CenterModalFooterButtons, FormField, inputClassName } from '@/app/ui/panels' import { GlobalNotificationModal } from '@/app/components/notifications/GlobalNotificationModal' import { SelectDropdown } from '@/app/ui/components/SelectDropdown' @@ -60,7 +61,7 @@ interface FormattedDepartment extends Department { displayName: string } -type SettingsTab = 'users' | 'notifications' | 'security' | 'reports' | 'departments' +type SettingsTab = 'users' | 'notifications' | 'security' | 'reports' | 'departments' | 'demo' const API_URL = API_BASE_URL @@ -91,6 +92,8 @@ export function SettingsPanel() { const router = useRouter() const searchParams = useSearchParams() const isUserAdmin = isAdminRole(currentUser?.role) + // Demo reset: only where the demo is on, and never for the demo account itself + const showDemoTab = DEMO_MODE && isUserAdmin && !currentUser?.is_demo const t = useTranslations('profile.settings') // Define tabs with translations (inside component to access t()) @@ -121,8 +124,18 @@ export function SettingsPanel() { icon: , adminOnly: true, }, + ...(showDemoTab + ? [ + { + id: 'demo' as const, + label: t('tabs.demo'), + icon: , + adminOnly: true, + }, + ] + : []), ], - [t] + [t, showDemoTab] ) const activeTab = @@ -298,6 +311,7 @@ export function SettingsPanel() { {activeTab === 'notifications' && } {activeTab === 'security' && } {activeTab === 'reports' && isUserAdmin && } + {activeTab === 'demo' && showDemoTab && } {/* Modal */} diff --git a/frontend/app/lib/auth/authService.ts b/frontend/app/lib/auth/authService.ts index bc0093ee..8eda4a1f 100644 --- a/frontend/app/lib/auth/authService.ts +++ b/frontend/app/lib/auth/authService.ts @@ -36,6 +36,18 @@ export const authLogin = { return data }, + // Demo entry without password (ADR-038) + demo: async () => { + const res = await fetch(`${API_BASE}/demo`, { + method: 'POST', + credentials: 'include', + cache: 'no-store', + }) + + if (!res.ok) throw new Error('demo-unavailable') + return res.json() + }, + logout: async () => { console.log(`[authLogin.logout] Cerrando sesión... (${isDev ? 'DEV' : 'PROD'})`) diff --git a/frontend/app/lib/auth/useAuth.tsx b/frontend/app/lib/auth/useAuth.tsx index 3d5d1727..9e97851a 100644 --- a/frontend/app/lib/auth/useAuth.tsx +++ b/frontend/app/lib/auth/useAuth.tsx @@ -39,6 +39,7 @@ interface AuthContextType { loading: boolean isAuthenticated: boolean login: (username: string, password: string) => Promise + loginDemo: () => Promise logout: () => Promise refreshUser: () => Promise } @@ -112,14 +113,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, [pathname, fetchUserData, user]) /** - * Login de usuario + * Inicio de sesión común: guarda el usuario y va a la página de destino */ - const login = useCallback( - async (username: string, password: string) => { + const startSession = useCallback( + async (signIn: () => Promise<{ user: User }>) => { setLoading(true) try { - const data = await authLogin.login(username, password) + const data = await signIn() setUser({ ...data.user, @@ -144,6 +145,23 @@ export function AuthProvider({ children }: { children: ReactNode }) { [router] ) + /** + * Login de usuario + */ + const login = useCallback( + async (username: string, password: string) => { + await startSession(() => authLogin.login(username, password)) + }, + [startSession] + ) + + /** + * Entrada a la demo pública, sin contraseña + */ + const loginDemo = useCallback(async () => { + await startSession(() => authLogin.demo()) + }, [startSession]) + /** * Logout de usuario */ @@ -179,10 +197,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { loading, isAuthenticated: !!user, login, + loginDemo, logout, refreshUser, }), - [user, loading, login, logout, refreshUser] + [user, loading, login, loginDemo, logout, refreshUser] ) return {children} diff --git a/frontend/app/lib/env.ts b/frontend/app/lib/env.ts index e03039df..fa644c0f 100644 --- a/frontend/app/lib/env.ts +++ b/frontend/app/lib/env.ts @@ -1,3 +1,5 @@ // Centraliza las URLs de API para cliente y servidor export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000' export const SERVER_API_BASE_URL = process.env.NEXT_SERVER_API_URL || API_BASE_URL +// Shows the demo button on the login page; the backend needs DEMO_MODE=true too +export const DEMO_MODE = process.env.NEXT_PUBLIC_DEMO_MODE === 'true' diff --git a/frontend/app/lib/helpers/safeHref.ts b/frontend/app/lib/helpers/safeHref.ts new file mode 100644 index 00000000..11a216dd --- /dev/null +++ b/frontend/app/lib/helpers/safeHref.ts @@ -0,0 +1,21 @@ +// app/lib/helpers/safeHref.ts + +// Schemes a link written in content (checklist notes, guides) may use. +// Anything else (javascript:, data:, vbscript:…) is not rendered as a link. +const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']) + +/** + * The href if it is safe to render, otherwise undefined. Parsed with URL, as + * the browser does: it drops tabs and newlines and ignores case, so + * "java\nscript:" or "JavaScript:" are caught as javascript:. Relative paths + * resolve against an https base and pass. + */ +export function safeHref(href: string | undefined | null): string | undefined { + if (!href) return undefined + try { + const { protocol } = new URL(href, 'https://four-points.invalid/') + return ALLOWED_PROTOCOLS.has(protocol) ? href : undefined + } catch { + return undefined + } +} diff --git a/frontend/app/lib/helpers/utils.ts b/frontend/app/lib/helpers/utils.ts index 8614ec6a..445270ea 100644 --- a/frontend/app/lib/helpers/utils.ts +++ b/frontend/app/lib/helpers/utils.ts @@ -56,21 +56,21 @@ export function daysUntil(date: string | Date): number { } /** - * Verifica si el usuario tiene rol de administrador (incluye demo-admin) + * Verifica si el usuario tiene rol de administrador * Usado para mostrar/ocultar elementos de UI admin-only * - * NOTA: demo-admin puede VER todo pero sus escrituras están limitadas - * por el middleware demoRestriction en el backend. + * La cuenta demo pública también es admin: el backend limita lo que puede + * cambiar (demoRestriction). */ export function isAdminRole(role: string | undefined | null): boolean { if (!role) return false const normalizedRole = role.toLowerCase().trim() - return normalizedRole === 'admin' || normalizedRole === 'demo-admin' + return normalizedRole === 'admin' } /** * Roles del middleware canRunNotificationCheck del backend: lanzan a mano la - * generación de avisos pendientes (demo-admin está deshabilitado) + * generación de avisos pendientes */ export function canRunNotificationCheckRole(role: string | undefined | null): boolean { const normalizedRole = role?.toLowerCase().trim() diff --git a/frontend/app/lib/logbooks/types.ts b/frontend/app/lib/logbooks/types.ts index 7410a00e..9a77abf1 100644 --- a/frontend/app/lib/logbooks/types.ts +++ b/frontend/app/lib/logbooks/types.ts @@ -62,6 +62,8 @@ export interface User { email?: string role?: string avatar_url?: string | null + // Public demo account (ADR-038) + is_demo?: number | boolean } // ========== HISTORIAL ========== diff --git a/frontend/messages/en/auth.json b/frontend/messages/en/auth.json index 3aa5e14d..aa6f865c 100644 --- a/frontend/messages/en/auth.json +++ b/frontend/messages/en/auth.json @@ -21,7 +21,8 @@ "askManagers": "Ask the managers", "signUp": "Sign up", "orContinueWith": "Or continue with", - "liveDemo": "Try our live demo" + "liveDemo": "Try our live demo", + "demoHint": "You sign in as an administrator with fictitious data that resets every day. User management and settings are locked." }, "register": { "title": "Create Account", @@ -68,6 +69,7 @@ }, "errors": { "invalidCredentials": "Invalid credentials", + "demoUnavailable": "The demo is not available right now. Please try again later.", "emailRequired": "Email is required", "passwordRequired": "Password is required", "invalidEmail": "Please enter a valid email", diff --git a/frontend/messages/en/profile.json b/frontend/messages/en/profile.json index 6c11c1b8..c61e29b8 100644 --- a/frontend/messages/en/profile.json +++ b/frontend/messages/en/profile.json @@ -87,7 +87,8 @@ "departments": "Departments", "notifications": "Notifications", "security": "Security", - "reports": "Reports" + "reports": "Reports", + "demo": "Demo" }, "users": { "title": "User Management", @@ -156,6 +157,27 @@ "groupAdmin": "Group Admin", "receptionist": "Receptionist", "maintenance": "Maintenance" + }, + "demo": { + "title": "Public demo", + "description": "Every day, on the first demo entry, the fictitious data of every module is loaded again and the saved scheduling base is restored. You can also do it by hand here.", + "lastReset": "Last reset", + "snapshot": "Saved scheduling base", + "never": "Never", + "noSnapshot": "Not saved: the reset leaves schedules alone", + "auto": "automatic", + "manual": "by hand", + "ok": "ok", + "failed": "failed", + "resetButton": "Reset the data now", + "snapshotButton": "Save current schedules as the base", + "resetWarning": "The data of every module (logbooks, parking, groups, cashier…) is deleted and the fictitious data loaded. Schedules go back to the saved base. Users are not touched.", + "snapshotWarning": "The current schedules, with their configuration, become the base restored every day. It replaces the previous base.", + "confirm": "Confirm", + "resetDone": "Demo data reset", + "snapshotDone": "Scheduling base saved", + "loadError": "Could not load the demo status", + "actionError": "Could not finish. A reset may already be running." } }, "messages": { diff --git a/frontend/messages/es/auth.json b/frontend/messages/es/auth.json index fb153789..a21ea3f6 100644 --- a/frontend/messages/es/auth.json +++ b/frontend/messages/es/auth.json @@ -21,7 +21,8 @@ "askManagers": "Pregunta a los gerentes", "signUp": "Regístrate", "orContinueWith": "O continúa con", - "liveDemo": "Prueba nuestra demo" + "liveDemo": "Prueba nuestra demo", + "demoHint": "Entras como administrador con datos ficticios que se reinician cada día. La gestión de usuarios y la configuración están bloqueadas." }, "register": { "title": "Crear Cuenta", @@ -68,6 +69,7 @@ }, "errors": { "invalidCredentials": "Credenciales inválidas", + "demoUnavailable": "La demo no está disponible ahora mismo. Inténtalo más tarde.", "emailRequired": "El correo es requerido", "passwordRequired": "La contraseña es requerida", "invalidEmail": "Por favor ingresa un correo válido", diff --git a/frontend/messages/es/profile.json b/frontend/messages/es/profile.json index 68bfb327..08544c27 100644 --- a/frontend/messages/es/profile.json +++ b/frontend/messages/es/profile.json @@ -87,7 +87,8 @@ "departments": "Departamentos", "notifications": "Notificaciones", "security": "Seguridad", - "reports": "Reportes" + "reports": "Reportes", + "demo": "Demo" }, "users": { "title": "Gestión de Usuarios", @@ -156,6 +157,27 @@ "groupAdmin": "Admin de Grupos", "receptionist": "Recepcionista", "maintenance": "Mantenimiento" + }, + "demo": { + "title": "Demo pública", + "description": "Cada día, con la primera entrada a la demo, se vuelven a cargar los datos ficticios de todos los módulos y se restaura la base de horarios guardada. Desde aquí puedes hacerlo a mano.", + "lastReset": "Último reinicio", + "snapshot": "Base de horarios guardada", + "never": "Nunca", + "noSnapshot": "Sin guardar: el reinicio no toca los horarios", + "auto": "automático", + "manual": "a mano", + "ok": "correcto", + "failed": "falló", + "resetButton": "Reiniciar los datos ahora", + "snapshotButton": "Guardar horarios actuales como base", + "resetWarning": "Se borran los datos de todos los módulos (partes, parking, grupos, caja…) y se cargan los ficticios. Los horarios vuelven a la base guardada. Los usuarios no se tocan.", + "snapshotWarning": "Los horarios actuales, con su configuración, pasan a ser la base que se restaura cada día. Sustituye a la base anterior.", + "confirm": "Confirmar", + "resetDone": "Datos de la demo reiniciados", + "snapshotDone": "Base de horarios guardada", + "loadError": "No se pudo cargar el estado de la demo", + "actionError": "No se pudo completar. Puede que ya haya un reinicio en marcha." } }, "messages": {