From c0a019dccd175618a09ce28c3839def10e0f8c2f Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 2 May 2026 12:49:11 +0200 Subject: [PATCH 1/3] Release V-01.05 hardening and package docs --- .github/workflows/ci.yml | 32 +- .gitignore | 2 + Atlas Balance/AGENTS.md | 93 +- Atlas Balance/CLAUDE.md | 93 +- Atlas Balance/Directory.Build.props | 8 +- Atlas Balance/README_RELEASE.md | 24 +- Atlas Balance/VERSION | 2 +- .../GestionCaja.API/Constants/AuditActions.cs | 3 + .../Controllers/AuthController.cs | 57 +- .../Controllers/CuentasController.cs | 27 +- .../Controllers/ExtractosController.cs | 39 +- .../IntegrationOpenClawController.cs | 8 +- .../Controllers/UsuariosController.cs | 38 +- .../src/GestionCaja.API/DTOs/AuthDtos.cs | 14 +- .../src/GestionCaja.API/Data/AppDbContext.cs | 2 + .../GestionCaja.API/Data/RlsContextSigner.cs | 42 + .../Data/RlsDbCommandInterceptor.cs | 205 + .../src/GestionCaja.API/Data/SeedData.cs | 20 +- .../Middleware/CsrfMiddleware.cs | 1 + .../Middleware/IntegrationAuthMiddleware.cs | 52 +- ...260501105704_RequireWebUserMfa.Designer.cs | 1691 ++ .../20260501105704_RequireWebUserMfa.cs | 70 + ...1120000_EnableRowLevelSecurity.Designer.cs | 33 + .../20260501120000_EnableRowLevelSecurity.cs | 537 + ...00_SignRowLevelSecurityContext.Designer.cs | 33 + ...60501133000_SignRowLevelSecurityContext.cs | 220 + .../Migrations/AppDbContextModelSnapshot.cs | 20 + .../src/GestionCaja.API/Models/Entities.cs | 4 + .../backend/src/GestionCaja.API/Program.cs | 105 +- .../Services/ActualizacionService.cs | 401 +- .../GestionCaja.API/Services/AuthService.cs | 403 +- .../Services/DashboardService.cs | 29 +- .../Services/ImportacionService.cs | 73 +- .../GestionCaja.API/Services/TotpService.cs | 167 + .../Services/UserAccessService.cs | 10 +- .../appsettings.Development.json.template | 9 +- .../appsettings.Production.json.template | 9 +- .../src/GestionCaja.API/appsettings.json | 6 + .../Services/WatchdogOperationsService.cs | 161 +- .../appsettings.Development.json.template | 5 +- .../appsettings.Production.json.template | 5 +- .../src/GestionCaja.Watchdog/appsettings.json | 5 +- .../ActualizacionServiceTests.cs | 243 +- .../GestionCaja.API.Tests/AuthServiceTests.cs | 219 +- .../CuentasControllerTests.cs | 113 +- .../DashboardServiceTests.cs | 132 +- .../ExtractosControllerTests.cs | 220 +- .../ImportacionServiceTests.cs | 164 +- .../IntegrationAuthMiddlewareTests.cs | 48 + .../IntegrationOpenClawControllerTests.cs | 84 + .../RowLevelSecurityTests.cs | 500 + .../GestionCaja.API.Tests/SeedDataTests.cs | 31 + .../UserAccessServiceTests.cs | 36 + .../UsuariosControllerTests.cs | 49 + .../WatchdogOperationsServiceTests.cs | 3 +- Atlas Balance/docker-compose.yml | 9 +- Atlas Balance/frontend/package-lock.json | 292 +- Atlas Balance/frontend/package.json | 6 +- .../components/dashboard/EvolucionChart.tsx | 32 +- .../dashboard/SaldoPorDivisaCard.tsx | 49 +- .../components/extractos/ExtractoTable.tsx | 234 +- .../src/components/layout/BottomNav.tsx | 84 +- .../src/components/layout/Sidebar.tsx | 71 +- .../frontend/src/pages/ConfiguracionPage.tsx | 8 +- .../frontend/src/pages/CuentaDetailPage.tsx | 153 + .../frontend/src/pages/CuentasPage.tsx | 97 +- .../frontend/src/pages/DashboardPage.tsx | 244 +- .../frontend/src/pages/ImportacionPage.tsx | 14 +- .../frontend/src/pages/LoginPage.tsx | 208 +- .../frontend/src/pages/TitularesPage.tsx | 15 +- Atlas Balance/frontend/src/styles/auth.css | 37 +- Atlas Balance/frontend/src/styles/global.css | 2 - .../frontend/src/styles/layout/dashboard.css | 187 +- .../frontend/src/styles/layout/entities.css | 116 + .../frontend/src/styles/layout/extractos.css | 207 +- .../frontend/src/styles/layout/shell.css | 87 +- Atlas Balance/frontend/src/types/index.ts | 10 +- .../frontend/src/utils/navigation.ts | 69 +- Atlas Balance/scripts/Build-Release.ps1 | 23 +- .../scripts/Instalar-AtlasBalance.ps1 | 76 +- Atlas Balance/scripts/Reset-AdminPassword.ps1 | 51 +- Atlas Balance/scripts/install.ps1 | 2 +- .../postgres-init/001-create-app-user.sh | 28 + Atlas Balance/scripts/update.ps1 | 19 +- CLAUDE.md | 93 +- ..._USO_BUGS_SEGURIDAD_V-01.05_2026-04-25.md} | 2 +- Documentacion/DOCUMENTACION_CAMBIOS.md | 13581 +++++++++------- Documentacion/DOCUMENTACION_TECNICA.md | 864 +- Documentacion/DOCUMENTACION_USUARIO.md | 107 +- Documentacion/Diseno/DESIGN.md | 1134 +- ...STALACION_WINDOWS_SERVER_2019_V-01.05.txt} | 56 +- Documentacion/LOG_ERRORES_INCIDENCIAS.md | 166 +- Documentacion/REGISTRO_BUGS.md | 133 +- ...1.04.md => SEGURIDAD_AUDITORIA_V-01.05.md} | 33 +- ...URIDAD_CHECKLIST_APP_V-01.05_2026-05-01.md | 97 + .../SEGURIDAD_RESPUESTA_INCIDENTES.md | 66 + Documentacion/Versiones/v-01.03.md | 12 +- Documentacion/Versiones/v-01.04.md | 184 - ...funciones-plazo-fijo-autonomos-alertas.md} | 10 +- Documentacion/Versiones/v-01.05.md | 447 + Documentacion/Versiones/version_actual.md | 8 +- Documentacion/documentacion.md | 78 +- 102 files changed, 18637 insertions(+), 7464 deletions(-) create mode 100644 Atlas Balance/backend/src/GestionCaja.API/Data/RlsContextSigner.cs create mode 100644 Atlas Balance/backend/src/GestionCaja.API/Data/RlsDbCommandInterceptor.cs create mode 100644 Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501105704_RequireWebUserMfa.Designer.cs create mode 100644 Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501105704_RequireWebUserMfa.cs create mode 100644 Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501120000_EnableRowLevelSecurity.Designer.cs create mode 100644 Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501120000_EnableRowLevelSecurity.cs create mode 100644 Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501133000_SignRowLevelSecurityContext.Designer.cs create mode 100644 Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501133000_SignRowLevelSecurityContext.cs create mode 100644 Atlas Balance/backend/src/GestionCaja.API/Services/TotpService.cs create mode 100644 Atlas Balance/backend/tests/GestionCaja.API.Tests/RowLevelSecurityTests.cs create mode 100644 Atlas Balance/scripts/postgres-init/001-create-app-user.sh rename Documentacion/{AUDITORIA_USO_BUGS_SEGURIDAD_V-01.04_2026-04-25.md => AUDITORIA_USO_BUGS_SEGURIDAD_V-01.05_2026-04-25.md} (99%) rename Documentacion/{INCIDENCIAS_INSTALACION_WINDOWS_SERVER_2019_V-01.04.txt => INCIDENCIAS_INSTALACION_WINDOWS_SERVER_2019_V-01.05.txt} (93%) rename Documentacion/{SEGURIDAD_AUDITORIA_V-01.04.md => SEGURIDAD_AUDITORIA_V-01.05.md} (68%) create mode 100644 Documentacion/SEGURIDAD_CHECKLIST_APP_V-01.05_2026-05-01.md create mode 100644 Documentacion/SEGURIDAD_RESPUESTA_INCIDENTES.md delete mode 100644 Documentacion/Versiones/v-01.04.md rename Documentacion/Versiones/{v-01.04-nuevas-funciones-plazo-fijo-autonomos-alertas.md => v-01.05-nuevas-funciones-plazo-fijo-autonomos-alertas.md} (98%) create mode 100644 Documentacion/Versiones/v-01.05.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e98ddf6..bf36a60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: run: dotnet restore "./Atlas Balance/backend/GestionCaja.sln" - name: Pull PostgreSQL test image - run: docker pull postgres:16-alpine + run: docker pull postgres:16-alpine@sha256:4e6e670bb069649261c9c18031f0aded7bb249a5b6664ddec29c013a89310d50 - name: Test backend run: dotnet test "./Atlas Balance/backend/GestionCaja.sln" -c Release --no-restore @@ -46,6 +46,36 @@ jobs: throw 'NuGet vulnerability audit failed.' } + - name: Scan high-confidence secrets + shell: pwsh + run: | + $patterns = @( + '-----BEGIN (RSA|DSA|EC|OPENSSH|PRIVATE) KEY-----', + 'AKIA[0-9A-Z]{16}', + 'gh[pousr]_[A-Za-z0-9_]{36,}', + 'xox[baprs]-[A-Za-z0-9-]{20,}', + 'sk-live-[A-Za-z0-9]{20,}' + ) + $files = (git -c core.quotepath=false ls-files -z) -split "`0" | Where-Object { + $_ -and + $_ -notmatch '(^|/)(Otros|Skills)/' -and + $_ -notmatch '(^|/)(bin|obj|node_modules|dist)/' + } + $findings = @() + foreach ($file in $files) { + if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { continue } + $content = Get-Content -LiteralPath $file -Raw -ErrorAction SilentlyContinue + foreach ($pattern in $patterns) { + if ($content -match $pattern) { + $findings += "$file matches $pattern" + } + } + } + if ($findings.Count -gt 0) { + $findings | ForEach-Object { Write-Error $_ } + throw 'Secret scan failed.' + } + - name: Install frontend dependencies working-directory: "Atlas Balance/frontend" run: npm ci diff --git a/.gitignore b/.gitignore index 05c909b..e9bd1e3 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,8 @@ logs/ .claude/ artifacts/ .codex-logs/ +.codex-runlogs/ +output/ .tmp*/ tmp* test-results/ diff --git a/Atlas Balance/AGENTS.md b/Atlas Balance/AGENTS.md index acec0e4..762b499 100644 --- a/Atlas Balance/AGENTS.md +++ b/Atlas Balance/AGENTS.md @@ -15,6 +15,7 @@ ## Que es este proyecto +Este proyecto pertenece a la empresa Atlas Labs y la aplicacion se llama Atlas Balance. Aplicacion web on-premise para gestion de tesoreria multi-banco, multi-titular, multi-divisa. Corre en Windows Server, accesible por 4-8 usuarios en red local via navegador. **Stack:** @@ -139,51 +140,51 @@ Guardar toda la documentacion en `Documentacion`. ``` Atlas Balance/ -├── CLAUDE.md -├── AGENTS.md -├── .github/ -├── .gitignore -├── .gitattributes -├── Atlas Balance/ -│ ├── AGENTS.md -│ ├── CLAUDE.md -│ ├── VERSION -│ ├── Directory.Build.props -│ ├── docker-compose.yml -│ ├── Atlas Balance Release/ -│ ├── backend/ -│ │ ├── GestionCaja.sln -│ │ ├── src/ -│ │ │ ├── GestionCaja.API/ -│ │ │ │ ├── Program.cs -│ │ │ │ ├── appsettings.json -│ │ │ │ ├── appsettings.Development.json -│ │ │ │ ├── Models/ -│ │ │ │ ├── Data/ -│ │ │ │ ├── DTOs/ -│ │ │ │ ├── Services/ -│ │ │ │ ├── Controllers/ -│ │ │ │ ├── Middleware/ -│ │ │ │ ├── Jobs/ -│ │ │ │ ├── Migrations/ -│ │ │ │ └── wwwroot/ -│ │ │ └── GestionCaja.Watchdog/ -│ │ └── tests/ -│ ├── frontend/ -│ │ ├── package.json -│ │ ├── vite.config.ts -│ │ ├── tsconfig.json -│ │ ├── index.html -│ │ └── src/ -│ ├── scripts/ -│ └── tests/ -├── Documentacion/ -│ ├── Versiones/ -│ ├── Diseno/ -│ ├── SPEC.md -│ ├── documentacion.md -│ └── DOCUMENTACION_CAMBIOS.md -└── Otros/ ++-- CLAUDE.md ++-- AGENTS.md ++-- .github/ ++-- .gitignore ++-- .gitattributes ++-- Atlas Balance/ +¦ +-- AGENTS.md +¦ +-- CLAUDE.md +¦ +-- VERSION +¦ +-- Directory.Build.props +¦ +-- docker-compose.yml +¦ +-- Atlas Balance Release/ +¦ +-- backend/ +¦ ¦ +-- GestionCaja.sln +¦ ¦ +-- src/ +¦ ¦ ¦ +-- GestionCaja.API/ +¦ ¦ ¦ ¦ +-- Program.cs +¦ ¦ ¦ ¦ +-- appsettings.json +¦ ¦ ¦ ¦ +-- appsettings.Development.json +¦ ¦ ¦ ¦ +-- Models/ +¦ ¦ ¦ ¦ +-- Data/ +¦ ¦ ¦ ¦ +-- DTOs/ +¦ ¦ ¦ ¦ +-- Services/ +¦ ¦ ¦ ¦ +-- Controllers/ +¦ ¦ ¦ ¦ +-- Middleware/ +¦ ¦ ¦ ¦ +-- Jobs/ +¦ ¦ ¦ ¦ +-- Migrations/ +¦ ¦ ¦ ¦ +-- wwwroot/ +¦ ¦ ¦ +-- GestionCaja.Watchdog/ +¦ ¦ +-- tests/ +¦ +-- frontend/ +¦ ¦ +-- package.json +¦ ¦ +-- vite.config.ts +¦ ¦ +-- tsconfig.json +¦ ¦ +-- index.html +¦ ¦ +-- src/ +¦ +-- scripts/ +¦ +-- tests/ ++-- Documentacion/ +¦ +-- Versiones/ +¦ +-- Diseno/ +¦ +-- SPEC.md +¦ +-- documentacion.md +¦ +-- DOCUMENTACION_CAMBIOS.md ++-- Otros/ ``` ## Esquema de BD corregido @@ -227,7 +228,7 @@ npm run build # Release Windows x64 cd "Atlas Balance" -powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.04 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.05 # Conectar a PostgreSQL psql -h localhost -p 5433 -U app_user -d atlas_balance diff --git a/Atlas Balance/CLAUDE.md b/Atlas Balance/CLAUDE.md index 707d447..97b7b65 100644 --- a/Atlas Balance/CLAUDE.md +++ b/Atlas Balance/CLAUDE.md @@ -15,6 +15,7 @@ ## Que es este proyecto +Este proyecto pertenece a la empresa Atlas Labs y la aplicacion se llama Atlas Balance. Aplicacion web on-premise para gestion de tesoreria multi-banco, multi-titular, multi-divisa. Corre en Windows Server, accesible por 4-8 usuarios en red local via navegador. **Stack:** @@ -139,51 +140,51 @@ Guardar toda la documentacion en `Documentacion`. ``` Atlas Balance/ -├── CLAUDE.md -├── AGENTS.md -├── .github/ -├── .gitignore -├── .gitattributes -├── Atlas Balance/ -│ ├── AGENTS.md -│ ├── CLAUDE.md -│ ├── VERSION -│ ├── Directory.Build.props -│ ├── docker-compose.yml -│ ├── Atlas Balance Release/ -│ ├── backend/ -│ │ ├── GestionCaja.sln -│ │ ├── src/ -│ │ │ ├── GestionCaja.API/ -│ │ │ │ ├── Program.cs -│ │ │ │ ├── appsettings.json -│ │ │ │ ├── appsettings.Development.json.template -│ │ │ │ ├── Constants/ -│ │ │ │ ├── Models/ -│ │ │ │ ├── Data/ -│ │ │ │ ├── DTOs/ -│ │ │ │ ├── Services/ -│ │ │ │ ├── Controllers/ -│ │ │ │ ├── Middleware/ -│ │ │ │ ├── Jobs/ -│ │ │ │ ├── Migrations/ -│ │ │ │ └── wwwroot/ -│ │ │ └── GestionCaja.Watchdog/ -│ │ └── tests/ -│ ├── frontend/ -│ │ ├── package.json -│ │ ├── vite.config.ts -│ │ ├── tsconfig.json -│ │ ├── index.html -│ │ └── src/ -│ └── scripts/ -├── Documentacion/ -│ ├── Versiones/ -│ ├── Diseno/ -│ ├── SPEC.md -│ ├── documentacion.md -│ └── DOCUMENTACION_CAMBIOS.md -└── Otros/ ++-- CLAUDE.md ++-- AGENTS.md ++-- .github/ ++-- .gitignore ++-- .gitattributes ++-- Atlas Balance/ +¦ +-- AGENTS.md +¦ +-- CLAUDE.md +¦ +-- VERSION +¦ +-- Directory.Build.props +¦ +-- docker-compose.yml +¦ +-- Atlas Balance Release/ +¦ +-- backend/ +¦ ¦ +-- GestionCaja.sln +¦ ¦ +-- src/ +¦ ¦ ¦ +-- GestionCaja.API/ +¦ ¦ ¦ ¦ +-- Program.cs +¦ ¦ ¦ ¦ +-- appsettings.json +¦ ¦ ¦ ¦ +-- appsettings.Development.json.template +¦ ¦ ¦ ¦ +-- Constants/ +¦ ¦ ¦ ¦ +-- Models/ +¦ ¦ ¦ ¦ +-- Data/ +¦ ¦ ¦ ¦ +-- DTOs/ +¦ ¦ ¦ ¦ +-- Services/ +¦ ¦ ¦ ¦ +-- Controllers/ +¦ ¦ ¦ ¦ +-- Middleware/ +¦ ¦ ¦ ¦ +-- Jobs/ +¦ ¦ ¦ ¦ +-- Migrations/ +¦ ¦ ¦ ¦ +-- wwwroot/ +¦ ¦ ¦ +-- GestionCaja.Watchdog/ +¦ ¦ +-- tests/ +¦ +-- frontend/ +¦ ¦ +-- package.json +¦ ¦ +-- vite.config.ts +¦ ¦ +-- tsconfig.json +¦ ¦ +-- index.html +¦ ¦ +-- src/ +¦ +-- scripts/ ++-- Documentacion/ +¦ +-- Versiones/ +¦ +-- Diseno/ +¦ +-- SPEC.md +¦ +-- documentacion.md +¦ +-- DOCUMENTACION_CAMBIOS.md ++-- Otros/ ``` ## Esquema de BD corregido @@ -227,7 +228,7 @@ npm run build # Release Windows x64 cd "Atlas Balance" -powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.04 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.05 # Conectar a PostgreSQL psql -h localhost -p 5433 -U app_user -d atlas_balance diff --git a/Atlas Balance/Directory.Build.props b/Atlas Balance/Directory.Build.props index 17b94e1..fc9160a 100644 --- a/Atlas Balance/Directory.Build.props +++ b/Atlas Balance/Directory.Build.props @@ -2,10 +2,10 @@ Atlas Labs Atlas Balance - 1.4.0 - 1.4.0.0 - 1.4.0.0 - V-01.04 + 1.5.0 + 1.5.0.0 + 1.5.0.0 + V-01.05 false diff --git a/Atlas Balance/README_RELEASE.md b/Atlas Balance/README_RELEASE.md index 14b5b4f..3088097 100644 --- a/Atlas Balance/README_RELEASE.md +++ b/Atlas Balance/README_RELEASE.md @@ -1,8 +1,8 @@ -# Atlas Balance V-01.04 - release Windows x64 +# Atlas Balance V-01.05 - release Windows x64 Este paquete es autonomo para servidor Windows: el frontend ya esta compilado, el backend y Watchdog van publicados self-contained y la base de datos se prepara desde el instalador. -El ZIP `main` de GitHub no sirve como instalador. Usa `AtlasBalance-V-01.04-win-x64.zip`; dentro deben existir `api\GestionCaja.API.exe` y `watchdog\GestionCaja.Watchdog.exe`. +El ZIP `main` de GitHub no sirve como instalador. Usa `AtlasBalance-V-01.05-win-x64.zip`; dentro deben existir `api\GestionCaja.API.exe` y `watchdog\GestionCaja.Watchdog.exe`. ## Scripts de un clic @@ -40,10 +40,10 @@ En Windows Server 2019, instala PostgreSQL manualmente si `winget` falla o no es El instalador genera passwords fuertes y guarda las credenciales iniciales en: ```text -C:\AtlasBalance\INSTALL_CREDENTIALS_ONCE.txt +C:\AtlasBalance\config\INSTALL_CREDENTIALS_ONCE.txt ``` -Guarda ese contenido en un gestor de passwords y borra el archivo despues del primer acceso. Dejarlo ahi es mala seguridad con sombrero. +El directorio `config` queda restringido a Administrators/SYSTEM antes de escribir ese archivo. Guarda ese contenido en un gestor de passwords y borra el archivo despues del primer acceso. Dejarlo ahi es mala seguridad con sombrero. Si ya tienes PostgreSQL y quieres usarlo: @@ -59,6 +59,8 @@ Si reinstalas sobre una BD existente, las credenciales iniciales no se regeneran powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Reset-AdminPassword.ps1" -InstallPath C:\AtlasBalance -AdminEmail admin@atlasbalance.local -GeneratePassword ``` +Ejecuta el reset como Administrador. Si usas `-GeneratePassword`, la password temporal queda en `C:\AtlasBalance\config\RESET_ADMIN_CREDENTIALS_ONCE.txt`. + Health check recomendado: ```powershell @@ -76,7 +78,19 @@ Desde la carpeta descomprimida de este paquete: Si la instalacion ya tiene los scripts nuevos, tambien puedes lanzar desde la carpeta instalada apuntando al paquete: ```powershell -C:\AtlasBalance\update.cmd -PackagePath C:\Temp\AtlasBalance-V-01.04-win-x64 -InstallPath C:\AtlasBalance +C:\AtlasBalance\update.cmd -PackagePath C:\Temp\AtlasBalance-V-01.05-win-x64 -InstallPath C:\AtlasBalance ``` El actualizador crea backup previo, conserva configuracion, reemplaza API/Watchdog, actualiza scripts operativos instalados, actualiza `VERSION`/runtime y valida `/api/health` con `curl.exe -k`. + +## Actualizacion desde la app + +En `Configuracion > Sistema`, deja el repositorio: + +```text +https://github.com/AtlasLabs797/AtlasBalance +``` + +`Verificar actualizacion` consulta el ultimo GitHub Release. `Actualizar ahora` descarga el asset `AtlasBalance-*-win-x64.zip`, lo valida, lo prepara en `C:\AtlasBalance\updates` y pide al Watchdog aplicar la API nueva. + +El Watchdog crea backup PostgreSQL previo, rollback de binarios y health check posterior. Si no puede crear backup o la API no responde despues de actualizar, revierte o rechaza la operacion. diff --git a/Atlas Balance/VERSION b/Atlas Balance/VERSION index f179ef7..de5476c 100644 --- a/Atlas Balance/VERSION +++ b/Atlas Balance/VERSION @@ -1 +1 @@ -V-01.04 +V-01.05 diff --git a/Atlas Balance/backend/src/GestionCaja.API/Constants/AuditActions.cs b/Atlas Balance/backend/src/GestionCaja.API/Constants/AuditActions.cs index 0256966..c523329 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Constants/AuditActions.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Constants/AuditActions.cs @@ -5,6 +5,9 @@ public static class AuditActions public const string Login = "LOGIN"; public const string Logout = "LOGOUT"; public const string LoginFailed = "LOGIN_FAILED"; + public const string LoginMfaRequired = "LOGIN_MFA_REQUIRED"; + public const string MfaVerified = "MFA_VERIFIED"; + public const string MfaEnabled = "MFA_ENABLED"; public const string AccountLocked = "ACCOUNT_LOCKED"; public const string PasswordChanged = "PASSWORD_CHANGED"; public const string PasswordReset = "PASSWORD_RESET"; diff --git a/Atlas Balance/backend/src/GestionCaja.API/Controllers/AuthController.cs b/Atlas Balance/backend/src/GestionCaja.API/Controllers/AuthController.cs index 4fe41dc..befdd08 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Controllers/AuthController.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Controllers/AuthController.cs @@ -35,7 +35,13 @@ public async Task Login([FromBody] LoginRequest request, Cancella try { - var result = await _authService.LoginAsync(request.Email, request.Password, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken); + var trustedMfaToken = Request.Cookies["mfa_trusted"]; + var result = await _authService.LoginAsync( + request.Email, + request.Password, + HttpContext.Connection.RemoteIpAddress?.ToString(), + cancellationToken, + trustedMfaToken); return Ok(AttachCookiesAndBuildAuthResponse(result)); } catch (AuthException ex) @@ -60,6 +66,31 @@ public async Task RefreshToken(CancellationToken cancellationToke } } + [HttpPost("mfa/verify")] + [AllowAnonymous] + public async Task VerifyMfa([FromBody] VerifyMfaRequest request, CancellationToken cancellationToken) + { + if (request is null) + { + return BadRequest(new { error = "Request inválido" }); + } + + try + { + var result = await _authService.VerifyMfaAsync( + request.ChallengeId, + request.Code, + HttpContext.Connection.RemoteIpAddress?.ToString(), + cancellationToken); + return Ok(AttachCookiesAndBuildAuthResponse(result)); + } + catch (AuthException ex) + { + return StatusCode(ex.StatusCode, new { error = ex.Message }); + } + } + + [HttpPost("logout")] [AllowAnonymous] public async Task Logout(CancellationToken cancellationToken) @@ -70,6 +101,7 @@ public async Task Logout(CancellationToken cancellationToken) DeleteCookie("access_token"); DeleteCookie("refresh_token"); DeleteCookie("csrf_token"); + DeleteCookie("mfa_trusted"); var actorUserId = TryGetUserId(out var authenticatedUserId) ? authenticatedUserId @@ -146,6 +178,18 @@ public async Task CambiarPassword([FromBody] ChangePasswordReques private object AttachCookiesAndBuildAuthResponse(AuthResult result) { + if (result.MfaRequired) + { + return new AuthResponse + { + MfaRequired = true, + MfaSetupRequired = result.MfaSetupRequired, + MfaChallengeId = result.MfaChallengeId, + MfaSecret = result.MfaSecret, + MfaOtpAuthUri = result.MfaOtpAuthUri + }; + } + if (!string.IsNullOrWhiteSpace(result.AccessToken)) { Response.Cookies.Append("access_token", result.AccessToken, BuildCookieOptions(TimeSpan.FromHours(1), httpOnly: true, secure: ShouldUseSecureCookie())); @@ -156,6 +200,15 @@ private object AttachCookiesAndBuildAuthResponse(AuthResult result) Response.Cookies.Append("refresh_token", result.RefreshToken, BuildCookieOptions(TimeSpan.FromDays(7), httpOnly: true, secure: ShouldUseSecureCookie())); } + if (!string.IsNullOrWhiteSpace(result.TrustedMfaToken) && result.TrustedMfaTokenExpiresAt.HasValue) + { + var maxAge = result.TrustedMfaTokenExpiresAt.Value - DateTime.UtcNow; + if (maxAge > TimeSpan.Zero) + { + Response.Cookies.Append("mfa_trusted", result.TrustedMfaToken, BuildCookieOptions(maxAge, httpOnly: true, secure: ShouldUseSecureCookie())); + } + } + var csrfToken = _csrfService.GenerateToken(); Response.Cookies.Append("csrf_token", csrfToken, BuildCookieOptions(TimeSpan.FromDays(7), httpOnly: false, secure: ShouldUseSecureCookie())); @@ -185,7 +238,7 @@ private void DeleteCookie(string name) { Response.Cookies.Delete(name, new CookieOptions { - HttpOnly = name is "access_token" or "refresh_token", + HttpOnly = name is "access_token" or "refresh_token" or "mfa_trusted", Secure = ShouldUseSecureCookie(), SameSite = SameSiteMode.Strict, IsEssential = true diff --git a/Atlas Balance/backend/src/GestionCaja.API/Controllers/CuentasController.cs b/Atlas Balance/backend/src/GestionCaja.API/Controllers/CuentasController.cs index eb10905..cbfa4bd 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Controllers/CuentasController.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Controllers/CuentasController.cs @@ -113,7 +113,7 @@ public async Task Listar( .Take(pageSize) .ToListAsync(cancellationToken); - var plazoMap = await BuildPlazoFijoMapAsync(pageRows.Select(x => x.Cuenta.Id).ToList(), cancellationToken); + var plazoMap = await BuildPlazoFijoMapAsync(pageRows.Select(x => x.Cuenta.Id).ToList(), scope, cancellationToken); var data = pageRows .Select(x => new CuentaListItemResponse { @@ -176,7 +176,7 @@ public async Task Obtener(Guid id, [FromQuery] bool incluirElimin .Where(t => t.Id == cuenta.TitularId) .Select(t => new { t.Nombre, t.Tipo }) .FirstOrDefaultAsync(cancellationToken); - var plazoMap = await BuildPlazoFijoMapAsync([cuenta.Id], cancellationToken); + var plazoMap = await BuildPlazoFijoMapAsync([cuenta.Id], scope, cancellationToken); return Ok(new CuentaListItemResponse { @@ -257,7 +257,7 @@ public async Task Resumen(Guid id, [FromQuery] string periodo = " .Select(e => (DateTime?)(e.FechaModificacion ?? e.FechaCreacion)) .FirstOrDefaultAsync(cancellationToken); var tipoCuenta = ResolveTipoCuenta(new Cuenta { TipoCuenta = cuenta.TipoCuenta, EsEfectivo = cuenta.EsEfectivo }); - var plazoMap = await BuildPlazoFijoMapAsync([cuenta.Id], cancellationToken); + var plazoMap = await BuildPlazoFijoMapAsync([cuenta.Id], scope, cancellationToken); return Ok(new CuentaResumenResponse { @@ -329,7 +329,7 @@ public async Task Crear([FromBody] SaveCuentaRequest request, Can Iban = validation.Iban, BancoNombre = validation.BancoNombre, Divisa = validation.Divisa!, - FormatoId = validation.TipoCuenta == TipoCuenta.NORMAL ? request.FormatoId : null, + FormatoId = SupportsFormatoImportacion(validation.TipoCuenta) ? request.FormatoId : null, TipoCuenta = validation.TipoCuenta, EsEfectivo = validation.TipoCuenta == TipoCuenta.EFECTIVO, Activa = request.Activa, @@ -396,7 +396,7 @@ public async Task Actualizar(Guid id, [FromBody] SaveCuentaReques cuenta.Iban = validation.Iban; cuenta.BancoNombre = validation.BancoNombre; cuenta.Divisa = validation.Divisa!; - cuenta.FormatoId = validation.TipoCuenta == TipoCuenta.NORMAL ? request.FormatoId : null; + cuenta.FormatoId = SupportsFormatoImportacion(validation.TipoCuenta) ? request.FormatoId : null; cuenta.TipoCuenta = validation.TipoCuenta; cuenta.EsEfectivo = validation.TipoCuenta == TipoCuenta.EFECTIVO; cuenta.Activa = request.Activa; @@ -597,7 +597,7 @@ private async Task ValidateCuentaRequestAsync( return CuentaValidationResult.Fail("La divisa indicada no esta activa", tipoCuenta); } - if (tipoCuenta == TipoCuenta.NORMAL && request.FormatoId.HasValue) + if (SupportsFormatoImportacion(tipoCuenta) && request.FormatoId.HasValue) { var formato = await _dbContext.FormatosImportacion.FirstOrDefaultAsync(f => f.Id == request.FormatoId.Value, cancellationToken); if (formato is null) @@ -706,6 +706,11 @@ private static TipoCuenta ResolveTipoCuenta(Cuenta cuenta) return cuenta.TipoCuenta; } + private static bool SupportsFormatoImportacion(TipoCuenta tipoCuenta) + { + return tipoCuenta != TipoCuenta.PLAZO_FIJO; + } + private static TipoCuenta ResolveRequestedTipoCuenta(SaveCuentaRequest request) { if (request.TipoCuenta.HasValue) @@ -744,23 +749,27 @@ request.CuentaReferenciaId is null && }; } - private async Task> BuildPlazoFijoMapAsync(IReadOnlyList cuentaIds, CancellationToken cancellationToken) + private async Task> BuildPlazoFijoMapAsync(IReadOnlyList cuentaIds, UserAccessScope scope, CancellationToken cancellationToken) { if (cuentaIds.Count == 0) { return []; } + var referenceAccounts = scope.IsAdmin + ? _dbContext.Cuentas.IgnoreQueryFilters() + : _userAccessService.ApplyCuentaScope(_dbContext.Cuentas, scope); + var rows = await ( from plazo in _dbContext.PlazosFijos - join refCuenta in _dbContext.Cuentas.IgnoreQueryFilters() on plazo.CuentaReferenciaId equals refCuenta.Id into refJoin + join refCuenta in referenceAccounts on plazo.CuentaReferenciaId equals refCuenta.Id into refJoin from cuentaReferencia in refJoin.DefaultIfEmpty() where cuentaIds.Contains(plazo.CuentaId) select new PlazoFijoResponse { Id = plazo.Id, CuentaId = plazo.CuentaId, - CuentaReferenciaId = plazo.CuentaReferenciaId, + CuentaReferenciaId = cuentaReferencia != null ? plazo.CuentaReferenciaId : null, CuentaReferenciaNombre = cuentaReferencia != null ? cuentaReferencia.Nombre : null, FechaInicio = plazo.FechaInicio, FechaVencimiento = plazo.FechaVencimiento, diff --git a/Atlas Balance/backend/src/GestionCaja.API/Controllers/ExtractosController.cs b/Atlas Balance/backend/src/GestionCaja.API/Controllers/ExtractosController.cs index b6e6b1a..34b8fa2 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Controllers/ExtractosController.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Controllers/ExtractosController.cs @@ -300,18 +300,23 @@ public async Task ToggleCheck(Guid id, [FromBody] ToggleCheckedRe [HttpPatch("{id:guid}/flag")] public async Task ToggleFlag(Guid id, [FromBody] ToggleFlagRequest req, CancellationToken ct) { + if (req is null) return BadRequest(new { error = "Solicitud invalida" }); if (!TryGetUser(out var actor)) return Unauthorized(new { error = "Usuario no autenticado" }); var ex = await _db.Extractos.FirstOrDefaultAsync(x => x.Id == id, ct); if (ex is null) return NotFound(new { error = "Extracto no encontrado" }); var cuenta = await _db.Cuentas.FirstOrDefaultAsync(c => c.Id == ex.CuentaId, ct); if (cuenta is null) return NotFound(new { error = "Cuenta no encontrada" }); var p = await GetPermission(actor, cuenta, ct); - if (!p.CanEdit || (!CanEditColumn(p, "flagged") && !CanEditColumn(p, "flagged_nota"))) return Forbid(); + if (!p.CanEdit) return Forbid(); var oldFlag = ex.Flagged; var oldNote = ex.FlaggedNota; + var newNote = req.Flagged ? req.Nota?.Trim() : null; + if (oldFlag != req.Flagged && !CanEditColumn(p, "flagged")) return Forbid(); + if (!string.Equals(oldNote, newNote, StringComparison.Ordinal) && !CanEditColumn(p, "flagged_nota")) return Forbid(); + ex.Flagged = req.Flagged; - ex.FlaggedNota = req.Flagged ? req.Nota?.Trim() : null; + ex.FlaggedNota = newNote; ex.FlaggedAt = req.Flagged ? DateTime.UtcNow : null; ex.FlaggedById = req.Flagged ? actor.Id : null; ex.UsuarioModificacionId = actor.Id; @@ -351,7 +356,10 @@ public async Task Restaurar(Guid id, CancellationToken ct) if (!TryGetUser(out var actor)) return Unauthorized(new { error = "Usuario no autenticado" }); var ex = await _db.Extractos.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct); if (ex is null) return NotFound(new { error = "Extracto no encontrado" }); - if (!await CanView(actor, ex.CuentaId, ct)) return Forbid(); + var cuenta = await _db.Cuentas.FirstOrDefaultAsync(c => c.Id == ex.CuentaId, ct); + if (cuenta is null) return NotFound(new { error = "Cuenta no encontrada" }); + var p = await GetPermission(actor, cuenta, ct); + if (!p.CanDelete) return Forbid(); ex.DeletedAt = null; ex.DeletedById = null; ex.UsuarioModificacionId = actor.Id; @@ -399,7 +407,7 @@ public async Task GetCuentaResumen(Guid cuentaId, [FromQuery] str var cuenta = await _db.Cuentas.Where(c => c.Id == cuentaId).Select(c => new { c.Id, c.Nombre, c.Divisa, c.EsEfectivo, c.TipoCuenta, c.TitularId, c.Notas }).FirstOrDefaultAsync(ct); if (cuenta is null) return NotFound(new { error = "Cuenta no encontrada" }); var titular = await _db.Titulares.Where(t => t.Id == cuenta.TitularId).Select(t => t.Nombre).FirstOrDefaultAsync(ct); - return Ok(await BuildSummary(cuenta.Id, cuenta.Nombre, cuenta.Divisa, cuenta.EsEfectivo, cuenta.TipoCuenta, cuenta.TitularId, titular ?? string.Empty, cuenta.Notas, periodo, ct)); + return Ok(await BuildSummary(actor, cuenta.Id, cuenta.Nombre, cuenta.Divisa, cuenta.EsEfectivo, cuenta.TipoCuenta, cuenta.TitularId, titular ?? string.Empty, cuenta.Notas, periodo, ct)); } [HttpGet("titulares/{titularId:guid}/cuentas")] @@ -414,7 +422,7 @@ public async Task GetCuentasTitular(Guid titularId, [FromQuery] s var summary = new List(); foreach (var c in cuentas) { - summary.Add(await BuildSummary(c.Id, c.Nombre, c.Divisa, c.EsEfectivo, c.TipoCuenta, titular.Id, titular.Nombre, c.Notas, periodo, ct)); + summary.Add(await BuildSummary(actor, c.Id, c.Nombre, c.Divisa, c.EsEfectivo, c.TipoCuenta, titular.Id, titular.Nombre, c.Notas, periodo, ct)); } return Ok(new TitularConCuentasResponse { TitularId = titular.Id, TitularNombre = titular.Nombre, Cuentas = summary }); } @@ -432,7 +440,7 @@ public async Task GetTitularesResumen([FromQuery] string periodo { var tc = cuentas.Where(c => c.TitularId == t.Id).ToList(); var s = new List(); - foreach (var c in tc) s.Add(await BuildSummary(c.Id, c.Nombre, c.Divisa, c.EsEfectivo, c.TipoCuenta, t.Id, t.Nombre, c.Notas, periodo, ct)); + foreach (var c in tc) s.Add(await BuildSummary(actor, c.Id, c.Nombre, c.Divisa, c.EsEfectivo, c.TipoCuenta, t.Id, t.Nombre, c.Notas, periodo, ct)); outData.Add(new TitularConCuentasResponse { TitularId = t.Id, TitularNombre = t.Nombre, Cuentas = s }); } return Ok(outData); @@ -481,7 +489,7 @@ public async Task SaveColumnasVisibles([FromBody] SaveColumnasVis return Ok(new { message = "Preferencias guardadas" }); } - private async Task BuildSummary(Guid cuentaId, string cuentaNombre, string divisa, bool esEfectivo, TipoCuenta tipoCuenta, Guid titularId, string titularNombre, string? notas, string periodo, CancellationToken ct) + private async Task BuildSummary(Actor actor, Guid cuentaId, string cuentaNombre, string divisa, bool esEfectivo, TipoCuenta tipoCuenta, Guid titularId, string titularNombre, string? notas, string periodo, CancellationToken ct) { var q = _db.Extractos.Where(e => e.CuentaId == cuentaId); var latest = await q @@ -498,14 +506,14 @@ private async Task BuildSummary(Guid cuentaId, string var plazoFijo = tipoCuenta == TipoCuenta.PLAZO_FIJO ? await ( from plazo in _db.PlazosFijos - join refCuenta in _db.Cuentas.IgnoreQueryFilters() on plazo.CuentaReferenciaId equals refCuenta.Id into refJoin + join refCuenta in (actor.IsAdmin ? _db.Cuentas.IgnoreQueryFilters() : _db.Cuentas) on plazo.CuentaReferenciaId equals refCuenta.Id into refJoin from cuentaReferencia in refJoin.DefaultIfEmpty() where plazo.CuentaId == cuentaId select new PlazoFijoResponse { Id = plazo.Id, CuentaId = plazo.CuentaId, - CuentaReferenciaId = plazo.CuentaReferenciaId, + CuentaReferenciaId = cuentaReferencia != null ? plazo.CuentaReferenciaId : null, CuentaReferenciaNombre = cuentaReferencia != null ? cuentaReferencia.Nombre : null, FechaInicio = plazo.FechaInicio, FechaVencimiento = plazo.FechaVencimiento, @@ -519,6 +527,12 @@ from cuentaReferencia in refJoin.DefaultIfEmpty() .FirstOrDefaultAsync(ct) : null; + if (!actor.IsAdmin && plazoFijo?.CuentaReferenciaId is Guid referenceId && !await CanView(actor, referenceId, ct)) + { + plazoFijo.CuentaReferenciaId = null; + plazoFijo.CuentaReferenciaNombre = null; + } + return new CuentaResumenKpiResponse { CuentaId = cuentaId, @@ -619,8 +633,9 @@ private async Task> GetAllowedAccountIds(Actor actor, Cancellation return [.. await _db.Cuentas.Select(c => c.Id).ToListAsync(ct)]; } - var ids = perms.Where(p => p.CuentaId.HasValue).Select(p => p.CuentaId!.Value).ToHashSet(); - var titularIds = perms.Where(p => p.TitularId.HasValue).Select(p => p.TitularId!.Value).ToList(); + var accountPerms = perms.Where(GrantsAccountAccess).ToList(); + var ids = accountPerms.Where(p => p.CuentaId.HasValue).Select(p => p.CuentaId!.Value).ToHashSet(); + var titularIds = accountPerms.Where(p => p.TitularId.HasValue).Select(p => p.TitularId!.Value).ToList(); if (titularIds.Any()) ids.UnionWith(await _db.Cuentas.Where(c => titularIds.Contains(c.TitularId)).Select(c => c.Id).ToListAsync(ct)); return ids; } @@ -645,7 +660,7 @@ private async Task CanViewTitular(Actor actor, Guid titularId, Cancellatio return true; } - if (perms.Any(p => p.TitularId == titularId)) + if (perms.Any(p => p.TitularId == titularId && GrantsAccountAccess(p))) { return true; } diff --git a/Atlas Balance/backend/src/GestionCaja.API/Controllers/IntegrationOpenClawController.cs b/Atlas Balance/backend/src/GestionCaja.API/Controllers/IntegrationOpenClawController.cs index fb68c09..1a992fc 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Controllers/IntegrationOpenClawController.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Controllers/IntegrationOpenClawController.cs @@ -290,8 +290,8 @@ public async Task Extractos( var usersById = await _dbContext.Usuarios .IgnoreQueryFilters() .Where(x => userIds.Contains(x.Id)) - .Select(x => new { x.Id, x.Email, IsDeleted = x.DeletedAt != null }) - .ToDictionaryAsync(x => x.Id, x => x.IsDeleted ? "usuario-eliminado" : x.Email, cancellationToken); + .Select(x => new { x.Id, x.NombreCompleto, IsDeleted = x.DeletedAt != null }) + .ToDictionaryAsync(x => x.Id, x => x.IsDeleted ? "usuario-eliminado" : x.NombreCompleto, cancellationToken); var cuentasById = cuentas.ToDictionary(x => x.CuentaId); var extractos = rows.Select(x => @@ -692,7 +692,6 @@ public async Task Auditoria( var cuentaIds = cuentas.Select(x => x.CuentaId).ToHashSet(); var extractosScope = _dbContext.Extractos - .IgnoreQueryFilters() .AsNoTracking() .Where(x => cuentaIds.Contains(x.CuentaId)) .Select(x => new { x.Id, x.CuentaId }); @@ -759,8 +758,7 @@ public async Task Auditoria( celda_referencia = x.CeldaReferencia, columna_nombre = x.ColumnaNombre, valor_anterior = x.ValorAnterior, - valor_nuevo = x.ValorNuevo, - ip_address = x.IpAddress != null ? x.IpAddress.ToString() : null + valor_nuevo = x.ValorNuevo }; }).ToList(); diff --git a/Atlas Balance/backend/src/GestionCaja.API/Controllers/UsuariosController.cs b/Atlas Balance/backend/src/GestionCaja.API/Controllers/UsuariosController.cs index d22700e..3e8c047 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Controllers/UsuariosController.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Controllers/UsuariosController.cs @@ -171,6 +171,7 @@ public async Task GuardarPermisos(Guid id, [FromBody] IReadOnlyLi var before = await LoadPermisosAuditSnapshotAsync(id, cancellationToken); await UpsertPermisosAsync(id, permisos, cancellationToken); + var revokedRefreshTokens = await RotateAndRevokeSessionsAsync(usuario, DateTime.UtcNow, cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken); var after = await LoadPermisosAuditSnapshotAsync(id, cancellationToken); @@ -180,7 +181,7 @@ await _auditService.LogAsync( "USUARIOS", id, HttpContext, - JsonSerializer.Serialize(new { before, after }), + JsonSerializer.Serialize(new { before, after, refresh_tokens_revocados = revokedRefreshTokens }), cancellationToken); return Ok(new { message = "Permisos actualizados" }); @@ -251,6 +252,7 @@ public async Task GuardarPermisoCuenta(Guid id, Guid cuentaId, [F await AddPermisoAsync(id, normalizedRequest); AddPreferencia(id, normalizedRequest.CuentaId, normalizedRequest.ColumnasVisibles, normalizedRequest.ColumnasEditables); + var revokedRefreshTokens = await RotateAndRevokeSessionsAsync(usuario, DateTime.UtcNow, cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken); var after = await LoadPermisosAuditSnapshotAsync(id, cancellationToken); @@ -260,7 +262,7 @@ await _auditService.LogAsync( "USUARIOS", id, HttpContext, - JsonSerializer.Serialize(new { before, after, cuenta_id = cuentaId }), + JsonSerializer.Serialize(new { before, after, cuenta_id = cuentaId, refresh_tokens_revocados = revokedRefreshTokens }), cancellationToken); return Ok(new { message = "Permiso de cuenta actualizado" }); @@ -320,6 +322,7 @@ public async Task CrearEmail(Guid id, [FromBody] SaveUsuarioEmail }; _dbContext.UsuarioEmails.Add(email); + var revokedRefreshTokens = await RotateAndRevokeSessionsAsync(usuario, DateTime.UtcNow, cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken); var after = await LoadEmailsAsync(id, cancellationToken); @@ -329,7 +332,7 @@ await _auditService.LogAsync( "USUARIOS", id, HttpContext, - JsonSerializer.Serialize(new { before, after }), + JsonSerializer.Serialize(new { before, after, refresh_tokens_revocados = revokedRefreshTokens }), cancellationToken); return CreatedAtAction(nameof(ObtenerEmails), new { id }, new UsuarioEmailResponse @@ -343,7 +346,8 @@ await _auditService.LogAsync( [HttpDelete("{id:guid}/emails/{emailId:guid}")] public async Task EliminarEmail(Guid id, Guid emailId, CancellationToken cancellationToken) { - if (!await UsuarioExisteAsync(id, includeDeleted: true, cancellationToken)) + var usuario = await _dbContext.Usuarios.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == id, cancellationToken); + if (usuario is null) { return NotFound(new { error = "Usuario no encontrado" }); } @@ -357,6 +361,7 @@ public async Task EliminarEmail(Guid id, Guid emailId, Cancellati var before = await LoadEmailsAsync(id, cancellationToken); var wasPrimary = email.EsPrincipal; _dbContext.UsuarioEmails.Remove(email); + var revokedRefreshTokens = await RotateAndRevokeSessionsAsync(usuario, DateTime.UtcNow, cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken); if (wasPrimary) @@ -371,7 +376,7 @@ await _auditService.LogAsync( "USUARIOS", id, HttpContext, - JsonSerializer.Serialize(new { before, after }), + JsonSerializer.Serialize(new { before, after, refresh_tokens_revocados = revokedRefreshTokens }), cancellationToken); return Ok(new { message = "Email eliminado" }); @@ -522,6 +527,10 @@ public async Task Actualizar(Guid id, [FromBody] UpdateUsuarioReq UserSessionState.RotateSecurityStamp(usuario); revokedRefreshTokens = await RevokeActiveRefreshTokensAsync(usuario.Id, now, cancellationToken); } + else + { + revokedRefreshTokens = await RotateAndRevokeSessionsAsync(usuario, DateTime.UtcNow, cancellationToken); + } var normalizedEmails = NormalizeEmails(request.Emails); await UpsertEmailsAsync(usuario.Id, normalizedEmails, cancellationToken); @@ -540,7 +549,7 @@ public async Task Actualizar(Guid id, [FromBody] UpdateUsuarioReq }; await _auditService.LogAsync(GetCurrentUserId(), AuditActions.UpdateUsuario, "USUARIOS", usuario.Id, HttpContext, - JsonSerializer.Serialize(new { before, after }), cancellationToken); + JsonSerializer.Serialize(new { before, after, refresh_tokens_revocados = revokedRefreshTokens }), cancellationToken); if (passwordChanged) { @@ -562,7 +571,12 @@ await _auditService.LogAsync( "USUARIOS", usuario.Id, HttpContext, - JsonSerializer.Serialize(new { before = before.permisos, after = after.permisos }), + JsonSerializer.Serialize(new + { + before = before.permisos, + after = after.permisos, + refresh_tokens_revocados = revokedRefreshTokens + }), cancellationToken); } @@ -631,7 +645,7 @@ public async Task Restaurar(Guid id, CancellationToken cancellati usuario.DeletedAt = null; usuario.DeletedById = null; usuario.Activo = true; - UserSessionState.RotateSecurityStamp(usuario); + var revokedRefreshTokens = await RotateAndRevokeSessionsAsync(usuario, DateTime.UtcNow, cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken); @@ -643,7 +657,7 @@ public async Task Restaurar(Guid id, CancellationToken cancellati }; await _auditService.LogAsync(GetCurrentUserId(), AuditActions.RestoreUsuario, "USUARIOS", usuario.Id, HttpContext, - JsonSerializer.Serialize(new { before, after }), cancellationToken); + JsonSerializer.Serialize(new { before, after, refresh_tokens_revocados = revokedRefreshTokens }), cancellationToken); return Ok(new { message = "Usuario restaurado" }); } @@ -733,6 +747,12 @@ private async Task RevokeActiveRefreshTokensAsync(Guid usuarioId, DateTime return activeRefreshTokens.Count; } + private async Task RotateAndRevokeSessionsAsync(Usuario usuario, DateTime revokedAt, CancellationToken cancellationToken) + { + UserSessionState.RotateSecurityStamp(usuario); + return await RevokeActiveRefreshTokensAsync(usuario.Id, revokedAt, cancellationToken); + } + private async Task> LoadPermisosAsync(Guid usuarioId, CancellationToken cancellationToken) { var permisos = await _dbContext.PermisosUsuario diff --git a/Atlas Balance/backend/src/GestionCaja.API/DTOs/AuthDtos.cs b/Atlas Balance/backend/src/GestionCaja.API/DTOs/AuthDtos.cs index 005a779..cf69313 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/DTOs/AuthDtos.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/DTOs/AuthDtos.cs @@ -12,6 +12,12 @@ public sealed class ChangePasswordRequest public string PasswordNueva { get; set; } = string.Empty; } +public sealed class VerifyMfaRequest +{ + public string ChallengeId { get; set; } = string.Empty; + public string Code { get; set; } = string.Empty; +} + public sealed class AuthUsuarioResponse { public Guid Id { get; set; } @@ -20,6 +26,7 @@ public sealed class AuthUsuarioResponse public string Rol { get; set; } = string.Empty; public bool Activo { get; set; } public bool PrimerLogin { get; set; } + public bool MfaEnabled { get; set; } public DateTime FechaCreacion { get; set; } public DateTime? FechaUltimaLogin { get; set; } } @@ -27,8 +34,13 @@ public sealed class AuthUsuarioResponse public sealed class AuthResponse { public string CsrfToken { get; set; } = string.Empty; - public AuthUsuarioResponse Usuario { get; set; } = new(); + public AuthUsuarioResponse? Usuario { get; set; } public IReadOnlyList Permisos { get; set; } = []; + public bool MfaRequired { get; set; } + public bool MfaSetupRequired { get; set; } + public string? MfaChallengeId { get; set; } + public string? MfaSecret { get; set; } + public string? MfaOtpAuthUri { get; set; } } public sealed class PermisoUsuarioResponse diff --git a/Atlas Balance/backend/src/GestionCaja.API/Data/AppDbContext.cs b/Atlas Balance/backend/src/GestionCaja.API/Data/AppDbContext.cs index fad0886..62face9 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Data/AppDbContext.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Data/AppDbContext.cs @@ -53,8 +53,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasIndex(e => e.Email).IsUnique(); entity.HasIndex(e => e.Rol); entity.HasIndex(e => e.Activo); + entity.HasIndex(e => e.MfaEnabled); entity.Property(e => e.Id).HasDefaultValueSql("gen_random_uuid()"); entity.Property(e => e.SecurityStamp).HasMaxLength(64).IsRequired(); + entity.Property(e => e.MfaSecret).HasMaxLength(2048); }); modelBuilder.Entity(entity => diff --git a/Atlas Balance/backend/src/GestionCaja.API/Data/RlsContextSigner.cs b/Atlas Balance/backend/src/GestionCaja.API/Data/RlsContextSigner.cs new file mode 100644 index 0000000..2161b39 --- /dev/null +++ b/Atlas Balance/backend/src/GestionCaja.API/Data/RlsContextSigner.cs @@ -0,0 +1,42 @@ +using System.Security.Cryptography; +using System.Text; + +namespace GestionCaja.API.Data; + +public static class RlsContextSigner +{ + public static string BuildPayload( + string authMode, + string userId, + string integrationTokenId, + string isAdmin, + string system, + string requestScope) => + string.Join( + "|", + authMode, + userId, + integrationTokenId, + isAdmin, + system, + requestScope); + + public static string Sign( + string secret, + string authMode, + string userId, + string integrationTokenId, + string isAdmin, + string system, + string requestScope) + { + if (string.IsNullOrWhiteSpace(secret)) + { + return string.Empty; + } + + var payload = BuildPayload(authMode, userId, integrationTokenId, isAdmin, system, requestScope); + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); + return Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant(); + } +} diff --git a/Atlas Balance/backend/src/GestionCaja.API/Data/RlsDbCommandInterceptor.cs b/Atlas Balance/backend/src/GestionCaja.API/Data/RlsDbCommandInterceptor.cs new file mode 100644 index 0000000..e0531c7 --- /dev/null +++ b/Atlas Balance/backend/src/GestionCaja.API/Data/RlsDbCommandInterceptor.cs @@ -0,0 +1,205 @@ +using System.Data.Common; +using System.Security.Claims; +using GestionCaja.API.Middleware; +using GestionCaja.API.Models; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace GestionCaja.API.Data; + +public sealed class RlsDbCommandInterceptor : DbCommandInterceptor +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly string _contextSecret; + + public RlsDbCommandInterceptor(IHttpContextAccessor httpContextAccessor, IConfiguration configuration) + { + _httpContextAccessor = httpContextAccessor; + _contextSecret = configuration["Security:RlsContextSecret"] + ?? configuration["JwtSettings:Secret"] + ?? string.Empty; + } + + public override InterceptionResult ReaderExecuting( + DbCommand command, + CommandEventData eventData, + InterceptionResult result) + { + ApplyRlsContext(command); + return result; + } + + public override async ValueTask> ReaderExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + await ApplyRlsContextAsync(command, cancellationToken); + return result; + } + + public override InterceptionResult NonQueryExecuting( + DbCommand command, + CommandEventData eventData, + InterceptionResult result) + { + ApplyRlsContext(command); + return result; + } + + public override async ValueTask> NonQueryExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + await ApplyRlsContextAsync(command, cancellationToken); + return result; + } + + public override InterceptionResult ScalarExecuting( + DbCommand command, + CommandEventData eventData, + InterceptionResult result) + { + ApplyRlsContext(command); + return result; + } + + public override async ValueTask> ScalarExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + await ApplyRlsContextAsync(command, cancellationToken); + return result; + } + + private void ApplyRlsContext(DbCommand command) + { + if (ShouldSkip(command)) + { + return; + } + + var context = BuildContext(); + using var contextCommand = CreateContextCommand(command, context, _contextSecret); + contextCommand.ExecuteNonQuery(); + } + + private async Task ApplyRlsContextAsync(DbCommand command, CancellationToken cancellationToken) + { + if (ShouldSkip(command)) + { + return; + } + + var context = BuildContext(); + await using var contextCommand = CreateContextCommand(command, context, _contextSecret); + await contextCommand.ExecuteNonQueryAsync(cancellationToken); + } + + private static bool ShouldSkip(DbCommand command) => + command.Connection is null || + command.Connection.State != System.Data.ConnectionState.Open || + command.CommandText.Contains("set_config('atlas.", StringComparison.OrdinalIgnoreCase); + + private RlsSessionContext BuildContext() + { + var httpContext = _httpContextAccessor.HttpContext; + if (httpContext is null) + { + return RlsSessionContext.System(); + } + + if (httpContext.Items.TryGetValue(IntegrationHttpContextItemKeys.CurrentIntegrationToken, out var tokenValue) && + tokenValue is IntegrationToken integrationToken) + { + return RlsSessionContext.Integration(integrationToken.Id); + } + + if (httpContext.User.Identity?.IsAuthenticated == true && + TryGetUserId(httpContext.User, out var userId)) + { + var isAdmin = httpContext.User.IsInRole(nameof(RolUsuario.ADMIN)); + var scope = httpContext.Request.Path.StartsWithSegments("/api/dashboard", StringComparison.OrdinalIgnoreCase) + ? "dashboard" + : "data"; + + return RlsSessionContext.User(userId, isAdmin, scope); + } + + if (httpContext.Request.Path.StartsWithSegments("/api/auth", StringComparison.OrdinalIgnoreCase)) + { + return RlsSessionContext.AuthFlow(); + } + + return RlsSessionContext.Anonymous(); + } + + private static bool TryGetUserId(ClaimsPrincipal user, out Guid userId) + { + var raw = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? user.FindFirstValue("sub"); + return Guid.TryParse(raw, out userId); + } + + private static DbCommand CreateContextCommand(DbCommand sourceCommand, RlsSessionContext context, string contextSecret) + { + var contextCommand = sourceCommand.Connection!.CreateCommand(); + contextCommand.Transaction = sourceCommand.Transaction; + var isAdmin = context.IsAdmin ? "true" : "false"; + var system = context.IsSystem ? "true" : "false"; + var signature = RlsContextSigner.Sign( + contextSecret, + context.AuthMode, + context.UserId, + context.IntegrationTokenId, + isAdmin, + system, + context.RequestScope); + + contextCommand.CommandText = """ + SELECT + set_config('atlas.auth_mode', @auth_mode, false), + set_config('atlas.user_id', @user_id, false), + set_config('atlas.integration_token_id', @integration_token_id, false), + set_config('atlas.is_admin', @is_admin, false), + set_config('atlas.system', @system, false), + set_config('atlas.request_scope', @request_scope, false), + set_config('atlas.context_signature', @context_signature, false) + """; + + AddParameter(contextCommand, "@auth_mode", context.AuthMode); + AddParameter(contextCommand, "@user_id", context.UserId); + AddParameter(contextCommand, "@integration_token_id", context.IntegrationTokenId); + AddParameter(contextCommand, "@is_admin", isAdmin); + AddParameter(contextCommand, "@system", system); + AddParameter(contextCommand, "@request_scope", context.RequestScope); + AddParameter(contextCommand, "@context_signature", signature); + return contextCommand; + } + + private static void AddParameter(DbCommand command, string name, string value) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = value; + command.Parameters.Add(parameter); + } + + private readonly record struct RlsSessionContext( + string AuthMode, + string UserId, + string IntegrationTokenId, + bool IsAdmin, + bool IsSystem, + string RequestScope) + { + public static RlsSessionContext System() => new("system", string.Empty, string.Empty, true, true, "system"); + public static RlsSessionContext AuthFlow() => new("auth", string.Empty, string.Empty, false, false, "auth"); + public static RlsSessionContext Anonymous() => new("anonymous", string.Empty, string.Empty, false, false, "anonymous"); + public static RlsSessionContext User(Guid userId, bool isAdmin, string requestScope) => new("user", userId.ToString(), string.Empty, isAdmin, false, requestScope); + public static RlsSessionContext Integration(Guid tokenId) => new("integration", string.Empty, tokenId.ToString(), false, false, "integration"); + } +} diff --git a/Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs b/Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs index 5220b92..ad37e55 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs @@ -69,8 +69,8 @@ public static void Initialize(AppDbContext context, IConfiguration? configuratio ["backup_retention_weeks"] = ("6", "int", "Semanas de retención de backups"), ["backup_path"] = ("C:/AtlasBalance/backups", "string", "Ruta de almacenamiento de backups"), ["export_path"] = ("C:/AtlasBalance/exports", "string", "Ruta de exportaciones"), - ["app_version"] = ("V-01.04", "string", "Versión instalada"), - ["app_update_check_url"] = (ConfigurationDefaults.UpdateCheckUrl, "string", "URL del servidor de actualizaciones"), + ["app_version"] = ("V-01.05", "string", "Versión instalada"), + ["app_update_check_url"] = (ConfigurationDefaults.UpdateCheckUrl, "string", "Repositorio oficial de GitHub para actualizaciones"), ["smtp_host"] = ("", "string", "Host SMTP"), ["smtp_port"] = ("587", "int", "Puerto SMTP"), ["smtp_user"] = ("", "string", "Usuario SMTP"), @@ -144,21 +144,31 @@ private static void EnsureDefaultFormatosImportacion(AppDbContext context, Guid? { foreach (var formato in DefaultFormatosImportacion) { - var exists = context.FormatosImportacion + var defaultId = Guid.Parse(formato.Id); + var existsById = context.FormatosImportacion + .IgnoreQueryFilters() + .Any(f => f.Id == defaultId); + + if (existsById) + { + continue; + } + + var existsByBankAndCurrency = context.FormatosImportacion .IgnoreQueryFilters() .Any(f => f.BancoNombre != null && f.BancoNombre.ToLower() == formato.BancoNombre.ToLower() && (f.Divisa ?? string.Empty).ToUpper() == formato.Divisa); - if (exists) + if (existsByBankAndCurrency) { continue; } context.FormatosImportacion.Add(new FormatoImportacion { - Id = Guid.Parse(formato.Id), + Id = defaultId, Nombre = formato.Nombre, BancoNombre = formato.BancoNombre, Divisa = formato.Divisa, diff --git a/Atlas Balance/backend/src/GestionCaja.API/Middleware/CsrfMiddleware.cs b/Atlas Balance/backend/src/GestionCaja.API/Middleware/CsrfMiddleware.cs index 8acfecd..c47d32e 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Middleware/CsrfMiddleware.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Middleware/CsrfMiddleware.cs @@ -9,6 +9,7 @@ public sealed class CsrfMiddleware private static readonly HashSet ExcludedPaths = new(StringComparer.OrdinalIgnoreCase) { "/api/auth/login", + "/api/auth/mfa/verify", "/api/auth/refresh-token", "/api/health" }; diff --git a/Atlas Balance/backend/src/GestionCaja.API/Middleware/IntegrationAuthMiddleware.cs b/Atlas Balance/backend/src/GestionCaja.API/Middleware/IntegrationAuthMiddleware.cs index e7f5b86..f6aecf6 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Middleware/IntegrationAuthMiddleware.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Middleware/IntegrationAuthMiddleware.cs @@ -18,6 +18,7 @@ public static class IntegrationHttpContextItemKeys public sealed class IntegrationAuthMiddleware { private const string RedactedMarker = "[REDACTED]"; + private const string IntegrationTokenPrefix = "sk_gestion_caja_"; private const int DefaultInvalidAuthLimitPerMinute = 30; private static readonly HashSet SensitiveQueryKeys = new(StringComparer.OrdinalIgnoreCase) @@ -35,6 +36,20 @@ public sealed class IntegrationAuthMiddleware "auth" }; + private static readonly string[] SensitiveQueryKeyFragments = + [ + "token", + "secret", + "password", + "passwd", + "pwd", + "apikey", + "authorization", + "auth", + "bearer", + "credential" + ]; + private readonly RequestDelegate _next; private readonly IMemoryCache _cache; private readonly IClock _clock; @@ -79,6 +94,7 @@ public async Task InvokeAsync(HttpContext context, AppDbContext dbContext, IInte } ClearInvalidAuthFailures(context); + context.Items[IntegrationHttpContextItemKeys.CurrentIntegrationToken] = integrationToken; var limit = await ResolveRateLimitAsync(dbContext, CancellationToken.None); if (!TryConsumeRateLimit(integrationToken.Id, limit)) @@ -98,7 +114,6 @@ await SaveIntegrationAuditAsync( var stopwatch = Stopwatch.StartNew(); Exception? caught = null; - context.Items[IntegrationHttpContextItemKeys.CurrentIntegrationToken] = integrationToken; try { @@ -121,7 +136,7 @@ await SaveIntegrationAuditAsync( var parametros = context.Request.Query.Any() ? JsonSerializer.Serialize(context.Request.Query.ToDictionary( x => x.Key, - x => SensitiveQueryKeys.Contains(x.Key) ? RedactedMarker : x.Value.ToString())) + x => RedactQueryValue(x.Key, x.Value.ToString()))) : null; await SaveIntegrationAuditAsync( @@ -173,6 +188,39 @@ private static async Task SaveIntegrationAuditAsync( : null; } + private static string RedactQueryValue(string key, string value) + { + return IsSensitiveQueryKey(key) || LooksSensitiveQueryValue(value) + ? RedactedMarker + : value; + } + + private static bool IsSensitiveQueryKey(string key) + { + if (SensitiveQueryKeys.Contains(key)) + { + return true; + } + + var normalized = NormalizeQueryKey(key); + return SensitiveQueryKeyFragments.Any(normalized.Contains); + } + + private static string NormalizeQueryKey(string key) + { + return new string(key + .Where(char.IsLetterOrDigit) + .Select(char.ToLowerInvariant) + .ToArray()); + } + + private static bool LooksSensitiveQueryValue(string value) + { + var trimmed = value.Trim(); + return trimmed.StartsWith(IntegrationTokenPrefix, StringComparison.OrdinalIgnoreCase) || + trimmed.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase); + } + private async Task ResolveRateLimitAsync(AppDbContext dbContext, CancellationToken cancellationToken) { const string cacheKey = "integration_rate_limit_per_minute"; diff --git a/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501105704_RequireWebUserMfa.Designer.cs b/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501105704_RequireWebUserMfa.Designer.cs new file mode 100644 index 0000000..12c8edb --- /dev/null +++ b/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501105704_RequireWebUserMfa.Designer.cs @@ -0,0 +1,1691 @@ +// +using System; +using System.Net; +using GestionCaja.API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GestionCaja.API.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260501105704_RequireWebUserMfa")] + partial class RequireWebUserMfa + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "estado_plazo_fijo", new[] { "activo", "proximo_vencer", "vencido", "renovado", "cancelado" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "estado_proceso", new[] { "pending", "success", "failed" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "estado_token_integracion", new[] { "activo", "revocado" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "fuente_tipo_cambio", new[] { "api", "manual" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "rol_usuario", new[] { "admin", "gerente", "empleado_ultra", "empleado_plus", "empleado" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "tipo_cuenta", new[] { "normal", "efectivo", "plazo_fijo" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "tipo_proceso", new[] { "auto", "manual" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "tipo_titular", new[] { "empresa", "particular", "autonomo" }); + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("GestionCaja.API.Models.AlertaDestinatario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AlertaId") + .HasColumnType("uuid") + .HasColumnName("alerta_id"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.HasKey("Id") + .HasName("pk_alerta_destinatarios"); + + b.HasIndex("UsuarioId") + .HasDatabaseName("ix_alerta_destinatarios_usuario_id"); + + b.HasIndex("AlertaId", "UsuarioId") + .IsUnique() + .HasDatabaseName("ix_alerta_destinatarios_alerta_id_usuario_id"); + + b.ToTable("ALERTA_DESTINATARIOS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.AlertaSaldo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Activa") + .HasColumnType("boolean") + .HasColumnName("activa"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("FechaUltimaAlerta") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_ultima_alerta"); + + b.Property("SaldoMinimo") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("saldo_minimo"); + + b.Property("TipoTitular") + .HasColumnType("integer") + .HasColumnName("tipo_titular"); + + b.HasKey("Id") + .HasName("pk_alertas_saldo"); + + b.HasIndex("CuentaId") + .IsUnique() + .HasDatabaseName("ix_alertas_saldo_cuenta_id_unique") + .HasFilter("\"cuenta_id\" IS NOT NULL"); + + b.HasIndex("TipoTitular") + .IsUnique() + .HasDatabaseName("ix_alertas_saldo_tipo_titular_unique") + .HasFilter("\"cuenta_id\" IS NULL AND \"tipo_titular\" IS NOT NULL"); + + b.ToTable("ALERTAS_SALDO", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Auditoria", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CeldaReferencia") + .HasColumnType("text") + .HasColumnName("celda_referencia"); + + b.Property("ColumnaNombre") + .HasColumnType("text") + .HasColumnName("columna_nombre"); + + b.Property("DetallesJson") + .HasColumnType("jsonb") + .HasColumnName("detalles_json"); + + b.Property("EntidadId") + .HasColumnType("uuid") + .HasColumnName("entidad_id"); + + b.Property("EntidadTipo") + .HasColumnType("text") + .HasColumnName("entidad_tipo"); + + b.Property("IpAddress") + .HasColumnType("inet") + .HasColumnName("ip_address"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.Property("TipoAccion") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tipo_accion"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.Property("ValorAnterior") + .HasColumnType("text") + .HasColumnName("valor_anterior"); + + b.Property("ValorNuevo") + .HasColumnType("text") + .HasColumnName("valor_nuevo"); + + b.HasKey("Id") + .HasName("pk_auditorias"); + + b.HasIndex("EntidadId") + .HasDatabaseName("ix_auditorias_entidad_id"); + + b.HasIndex("Timestamp") + .HasDatabaseName("ix_auditorias_timestamp"); + + b.HasIndex("TipoAccion") + .HasDatabaseName("ix_auditorias_tipo_accion"); + + b.HasIndex("UsuarioId", "Timestamp") + .HasDatabaseName("ix_auditorias_usuario_id_timestamp"); + + b.ToTable("AUDITORIAS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.AuditoriaIntegracion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CodigoRespuesta") + .HasColumnType("integer") + .HasColumnName("codigo_respuesta"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text") + .HasColumnName("endpoint"); + + b.Property("IpAddress") + .HasColumnType("inet") + .HasColumnName("ip_address"); + + b.Property("Metodo") + .IsRequired() + .HasColumnType("text") + .HasColumnName("metodo"); + + b.Property("Parametros") + .HasColumnType("jsonb") + .HasColumnName("parametros"); + + b.Property("TiempoEjecucionMs") + .HasColumnType("integer") + .HasColumnName("tiempo_ejecucion_ms"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.Property("TokenId") + .HasColumnType("uuid") + .HasColumnName("token_id"); + + b.HasKey("Id") + .HasName("pk_auditoria_integraciones"); + + b.HasIndex("CodigoRespuesta") + .HasDatabaseName("ix_auditoria_integraciones_codigo_respuesta"); + + b.HasIndex("Timestamp") + .HasDatabaseName("ix_auditoria_integraciones_timestamp"); + + b.HasIndex("TokenId") + .HasDatabaseName("ix_auditoria_integraciones_token_id"); + + b.ToTable("AUDITORIA_INTEGRACIONES", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Backup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Estado") + .HasColumnType("integer") + .HasColumnName("estado"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("IniciadoPorId") + .HasColumnType("uuid") + .HasColumnName("iniciado_por_id"); + + b.Property("Notas") + .HasColumnType("text") + .HasColumnName("notas"); + + b.Property("RutaArchivo") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ruta_archivo"); + + b.Property("TamanioBytes") + .HasColumnType("bigint") + .HasColumnName("tamanio_bytes"); + + b.Property("Tipo") + .HasColumnType("integer") + .HasColumnName("tipo"); + + b.HasKey("Id") + .HasName("pk_backups"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_backups_deleted_by_id"); + + b.HasIndex("IniciadoPorId") + .HasDatabaseName("ix_backups_iniciado_por_id"); + + b.ToTable("BACKUPS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Configuracion", b => + { + b.Property("Clave") + .HasColumnType("text") + .HasColumnName("clave"); + + b.Property("Descripcion") + .HasColumnType("text") + .HasColumnName("descripcion"); + + b.Property("FechaModificacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_modificacion"); + + b.Property("Tipo") + .HasColumnType("text") + .HasColumnName("tipo"); + + b.Property("UsuarioModificacionId") + .HasColumnType("uuid") + .HasColumnName("usuario_modificacion_id"); + + b.Property("Valor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("valor"); + + b.HasKey("Clave") + .HasName("pk_configuracion"); + + b.HasIndex("UsuarioModificacionId") + .HasDatabaseName("ix_configuracion_usuario_modificacion_id"); + + b.ToTable("CONFIGURACION", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Cuenta", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Activa") + .HasColumnType("boolean") + .HasColumnName("activa"); + + b.Property("BancoNombre") + .HasColumnType("text") + .HasColumnName("banco_nombre"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Divisa") + .IsRequired() + .HasColumnType("text") + .HasColumnName("divisa"); + + b.Property("EsEfectivo") + .HasColumnType("boolean") + .HasColumnName("es_efectivo"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("FormatoId") + .HasColumnType("uuid") + .HasColumnName("formato_id"); + + b.Property("Iban") + .HasColumnType("text") + .HasColumnName("iban"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nombre"); + + b.Property("Notas") + .HasColumnType("text") + .HasColumnName("notas"); + + b.Property("NumeroCuenta") + .HasColumnType("text") + .HasColumnName("numero_cuenta"); + + b.Property("TipoCuenta") + .HasColumnType("integer") + .HasColumnName("tipo_cuenta"); + + b.Property("TitularId") + .HasColumnType("uuid") + .HasColumnName("titular_id"); + + b.HasKey("Id") + .HasName("pk_cuentas"); + + b.HasIndex("Activa") + .HasDatabaseName("ix_cuentas_activa"); + + b.HasIndex("DeletedAt") + .HasDatabaseName("ix_cuentas_deleted_at"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_cuentas_deleted_by_id"); + + b.HasIndex("Divisa") + .HasDatabaseName("ix_cuentas_divisa"); + + b.HasIndex("EsEfectivo") + .HasDatabaseName("ix_cuentas_es_efectivo"); + + b.HasIndex("FormatoId") + .HasDatabaseName("ix_cuentas_formato_id"); + + b.HasIndex("TipoCuenta") + .HasDatabaseName("ix_cuentas_tipo_cuenta"); + + b.HasIndex("TitularId") + .HasDatabaseName("ix_cuentas_titular_id"); + + b.ToTable("CUENTAS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.DivisaActiva", b => + { + b.Property("Codigo") + .HasColumnType("text") + .HasColumnName("codigo"); + + b.Property("Activa") + .HasColumnType("boolean") + .HasColumnName("activa"); + + b.Property("EsBase") + .HasColumnType("boolean") + .HasColumnName("es_base"); + + b.Property("Nombre") + .HasColumnType("text") + .HasColumnName("nombre"); + + b.Property("Simbolo") + .HasColumnType("text") + .HasColumnName("simbolo"); + + b.HasKey("Codigo") + .HasName("pk_divisas_activas"); + + b.ToTable("DIVISAS_ACTIVAS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Exportacion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Estado") + .HasColumnType("integer") + .HasColumnName("estado"); + + b.Property("FechaExportacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_exportacion"); + + b.Property("IniciadoPorId") + .HasColumnType("uuid") + .HasColumnName("iniciado_por_id"); + + b.Property("RutaArchivo") + .HasColumnType("text") + .HasColumnName("ruta_archivo"); + + b.Property("TamanioBytes") + .HasColumnType("bigint") + .HasColumnName("tamanio_bytes"); + + b.Property("Tipo") + .HasColumnType("integer") + .HasColumnName("tipo"); + + b.HasKey("Id") + .HasName("pk_exportaciones"); + + b.HasIndex("CuentaId") + .HasDatabaseName("ix_exportaciones_cuenta_id"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_exportaciones_deleted_by_id"); + + b.HasIndex("IniciadoPorId") + .HasDatabaseName("ix_exportaciones_iniciado_por_id"); + + b.ToTable("EXPORTACIONES", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Extracto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Checked") + .HasColumnType("boolean") + .HasColumnName("checked"); + + b.Property("CheckedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("checked_at"); + + b.Property("CheckedById") + .HasColumnType("uuid") + .HasColumnName("checked_by_id"); + + b.Property("Comentarios") + .HasColumnType("text") + .HasColumnName("comentarios"); + + b.Property("Concepto") + .HasColumnType("text") + .HasColumnName("concepto"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Fecha") + .HasColumnType("date") + .HasColumnName("fecha"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("FechaModificacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_modificacion"); + + b.Property("FilaNumero") + .HasColumnType("integer") + .HasColumnName("fila_numero"); + + b.Property("Flagged") + .HasColumnType("boolean") + .HasColumnName("flagged"); + + b.Property("FlaggedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("flagged_at"); + + b.Property("FlaggedById") + .HasColumnType("uuid") + .HasColumnName("flagged_by_id"); + + b.Property("FlaggedNota") + .HasColumnType("text") + .HasColumnName("flagged_nota"); + + b.Property("Monto") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("monto"); + + b.Property("Saldo") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("saldo"); + + b.Property("UsuarioCreacionId") + .HasColumnType("uuid") + .HasColumnName("usuario_creacion_id"); + + b.Property("UsuarioModificacionId") + .HasColumnType("uuid") + .HasColumnName("usuario_modificacion_id"); + + b.HasKey("Id") + .HasName("pk_extractos"); + + b.HasIndex("Checked") + .HasDatabaseName("ix_extractos_checked"); + + b.HasIndex("CheckedById") + .HasDatabaseName("ix_extractos_checked_by_id"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_extractos_deleted_by_id"); + + b.HasIndex("Fecha") + .HasDatabaseName("ix_extractos_fecha"); + + b.HasIndex("Flagged") + .HasDatabaseName("ix_extractos_flagged"); + + b.HasIndex("FlaggedById") + .HasDatabaseName("ix_extractos_flagged_by_id"); + + b.HasIndex("UsuarioCreacionId") + .HasDatabaseName("ix_extractos_usuario_creacion_id"); + + b.HasIndex("UsuarioModificacionId") + .HasDatabaseName("ix_extractos_usuario_modificacion_id"); + + b.HasIndex("CuentaId", "DeletedAt") + .HasDatabaseName("ix_extractos_cuenta_id_deleted_at"); + + b.HasIndex("CuentaId", "Fecha") + .HasDatabaseName("ix_extractos_cuenta_id_fecha"); + + b.HasIndex("CuentaId", "FilaNumero") + .IsUnique() + .HasDatabaseName("ix_extractos_cuenta_id_fila_numero"); + + b.ToTable("EXTRACTOS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.ExtractoColumnaExtra", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ExtractoId") + .HasColumnType("uuid") + .HasColumnName("extracto_id"); + + b.Property("NombreColumna") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nombre_columna"); + + b.Property("Valor") + .HasColumnType("text") + .HasColumnName("valor"); + + b.HasKey("Id") + .HasName("pk_extractos_columnas_extra"); + + b.HasIndex("ExtractoId") + .HasDatabaseName("ix_extractos_columnas_extra_extracto_id"); + + b.HasIndex("NombreColumna") + .HasDatabaseName("ix_extractos_columnas_extra_nombre_columna"); + + b.ToTable("EXTRACTOS_COLUMNAS_EXTRA", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.FormatoImportacion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Activo") + .HasColumnType("boolean") + .HasColumnName("activo"); + + b.Property("BancoNombre") + .HasColumnType("text") + .HasColumnName("banco_nombre"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Divisa") + .HasColumnType("text") + .HasColumnName("divisa"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("MapeoJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("mapeo_json"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nombre"); + + b.Property("UsuarioCreadorId") + .HasColumnType("uuid") + .HasColumnName("usuario_creador_id"); + + b.HasKey("Id") + .HasName("pk_formatos_importacion"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_formatos_importacion_deleted_by_id"); + + b.HasIndex("UsuarioCreadorId") + .HasDatabaseName("ix_formatos_importacion_usuario_creador_id"); + + b.ToTable("FORMATOS_IMPORTACION", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.IntegrationPermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccesoTipo") + .IsRequired() + .HasColumnType("text") + .HasColumnName("acceso_tipo"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("TitularId") + .HasColumnType("uuid") + .HasColumnName("titular_id"); + + b.Property("TokenId") + .HasColumnType("uuid") + .HasColumnName("token_id"); + + b.HasKey("Id") + .HasName("pk_integration_permissions"); + + b.HasIndex("CuentaId") + .HasDatabaseName("ix_integration_permissions_cuenta_id"); + + b.HasIndex("TitularId") + .HasDatabaseName("ix_integration_permissions_titular_id"); + + b.HasIndex("TokenId") + .HasDatabaseName("ix_integration_permissions_token_id"); + + b.ToTable("INTEGRATION_PERMISSIONS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.IntegrationToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Descripcion") + .HasColumnType("text") + .HasColumnName("descripcion"); + + b.Property("Estado") + .HasColumnType("integer") + .HasColumnName("estado"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("FechaRevocacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_revocacion"); + + b.Property("FechaUltimaUso") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_ultima_uso"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nombre"); + + b.Property("PermisoEscritura") + .HasColumnType("boolean") + .HasColumnName("permiso_escritura"); + + b.Property("PermisoLectura") + .HasColumnType("boolean") + .HasColumnName("permiso_lectura"); + + b.Property("Tipo") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tipo"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("token_hash"); + + b.Property("UsuarioCreadorId") + .HasColumnType("uuid") + .HasColumnName("usuario_creador_id"); + + b.HasKey("Id") + .HasName("pk_integration_tokens"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_integration_tokens_deleted_by_id"); + + b.HasIndex("Estado") + .HasDatabaseName("ix_integration_tokens_estado"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_integration_tokens_token_hash"); + + b.HasIndex("UsuarioCreadorId") + .HasDatabaseName("ix_integration_tokens_usuario_creador_id"); + + b.ToTable("INTEGRATION_TOKENS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.NotificacionAdmin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DetallesJson") + .HasColumnType("jsonb") + .HasColumnName("detalles_json"); + + b.Property("Fecha") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha"); + + b.Property("Leida") + .HasColumnType("boolean") + .HasColumnName("leida"); + + b.Property("Mensaje") + .HasColumnType("text") + .HasColumnName("mensaje"); + + b.Property("Tipo") + .HasColumnType("text") + .HasColumnName("tipo"); + + b.HasKey("Id") + .HasName("pk_notificaciones_admin"); + + b.ToTable("NOTIFICACIONES_ADMIN", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.PermisoUsuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("PuedeAgregarLineas") + .HasColumnType("boolean") + .HasColumnName("puede_agregar_lineas"); + + b.Property("PuedeEditarLineas") + .HasColumnType("boolean") + .HasColumnName("puede_editar_lineas"); + + b.Property("PuedeEliminarLineas") + .HasColumnType("boolean") + .HasColumnName("puede_eliminar_lineas"); + + b.Property("PuedeImportar") + .HasColumnType("boolean") + .HasColumnName("puede_importar"); + + b.Property("PuedeVerCuentas") + .HasColumnType("boolean") + .HasColumnName("puede_ver_cuentas"); + + b.Property("PuedeVerDashboard") + .HasColumnType("boolean") + .HasColumnName("puede_ver_dashboard"); + + b.Property("TitularId") + .HasColumnType("uuid") + .HasColumnName("titular_id"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.HasKey("Id") + .HasName("pk_permisos_usuario"); + + b.HasIndex("CuentaId") + .HasDatabaseName("ix_permisos_usuario_cuenta_id"); + + b.HasIndex("TitularId") + .HasDatabaseName("ix_permisos_usuario_titular_id"); + + b.HasIndex("UsuarioId") + .HasDatabaseName("ix_permisos_usuario_usuario_id"); + + b.HasIndex("UsuarioId", "CuentaId") + .HasDatabaseName("ix_permisos_usuario_usuario_id_cuenta_id"); + + b.ToTable("PERMISOS_USUARIO", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.PlazoFijo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("CuentaReferenciaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_referencia_id"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Estado") + .HasColumnType("integer") + .HasColumnName("estado"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("FechaInicio") + .HasColumnType("date") + .HasColumnName("fecha_inicio"); + + b.Property("FechaModificacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_modificacion"); + + b.Property("FechaRenovacion") + .HasColumnType("date") + .HasColumnName("fecha_renovacion"); + + b.Property("FechaUltimaNotificacion") + .HasColumnType("date") + .HasColumnName("fecha_ultima_notificacion"); + + b.Property("FechaVencimiento") + .HasColumnType("date") + .HasColumnName("fecha_vencimiento"); + + b.Property("InteresPrevisto") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasColumnName("interes_previsto"); + + b.Property("Notas") + .HasColumnType("text") + .HasColumnName("notas"); + + b.Property("Renovable") + .HasColumnType("boolean") + .HasColumnName("renovable"); + + b.HasKey("Id") + .HasName("pk_plazos_fijos"); + + b.HasIndex("CuentaId") + .IsUnique() + .HasDatabaseName("ix_plazos_fijos_cuenta_id"); + + b.HasIndex("CuentaReferenciaId") + .HasDatabaseName("ix_plazos_fijos_cuenta_referencia_id"); + + b.HasIndex("DeletedAt") + .HasDatabaseName("ix_plazos_fijos_deleted_at"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_plazos_fijos_deleted_by_id"); + + b.HasIndex("Estado") + .HasDatabaseName("ix_plazos_fijos_estado"); + + b.HasIndex("FechaVencimiento") + .HasDatabaseName("ix_plazos_fijos_fecha_vencimiento"); + + b.ToTable("PLAZOS_FIJOS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.PreferenciaUsuarioCuenta", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ColumnasEditables") + .HasColumnType("jsonb") + .HasColumnName("columnas_editables"); + + b.Property("ColumnasVisibles") + .HasColumnType("jsonb") + .HasColumnName("columnas_visibles"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CuentaId") + .HasColumnType("uuid") + .HasColumnName("cuenta_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.HasKey("Id") + .HasName("pk_preferencias_usuario_cuenta"); + + b.HasIndex("CuentaId") + .HasDatabaseName("ix_preferencias_usuario_cuenta_cuenta_id"); + + b.HasIndex("UsuarioId") + .IsUnique() + .HasDatabaseName("ix_preferencias_usuario_cuenta_usuario_id") + .HasFilter("\"cuenta_id\" IS NULL"); + + b.HasIndex("UsuarioId", "CuentaId") + .IsUnique() + .HasDatabaseName("ix_preferencias_usuario_cuenta_usuario_id_cuenta_id") + .HasFilter("\"cuenta_id\" IS NOT NULL"); + + b.ToTable("PREFERENCIAS_USUARIO_CUENTA", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreadoEn") + .HasColumnType("timestamp with time zone") + .HasColumnName("creado_en"); + + b.Property("ExpiraEn") + .HasColumnType("timestamp with time zone") + .HasColumnName("expira_en"); + + b.Property("IpAddress") + .HasColumnType("inet") + .HasColumnName("ip_address"); + + b.Property("ReemplazadoPor") + .HasColumnType("text") + .HasColumnName("reemplazado_por"); + + b.Property("RevocadoEn") + .HasColumnType("timestamp with time zone") + .HasColumnName("revocado_en"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("token_hash"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.HasKey("Id") + .HasName("pk_refresh_tokens"); + + b.HasIndex("ExpiraEn") + .HasDatabaseName("ix_refresh_tokens_expira_en"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_refresh_tokens_token_hash"); + + b.HasIndex("UsuarioId") + .HasDatabaseName("ix_refresh_tokens_usuario_id"); + + b.ToTable("REFRESH_TOKENS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.TipoCambio", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DivisaDestino") + .IsRequired() + .HasColumnType("text") + .HasColumnName("divisa_destino"); + + b.Property("DivisaOrigen") + .IsRequired() + .HasColumnType("text") + .HasColumnName("divisa_origen"); + + b.Property("FechaActualizacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_actualizacion"); + + b.Property("Fuente") + .HasColumnType("integer") + .HasColumnName("fuente"); + + b.Property("Tasa") + .HasPrecision(18, 8) + .HasColumnType("numeric(18,8)") + .HasColumnName("tasa"); + + b.HasKey("Id") + .HasName("pk_tipos_cambio"); + + b.HasIndex("DivisaOrigen", "DivisaDestino") + .IsUnique() + .HasDatabaseName("ix_tipos_cambio_divisa_origen_divisa_destino"); + + b.ToTable("TIPOS_CAMBIO", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Titular", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ContactoEmail") + .HasColumnType("text") + .HasColumnName("contacto_email"); + + b.Property("ContactoTelefono") + .HasColumnType("text") + .HasColumnName("contacto_telefono"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("Identificacion") + .HasColumnType("text") + .HasColumnName("identificacion"); + + b.Property("Nombre") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nombre"); + + b.Property("Notas") + .HasColumnType("text") + .HasColumnName("notas"); + + b.Property("Tipo") + .HasColumnType("integer") + .HasColumnName("tipo"); + + b.HasKey("Id") + .HasName("pk_titulares"); + + b.HasIndex("DeletedAt") + .HasDatabaseName("ix_titulares_deleted_at"); + + b.HasIndex("DeletedById") + .HasDatabaseName("ix_titulares_deleted_by_id"); + + b.HasIndex("Nombre") + .HasDatabaseName("ix_titulares_nombre"); + + b.HasIndex("Tipo") + .HasDatabaseName("ix_titulares_tipo"); + + b.ToTable("TITULARES", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Usuario", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Activo") + .HasColumnType("boolean") + .HasColumnName("activo"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("FailedLoginAttempts") + .HasColumnType("integer") + .HasColumnName("failed_login_attempts"); + + b.Property("FechaCreacion") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_creacion"); + + b.Property("FechaUltimaLogin") + .HasColumnType("timestamp with time zone") + .HasColumnName("fecha_ultima_login"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("locked_until"); + + b.Property("MfaEnabled") + .HasColumnType("boolean") + .HasColumnName("mfa_enabled"); + + b.Property("MfaEnabledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("mfa_enabled_at"); + + b.Property("MfaLastAcceptedStep") + .HasColumnType("bigint") + .HasColumnName("mfa_last_accepted_step"); + + b.Property("MfaSecret") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("mfa_secret"); + + b.Property("NombreCompleto") + .IsRequired() + .HasColumnType("text") + .HasColumnName("nombre_completo"); + + b.Property("PasswordChangedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("password_changed_at"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password_hash"); + + b.Property("PrimerLogin") + .HasColumnType("boolean") + .HasColumnName("primer_login"); + + b.Property("Rol") + .HasColumnType("integer") + .HasColumnName("rol"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("security_stamp"); + + b.HasKey("Id") + .HasName("pk_usuarios"); + + b.HasIndex("Activo") + .HasDatabaseName("ix_usuarios_activo"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_usuarios_email"); + + b.HasIndex("MfaEnabled") + .HasDatabaseName("ix_usuarios_mfa_enabled"); + + b.HasIndex("Rol") + .HasDatabaseName("ix_usuarios_rol"); + + b.ToTable("USUARIOS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.UsuarioEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("EsPrincipal") + .HasColumnType("boolean") + .HasColumnName("es_principal"); + + b.Property("UsuarioId") + .HasColumnType("uuid") + .HasColumnName("usuario_id"); + + b.HasKey("Id") + .HasName("pk_usuario_emails"); + + b.HasIndex("UsuarioId") + .HasDatabaseName("ix_usuario_emails_usuario_id"); + + b.ToTable("USUARIO_EMAILS", (string)null); + }); + + modelBuilder.Entity("GestionCaja.API.Models.AlertaDestinatario", b => + { + b.HasOne("GestionCaja.API.Models.AlertaSaldo", null) + .WithMany() + .HasForeignKey("AlertaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_alerta_destinatarios_alertas_saldo_alerta_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_alerta_destinatarios_usuarios_usuario_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.AlertaSaldo", b => + { + b.HasOne("GestionCaja.API.Models.Cuenta", null) + .WithMany() + .HasForeignKey("CuentaId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_alertas_saldo_cuentas_cuenta_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Auditoria", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_auditorias_usuarios_usuario_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.AuditoriaIntegracion", b => + { + b.HasOne("GestionCaja.API.Models.IntegrationToken", null) + .WithMany() + .HasForeignKey("TokenId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_auditoria_integraciones_integration_tokens_token_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Backup", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_backups_usuarios_deleted_by_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("IniciadoPorId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_backups_usuarios_iniciado_por_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Configuracion", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioModificacionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_configuracion_usuarios_usuario_modificacion_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Cuenta", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_cuentas_usuarios_deleted_by_id"); + + b.HasOne("GestionCaja.API.Models.FormatoImportacion", null) + .WithMany() + .HasForeignKey("FormatoId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_cuentas_formatos_importacion_formato_id"); + + b.HasOne("GestionCaja.API.Models.Titular", "Titular") + .WithMany() + .HasForeignKey("TitularId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_cuentas_titulares_titular_id"); + + b.Navigation("Titular"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Exportacion", b => + { + b.HasOne("GestionCaja.API.Models.Cuenta", null) + .WithMany() + .HasForeignKey("CuentaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_exportaciones_cuentas_cuenta_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_exportaciones_usuarios_deleted_by_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("IniciadoPorId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_exportaciones_usuarios_iniciado_por_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Extracto", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("CheckedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_extractos_usuarios_checked_by_id"); + + b.HasOne("GestionCaja.API.Models.Cuenta", null) + .WithMany() + .HasForeignKey("CuentaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_extractos_cuentas_cuenta_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_extractos_usuarios_deleted_by_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("FlaggedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_extractos_usuarios_flagged_by_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioCreacionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_extractos_usuarios_usuario_creacion_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioModificacionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_extractos_usuarios_usuario_modificacion_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.ExtractoColumnaExtra", b => + { + b.HasOne("GestionCaja.API.Models.Extracto", null) + .WithMany() + .HasForeignKey("ExtractoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_extractos_columnas_extra_extractos_extracto_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.FormatoImportacion", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_formatos_importacion_usuarios_deleted_by_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioCreadorId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_formatos_importacion_usuarios_usuario_creador_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.IntegrationPermission", b => + { + b.HasOne("GestionCaja.API.Models.Cuenta", null) + .WithMany() + .HasForeignKey("CuentaId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_integration_permissions_cuentas_cuenta_id"); + + b.HasOne("GestionCaja.API.Models.Titular", null) + .WithMany() + .HasForeignKey("TitularId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_integration_permissions_titulares_titular_id"); + + b.HasOne("GestionCaja.API.Models.IntegrationToken", null) + .WithMany() + .HasForeignKey("TokenId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_integration_permissions_integration_tokens_token_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.IntegrationToken", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_integration_tokens_usuarios_deleted_by_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioCreadorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_integration_tokens_usuarios_usuario_creador_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.PermisoUsuario", b => + { + b.HasOne("GestionCaja.API.Models.Cuenta", null) + .WithMany() + .HasForeignKey("CuentaId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_permisos_usuario_cuentas_cuenta_id"); + + b.HasOne("GestionCaja.API.Models.Titular", null) + .WithMany() + .HasForeignKey("TitularId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_permisos_usuario_titulares_titular_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_permisos_usuario_usuarios_usuario_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.PlazoFijo", b => + { + b.HasOne("GestionCaja.API.Models.Cuenta", "Cuenta") + .WithOne() + .HasForeignKey("GestionCaja.API.Models.PlazoFijo", "CuentaId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_plazos_fijos_cuentas_cuenta_id"); + + b.HasOne("GestionCaja.API.Models.Cuenta", "CuentaReferencia") + .WithMany() + .HasForeignKey("CuentaReferenciaId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_plazos_fijos_cuentas_cuenta_referencia_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_plazos_fijos_usuarios_deleted_by_id"); + + b.Navigation("Cuenta"); + + b.Navigation("CuentaReferencia"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.PreferenciaUsuarioCuenta", b => + { + b.HasOne("GestionCaja.API.Models.Cuenta", null) + .WithMany() + .HasForeignKey("CuentaId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_preferencias_usuario_cuenta_cuentas_cuenta_id"); + + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_preferencias_usuario_cuenta_usuarios_usuario_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.RefreshToken", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", "Usuario") + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_refresh_tokens_usuarios_usuario_id"); + + b.Navigation("Usuario"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.Titular", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("DeletedById") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_titulares_usuarios_deleted_by_id"); + }); + + modelBuilder.Entity("GestionCaja.API.Models.UsuarioEmail", b => + { + b.HasOne("GestionCaja.API.Models.Usuario", null) + .WithMany() + .HasForeignKey("UsuarioId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_usuario_emails_usuarios_usuario_id"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501105704_RequireWebUserMfa.cs b/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501105704_RequireWebUserMfa.cs new file mode 100644 index 0000000..081d47d --- /dev/null +++ b/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501105704_RequireWebUserMfa.cs @@ -0,0 +1,70 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GestionCaja.API.Migrations +{ + /// + public partial class RequireWebUserMfa : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "mfa_enabled", + table: "USUARIOS", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "mfa_enabled_at", + table: "USUARIOS", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "mfa_last_accepted_step", + table: "USUARIOS", + type: "bigint", + nullable: true); + + migrationBuilder.AddColumn( + name: "mfa_secret", + table: "USUARIOS", + type: "character varying(2048)", + maxLength: 2048, + nullable: true); + + migrationBuilder.CreateIndex( + name: "ix_usuarios_mfa_enabled", + table: "USUARIOS", + column: "mfa_enabled"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_usuarios_mfa_enabled", + table: "USUARIOS"); + + migrationBuilder.DropColumn( + name: "mfa_enabled", + table: "USUARIOS"); + + migrationBuilder.DropColumn( + name: "mfa_enabled_at", + table: "USUARIOS"); + + migrationBuilder.DropColumn( + name: "mfa_last_accepted_step", + table: "USUARIOS"); + + migrationBuilder.DropColumn( + name: "mfa_secret", + table: "USUARIOS"); + } + } +} diff --git a/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501120000_EnableRowLevelSecurity.Designer.cs b/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501120000_EnableRowLevelSecurity.Designer.cs new file mode 100644 index 0000000..fade812 --- /dev/null +++ b/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501120000_EnableRowLevelSecurity.Designer.cs @@ -0,0 +1,33 @@ +using GestionCaja.API.Data; +using GestionCaja.API.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GestionCaja.API.Migrations; + +[DbContext(typeof(AppDbContext))] +[Migration("20260501120000_EnableRowLevelSecurity")] +public partial class EnableRowLevelSecurity +{ + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder); +#pragma warning restore 612, 618 + } +} diff --git a/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501120000_EnableRowLevelSecurity.cs b/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501120000_EnableRowLevelSecurity.cs new file mode 100644 index 0000000..f785573 --- /dev/null +++ b/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501120000_EnableRowLevelSecurity.cs @@ -0,0 +1,537 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GestionCaja.API.Migrations; + +public partial class EnableRowLevelSecurity : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + CREATE SCHEMA IF NOT EXISTS atlas_security; + CREATE EXTENSION IF NOT EXISTS pgcrypto; + + CREATE TABLE IF NOT EXISTS atlas_security.rls_context_secret ( + id boolean PRIMARY KEY DEFAULT true CHECK (id), + secret text NOT NULL, + updated_at timestamp with time zone NOT NULL DEFAULT now() + ); + REVOKE ALL ON TABLE atlas_security.rls_context_secret FROM PUBLIC; + + CREATE OR REPLACE FUNCTION atlas_security.context_payload() + RETURNS text + LANGUAGE sql + STABLE + AS $$ + SELECT concat_ws( + '|', + coalesce(current_setting('atlas.auth_mode', true), ''), + coalesce(current_setting('atlas.user_id', true), ''), + coalesce(current_setting('atlas.integration_token_id', true), ''), + coalesce(current_setting('atlas.is_admin', true), ''), + coalesce(current_setting('atlas.system', true), ''), + coalesce(current_setting('atlas.request_scope', true), '') + ) + $$; + + CREATE OR REPLACE FUNCTION atlas_security.context_is_valid() + RETURNS boolean + LANGUAGE sql + STABLE + SECURITY DEFINER + SET search_path = pg_catalog, atlas_security + AS $$ + SELECT EXISTS ( + SELECT 1 + FROM atlas_security.rls_context_secret s + WHERE lower(coalesce(current_setting('atlas.context_signature', true), '')) = + encode( + public.hmac( + convert_to(atlas_security.context_payload(), 'UTF8'), + convert_to(s.secret, 'UTF8'), + 'sha256'), + 'hex') + ) + $$; + + CREATE OR REPLACE FUNCTION atlas_security.current_user_id() + RETURNS uuid + LANGUAGE sql + STABLE + AS $$ + WITH setting AS ( + SELECT NULLIF(current_setting('atlas.user_id', true), '') AS value + ) + SELECT CASE + WHEN value ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + THEN value::uuid + ELSE NULL + END + FROM setting + $$; + + CREATE OR REPLACE FUNCTION atlas_security.current_integration_token_id() + RETURNS uuid + LANGUAGE sql + STABLE + AS $$ + WITH setting AS ( + SELECT NULLIF(current_setting('atlas.integration_token_id', true), '') AS value + ) + SELECT CASE + WHEN value ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + THEN value::uuid + ELSE NULL + END + FROM setting + $$; + + CREATE OR REPLACE FUNCTION atlas_security.is_system() + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT atlas_security.context_is_valid() + AND current_setting('atlas.system', true) = 'true' + $$; + + CREATE OR REPLACE FUNCTION atlas_security.is_admin() + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT atlas_security.context_is_valid() + AND current_setting('atlas.is_admin', true) = 'true' + $$; + + CREATE OR REPLACE FUNCTION atlas_security.is_admin_or_system() + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT atlas_security.is_system() OR atlas_security.is_admin() + $$; + + CREATE OR REPLACE FUNCTION atlas_security.is_user_mode() + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT atlas_security.context_is_valid() + AND current_setting('atlas.auth_mode', true) = 'user' + $$; + + CREATE OR REPLACE FUNCTION atlas_security.is_integration_mode() + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT atlas_security.context_is_valid() + AND current_setting('atlas.auth_mode', true) = 'integration' + $$; + + CREATE OR REPLACE FUNCTION atlas_security.is_auth_flow() + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT atlas_security.context_is_valid() + AND current_setting('atlas.auth_mode', true) = 'auth' + $$; + + CREATE OR REPLACE FUNCTION atlas_security.is_dashboard_scope() + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT atlas_security.context_is_valid() + AND current_setting('atlas.request_scope', true) = 'dashboard' + $$; + + CREATE OR REPLACE FUNCTION atlas_security.can_read_cuenta(target_cuenta_id uuid, target_titular_id uuid) + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT atlas_security.is_admin_or_system() + OR ( + atlas_security.is_user_mode() + AND atlas_security.current_user_id() IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM "PERMISOS_USUARIO" p + WHERE p.usuario_id = atlas_security.current_user_id() + AND (p.cuenta_id IS NULL OR p.cuenta_id = target_cuenta_id) + AND (p.titular_id IS NULL OR p.titular_id = target_titular_id) + AND ( + p.puede_ver_cuentas + OR p.puede_agregar_lineas + OR p.puede_editar_lineas + OR p.puede_eliminar_lineas + OR p.puede_importar + OR (atlas_security.is_dashboard_scope() AND p.puede_ver_dashboard) + ) + ) + ) + OR ( + atlas_security.is_integration_mode() + AND atlas_security.current_integration_token_id() IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM "INTEGRATION_PERMISSIONS" p + WHERE p.token_id = atlas_security.current_integration_token_id() + AND p.acceso_tipo IN ('lectura', 'escritura') + AND (p.cuenta_id IS NULL OR p.cuenta_id = target_cuenta_id) + AND (p.titular_id IS NULL OR p.titular_id = target_titular_id) + ) + ) + $$; + + CREATE OR REPLACE FUNCTION atlas_security.can_write_cuenta(target_cuenta_id uuid, target_titular_id uuid) + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT atlas_security.is_admin_or_system() + OR ( + atlas_security.is_user_mode() + AND atlas_security.current_user_id() IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM "PERMISOS_USUARIO" p + WHERE p.usuario_id = atlas_security.current_user_id() + AND (p.cuenta_id IS NULL OR p.cuenta_id = target_cuenta_id) + AND (p.titular_id IS NULL OR p.titular_id = target_titular_id) + AND ( + p.puede_agregar_lineas + OR p.puede_editar_lineas + OR p.puede_eliminar_lineas + OR p.puede_importar + ) + ) + ) + OR ( + atlas_security.is_integration_mode() + AND atlas_security.current_integration_token_id() IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM "INTEGRATION_PERMISSIONS" p + WHERE p.token_id = atlas_security.current_integration_token_id() + AND p.acceso_tipo = 'escritura' + AND (p.cuenta_id IS NULL OR p.cuenta_id = target_cuenta_id) + AND (p.titular_id IS NULL OR p.titular_id = target_titular_id) + ) + ) + $$; + + CREATE OR REPLACE FUNCTION atlas_security.can_read_cuenta_by_id(target_cuenta_id uuid) + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT atlas_security.is_admin_or_system() + OR EXISTS ( + SELECT 1 + FROM "CUENTAS" c + WHERE c.id = target_cuenta_id + AND atlas_security.can_read_cuenta(c.id, c.titular_id) + ) + $$; + + CREATE OR REPLACE FUNCTION atlas_security.can_write_cuenta_by_id(target_cuenta_id uuid) + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT atlas_security.is_admin_or_system() + OR EXISTS ( + SELECT 1 + FROM "CUENTAS" c + WHERE c.id = target_cuenta_id + AND atlas_security.can_write_cuenta(c.id, c.titular_id) + ) + $$; + + CREATE OR REPLACE FUNCTION atlas_security.can_read_titular(target_titular_id uuid) + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT atlas_security.is_admin_or_system() + OR ( + atlas_security.is_user_mode() + AND atlas_security.current_user_id() IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM "PERMISOS_USUARIO" p + WHERE p.usuario_id = atlas_security.current_user_id() + AND (p.cuenta_id IS NULL OR p.cuenta_id IN ( + SELECT c.id FROM "CUENTAS" c WHERE c.titular_id = target_titular_id + )) + AND (p.titular_id IS NULL OR p.titular_id = target_titular_id) + AND ( + p.puede_ver_cuentas + OR p.puede_agregar_lineas + OR p.puede_editar_lineas + OR p.puede_eliminar_lineas + OR p.puede_importar + OR (atlas_security.is_dashboard_scope() AND p.puede_ver_dashboard) + ) + ) + ) + OR ( + atlas_security.is_integration_mode() + AND atlas_security.current_integration_token_id() IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM "INTEGRATION_PERMISSIONS" p + WHERE p.token_id = atlas_security.current_integration_token_id() + AND p.acceso_tipo IN ('lectura', 'escritura') + AND (p.cuenta_id IS NULL OR p.cuenta_id IN ( + SELECT c.id FROM "CUENTAS" c WHERE c.titular_id = target_titular_id + )) + AND (p.titular_id IS NULL OR p.titular_id = target_titular_id) + ) + ) + $$; + + CREATE OR REPLACE FUNCTION atlas_security.can_read_extracto(target_extracto_id uuid) + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT atlas_security.is_admin_or_system() + OR EXISTS ( + SELECT 1 + FROM "EXTRACTOS" e + WHERE e.id = target_extracto_id + AND atlas_security.can_read_cuenta_by_id(e.cuenta_id) + ) + $$; + + CREATE OR REPLACE FUNCTION atlas_security.can_write_extracto(target_extracto_id uuid) + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT atlas_security.is_admin_or_system() + OR EXISTS ( + SELECT 1 + FROM "EXTRACTOS" e + WHERE e.id = target_extracto_id + AND atlas_security.can_write_cuenta_by_id(e.cuenta_id) + ) + $$; + + DROP POLICY IF EXISTS titulares_select ON "TITULARES"; + DROP POLICY IF EXISTS titulares_write ON "TITULARES"; + ALTER TABLE "TITULARES" ENABLE ROW LEVEL SECURITY; + ALTER TABLE "TITULARES" FORCE ROW LEVEL SECURITY; + CREATE POLICY titulares_select ON "TITULARES" + FOR SELECT USING (atlas_security.can_read_titular(id)); + CREATE POLICY titulares_write ON "TITULARES" + FOR ALL USING (atlas_security.is_admin_or_system()) + WITH CHECK (atlas_security.is_admin_or_system()); + + DROP POLICY IF EXISTS cuentas_select ON "CUENTAS"; + DROP POLICY IF EXISTS cuentas_write ON "CUENTAS"; + ALTER TABLE "CUENTAS" ENABLE ROW LEVEL SECURITY; + ALTER TABLE "CUENTAS" FORCE ROW LEVEL SECURITY; + CREATE POLICY cuentas_select ON "CUENTAS" + FOR SELECT USING (atlas_security.can_read_cuenta(id, titular_id)); + CREATE POLICY cuentas_write ON "CUENTAS" + FOR ALL USING (atlas_security.is_admin_or_system()) + WITH CHECK (atlas_security.is_admin_or_system()); + + DROP POLICY IF EXISTS plazos_fijos_select ON "PLAZOS_FIJOS"; + DROP POLICY IF EXISTS plazos_fijos_write ON "PLAZOS_FIJOS"; + ALTER TABLE "PLAZOS_FIJOS" ENABLE ROW LEVEL SECURITY; + ALTER TABLE "PLAZOS_FIJOS" FORCE ROW LEVEL SECURITY; + CREATE POLICY plazos_fijos_select ON "PLAZOS_FIJOS" + FOR SELECT USING (atlas_security.can_read_cuenta_by_id(cuenta_id)); + CREATE POLICY plazos_fijos_write ON "PLAZOS_FIJOS" + FOR ALL USING (atlas_security.is_admin_or_system()) + WITH CHECK (atlas_security.is_admin_or_system()); + + DROP POLICY IF EXISTS extractos_select ON "EXTRACTOS"; + DROP POLICY IF EXISTS extractos_write ON "EXTRACTOS"; + ALTER TABLE "EXTRACTOS" ENABLE ROW LEVEL SECURITY; + ALTER TABLE "EXTRACTOS" FORCE ROW LEVEL SECURITY; + CREATE POLICY extractos_select ON "EXTRACTOS" + FOR SELECT USING (atlas_security.can_read_cuenta_by_id(cuenta_id)); + CREATE POLICY extractos_write ON "EXTRACTOS" + FOR ALL USING (atlas_security.can_write_cuenta_by_id(cuenta_id)) + WITH CHECK (atlas_security.can_write_cuenta_by_id(cuenta_id)); + + DROP POLICY IF EXISTS extractos_columnas_extra_select ON "EXTRACTOS_COLUMNAS_EXTRA"; + DROP POLICY IF EXISTS extractos_columnas_extra_write ON "EXTRACTOS_COLUMNAS_EXTRA"; + ALTER TABLE "EXTRACTOS_COLUMNAS_EXTRA" ENABLE ROW LEVEL SECURITY; + ALTER TABLE "EXTRACTOS_COLUMNAS_EXTRA" FORCE ROW LEVEL SECURITY; + CREATE POLICY extractos_columnas_extra_select ON "EXTRACTOS_COLUMNAS_EXTRA" + FOR SELECT USING (atlas_security.can_read_extracto(extracto_id)); + CREATE POLICY extractos_columnas_extra_write ON "EXTRACTOS_COLUMNAS_EXTRA" + FOR ALL USING (atlas_security.can_write_extracto(extracto_id)) + WITH CHECK (atlas_security.can_write_extracto(extracto_id)); + + DROP POLICY IF EXISTS exportaciones_select ON "EXPORTACIONES"; + DROP POLICY IF EXISTS exportaciones_write ON "EXPORTACIONES"; + ALTER TABLE "EXPORTACIONES" ENABLE ROW LEVEL SECURITY; + ALTER TABLE "EXPORTACIONES" FORCE ROW LEVEL SECURITY; + CREATE POLICY exportaciones_select ON "EXPORTACIONES" + FOR SELECT USING (atlas_security.can_read_cuenta_by_id(cuenta_id)); + CREATE POLICY exportaciones_write ON "EXPORTACIONES" + FOR ALL USING (atlas_security.can_write_cuenta_by_id(cuenta_id)) + WITH CHECK (atlas_security.can_write_cuenta_by_id(cuenta_id)); + + DROP POLICY IF EXISTS preferencias_usuario_cuenta_select ON "PREFERENCIAS_USUARIO_CUENTA"; + DROP POLICY IF EXISTS preferencias_usuario_cuenta_write ON "PREFERENCIAS_USUARIO_CUENTA"; + ALTER TABLE "PREFERENCIAS_USUARIO_CUENTA" ENABLE ROW LEVEL SECURITY; + ALTER TABLE "PREFERENCIAS_USUARIO_CUENTA" FORCE ROW LEVEL SECURITY; + CREATE POLICY preferencias_usuario_cuenta_select ON "PREFERENCIAS_USUARIO_CUENTA" + FOR SELECT USING ( + atlas_security.is_admin_or_system() + OR (atlas_security.is_user_mode() AND usuario_id = atlas_security.current_user_id()) + ); + CREATE POLICY preferencias_usuario_cuenta_write ON "PREFERENCIAS_USUARIO_CUENTA" + FOR ALL USING ( + atlas_security.is_admin_or_system() + OR (atlas_security.is_user_mode() AND usuario_id = atlas_security.current_user_id()) + ) + WITH CHECK ( + atlas_security.is_admin_or_system() + OR (atlas_security.is_user_mode() AND usuario_id = atlas_security.current_user_id()) + ); + + DROP POLICY IF EXISTS auditorias_select ON "AUDITORIAS"; + DROP POLICY IF EXISTS auditorias_insert ON "AUDITORIAS"; + ALTER TABLE "AUDITORIAS" ENABLE ROW LEVEL SECURITY; + ALTER TABLE "AUDITORIAS" FORCE ROW LEVEL SECURITY; + CREATE POLICY auditorias_select ON "AUDITORIAS" + FOR SELECT USING ( + atlas_security.is_admin_or_system() + OR (atlas_security.is_user_mode() AND usuario_id = atlas_security.current_user_id()) + OR (entidad_tipo = 'EXTRACTOS' AND entidad_id IS NOT NULL AND atlas_security.can_read_extracto(entidad_id)) + OR (entidad_tipo = 'CUENTAS' AND entidad_id IS NOT NULL AND atlas_security.can_read_cuenta_by_id(entidad_id)) + OR (entidad_tipo = 'TITULARES' AND entidad_id IS NOT NULL AND atlas_security.can_read_titular(entidad_id)) + ); + CREATE POLICY auditorias_insert ON "AUDITORIAS" + FOR INSERT WITH CHECK ( + atlas_security.is_admin_or_system() + OR atlas_security.is_auth_flow() + OR (atlas_security.is_user_mode() AND atlas_security.current_user_id() IS NOT NULL) + ); + + DROP POLICY IF EXISTS auditoria_integraciones_select ON "AUDITORIA_INTEGRACIONES"; + DROP POLICY IF EXISTS auditoria_integraciones_insert ON "AUDITORIA_INTEGRACIONES"; + ALTER TABLE "AUDITORIA_INTEGRACIONES" ENABLE ROW LEVEL SECURITY; + ALTER TABLE "AUDITORIA_INTEGRACIONES" FORCE ROW LEVEL SECURITY; + CREATE POLICY auditoria_integraciones_select ON "AUDITORIA_INTEGRACIONES" + FOR SELECT USING ( + atlas_security.is_admin_or_system() + OR (atlas_security.is_integration_mode() AND token_id = atlas_security.current_integration_token_id()) + ); + CREATE POLICY auditoria_integraciones_insert ON "AUDITORIA_INTEGRACIONES" + FOR INSERT WITH CHECK ( + atlas_security.is_admin_or_system() + OR (atlas_security.is_integration_mode() AND token_id = atlas_security.current_integration_token_id()) + ); + + DROP POLICY IF EXISTS backups_admin ON "BACKUPS"; + ALTER TABLE "BACKUPS" ENABLE ROW LEVEL SECURITY; + ALTER TABLE "BACKUPS" FORCE ROW LEVEL SECURITY; + CREATE POLICY backups_admin ON "BACKUPS" + FOR ALL USING (atlas_security.is_admin_or_system()) + WITH CHECK (atlas_security.is_admin_or_system()); + + DROP POLICY IF EXISTS notificaciones_admin_admin ON "NOTIFICACIONES_ADMIN"; + ALTER TABLE "NOTIFICACIONES_ADMIN" ENABLE ROW LEVEL SECURITY; + ALTER TABLE "NOTIFICACIONES_ADMIN" FORCE ROW LEVEL SECURITY; + CREATE POLICY notificaciones_admin_admin ON "NOTIFICACIONES_ADMIN" + FOR ALL USING (atlas_security.is_admin_or_system()) + WITH CHECK (atlas_security.is_admin_or_system()); + + """); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + DROP POLICY IF EXISTS titulares_select ON "TITULARES"; + DROP POLICY IF EXISTS titulares_write ON "TITULARES"; + ALTER TABLE "TITULARES" NO FORCE ROW LEVEL SECURITY; + ALTER TABLE "TITULARES" DISABLE ROW LEVEL SECURITY; + + DROP POLICY IF EXISTS cuentas_select ON "CUENTAS"; + DROP POLICY IF EXISTS cuentas_write ON "CUENTAS"; + ALTER TABLE "CUENTAS" NO FORCE ROW LEVEL SECURITY; + ALTER TABLE "CUENTAS" DISABLE ROW LEVEL SECURITY; + + DROP POLICY IF EXISTS plazos_fijos_select ON "PLAZOS_FIJOS"; + DROP POLICY IF EXISTS plazos_fijos_write ON "PLAZOS_FIJOS"; + ALTER TABLE "PLAZOS_FIJOS" NO FORCE ROW LEVEL SECURITY; + ALTER TABLE "PLAZOS_FIJOS" DISABLE ROW LEVEL SECURITY; + + DROP POLICY IF EXISTS extractos_select ON "EXTRACTOS"; + DROP POLICY IF EXISTS extractos_write ON "EXTRACTOS"; + ALTER TABLE "EXTRACTOS" NO FORCE ROW LEVEL SECURITY; + ALTER TABLE "EXTRACTOS" DISABLE ROW LEVEL SECURITY; + + DROP POLICY IF EXISTS extractos_columnas_extra_select ON "EXTRACTOS_COLUMNAS_EXTRA"; + DROP POLICY IF EXISTS extractos_columnas_extra_write ON "EXTRACTOS_COLUMNAS_EXTRA"; + ALTER TABLE "EXTRACTOS_COLUMNAS_EXTRA" NO FORCE ROW LEVEL SECURITY; + ALTER TABLE "EXTRACTOS_COLUMNAS_EXTRA" DISABLE ROW LEVEL SECURITY; + + DROP POLICY IF EXISTS exportaciones_select ON "EXPORTACIONES"; + DROP POLICY IF EXISTS exportaciones_write ON "EXPORTACIONES"; + ALTER TABLE "EXPORTACIONES" NO FORCE ROW LEVEL SECURITY; + ALTER TABLE "EXPORTACIONES" DISABLE ROW LEVEL SECURITY; + + DROP POLICY IF EXISTS preferencias_usuario_cuenta_select ON "PREFERENCIAS_USUARIO_CUENTA"; + DROP POLICY IF EXISTS preferencias_usuario_cuenta_write ON "PREFERENCIAS_USUARIO_CUENTA"; + ALTER TABLE "PREFERENCIAS_USUARIO_CUENTA" NO FORCE ROW LEVEL SECURITY; + ALTER TABLE "PREFERENCIAS_USUARIO_CUENTA" DISABLE ROW LEVEL SECURITY; + + DROP POLICY IF EXISTS auditorias_select ON "AUDITORIAS"; + DROP POLICY IF EXISTS auditorias_insert ON "AUDITORIAS"; + ALTER TABLE "AUDITORIAS" NO FORCE ROW LEVEL SECURITY; + ALTER TABLE "AUDITORIAS" DISABLE ROW LEVEL SECURITY; + + DROP POLICY IF EXISTS auditoria_integraciones_select ON "AUDITORIA_INTEGRACIONES"; + DROP POLICY IF EXISTS auditoria_integraciones_insert ON "AUDITORIA_INTEGRACIONES"; + ALTER TABLE "AUDITORIA_INTEGRACIONES" NO FORCE ROW LEVEL SECURITY; + ALTER TABLE "AUDITORIA_INTEGRACIONES" DISABLE ROW LEVEL SECURITY; + + DROP POLICY IF EXISTS backups_admin ON "BACKUPS"; + ALTER TABLE "BACKUPS" NO FORCE ROW LEVEL SECURITY; + ALTER TABLE "BACKUPS" DISABLE ROW LEVEL SECURITY; + + DROP POLICY IF EXISTS notificaciones_admin_admin ON "NOTIFICACIONES_ADMIN"; + ALTER TABLE "NOTIFICACIONES_ADMIN" NO FORCE ROW LEVEL SECURITY; + ALTER TABLE "NOTIFICACIONES_ADMIN" DISABLE ROW LEVEL SECURITY; + + DROP FUNCTION IF EXISTS atlas_security.can_write_extracto(uuid); + DROP FUNCTION IF EXISTS atlas_security.can_read_extracto(uuid); + DROP FUNCTION IF EXISTS atlas_security.can_write_cuenta_by_id(uuid); + DROP FUNCTION IF EXISTS atlas_security.can_read_cuenta_by_id(uuid); + DROP FUNCTION IF EXISTS atlas_security.can_read_titular(uuid); + DROP FUNCTION IF EXISTS atlas_security.can_write_cuenta(uuid, uuid); + DROP FUNCTION IF EXISTS atlas_security.can_read_cuenta(uuid, uuid); + DROP FUNCTION IF EXISTS atlas_security.is_dashboard_scope(); + DROP FUNCTION IF EXISTS atlas_security.is_auth_flow(); + DROP FUNCTION IF EXISTS atlas_security.is_integration_mode(); + DROP FUNCTION IF EXISTS atlas_security.is_user_mode(); + DROP FUNCTION IF EXISTS atlas_security.is_admin_or_system(); + DROP FUNCTION IF EXISTS atlas_security.is_admin(); + DROP FUNCTION IF EXISTS atlas_security.is_system(); + DROP FUNCTION IF EXISTS atlas_security.current_integration_token_id(); + DROP FUNCTION IF EXISTS atlas_security.current_user_id(); + DROP FUNCTION IF EXISTS atlas_security.context_is_valid(); + DROP FUNCTION IF EXISTS atlas_security.context_payload(); + DROP TABLE IF EXISTS atlas_security.rls_context_secret; + DROP SCHEMA IF EXISTS atlas_security; + """); + } +} diff --git a/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501133000_SignRowLevelSecurityContext.Designer.cs b/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501133000_SignRowLevelSecurityContext.Designer.cs new file mode 100644 index 0000000..30893c3 --- /dev/null +++ b/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501133000_SignRowLevelSecurityContext.Designer.cs @@ -0,0 +1,33 @@ +using GestionCaja.API.Data; +using GestionCaja.API.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GestionCaja.API.Migrations; + +[DbContext(typeof(AppDbContext))] +[Migration("20260501133000_SignRowLevelSecurityContext")] +public partial class SignRowLevelSecurityContext +{ + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pgcrypto"); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder); +#pragma warning restore 612, 618 + } +} diff --git a/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501133000_SignRowLevelSecurityContext.cs b/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501133000_SignRowLevelSecurityContext.cs new file mode 100644 index 0000000..2c8dd31 --- /dev/null +++ b/Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501133000_SignRowLevelSecurityContext.cs @@ -0,0 +1,220 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GestionCaja.API.Migrations; + +public partial class SignRowLevelSecurityContext : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + CREATE SCHEMA IF NOT EXISTS atlas_security; + CREATE EXTENSION IF NOT EXISTS pgcrypto; + + CREATE TABLE IF NOT EXISTS atlas_security.rls_context_secret ( + id boolean PRIMARY KEY DEFAULT true CHECK (id), + secret text NOT NULL, + updated_at timestamp with time zone NOT NULL DEFAULT now() + ); + REVOKE ALL ON TABLE atlas_security.rls_context_secret FROM PUBLIC; + + CREATE OR REPLACE FUNCTION atlas_security.context_payload() + RETURNS text + LANGUAGE sql + STABLE + AS $$ + SELECT concat_ws( + '|', + coalesce(current_setting('atlas.auth_mode', true), ''), + coalesce(current_setting('atlas.user_id', true), ''), + coalesce(current_setting('atlas.integration_token_id', true), ''), + coalesce(current_setting('atlas.is_admin', true), ''), + coalesce(current_setting('atlas.system', true), ''), + coalesce(current_setting('atlas.request_scope', true), '') + ) + $$; + + CREATE OR REPLACE FUNCTION atlas_security.context_is_valid() + RETURNS boolean + LANGUAGE sql + STABLE + SECURITY DEFINER + SET search_path = pg_catalog, atlas_security + AS $$ + SELECT EXISTS ( + SELECT 1 + FROM atlas_security.rls_context_secret s + WHERE lower(coalesce(current_setting('atlas.context_signature', true), '')) = + encode( + public.hmac( + convert_to(atlas_security.context_payload(), 'UTF8'), + convert_to(s.secret, 'UTF8'), + 'sha256'), + 'hex') + ) + $$; + + CREATE OR REPLACE FUNCTION atlas_security.current_user_id() + RETURNS uuid + LANGUAGE sql + STABLE + AS $$ + WITH setting AS ( + SELECT NULLIF(current_setting('atlas.user_id', true), '') AS value + ) + SELECT CASE + WHEN value ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + THEN value::uuid + ELSE NULL + END + FROM setting + $$; + + CREATE OR REPLACE FUNCTION atlas_security.current_integration_token_id() + RETURNS uuid + LANGUAGE sql + STABLE + AS $$ + WITH setting AS ( + SELECT NULLIF(current_setting('atlas.integration_token_id', true), '') AS value + ) + SELECT CASE + WHEN value ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + THEN value::uuid + ELSE NULL + END + FROM setting + $$; + + CREATE OR REPLACE FUNCTION atlas_security.is_system() + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT atlas_security.context_is_valid() + AND current_setting('atlas.system', true) = 'true' + $$; + + CREATE OR REPLACE FUNCTION atlas_security.is_admin() + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT atlas_security.context_is_valid() + AND current_setting('atlas.is_admin', true) = 'true' + $$; + + CREATE OR REPLACE FUNCTION atlas_security.is_user_mode() + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT atlas_security.context_is_valid() + AND current_setting('atlas.auth_mode', true) = 'user' + $$; + + CREATE OR REPLACE FUNCTION atlas_security.is_integration_mode() + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT atlas_security.context_is_valid() + AND current_setting('atlas.auth_mode', true) = 'integration' + $$; + + CREATE OR REPLACE FUNCTION atlas_security.is_auth_flow() + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT atlas_security.context_is_valid() + AND current_setting('atlas.auth_mode', true) = 'auth' + $$; + + CREATE OR REPLACE FUNCTION atlas_security.is_dashboard_scope() + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT atlas_security.context_is_valid() + AND current_setting('atlas.request_scope', true) = 'dashboard' + $$; + """); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + CREATE OR REPLACE FUNCTION atlas_security.current_user_id() + RETURNS uuid + LANGUAGE sql + STABLE + AS $$ + SELECT NULLIF(current_setting('atlas.user_id', true), '')::uuid + $$; + + CREATE OR REPLACE FUNCTION atlas_security.current_integration_token_id() + RETURNS uuid + LANGUAGE sql + STABLE + AS $$ + SELECT NULLIF(current_setting('atlas.integration_token_id', true), '')::uuid + $$; + + CREATE OR REPLACE FUNCTION atlas_security.is_system() + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT current_setting('atlas.system', true) = 'true' + $$; + + CREATE OR REPLACE FUNCTION atlas_security.is_admin() + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT current_setting('atlas.is_admin', true) = 'true' + $$; + + CREATE OR REPLACE FUNCTION atlas_security.is_user_mode() + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT current_setting('atlas.auth_mode', true) = 'user' + $$; + + CREATE OR REPLACE FUNCTION atlas_security.is_integration_mode() + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT current_setting('atlas.auth_mode', true) = 'integration' + $$; + + CREATE OR REPLACE FUNCTION atlas_security.is_auth_flow() + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT current_setting('atlas.auth_mode', true) = 'auth' + $$; + + CREATE OR REPLACE FUNCTION atlas_security.is_dashboard_scope() + RETURNS boolean + LANGUAGE sql + STABLE + AS $$ + SELECT current_setting('atlas.request_scope', true) = 'dashboard' + $$; + + DROP FUNCTION IF EXISTS atlas_security.context_is_valid(); + DROP FUNCTION IF EXISTS atlas_security.context_payload(); + DROP TABLE IF EXISTS atlas_security.rls_context_secret; + """); + } +} diff --git a/Atlas Balance/backend/src/GestionCaja.API/Migrations/AppDbContextModelSnapshot.cs b/Atlas Balance/backend/src/GestionCaja.API/Migrations/AppDbContextModelSnapshot.cs index f192cfa..ca56549 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Migrations/AppDbContextModelSnapshot.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Migrations/AppDbContextModelSnapshot.cs @@ -1277,6 +1277,23 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("timestamp with time zone") .HasColumnName("locked_until"); + b.Property("MfaEnabled") + .HasColumnType("boolean") + .HasColumnName("mfa_enabled"); + + b.Property("MfaEnabledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("mfa_enabled_at"); + + b.Property("MfaLastAcceptedStep") + .HasColumnType("bigint") + .HasColumnName("mfa_last_accepted_step"); + + b.Property("MfaSecret") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("mfa_secret"); + b.Property("NombreCompleto") .IsRequired() .HasColumnType("text") @@ -1315,6 +1332,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsUnique() .HasDatabaseName("ix_usuarios_email"); + b.HasIndex("MfaEnabled") + .HasDatabaseName("ix_usuarios_mfa_enabled"); + b.HasIndex("Rol") .HasDatabaseName("ix_usuarios_rol"); diff --git a/Atlas Balance/backend/src/GestionCaja.API/Models/Entities.cs b/Atlas Balance/backend/src/GestionCaja.API/Models/Entities.cs index 3bd612a..6146855 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Models/Entities.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Models/Entities.cs @@ -21,6 +21,10 @@ public class Usuario : ISoftDelete public DateTime? PasswordChangedAt { get; set; } public int FailedLoginAttempts { get; set; } public DateTime? LockedUntil { get; set; } + public bool MfaEnabled { get; set; } + public string? MfaSecret { get; set; } + public DateTime? MfaEnabledAt { get; set; } + public long? MfaLastAcceptedStep { get; set; } public DateTime? DeletedAt { get; set; } public Guid? DeletedById { get; set; } } diff --git a/Atlas Balance/backend/src/GestionCaja.API/Program.cs b/Atlas Balance/backend/src/GestionCaja.API/Program.cs index f7fd3fb..22149e6 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Program.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Program.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; +using Npgsql; using Serilog; using System.Security.Cryptography; using System.Text; @@ -22,12 +23,16 @@ .WriteTo.Console() .WriteTo.File("logs/atlas-balance-.log", rollingInterval: RollingInterval.Day)); -builder.Services.AddDbContext(options => +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); +builder.Services.AddDbContext((serviceProvider, options) => options .UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")) - .UseSnakeCaseNamingConvention()); + .UseSnakeCaseNamingConvention() + .AddInterceptors(serviceProvider.GetRequiredService())); var jwtSecret = ResolveJwtSecret(builder.Configuration, builder.Environment); +var rlsContextSecret = ResolveRlsContextSecret(builder.Configuration, jwtSecret); var jwtIssuer = builder.Configuration["JwtSettings:Issuer"] ?? "atlas-balance-api"; var jwtAudience = builder.Configuration["JwtSettings:Audience"] ?? "atlas-balance-app"; @@ -143,7 +148,6 @@ }); } -builder.Services.AddHttpContextAccessor(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -173,8 +177,24 @@ using (var scope = app.Services.CreateScope()) { + var runtimeConnectionString = app.Configuration.GetConnectionString("DefaultConnection") + ?? throw new InvalidOperationException("ConnectionStrings:DefaultConnection must be configured."); + var migrationConnectionString = app.Configuration.GetConnectionString("MigrationConnection"); + var effectiveMigrationConnectionString = string.IsNullOrWhiteSpace(migrationConnectionString) + ? runtimeConnectionString + : migrationConnectionString; + + var migrationOptions = CreateDbContextOptions(effectiveMigrationConnectionString); + using (var migrationDb = new AppDbContext(migrationOptions)) + { + migrationDb.Database.Migrate(); + } + + EnsureRlsContextSecret(effectiveMigrationConnectionString, rlsContextSecret); + GrantRuntimeDatabasePrivileges(effectiveMigrationConnectionString, runtimeConnectionString); + NpgsqlConnection.ClearAllPools(); + var db = scope.ServiceProvider.GetRequiredService(); - db.Database.Migrate(); SeedData.Initialize(db, app.Configuration, app.Environment); ProtectExistingConfigurationSecrets( db, @@ -328,6 +348,83 @@ static string ResolveJwtSecret(IConfiguration configuration, IHostEnvironment en return generated; } +static string ResolveRlsContextSecret(IConfiguration configuration, string jwtSecret) +{ + var configured = configuration["Security:RlsContextSecret"]; + return string.IsNullOrWhiteSpace(configured) ? jwtSecret : configured; +} + +static DbContextOptions CreateDbContextOptions(string connectionString) => + new DbContextOptionsBuilder() + .UseNpgsql(connectionString) + .UseSnakeCaseNamingConvention() + .Options; + +static void EnsureRlsContextSecret(string connectionString, string secret) +{ + using var connection = new NpgsqlConnection(connectionString); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = """ + CREATE SCHEMA IF NOT EXISTS atlas_security; + CREATE EXTENSION IF NOT EXISTS pgcrypto; + CREATE TABLE IF NOT EXISTS atlas_security.rls_context_secret ( + id boolean PRIMARY KEY DEFAULT true CHECK (id), + secret text NOT NULL, + updated_at timestamp with time zone NOT NULL DEFAULT now() + ); + INSERT INTO atlas_security.rls_context_secret (id, secret, updated_at) + VALUES (true, @secret, now()) + ON CONFLICT (id) DO UPDATE + SET secret = EXCLUDED.secret, + updated_at = now(); + REVOKE ALL ON TABLE atlas_security.rls_context_secret FROM PUBLIC; + """; + command.Parameters.AddWithValue("secret", secret); + command.ExecuteNonQuery(); +} + +static void GrantRuntimeDatabasePrivileges(string migrationConnectionString, string runtimeConnectionString) +{ + var migrationBuilder = new NpgsqlConnectionStringBuilder(migrationConnectionString); + var runtimeBuilder = new NpgsqlConnectionStringBuilder(runtimeConnectionString); + if (string.Equals(migrationBuilder.Username, runtimeBuilder.Username, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (string.IsNullOrWhiteSpace(runtimeBuilder.Username)) + { + throw new InvalidOperationException("Runtime database username is required for RLS grants."); + } + + var databaseName = string.IsNullOrWhiteSpace(migrationBuilder.Database) + ? runtimeBuilder.Database + : migrationBuilder.Database; + if (string.IsNullOrWhiteSpace(databaseName)) + { + throw new InvalidOperationException("Database name is required for RLS grants."); + } + + var runtimeRole = QuotePostgresIdentifier(runtimeBuilder.Username); + using var connection = new NpgsqlConnection(migrationConnectionString); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = $$""" + GRANT CONNECT ON DATABASE {{QuotePostgresIdentifier(databaseName)}} TO {{runtimeRole}}; + GRANT USAGE ON SCHEMA public TO {{runtimeRole}}; + GRANT USAGE ON SCHEMA atlas_security TO {{runtimeRole}}; + GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO {{runtimeRole}}; + GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO {{runtimeRole}}; + GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA atlas_security TO {{runtimeRole}}; + REVOKE ALL ON TABLE atlas_security.rls_context_secret FROM {{runtimeRole}}; + """; + command.ExecuteNonQuery(); +} + +static string QuotePostgresIdentifier(string value) => + "\"" + value.Replace("\"", "\"\"", StringComparison.Ordinal) + "\""; + static bool LooksLikePlaceholder(string value) { var lower = value.ToLowerInvariant(); diff --git a/Atlas Balance/backend/src/GestionCaja.API/Services/ActualizacionService.cs b/Atlas Balance/backend/src/GestionCaja.API/Services/ActualizacionService.cs index f467e48..618fa9b 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Services/ActualizacionService.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Services/ActualizacionService.cs @@ -1,5 +1,8 @@ +using System.IO.Compression; using System.Reflection; +using System.Security.Cryptography; using System.Text.Json; +using System.Text.RegularExpressions; using GestionCaja.API.Data; using GestionCaja.API.DTOs; using Microsoft.EntityFrameworkCore; @@ -123,6 +126,11 @@ public async Task IniciarActualizacionAsync(string? sourcePath, string? ta if (payload is not null) { finalSourcePath ??= payload.SourcePath; + if (string.IsNullOrWhiteSpace(finalSourcePath) && + !string.IsNullOrWhiteSpace(payload.AssetDownloadUrl)) + { + finalSourcePath = await DownloadAndPreparePackageAsync(payload, cancellationToken); + } } } } @@ -160,14 +168,10 @@ private static int CompareVersions(string left, string right) private static string NormalizeVersion(string version) { - var core = version.Trim().TrimStart('v', 'V'); - var dashIndex = core.IndexOf('-'); - if (dashIndex >= 0) - { - core = core[..dashIndex]; - } - - return core; + var match = Regex.Match(version.Trim(), @"(?i)v?[-_]?(\d+(?:[.-]\d+){0,3})"); + return match.Success + ? match.Groups[1].Value.Replace('-', '.') + : version.Trim().TrimStart('v', 'V'); } private static string ResolveCurrentVersion() @@ -239,6 +243,89 @@ private static Uri ResolveUpdateCheckEndpoint(string checkUrl) return new Uri($"https://api.github.com/repos/{segments[0]}/{segments[1]}/releases/latest"); } + private async Task DownloadAndPreparePackageAsync(UpdateCheckPayload payload, CancellationToken cancellationToken) + { + var assetUrl = payload.AssetDownloadUrl; + if (!IsOfficialReleaseAssetUrl(assetUrl)) + { + _logger.LogWarning("Asset de actualizacion rechazado por no pertenecer al repo oficial."); + return null; + } + + var sourceRoot = ResolveConfiguredUpdateSourceRoot(); + if (string.IsNullOrWhiteSpace(sourceRoot) || !IsExplicitlyRooted(sourceRoot)) + { + _logger.LogWarning("No se puede descargar actualizacion: WatchdogSettings:UpdateSourceRoot no configurado"); + return null; + } + + var packageVersion = string.IsNullOrWhiteSpace(payload.Version) ? "latest" : payload.Version; + var safeVersion = ToSafePathSegment(packageVersion); + var packageRoot = Path.Combine(sourceRoot, safeVersion); + var zipPath = Path.Combine(sourceRoot, $"{safeVersion}.zip"); + + Directory.CreateDirectory(sourceRoot); + EnsurePathWithinRoot(packageRoot, sourceRoot); + EnsurePathWithinRoot(zipPath, sourceRoot); + + var http = _httpClientFactory.CreateClient(); + http.Timeout = TimeSpan.FromMinutes(5); + + using var request = new HttpRequestMessage(HttpMethod.Get, assetUrl); + request.Headers.UserAgent.ParseAdd($"AtlasBalance/{ResolveCurrentVersion()}"); + var token = ResolveGitHubUpdateToken(); + if (!string.IsNullOrWhiteSpace(token)) + { + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + } + + using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("No se pudo descargar asset de actualizacion: {StatusCode}", (int)response.StatusCode); + return null; + } + + await using (var output = File.Create(zipPath)) + await using (var input = await response.Content.ReadAsStreamAsync(cancellationToken)) + { + await input.CopyToAsync(output, cancellationToken); + } + + if (!VerifyAssetDigest(zipPath, payload.AssetDigest)) + { + _logger.LogWarning("Asset de actualizacion rechazado por digest SHA-256 ausente o invalido."); + TryDeleteFile(zipPath); + return null; + } + + if (!await VerifyAssetSignatureAsync(http, zipPath, payload.AssetSignatureDownloadUrl, token, cancellationToken)) + { + _logger.LogWarning("Asset de actualizacion rechazado por firma ausente o invalida."); + TryDeleteFile(zipPath); + return null; + } + + if (Directory.Exists(packageRoot)) + { + Directory.Delete(packageRoot, recursive: true); + } + + if (!TryExtractPackageSafely(zipPath, packageRoot)) + { + _logger.LogWarning("Asset de actualizacion rechazado por entradas fuera de la raiz prevista."); + return null; + } + var resolvedPackageRoot = ResolveExtractedPackageRoot(packageRoot); + if (!IsValidReleasePackage(resolvedPackageRoot)) + { + _logger.LogWarning("Paquete de actualizacion descargado invalido."); + return null; + } + + return Path.Combine(resolvedPackageRoot, "api"); + } + private static bool IsGitHubApiEndpoint(Uri endpoint) { return endpoint.IsAbsoluteUri && @@ -251,6 +338,12 @@ private static bool IsGitHubApiEndpoint(Uri endpoint) ?? _configuration["GITHUB_UPDATE_TOKEN"]; } + private string? ResolveConfiguredUpdateSourceRoot() + { + var configured = _configuration["WatchdogSettings:UpdateSourceRoot"]; + return string.IsNullOrWhiteSpace(configured) ? null : configured.Trim(); + } + private string? ResolveConfiguredUpdateTargetPath() { var configured = _configuration["WatchdogSettings:UpdateTargetPath"]; @@ -305,6 +398,9 @@ private sealed class UpdateCheckPayload public string? Message { get; init; } public string? SourcePath { get; init; } public string? TargetPath { get; init; } + public string? AssetDownloadUrl { get; init; } + public string? AssetDigest { get; init; } + public string? AssetSignatureDownloadUrl { get; init; } } private static UpdateCheckPayload? ParseUpdatePayload(string json) @@ -318,12 +414,16 @@ private sealed class UpdateCheckPayload { using var doc = JsonDocument.Parse(json); var root = doc.RootElement; + var asset = TryGetReleaseAsset(root); return new UpdateCheckPayload { Version = TryGetString(root, "version", "version_disponible", "latest_version", "tag_name"), Message = TryGetString(root, "message", "mensaje", "name", "body"), SourcePath = TryGetString(root, "source_path", "sourcePath", "package_path"), - TargetPath = TryGetString(root, "target_path", "targetPath", "install_path") + TargetPath = TryGetString(root, "target_path", "targetPath", "install_path"), + AssetDownloadUrl = asset.DownloadUrl, + AssetDigest = asset.Digest, + AssetSignatureDownloadUrl = asset.SignatureDownloadUrl }; } catch @@ -349,4 +449,287 @@ private sealed class UpdateCheckPayload return null; } + + private static ReleaseAssetRef TryGetReleaseAsset(JsonElement root) + { + var direct = TryGetString(root, "asset_download_url", "assetDownloadUrl", "download_url", "browser_download_url"); + if (!string.IsNullOrWhiteSpace(direct)) + { + return new ReleaseAssetRef( + direct, + TryGetString(root, "asset_digest", "assetDigest", "digest"), + TryGetString(root, "asset_signature_url", "assetSignatureUrl", "signature_download_url", "signatureDownloadUrl")); + } + + if (!root.TryGetProperty("assets", out var assets) || assets.ValueKind != JsonValueKind.Array) + { + return new ReleaseAssetRef(null, null, null); + } + + string? zipName = null; + string? zipDownloadUrl = null; + string? zipDigest = null; + var signatureAssets = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var asset in assets.EnumerateArray()) + { + var name = TryGetString(asset, "name") ?? string.Empty; + var downloadUrl = TryGetString(asset, "browser_download_url"); + if (string.IsNullOrWhiteSpace(downloadUrl)) + { + continue; + } + + if (name.EndsWith(".sig", StringComparison.OrdinalIgnoreCase)) + { + signatureAssets[name] = downloadUrl; + continue; + } + + if (name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) && + name.Contains("AtlasBalance", StringComparison.OrdinalIgnoreCase) && + name.Contains("win-x64", StringComparison.OrdinalIgnoreCase)) + { + zipName = name; + zipDownloadUrl = downloadUrl; + zipDigest = TryGetString(asset, "digest"); + } + } + + var signatureDownloadUrl = zipName is not null && + signatureAssets.TryGetValue($"{zipName}.sig", out var matchingSignature) + ? matchingSignature + : null; + + return new ReleaseAssetRef(zipDownloadUrl, zipDigest, signatureDownloadUrl); + } + + private static bool VerifyAssetDigest(string zipPath, string? expectedDigest) + { + if (string.IsNullOrWhiteSpace(expectedDigest)) + { + return false; + } + + var normalized = expectedDigest.Trim(); + const string prefix = "sha256:"; + if (!normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var expectedHash = normalized[prefix.Length..].Trim(); + if (expectedHash.Length != 64 || expectedHash.Any(ch => !Uri.IsHexDigit(ch))) + { + return false; + } + + using var stream = File.OpenRead(zipPath); + var actualHash = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + return string.Equals(actualHash, expectedHash.ToLowerInvariant(), StringComparison.Ordinal); + } + + private async Task VerifyAssetSignatureAsync(HttpClient http, string zipPath, string? signatureUrl, string? githubToken, CancellationToken cancellationToken) + { + var publicKeyPem = ResolveReleaseSigningPublicKeyPem(); + if (string.IsNullOrWhiteSpace(publicKeyPem) || !IsOfficialReleaseSignatureUrl(signatureUrl)) + { + return false; + } + + byte[] signature; + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, signatureUrl); + request.Headers.UserAgent.ParseAdd($"AtlasBalance/{ResolveCurrentVersion()}"); + if (!string.IsNullOrWhiteSpace(githubToken)) + { + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", githubToken); + } + + using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (!response.IsSuccessStatusCode || response.Content.Headers.ContentLength is > 8192) + { + return false; + } + + var rawSignature = await response.Content.ReadAsByteArrayAsync(cancellationToken); + if (rawSignature.Length is 0 or > 8192) + { + return false; + } + + signature = NormalizeSignatureBytes(rawSignature); + } + catch + { + return false; + } + + try + { + using var rsa = RSA.Create(); + rsa.ImportFromPem(publicKeyPem.AsSpan()); + using var stream = File.OpenRead(zipPath); + return rsa.VerifyData(stream, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + } + catch + { + return false; + } + } + + private string? ResolveReleaseSigningPublicKeyPem() + { + var pem = _configuration["UpdateSecurity:ReleaseSigningPublicKeyPem"] + ?? _configuration["ATLAS_RELEASE_SIGNING_PUBLIC_KEY_PEM"]; + return string.IsNullOrWhiteSpace(pem) + ? null + : pem.Replace("\\n", "\n", StringComparison.Ordinal); + } + + private static byte[] NormalizeSignatureBytes(byte[] rawSignature) + { + var text = System.Text.Encoding.ASCII.GetString(rawSignature).Trim(); + if (text.Length > 0 && text.All(ch => char.IsLetterOrDigit(ch) || ch is '+' or '/' or '=' or '\r' or '\n')) + { + try + { + return Convert.FromBase64String(text); + } + catch + { + return rawSignature; + } + } + + return rawSignature; + } + + private static bool TryExtractPackageSafely(string zipPath, string packageRoot) + { + Directory.CreateDirectory(packageRoot); + var rootFullPath = Path.GetFullPath(packageRoot); + if (!rootFullPath.EndsWith(Path.DirectorySeparatorChar)) + { + rootFullPath += Path.DirectorySeparatorChar; + } + + using var archive = ZipFile.OpenRead(zipPath); + foreach (var entry in archive.Entries) + { + if (string.IsNullOrEmpty(entry.FullName)) + { + continue; + } + + var destinationFullPath = Path.GetFullPath(Path.Combine(packageRoot, entry.FullName)); + var isDirectoryEntry = entry.FullName.EndsWith('/') || entry.FullName.EndsWith('\\'); + + if (!destinationFullPath.StartsWith(rootFullPath, StringComparison.OrdinalIgnoreCase) && + !string.Equals(destinationFullPath + Path.DirectorySeparatorChar, rootFullPath, StringComparison.OrdinalIgnoreCase)) + { + Directory.Delete(packageRoot, recursive: true); + return false; + } + + if (isDirectoryEntry) + { + Directory.CreateDirectory(destinationFullPath); + continue; + } + + var directory = Path.GetDirectoryName(destinationFullPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + entry.ExtractToFile(destinationFullPath, overwrite: true); + } + + return true; + } + + private static void TryDeleteFile(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + // Best-effort cleanup only; the update is already rejected. + } + } + + private static bool IsOfficialReleaseAssetUrl(string? assetUrl) + { + if (!Uri.TryCreate(assetUrl, UriKind.Absolute, out var uri) || + !uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) || + !uri.Host.Equals("github.com", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var expectedPrefix = $"/{ConfigurationDefaults.GitHubOwner}/{ConfigurationDefaults.GitHubRepository}/releases/download/"; + return uri.AbsolutePath.StartsWith(expectedPrefix, StringComparison.OrdinalIgnoreCase) && + uri.AbsolutePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsOfficialReleaseSignatureUrl(string? signatureUrl) + { + if (!Uri.TryCreate(signatureUrl, UriKind.Absolute, out var uri) || + !uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) || + !uri.Host.Equals("github.com", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var expectedPrefix = $"/{ConfigurationDefaults.GitHubOwner}/{ConfigurationDefaults.GitHubRepository}/releases/download/"; + return uri.AbsolutePath.StartsWith(expectedPrefix, StringComparison.OrdinalIgnoreCase) && + uri.AbsolutePath.EndsWith(".zip.sig", StringComparison.OrdinalIgnoreCase); + } + + private static string ToSafePathSegment(string value) + { + var cleaned = value.Trim(); + foreach (var invalid in Path.GetInvalidFileNameChars()) + { + cleaned = cleaned.Replace(invalid, '-'); + } + + return string.IsNullOrWhiteSpace(cleaned) ? "latest" : cleaned; + } + + private static string ResolveExtractedPackageRoot(string extractionRoot) + { + if (IsValidReleasePackage(extractionRoot)) + { + return extractionRoot; + } + + var children = Directory.GetDirectories(extractionRoot); + return children.Length == 1 ? children[0] : extractionRoot; + } + + private static bool IsValidReleasePackage(string packageRoot) + { + return File.Exists(Path.Combine(packageRoot, "VERSION")) && + File.Exists(Path.Combine(packageRoot, "api", "GestionCaja.API.exe")) && + File.Exists(Path.Combine(packageRoot, "watchdog", "GestionCaja.Watchdog.exe")); + } + + private static void EnsurePathWithinRoot(string path, string root) + { + if (!IsAllowedSourcePath(path, root)) + { + throw new InvalidOperationException("Ruta de actualizacion fuera de UpdateSourceRoot."); + } + } + + private readonly record struct ReleaseAssetRef(string? DownloadUrl, string? Digest, string? SignatureDownloadUrl); } diff --git a/Atlas Balance/backend/src/GestionCaja.API/Services/AuthService.cs b/Atlas Balance/backend/src/GestionCaja.API/Services/AuthService.cs index 1ae8087..43ef63a 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Services/AuthService.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Services/AuthService.cs @@ -17,7 +17,8 @@ namespace GestionCaja.API.Services; public interface IAuthService { - Task LoginAsync(string email, string password, string? ipAddress, CancellationToken cancellationToken); + Task LoginAsync(string email, string password, string? ipAddress, CancellationToken cancellationToken, string? trustedMfaToken = null); + Task VerifyMfaAsync(string challengeId, string code, string? ipAddress, CancellationToken cancellationToken); Task RefreshTokenAsync(string refreshToken, string? ipAddress, CancellationToken cancellationToken); Task LogoutAsync(string? refreshToken, CancellationToken cancellationToken); Task GetCurrentAsync(Guid userId, CancellationToken cancellationToken); @@ -26,27 +27,42 @@ public interface IAuthService public sealed class AuthService : IAuthService { - private const int MaxFailedLoginAttempts = 20; + private const int MaxFailedLoginAttempts = 5; private const int MaxLoginFailuresPerClientAndEmail = 5; + private const int MaxMfaFailuresPerChallenge = 5; + private const int MaxMfaFailuresPerUser = 5; + private const string MfaIssuer = "Atlas Balance"; + private const string MfaRememberTokenVersion = "v1"; private static readonly object LoginRateLimitLock = new(); + private static readonly object MfaRateLimitLock = new(); private static readonly TimeSpan LockDuration = TimeSpan.FromMinutes(30); private static readonly TimeSpan LoginFailureWindow = TimeSpan.FromMinutes(15); + private static readonly TimeSpan MfaChallengeDuration = TimeSpan.FromMinutes(5); + private static readonly TimeSpan MfaFailureWindow = TimeSpan.FromMinutes(15); + private static readonly TimeSpan MfaRememberDuration = TimeSpan.FromDays(90); private static readonly IMemoryCache FallbackMemoryCache = new MemoryCache(new MemoryCacheOptions()); private readonly AppDbContext _dbContext; private readonly IConfiguration _configuration; private readonly IAuditService _auditService; private readonly IMemoryCache _cache; - - public AuthService(AppDbContext dbContext, IConfiguration configuration, IAuditService auditService, IMemoryCache? cache = null) + private readonly ISecretProtector _secretProtector; + + public AuthService( + AppDbContext dbContext, + IConfiguration configuration, + IAuditService auditService, + IMemoryCache? cache = null, + ISecretProtector? secretProtector = null) { _dbContext = dbContext; _configuration = configuration; _auditService = auditService; _cache = cache ?? FallbackMemoryCache; + _secretProtector = secretProtector ?? PassthroughSecretProtector.Instance; } - public async Task LoginAsync(string email, string password, string? ipAddress, CancellationToken cancellationToken) + public async Task LoginAsync(string email, string password, string? ipAddress, CancellationToken cancellationToken, string? trustedMfaToken = null) { if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(password)) { @@ -112,19 +128,6 @@ await _auditService.LogAsync( if (!BCrypt.Net.BCrypt.Verify(password, usuario.PasswordHash)) { var throttled = RecordLoginFailure(normalizedEmail, ipAddress); - if (throttled) - { - await _auditService.LogAsync( - usuario.Id, - AuditActions.LoginFailed, - "USUARIOS", - usuario.Id, - ipAddress, - JsonSerializer.Serialize(new { email = normalizedEmail, motivo = "rate_limited" }), - cancellationToken); - throw new AuthException("Demasiados intentos. Espera unos minutos.", StatusCodes.Status429TooManyRequests); - } - usuario.FailedLoginAttempts += 1; var lockTriggered = false; if (usuario.FailedLoginAttempts >= MaxFailedLoginAttempts) @@ -168,15 +171,50 @@ await _auditService.LogAsync( throw new AuthException("Credenciales inválidas", StatusCodes.Status401Unauthorized); } + if (throttled) + { + throw new AuthException("Demasiados intentos. Espera unos minutos.", StatusCodes.Status429TooManyRequests); + } + throw new AuthException("Credenciales inválidas", StatusCodes.Status401Unauthorized); } usuario.FailedLoginAttempts = 0; usuario.LockedUntil = null; - usuario.FechaUltimaLogin = now; UserSessionState.EnsureSecurityStamp(usuario); ClearLoginFailures(normalizedEmail, ipAddress); + if (RequiresMfa(usuario) && !IsTrustedMfaTokenValid(usuario, trustedMfaToken, now)) + { + var challenge = CreateMfaChallenge(usuario, ipAddress); + await _dbContext.SaveChangesAsync(cancellationToken); + await _auditService.LogAsync( + usuario.Id, + AuditActions.LoginMfaRequired, + "USUARIOS", + usuario.Id, + ipAddress, + JsonSerializer.Serialize(new + { + email = normalizedEmail, + setup_required = challenge.SetupRequired + }), + cancellationToken); + + return new AuthResult + { + MfaRequired = true, + MfaSetupRequired = challenge.SetupRequired, + MfaChallengeId = challenge.ChallengeId, + MfaSecret = challenge.SetupRequired ? challenge.Secret : null, + MfaOtpAuthUri = challenge.SetupRequired + ? TotpService.BuildOtpAuthUri(MfaIssuer, usuario.Email, challenge.Secret) + : null + }; + } + + usuario.FechaUltimaLogin = now; + ClearMfaFailures(usuario.Id); var tokens = await IssueTokensAsync(usuario, ipAddress, cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken); await _auditService.LogAsync( @@ -191,6 +229,141 @@ await _auditService.LogAsync( return await BuildAuthResultAsync(usuario, tokens.AccessToken, tokens.RefreshToken, cancellationToken); } + public async Task VerifyMfaAsync(string challengeId, string code, string? ipAddress, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(challengeId) || string.IsNullOrWhiteSpace(code)) + { + throw new AuthException("Codigo MFA invalido", StatusCodes.Status401Unauthorized); + } + + if (!_cache.TryGetValue(BuildMfaChallengeCacheKey(challengeId), out var challenge) || + challenge is null) + { + throw new AuthException("Codigo MFA invalido o expirado", StatusCodes.Status401Unauthorized); + } + + if (!string.IsNullOrWhiteSpace(challenge.IpAddress) && + !string.IsNullOrWhiteSpace(ipAddress) && + !string.Equals(challenge.IpAddress, ipAddress, StringComparison.Ordinal)) + { + RemoveMfaChallenge(challengeId); + throw new AuthException("Codigo MFA invalido o expirado", StatusCodes.Status401Unauthorized); + } + + var usuario = await _dbContext.Usuarios + .FirstOrDefaultAsync(u => u.Id == challenge.UserId && u.Activo, cancellationToken); + if (usuario is null) + { + RemoveMfaChallenge(challengeId); + throw new AuthException("Usuario no valido", StatusCodes.Status401Unauthorized); + } + + var now = DateTime.UtcNow; + if (usuario.LockedUntil.HasValue && usuario.LockedUntil.Value > now) + { + RemoveMfaChallenge(challengeId); + throw new AuthException("Codigo MFA invalido o expirado", StatusCodes.Status401Unauthorized); + } + + var secret = challenge.Secret; + if (!TotpService.TryValidateCode(secret, code, DateTime.UtcNow, out var matchedStep) || + (usuario.MfaLastAcceptedStep.HasValue && matchedStep <= usuario.MfaLastAcceptedStep.Value)) + { + var userMfaFailures = RecordMfaFailure(usuario.Id); + challenge = challenge with { FailedAttempts = challenge.FailedAttempts + 1 }; + var lockTriggered = userMfaFailures >= MaxMfaFailuresPerUser; + if (lockTriggered) + { + usuario.FailedLoginAttempts = MaxFailedLoginAttempts; + usuario.LockedUntil = now.Add(LockDuration); + await _dbContext.SaveChangesAsync(cancellationToken); + } + + if (lockTriggered || challenge.FailedAttempts >= MaxMfaFailuresPerChallenge) + { + RemoveMfaChallenge(challengeId); + } + else + { + StoreMfaChallenge(challenge); + } + + await _auditService.LogAsync( + usuario.Id, + AuditActions.LoginFailed, + "USUARIOS", + usuario.Id, + ipAddress, + JsonSerializer.Serialize(new { email = usuario.Email, motivo = "mfa_invalido" }), + cancellationToken); + + if (lockTriggered) + { + await _auditService.LogAsync( + usuario.Id, + AuditActions.AccountLocked, + "USUARIOS", + usuario.Id, + ipAddress, + JsonSerializer.Serialize(new + { + email = usuario.Email, + motivo = "mfa_invalido", + locked_until = usuario.LockedUntil + }), + cancellationToken); + } + + throw new AuthException("Codigo MFA invalido", StatusCodes.Status401Unauthorized); + } + + if (challenge.SetupRequired) + { + usuario.MfaSecret = _secretProtector.ProtectForStorage(secret); + usuario.MfaEnabled = true; + usuario.MfaEnabledAt = now; + await _auditService.LogAsync( + usuario.Id, + AuditActions.MfaEnabled, + "USUARIOS", + usuario.Id, + ipAddress, + JsonSerializer.Serialize(new { email = usuario.Email }), + cancellationToken); + } + + usuario.MfaLastAcceptedStep = matchedStep; + usuario.FailedLoginAttempts = 0; + usuario.LockedUntil = null; + usuario.FechaUltimaLogin = now; + UserSessionState.EnsureSecurityStamp(usuario); + ClearMfaFailures(usuario.Id); + + var tokens = await IssueTokensAsync(usuario, ipAddress, cancellationToken); + await _auditService.LogAsync( + usuario.Id, + AuditActions.MfaVerified, + "USUARIOS", + usuario.Id, + ipAddress, + JsonSerializer.Serialize(new { email = usuario.Email }), + cancellationToken); + await _auditService.LogAsync( + usuario.Id, + AuditActions.Login, + "USUARIOS", + usuario.Id, + ipAddress, + JsonSerializer.Serialize(new { email = usuario.Email }), + cancellationToken); + + RemoveMfaChallenge(challengeId); + var result = await BuildAuthResultAsync(usuario, tokens.AccessToken, tokens.RefreshToken, cancellationToken); + result.TrustedMfaTokenExpiresAt = now.Add(MfaRememberDuration); + result.TrustedMfaToken = GenerateTrustedMfaToken(usuario, result.TrustedMfaTokenExpiresAt.Value); + return result; + } + public async Task RefreshTokenAsync(string refreshToken, string? ipAddress, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(refreshToken)) @@ -414,6 +587,7 @@ private async Task BuildAuthResultAsync(Usuario usuario, string? acc Rol = usuario.Rol.ToString(), Activo = usuario.Activo, PrimerLogin = usuario.PrimerLogin, + MfaEnabled = usuario.MfaEnabled, FechaCreacion = usuario.FechaCreacion, FechaUltimaLogin = usuario.FechaUltimaLogin }, @@ -440,6 +614,88 @@ private async Task BuildAuthResultAsync(Usuario usuario, string? acc return (accessToken, refreshToken); } + private bool RequiresMfa(Usuario usuario) + { + return usuario.Activo && + _configuration.GetValue("Security:RequireMfaForWebUsers", true); + } + + private MfaChallengeState CreateMfaChallenge(Usuario usuario, string? ipAddress) + { + var setupRequired = !usuario.MfaEnabled || string.IsNullOrWhiteSpace(usuario.MfaSecret); + var secret = setupRequired + ? TotpService.GenerateSecret() + : _secretProtector.UnprotectFromStorage(usuario.MfaSecret) ?? string.Empty; + + if (string.IsNullOrWhiteSpace(secret)) + { + setupRequired = true; + secret = TotpService.GenerateSecret(); + } + + var challenge = new MfaChallengeState( + ChallengeId: GenerateChallengeId(), + UserId: usuario.Id, + Secret: secret, + SetupRequired: setupRequired, + IpAddress: ipAddress, + FailedAttempts: 0); + + StoreMfaChallenge(challenge); + return challenge; + } + + private void StoreMfaChallenge(MfaChallengeState challenge) + { + _cache.Set( + BuildMfaChallengeCacheKey(challenge.ChallengeId), + challenge, + new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = MfaChallengeDuration + }); + } + + private void RemoveMfaChallenge(string challengeId) + { + _cache.Remove(BuildMfaChallengeCacheKey(challengeId)); + } + + private static string BuildMfaChallengeCacheKey(string challengeId) + { + return $"auth:mfa-challenge:{challengeId}"; + } + + private int RecordMfaFailure(Guid userId) + { + var key = BuildMfaFailureCacheKey(userId); + lock (MfaRateLimitLock) + { + var count = _cache.Get(key) + 1; + _cache.Set(key, count, MfaFailureWindow); + return count; + } + } + + private void ClearMfaFailures(Guid userId) + { + _cache.Remove(BuildMfaFailureCacheKey(userId)); + } + + private static string BuildMfaFailureCacheKey(Guid userId) + { + return $"auth:mfa-failures:{userId:N}"; + } + + private static string GenerateChallengeId() + { + var bytes = RandomNumberGenerator.GetBytes(32); + return Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + private string GenerateAccessToken(Usuario usuario) { UserSessionState.EnsureSecurityStamp(usuario); @@ -577,6 +833,81 @@ private async Task AcquireRefreshTokenLockAsync(string refreshHash, Cancellation private int GetRefreshTokenExpDays() => _configuration.GetValue("JwtSettings:RefreshTokenExpDays", 7); + private bool IsTrustedMfaTokenValid(Usuario usuario, string? token, DateTime now) + { + if (string.IsNullOrWhiteSpace(token) || !usuario.MfaEnabled || string.IsNullOrWhiteSpace(usuario.MfaSecret)) + { + return false; + } + + var parts = token.Split('.', 2); + if (parts.Length != 2 || + string.IsNullOrWhiteSpace(parts[0]) || + string.IsNullOrWhiteSpace(parts[1]) || + !FixedTimeEquals(parts[1], ComputeMfaRememberSignature(parts[0]))) + { + return false; + } + + MfaRememberPayload? payload; + try + { + payload = JsonSerializer.Deserialize(Encoding.UTF8.GetString(Base64UrlDecode(parts[0]))); + } + catch + { + return false; + } + + return payload is not null && + payload.Version == MfaRememberTokenVersion && + payload.UserId == usuario.Id && + payload.SecurityStamp == usuario.SecurityStamp && + payload.ExpiresAtUnix > new DateTimeOffset(now).ToUnixTimeSeconds(); + } + + private string GenerateTrustedMfaToken(Usuario usuario, DateTime expiresAtUtc) + { + var payload = new MfaRememberPayload( + MfaRememberTokenVersion, + usuario.Id, + usuario.SecurityStamp, + new DateTimeOffset(expiresAtUtc).ToUnixTimeSeconds()); + var payloadBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload))); + return $"{payloadBase64}.{ComputeMfaRememberSignature(payloadBase64)}"; + } + + private string ComputeMfaRememberSignature(string payloadBase64) + { + var jwtSecret = _configuration["JwtSettings:Secret"] + ?? throw new InvalidOperationException("JwtSettings:Secret is required"); + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(jwtSecret)); + return Base64UrlEncode(hmac.ComputeHash(Encoding.UTF8.GetBytes(payloadBase64))); + } + + private static string Base64UrlEncode(byte[] bytes) + { + return Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + private static byte[] Base64UrlDecode(string value) + { + var padded = value.Replace('-', '+').Replace('_', '/'); + padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '='); + return Convert.FromBase64String(padded); + } + + private static bool FixedTimeEquals(string left, string right) + { + var leftBytes = Encoding.ASCII.GetBytes(left); + var rightBytes = Encoding.ASCII.GetBytes(right); + return leftBytes.Length == rightBytes.Length && + CryptographicOperations.FixedTimeEquals(leftBytes, rightBytes); + } + private static IReadOnlyList? ParseJsonArray(string? rawJson) { if (string.IsNullOrWhiteSpace(rawJson)) @@ -611,6 +942,13 @@ public sealed class AuthResult public string? RefreshToken { get; set; } public AuthUsuarioResponse Usuario { get; set; } = new(); public IReadOnlyList Permisos { get; set; } = []; + public bool MfaRequired { get; set; } + public bool MfaSetupRequired { get; set; } + public string? MfaChallengeId { get; set; } + public string? MfaSecret { get; set; } + public string? MfaOtpAuthUri { get; set; } + public string? TrustedMfaToken { get; set; } + public DateTime? TrustedMfaTokenExpiresAt { get; set; } } public sealed class AuthException : Exception @@ -622,3 +960,30 @@ public AuthException(string message, int statusCode) : base(message) StatusCode = statusCode; } } + +internal sealed record MfaChallengeState( + string ChallengeId, + Guid UserId, + string Secret, + bool SetupRequired, + string? IpAddress, + int FailedAttempts); + +internal sealed record MfaRememberPayload( + string Version, + Guid UserId, + string SecurityStamp, + long ExpiresAtUnix); + +internal sealed class PassthroughSecretProtector : ISecretProtector +{ + public static readonly PassthroughSecretProtector Instance = new(); + + private PassthroughSecretProtector() + { + } + + public string ProtectForStorage(string? value) => value?.Trim() ?? string.Empty; + public string? UnprotectFromStorage(string? storedValue) => storedValue; + public bool IsProtected(string? storedValue) => false; +} diff --git a/Atlas Balance/backend/src/GestionCaja.API/Services/DashboardService.cs b/Atlas Balance/backend/src/GestionCaja.API/Services/DashboardService.cs index ce72186..15350e3 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Services/DashboardService.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Services/DashboardService.cs @@ -569,7 +569,16 @@ private async Task GetAuthorizedScopeAsync(Guid userId, Cancella var permisos = await _dbContext.PermisosUsuario .AsNoTracking() .Where(x => x.UsuarioId == userId && x.PuedeVerDashboard) - .Select(x => new { x.CuentaId, x.TitularId }) + .Select(x => new + { + x.CuentaId, + x.TitularId, + x.PuedeVerCuentas, + x.PuedeAgregarLineas, + x.PuedeEditarLineas, + x.PuedeEliminarLineas, + x.PuedeImportar + }) .ToListAsync(cancellationToken); if (permisos.Count == 0) @@ -577,14 +586,18 @@ private async Task GetAuthorizedScopeAsync(Guid userId, Cancella throw new DashboardAccessException("No tienes permisos para ver dashboards", StatusCodes.Status403Forbidden); } - var globalAccess = permisos.Any(x => x.CuentaId == null && x.TitularId == null); + var globalAccess = permisos.Any(x => + x.CuentaId == null && + x.TitularId == null && + GrantsAccountDataAccess(x.PuedeVerCuentas, x.PuedeAgregarLineas, x.PuedeEditarLineas, x.PuedeEliminarLineas, x.PuedeImportar)); if (globalAccess) { return DashboardScope.GlobalForManager(); } - var titularIds = permisos.Where(x => x.TitularId.HasValue).Select(x => x.TitularId!.Value).ToHashSet(); - var cuentaIds = permisos.Where(x => x.CuentaId.HasValue).Select(x => x.CuentaId!.Value).ToHashSet(); + var scopedPermisos = permisos.Where(x => x.CuentaId.HasValue || x.TitularId.HasValue).ToList(); + var titularIds = scopedPermisos.Where(x => x.TitularId.HasValue).Select(x => x.TitularId!.Value).ToHashSet(); + var cuentaIds = scopedPermisos.Where(x => x.CuentaId.HasValue).Select(x => x.CuentaId!.Value).ToHashSet(); if (titularIds.Count == 0 && cuentaIds.Count == 0) { @@ -594,6 +607,14 @@ private async Task GetAuthorizedScopeAsync(Guid userId, Cancella return new DashboardScope(false, titularIds, cuentaIds); } + private static bool GrantsAccountDataAccess( + bool puedeVerCuentas, + bool puedeAgregarLineas, + bool puedeEditarLineas, + bool puedeEliminarLineas, + bool puedeImportar) => + puedeVerCuentas || puedeAgregarLineas || puedeEditarLineas || puedeEliminarLineas || puedeImportar; + private async Task CanAccessTitularAsync(DashboardScope scope, Guid titularId, CancellationToken cancellationToken) { if (scope.GlobalAccess) diff --git a/Atlas Balance/backend/src/GestionCaja.API/Services/ImportacionService.cs b/Atlas Balance/backend/src/GestionCaja.API/Services/ImportacionService.cs index 0413faa..677a61e 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Services/ImportacionService.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Services/ImportacionService.cs @@ -22,6 +22,8 @@ public sealed class ImportacionService : IImportacionService { private const int MaxRawDataLength = 5 * 1024 * 1024; private const int MaxRows = 50_000; + private const int MaxExtraColumns = 64; + private const int MaxExtraColumnNameLength = 80; private static readonly string[] DateFormats = [ @@ -130,6 +132,7 @@ public async Task ValidarAsync(Guid usuarioId, strin var normalizedMap = NormalizeMap(request.Mapeo); var (rows, separator) = ParseRows(request.RawData, request.Separador); + EnsureExtraColumnIndexesExist(rows, normalizedMap); var validationRows = ValidateRows(rows, normalizedMap); return new ImportacionValidarResponse @@ -155,6 +158,7 @@ public async Task ConfirmarAsync(Guid usuarioId, s EnsureNotPlazoFijoForFormattedImport(cuenta); var normalizedMap = NormalizeMap(request.Mapeo); var (rows, separator) = ParseRows(request.RawData, request.Separador); + EnsureExtraColumnIndexesExist(rows, normalizedMap); var validationRows = ValidateRows(rows, normalizedMap); var allowedRowSet = request.FilasAImportar?.ToHashSet() ?? validationRows.Where(r => r.Valida).Select(r => r.Indice).ToHashSet(); @@ -198,31 +202,22 @@ public async Task ConfirmarAsync(Guid usuarioId, s .Select(e => (int?)e.FilaNumero) .MaxAsync(cancellationToken) ?? 0; - var selectedValidRowsOrdered = selectedValidRows - .Select(row => - { - var fecha = ParseDate(row.Datos["fecha"], out _, out var parsedDate) - ? parsedDate - : throw new InvalidOperationException("Fila validada sin fecha parseable."); - - return new - { - Row = row, - Fecha = fecha - }; - }) - .OrderBy(item => item.Fecha) - .ThenBy(item => item.Row.Indice) + // Number from bottom to top so the upper line in the pasted statement remains the latest/highest row. + var selectedValidRowsForFilaNumbering = selectedValidRows + .OrderByDescending(row => row.Indice) .ToList(); - var extractos = new List(selectedValidRowsOrdered.Count); - var extras = new List(selectedValidRowsOrdered.Count * Math.Max(1, normalizedMap.ColumnasExtra.Count)); + var extractos = new List(selectedValidRowsForFilaNumbering.Count); + var extras = new List(selectedValidRowsForFilaNumbering.Count * Math.Max(1, normalizedMap.ColumnasExtra.Count)); - foreach (var item in selectedValidRowsOrdered) + foreach (var row in selectedValidRowsForFilaNumbering) { - var row = item.Row; maxFila += 1; + var fecha = ParseDate(row.Datos["fecha"], out _, out var parsedDate) + ? parsedDate + : throw new InvalidOperationException("Fila validada sin fecha parseable."); + var monto = TryParseDecimalSmart(row.Datos["monto"], out var parsedMonto) ? parsedMonto : throw new InvalidOperationException("Fila validada sin monto parseable."); @@ -235,7 +230,7 @@ public async Task ConfirmarAsync(Guid usuarioId, s { Id = Guid.NewGuid(), CuentaId = cuenta.Id, - Fecha = item.Fecha, + Fecha = fecha, Concepto = string.IsNullOrWhiteSpace(row.Datos["concepto"]) ? null : row.Datos["concepto"]!.Trim(), Monto = monto, Saldo = saldo, @@ -247,13 +242,18 @@ public async Task ConfirmarAsync(Guid usuarioId, s foreach (var pair in row.Datos.Where(d => d.Key.StartsWith("extra:", StringComparison.OrdinalIgnoreCase))) { + if (string.IsNullOrWhiteSpace(pair.Value)) + { + continue; + } + var columnName = pair.Key["extra:".Length..]; extras.Add(new ExtractoColumnaExtra { Id = Guid.NewGuid(), ExtractoId = extracto.Id, NombreColumna = columnName, - Valor = string.IsNullOrWhiteSpace(pair.Value) ? null : pair.Value!.Trim() + Valor = pair.Value!.Trim() }); } } @@ -279,7 +279,7 @@ public async Task ConfirmarAsync(Guid usuarioId, s filas_procesadas = validationRows.Count, filas_importadas = extractos.Count, filas_con_error = validationRows.Count(r => !r.Valida), - primeras_filas = selectedValidRowsOrdered.Take(5).Select(item => item.Row.Indice).ToArray() + primeras_filas = selectedValidRows.OrderBy(row => row.Indice).Take(5).Select(row => row.Indice).ToArray() }); await _auditService.LogAsync(usuarioId, "importacion_confirmada", "EXTRACTOS", cuenta.Id, httpContext, auditDetails, cancellationToken); @@ -534,6 +534,11 @@ private static MapeoColumnasRequest NormalizeMap(MapeoColumnasRequest? map) }; var usedIndices = new Dictionary(); + if (normalized.ColumnasExtra.Count > MaxExtraColumns) + { + throw new ImportacionException($"El mapeo no puede incluir mas de {MaxExtraColumns} columnas extra", StatusCodes.Status400BadRequest); + } + foreach (var (fieldName, index) in baseFields) { ValidateColumnIndex(index, fieldName); @@ -551,6 +556,11 @@ private static MapeoColumnasRequest NormalizeMap(MapeoColumnasRequest? map) throw new ImportacionException("El nombre de columna extra es obligatorio", StatusCodes.Status400BadRequest); } + if (extra.Nombre.Length > MaxExtraColumnNameLength) + { + throw new ImportacionException($"El nombre de columna extra no puede superar {MaxExtraColumnNameLength} caracteres", StatusCodes.Status400BadRequest); + } + ValidateColumnIndex(extra.Indice, $"extra:{extra.Nombre}"); if (!usedIndices.TryAdd(extra.Indice, $"extra:{extra.Nombre}")) { @@ -566,6 +576,23 @@ private static MapeoColumnasRequest NormalizeMap(MapeoColumnasRequest? map) return normalized; } + private static void EnsureExtraColumnIndexesExist(IReadOnlyList rows, MapeoColumnasRequest map) + { + if (map.ColumnasExtra.Count == 0) + { + return; + } + + var maxColumnCount = rows.Count == 0 ? 0 : rows.Max(row => row.Length); + foreach (var extra in map.ColumnasExtra) + { + if (extra.Indice >= maxColumnCount) + { + throw new ImportacionException($"La columna extra '{extra.Nombre}' no existe en los datos importados", StatusCodes.Status400BadRequest); + } + } + } + private static string NormalizeTipoMonto(string? raw) { if (string.IsNullOrWhiteSpace(raw)) @@ -733,7 +760,6 @@ private static List ValidateRows(IReadOnlyList allowIncompleteConceptRow = hasConcept && string.IsNullOrWhiteSpace(data["fecha"]) && - string.IsNullOrWhiteSpace(data["saldo"]) && IsBlankAmountRow(data["ingreso"], data["egreso"]); if (allowIncompleteConceptRow) @@ -769,7 +795,6 @@ private static List ValidateRows(IReadOnlyList allowIncompleteConceptRow = hasConcept && string.IsNullOrWhiteSpace(data["fecha"]) && - string.IsNullOrWhiteSpace(data["saldo"]) && string.IsNullOrWhiteSpace(data["monto"]); if (allowIncompleteConceptRow) diff --git a/Atlas Balance/backend/src/GestionCaja.API/Services/TotpService.cs b/Atlas Balance/backend/src/GestionCaja.API/Services/TotpService.cs new file mode 100644 index 0000000..6b352ed --- /dev/null +++ b/Atlas Balance/backend/src/GestionCaja.API/Services/TotpService.cs @@ -0,0 +1,167 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; + +namespace GestionCaja.API.Services; + +public static class TotpService +{ + private const int SecretBytes = 20; + private const int Digits = 6; + private const int PeriodSeconds = 30; + private const string Base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + + public static string GenerateSecret() + { + return Base32Encode(RandomNumberGenerator.GetBytes(SecretBytes)); + } + + public static string BuildOtpAuthUri(string issuer, string account, string secret) + { + var escapedIssuer = Uri.EscapeDataString(issuer); + var escapedAccount = Uri.EscapeDataString(account); + return $"otpauth://totp/{escapedIssuer}:{escapedAccount}?secret={secret}&issuer={escapedIssuer}&algorithm=SHA1&digits={Digits}&period={PeriodSeconds}"; + } + + public static bool TryValidateCode(string secret, string code, DateTime utcNow, out long matchedStep) + { + matchedStep = 0; + var normalizedCode = NormalizeCode(code); + if (normalizedCode.Length != Digits || normalizedCode.Any(ch => ch < '0' || ch > '9')) + { + return false; + } + + byte[] secretBytes; + try + { + secretBytes = Base32Decode(secret); + } + catch (FormatException) + { + return false; + } + + var currentStep = ToTimeStep(utcNow); + for (var offset = -1; offset <= 1; offset++) + { + var step = currentStep + offset; + if (step < 0) + { + continue; + } + + var expected = GenerateCode(secretBytes, step); + if (FixedTimeEquals(expected, normalizedCode)) + { + matchedStep = step; + return true; + } + } + + return false; + } + + public static string GenerateCode(string secret, DateTime utcNow) + { + return GenerateCode(Base32Decode(secret), ToTimeStep(utcNow)); + } + + private static long ToTimeStep(DateTime utcNow) + { + return new DateTimeOffset(utcNow.ToUniversalTime()).ToUnixTimeSeconds() / PeriodSeconds; + } + + private static string GenerateCode(byte[] secret, long step) + { + var counter = IPAddress.HostToNetworkOrder(step); + var counterBytes = BitConverter.GetBytes(counter); + using var hmac = new HMACSHA1(secret); + var hash = hmac.ComputeHash(counterBytes); + var offset = hash[^1] & 0x0F; + var binary = + ((hash[offset] & 0x7F) << 24) | + ((hash[offset + 1] & 0xFF) << 16) | + ((hash[offset + 2] & 0xFF) << 8) | + (hash[offset + 3] & 0xFF); + var otp = binary % 1_000_000; + return otp.ToString("D6"); + } + + private static string NormalizeCode(string code) + { + return new string(code.Where(char.IsDigit).ToArray()); + } + + private static string Base32Encode(byte[] data) + { + var output = new StringBuilder(); + var buffer = 0; + var bitsLeft = 0; + + foreach (var value in data) + { + buffer = (buffer << 8) | value; + bitsLeft += 8; + + while (bitsLeft >= 5) + { + output.Append(Base32Alphabet[(buffer >> (bitsLeft - 5)) & 31]); + bitsLeft -= 5; + } + } + + if (bitsLeft > 0) + { + output.Append(Base32Alphabet[(buffer << (5 - bitsLeft)) & 31]); + } + + return output.ToString(); + } + + private static byte[] Base32Decode(string secret) + { + var normalized = secret + .Replace(" ", string.Empty, StringComparison.Ordinal) + .Replace("-", string.Empty, StringComparison.Ordinal) + .TrimEnd('=') + .ToUpperInvariant(); + + if (string.IsNullOrWhiteSpace(normalized)) + { + throw new FormatException("Empty TOTP secret."); + } + + var bytes = new List(); + var buffer = 0; + var bitsLeft = 0; + + foreach (var ch in normalized) + { + var value = Base32Alphabet.IndexOf(ch, StringComparison.Ordinal); + if (value < 0) + { + throw new FormatException("Invalid TOTP secret."); + } + + buffer = (buffer << 5) | value; + bitsLeft += 5; + + if (bitsLeft >= 8) + { + bytes.Add((byte)((buffer >> (bitsLeft - 8)) & 0xFF)); + bitsLeft -= 8; + } + } + + return bytes.ToArray(); + } + + private static bool FixedTimeEquals(string left, string right) + { + var leftBytes = Encoding.ASCII.GetBytes(left); + var rightBytes = Encoding.ASCII.GetBytes(right); + return leftBytes.Length == rightBytes.Length && + CryptographicOperations.FixedTimeEquals(leftBytes, rightBytes); + } +} diff --git a/Atlas Balance/backend/src/GestionCaja.API/Services/UserAccessService.cs b/Atlas Balance/backend/src/GestionCaja.API/Services/UserAccessService.cs index c15465d..f8dd1c2 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/Services/UserAccessService.cs +++ b/Atlas Balance/backend/src/GestionCaja.API/Services/UserAccessService.cs @@ -71,19 +71,23 @@ public async Task GetScopeAsync(ClaimsPrincipal user, Cancellat }) .ToListAsync(cancellationToken); - var titularIds = permisos + var dataPermissions = permisos + .Where(p => GrantsAccountAccess(p.PuedeVerCuentas, p.PuedeAgregarLineas, p.PuedeEditarLineas, p.PuedeEliminarLineas, p.PuedeImportar)) + .ToList(); + + var titularIds = dataPermissions .Where(p => p.TitularId.HasValue) .Select(p => p.TitularId!.Value) .Distinct() .ToList(); - var cuentaIds = permisos + var cuentaIds = dataPermissions .Where(p => p.CuentaId.HasValue) .Select(p => p.CuentaId!.Value) .Distinct() .ToList(); - var hasGlobalAccess = permisos.Any(p => + var hasGlobalAccess = dataPermissions.Any(p => p.TitularId is null && p.CuentaId is null && GrantsAccountAccess(p.PuedeVerCuentas, p.PuedeAgregarLineas, p.PuedeEditarLineas, p.PuedeEliminarLineas, p.PuedeImportar)); diff --git a/Atlas Balance/backend/src/GestionCaja.API/appsettings.Development.json.template b/Atlas Balance/backend/src/GestionCaja.API/appsettings.Development.json.template index 7000140..1552f11 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/appsettings.Development.json.template +++ b/Atlas Balance/backend/src/GestionCaja.API/appsettings.Development.json.template @@ -1,6 +1,7 @@ { "ConnectionStrings": { - "DefaultConnection": "Host=localhost;Port=5433;Database=atlas_balance;Username=app_user;Password=" + "DefaultConnection": "Host=localhost;Port=5433;Database=atlas_balance;Username=app_user;Password=", + "MigrationConnection": "Host=localhost;Port=5433;Database=atlas_balance;Username=atlas_owner;Password=" }, "JwtSettings": { "Secret": "", @@ -11,6 +12,9 @@ "Email": "admin@atlasbalance.local", "Password": "" }, + "Security": { + "RequireMfaForWebUsers": true + }, "WatchdogSettings": { "BaseUrl": "http://localhost:5001", "SharedSecret": "", @@ -23,6 +27,9 @@ "GitHubSettings": { "UpdateToken": "" }, + "UpdateSecurity": { + "ReleaseSigningPublicKeyPem": "" + }, "Kestrel": { "Endpoints": { "Https": { diff --git a/Atlas Balance/backend/src/GestionCaja.API/appsettings.Production.json.template b/Atlas Balance/backend/src/GestionCaja.API/appsettings.Production.json.template index 3a57102..567a191 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/appsettings.Production.json.template +++ b/Atlas Balance/backend/src/GestionCaja.API/appsettings.Production.json.template @@ -1,6 +1,7 @@ { "ConnectionStrings": { - "DefaultConnection": "Host=localhost;Port=5432;Database=atlas_balance;Username=atlas_balance_app;Password=CAMBIAR_PASSWORD_AQUI" + "DefaultConnection": "Host=localhost;Port=5432;Database=atlas_balance;Username=atlas_balance_app;Password=CAMBIAR_PASSWORD_APP_AQUI", + "MigrationConnection": "Host=localhost;Port=5432;Database=atlas_balance;Username=atlas_balance_owner;Password=CAMBIAR_PASSWORD_OWNER_AQUI" }, "JwtSettings": { "Secret": "GENERAR-CLAVE-SECRETA-MINIMO-32-CARACTERES-AQUI", @@ -11,6 +12,9 @@ "Email": "admin@atlasbalance.local", "Password": "CAMBIAR_PASSWORD_ADMIN_INICIAL_AQUI" }, + "Security": { + "RequireMfaForWebUsers": true + }, "WatchdogSettings": { "BaseUrl": "http://localhost:5001", "SharedSecret": "GENERAR-SHARED-SECRET-MINIMO-32-CARACTERES-AQUI", @@ -23,6 +27,9 @@ "GitHubSettings": { "UpdateToken": "PEGAR_TOKEN_FINE_GRAINED_READ_ONLY_O_USAR_VARIABLE_GITHUB_UPDATE_TOKEN" }, + "UpdateSecurity": { + "ReleaseSigningPublicKeyPem": "PEGAR_PUBLIC_KEY_PEM_DE_FIRMA_RELEASE_O_USAR_VARIABLE_ATLAS_RELEASE_SIGNING_PUBLIC_KEY_PEM" + }, "DataProtection": { "KeysPath": "C:\\ProgramData\\AtlasBalance\\keys" }, diff --git a/Atlas Balance/backend/src/GestionCaja.API/appsettings.json b/Atlas Balance/backend/src/GestionCaja.API/appsettings.json index 8854750..3e28b15 100644 --- a/Atlas Balance/backend/src/GestionCaja.API/appsettings.json +++ b/Atlas Balance/backend/src/GestionCaja.API/appsettings.json @@ -9,6 +9,9 @@ "SeedAdmin": { "Email": "admin@atlasbalance.local" }, + "Security": { + "RequireMfaForWebUsers": true + }, "WatchdogSettings": { "BaseUrl": "http://localhost:5001", "PostgresBinPath": "C:\\Program Files\\PostgreSQL\\14\\bin", @@ -20,6 +23,9 @@ "GitHubSettings": { "UpdateToken": "" }, + "UpdateSecurity": { + "ReleaseSigningPublicKeyPem": "" + }, "Serilog": { "MinimumLevel": { "Default": "Information", diff --git a/Atlas Balance/backend/src/GestionCaja.Watchdog/Services/WatchdogOperationsService.cs b/Atlas Balance/backend/src/GestionCaja.Watchdog/Services/WatchdogOperationsService.cs index be64ddb..42c8ba7 100644 --- a/Atlas Balance/backend/src/GestionCaja.Watchdog/Services/WatchdogOperationsService.cs +++ b/Atlas Balance/backend/src/GestionCaja.Watchdog/Services/WatchdogOperationsService.cs @@ -127,20 +127,51 @@ await _stateStore.SetAsync( _ = Task.Run(async () => { var finalState = CreateState("FAILED", "UPDATE_APP", "Operacion interrumpida"); + string? rollbackPath = null; + var apiStartedInOperation = false; try { + if (RequireDatabaseBackupBeforeUpdate()) + { + var backupResult = await CreateDatabaseBackupAsync(CancellationToken.None); + if (!backupResult.Success) + { + finalState = CreateState("FAILED", "UPDATE_APP", backupResult.Error ?? "No se actualiza sin backup previo de base de datos"); + return; + } + } + await StopApiServiceSafeAsync(CancellationToken.None); + rollbackPath = CreateRollbackCopy(fullTargetPath); SyncDirectory(fullSourcePath, fullTargetPath); + await StartApiServiceSafeAsync(CancellationToken.None); + apiStartedInOperation = true; + + if (RequireHealthCheckAfterUpdate() && + !await WaitForApiHealthAsync(CancellationToken.None)) + { + finalState = CreateState("FAILED", "UPDATE_APP", "Health check fallo tras actualizar; rollback de binarios aplicado."); + await StopApiServiceSafeAsync(CancellationToken.None); + TryRestoreRollback(rollbackPath, fullTargetPath); + await StartApiServiceSafeAsync(CancellationToken.None); + return; + } + finalState = CreateState("SUCCESS", "UPDATE_APP", "Actualizacion completada"); } catch (Exception ex) { _logger.LogError(ex, "Update operation failed"); + TryRestoreRollback(rollbackPath, fullTargetPath); finalState = CreateState("FAILED", "UPDATE_APP", ex.Message); } finally { - await StartApiServiceSafeAsync(CancellationToken.None); + if (!apiStartedInOperation) + { + await StartApiServiceSafeAsync(CancellationToken.None); + } + await _stateStore.SetAsync(finalState, CancellationToken.None); _operationLock.Release(); } @@ -446,6 +477,134 @@ private bool IsAllowedBackupPath(string backupPath) } } + private async Task<(bool Success, string? Error)> CreateDatabaseBackupAsync(CancellationToken cancellationToken) + { + var dbPassword = _configuration["WatchdogSettings:DbPassword"]; + if (string.IsNullOrWhiteSpace(dbPassword)) + { + return (false, "WatchdogSettings:DbPassword no configurado; no se actualiza sin backup previo."); + } + + var backupRoot = _configuration["WatchdogSettings:BackupPath"] ?? @"C:\AtlasBalance\backups"; + Directory.CreateDirectory(backupRoot); + var backupPath = Path.Combine(backupRoot, $"pre_update_watchdog_{DateTime.UtcNow:yyyyMMdd_HHmmss}.dump"); + + var pgBinPath = _configuration["WatchdogSettings:PostgresBinPath"]; + var dumpCandidate = string.IsNullOrWhiteSpace(pgBinPath) ? string.Empty : Path.Combine(pgBinPath, "pg_dump.exe"); + var executable = File.Exists(dumpCandidate) ? dumpCandidate : "pg_dump"; + var dbHost = _configuration["WatchdogSettings:DbHost"] ?? "localhost"; + var dbPort = int.TryParse(_configuration["WatchdogSettings:DbPort"], out var parsedPort) ? parsedPort : 5432; + var dbName = _configuration["WatchdogSettings:DbName"] ?? "atlas_balance"; + var dbUser = _configuration["WatchdogSettings:DbUser"] ?? "app_user"; + + var result = await RunProcessAsync( + executable, + ["-h", dbHost, "-p", dbPort.ToString(), "-U", dbUser, "-F", "c", "-b", "-f", backupPath, dbName], + dbPassword, + cancellationToken); + + return result.Success + ? (true, null) + : (false, $"pg_dump fallo antes de actualizar: {result.ErrorMessage}"); + } + + private string CreateRollbackCopy(string targetPath) + { + var backupRoot = _configuration["WatchdogSettings:BackupPath"] ?? + Path.Combine(Path.GetDirectoryName(targetPath) ?? targetPath, "backups"); + Directory.CreateDirectory(backupRoot); + var rollbackPath = Path.Combine(backupRoot, $"app_before_watchdog_update_{DateTime.UtcNow:yyyyMMdd_HHmmss}"); + CopyDirectory(targetPath, rollbackPath); + return rollbackPath; + } + + private bool RequireDatabaseBackupBeforeUpdate() + { + var raw = _configuration["WatchdogSettings:RequireDatabaseBackupBeforeUpdate"]; + return !bool.TryParse(raw, out var parsed) || parsed; + } + + private bool RequireHealthCheckAfterUpdate() + { + var raw = _configuration["WatchdogSettings:RequireHealthCheckAfterUpdate"]; + return bool.TryParse(raw, out var parsed) && parsed; + } + + private async Task WaitForApiHealthAsync(CancellationToken cancellationToken) + { + var healthUrl = _configuration["WatchdogSettings:ApiHealthUrl"]; + if (string.IsNullOrWhiteSpace(healthUrl)) + { + healthUrl = "https://localhost/api/health"; + } + + using var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }; + using var http = new HttpClient(handler) + { + Timeout = TimeSpan.FromSeconds(10) + }; + + var deadline = DateTime.UtcNow.AddMinutes(2); + while (DateTime.UtcNow < deadline) + { + try + { + using var response = await http.GetAsync(healthUrl, cancellationToken); + if (response.IsSuccessStatusCode) + { + return true; + } + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + // API can still be booting and applying migrations. + } + + await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken); + } + + return false; + } + + private void TryRestoreRollback(string? rollbackPath, string targetPath) + { + if (string.IsNullOrWhiteSpace(rollbackPath) || !Directory.Exists(rollbackPath)) + { + return; + } + + try + { + SyncDirectory(rollbackPath, targetPath); + _logger.LogWarning("Rollback de binarios aplicado desde {RollbackPath}", rollbackPath); + } + catch (Exception rollbackEx) + { + _logger.LogError(rollbackEx, "No se pudo aplicar rollback de binarios desde {RollbackPath}", rollbackPath); + } + } + + private static void CopyDirectory(string sourcePath, string targetPath) + { + Directory.CreateDirectory(targetPath); + foreach (var directory in Directory.GetDirectories(sourcePath, "*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(sourcePath, directory); + Directory.CreateDirectory(Path.Combine(targetPath, relative)); + } + + foreach (var file in Directory.GetFiles(sourcePath, "*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(sourcePath, file); + var destination = Path.Combine(targetPath, relative); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + File.Copy(file, destination, overwrite: true); + } + } + private static bool IsExplicitlyRooted(string path) { return Path.IsPathRooted(path) || diff --git a/Atlas Balance/backend/src/GestionCaja.Watchdog/appsettings.Development.json.template b/Atlas Balance/backend/src/GestionCaja.Watchdog/appsettings.Development.json.template index e3abe3a..a5cfb12 100644 --- a/Atlas Balance/backend/src/GestionCaja.Watchdog/appsettings.Development.json.template +++ b/Atlas Balance/backend/src/GestionCaja.Watchdog/appsettings.Development.json.template @@ -12,7 +12,10 @@ "DbPassword": "", "DockerPostgresContainer": "atlas_balance_db", "UpdateSourceRoot": "C:\\\\updates", - "UpdateTargetPath": "C:\\\\api" + "UpdateTargetPath": "C:\\\\api", + "RequireDatabaseBackupBeforeUpdate": true, + "RequireHealthCheckAfterUpdate": true, + "ApiHealthUrl": "https://localhost:5000/api/health" }, "Serilog": { "MinimumLevel": { diff --git a/Atlas Balance/backend/src/GestionCaja.Watchdog/appsettings.Production.json.template b/Atlas Balance/backend/src/GestionCaja.Watchdog/appsettings.Production.json.template index 99ab4b3..4448aff 100644 --- a/Atlas Balance/backend/src/GestionCaja.Watchdog/appsettings.Production.json.template +++ b/Atlas Balance/backend/src/GestionCaja.Watchdog/appsettings.Production.json.template @@ -12,7 +12,10 @@ "DbPassword": "CAMBIAR_PASSWORD_POSTGRES_AQUI", "DockerPostgresContainer": "atlas_balance_db", "UpdateSourceRoot": "C:\\AtlasBalance\\updates", - "UpdateTargetPath": "C:\\AtlasBalance\\api" + "UpdateTargetPath": "C:\\AtlasBalance\\api", + "RequireDatabaseBackupBeforeUpdate": true, + "RequireHealthCheckAfterUpdate": true, + "ApiHealthUrl": "https://localhost/api/health" }, "Serilog": { "MinimumLevel": { diff --git a/Atlas Balance/backend/src/GestionCaja.Watchdog/appsettings.json b/Atlas Balance/backend/src/GestionCaja.Watchdog/appsettings.json index 69b27d1..5619339 100644 --- a/Atlas Balance/backend/src/GestionCaja.Watchdog/appsettings.json +++ b/Atlas Balance/backend/src/GestionCaja.Watchdog/appsettings.json @@ -12,7 +12,10 @@ "DbPassword": "", "DockerPostgresContainer": "atlas_balance_db", "UpdateSourceRoot": "C:\\AtlasBalance\\updates", - "UpdateTargetPath": "C:\\AtlasBalance\\api" + "UpdateTargetPath": "C:\\AtlasBalance\\api", + "RequireDatabaseBackupBeforeUpdate": true, + "RequireHealthCheckAfterUpdate": true, + "ApiHealthUrl": "https://localhost/api/health" }, "Serilog": { "MinimumLevel": { diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/ActualizacionServiceTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/ActualizacionServiceTests.cs index c0bd3e2..c28aedf 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/ActualizacionServiceTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/ActualizacionServiceTests.cs @@ -1,4 +1,6 @@ -using System.Net; +using System.IO.Compression; +using System.Net; +using System.Security.Cryptography; using FluentAssertions; using GestionCaja.API; using GestionCaja.API.Data; @@ -193,13 +195,217 @@ public async Task IniciarActualizacionAsync_Should_Reject_Source_Outside_Update_ Directory.Delete(root, recursive: true); } + [Fact] + public async Task IniciarActualizacionAsync_Should_Download_GitHub_Release_Asset_When_SourcePath_Is_Not_Provided() + { + await using var db = BuildDbContext(); + db.Configuraciones.Add(new Configuracion + { + Clave = "app_update_check_url", + Valor = ConfigurationDefaults.UpdateCheckUrl + }); + await db.SaveChangesAsync(); + + var root = Path.Combine(Path.GetTempPath(), $"atlas-balance-update-{Guid.NewGuid():N}"); + var updateRoot = Path.Combine(root, "updates"); + var configuredTarget = Path.Combine(root, "app"); + Directory.CreateDirectory(updateRoot); + + var zipBytes = CreateReleaseZipBytes("V-99.00"); + var digest = Sha256Digest(zipBytes); + using var signingKey = RSA.Create(2048); + var signature = SignZipBytes(zipBytes, signingKey); + var watchdog = new RecordingWatchdogClientService(); + var handler = new StubHttpMessageHandler(request => + { + if (request.RequestUri == new Uri("https://api.github.com/repos/AtlasLabs797/AtlasBalance/releases/latest")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(""" + { + "tag_name": "V-99.00-win-x64", + "name": "Release V-99.00", + "assets": [ + { + "name": "AtlasBalance-V-99.00-win-x64.zip", + "browser_download_url": "https://github.com/AtlasLabs797/AtlasBalance/releases/download/V-99.00-win-x64/AtlasBalance-V-99.00-win-x64.zip", + "digest": "__DIGEST__" + }, + { + "name": "AtlasBalance-V-99.00-win-x64.zip.sig", + "browser_download_url": "https://github.com/AtlasLabs797/AtlasBalance/releases/download/V-99.00-win-x64/AtlasBalance-V-99.00-win-x64.zip.sig" + } + ] + } + """.Replace("__DIGEST__", digest, StringComparison.Ordinal)) + }; + } + + if (request.RequestUri == new Uri("https://github.com/AtlasLabs797/AtlasBalance/releases/download/V-99.00-win-x64/AtlasBalance-V-99.00-win-x64.zip.sig")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(signature) + }; + } + + request.RequestUri.Should().Be(new Uri("https://github.com/AtlasLabs797/AtlasBalance/releases/download/V-99.00-win-x64/AtlasBalance-V-99.00-win-x64.zip")); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(zipBytes) + }; + }); + var service = BuildService( + db, + handler, + watchdog: watchdog, + updateSourceRoot: updateRoot, + updateTargetPath: configuredTarget, + releaseSigningPublicKeyPem: signingKey.ExportSubjectPublicKeyInfoPem()); + + var accepted = await service.IniciarActualizacionAsync(null, null, CancellationToken.None); + + accepted.Should().BeTrue(); + watchdog.Calls.Should().Be(1); + watchdog.SourcePath.Should().NotBeNull(); + watchdog.SourcePath!.Replace('\\', '/').Should().EndWith("/api"); + watchdog.SourcePath.Should().StartWith(updateRoot); + File.Exists(Path.Combine(watchdog.SourcePath, "GestionCaja.API.exe")).Should().BeTrue(); + watchdog.TargetPath.Should().Be(configuredTarget); + + Directory.Delete(root, recursive: true); + } + + [Fact] + public async Task IniciarActualizacionAsync_Should_Reject_Downloaded_Asset_When_Signature_Is_Missing() + { + await using var db = BuildDbContext(); + db.Configuraciones.Add(new Configuracion + { + Clave = "app_update_check_url", + Valor = ConfigurationDefaults.UpdateCheckUrl + }); + await db.SaveChangesAsync(); + + var root = Path.Combine(Path.GetTempPath(), $"atlas-balance-update-{Guid.NewGuid():N}"); + var updateRoot = Path.Combine(root, "updates"); + var configuredTarget = Path.Combine(root, "app"); + Directory.CreateDirectory(updateRoot); + + var zipBytes = CreateReleaseZipBytes("V-99.00"); + var digest = Sha256Digest(zipBytes); + using var signingKey = RSA.Create(2048); + var watchdog = new RecordingWatchdogClientService(); + var handler = new StubHttpMessageHandler(request => + { + if (request.RequestUri == new Uri("https://api.github.com/repos/AtlasLabs797/AtlasBalance/releases/latest")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(""" + { + "tag_name": "V-99.00-win-x64", + "assets": [ + { + "name": "AtlasBalance-V-99.00-win-x64.zip", + "browser_download_url": "https://github.com/AtlasLabs797/AtlasBalance/releases/download/V-99.00-win-x64/AtlasBalance-V-99.00-win-x64.zip", + "digest": "__DIGEST__" + } + ] + } + """.Replace("__DIGEST__", digest, StringComparison.Ordinal)) + }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(zipBytes) + }; + }); + var service = BuildService( + db, + handler, + watchdog: watchdog, + updateSourceRoot: updateRoot, + updateTargetPath: configuredTarget, + releaseSigningPublicKeyPem: signingKey.ExportSubjectPublicKeyInfoPem()); + + var accepted = await service.IniciarActualizacionAsync(null, null, CancellationToken.None); + + accepted.Should().BeFalse(); + watchdog.Calls.Should().Be(0); + + Directory.Delete(root, recursive: true); + } + + [Fact] + public async Task IniciarActualizacionAsync_Should_Reject_Downloaded_Asset_When_Digest_Does_Not_Match() + { + await using var db = BuildDbContext(); + db.Configuraciones.Add(new Configuracion + { + Clave = "app_update_check_url", + Valor = ConfigurationDefaults.UpdateCheckUrl + }); + await db.SaveChangesAsync(); + + var root = Path.Combine(Path.GetTempPath(), $"atlas-balance-update-{Guid.NewGuid():N}"); + var updateRoot = Path.Combine(root, "updates"); + var configuredTarget = Path.Combine(root, "app"); + Directory.CreateDirectory(updateRoot); + + var zipBytes = CreateReleaseZipBytes("V-99.00"); + var watchdog = new RecordingWatchdogClientService(); + var handler = new StubHttpMessageHandler(request => + { + if (request.RequestUri == new Uri("https://api.github.com/repos/AtlasLabs797/AtlasBalance/releases/latest")) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(""" + { + "tag_name": "V-99.00-win-x64", + "assets": [ + { + "name": "AtlasBalance-V-99.00-win-x64.zip", + "browser_download_url": "https://github.com/AtlasLabs797/AtlasBalance/releases/download/V-99.00-win-x64/AtlasBalance-V-99.00-win-x64.zip", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + """) + }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(zipBytes) + }; + }); + var service = BuildService( + db, + handler, + watchdog: watchdog, + updateSourceRoot: updateRoot, + updateTargetPath: configuredTarget); + + var accepted = await service.IniciarActualizacionAsync(null, null, CancellationToken.None); + + accepted.Should().BeFalse(); + watchdog.Calls.Should().Be(0); + + Directory.Delete(root, recursive: true); + } + private static ActualizacionService BuildService( AppDbContext db, HttpMessageHandler handler, string? githubUpdateToken = null, IWatchdogClientService? watchdog = null, string? updateSourceRoot = null, - string? updateTargetPath = "C:/AtlasBalance/app") + string? updateTargetPath = "C:/AtlasBalance/app", + string? releaseSigningPublicKeyPem = null) { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -207,7 +413,8 @@ private static ActualizacionService BuildService( ["WatchdogSettings:SharedSecret"] = "test-secret", ["WatchdogSettings:UpdateSourceRoot"] = updateSourceRoot ?? "C:/AtlasBalance/updates", ["WatchdogSettings:UpdateTargetPath"] = updateTargetPath, - ["GitHubSettings:UpdateToken"] = githubUpdateToken + ["GitHubSettings:UpdateToken"] = githubUpdateToken, + ["UpdateSecurity:ReleaseSigningPublicKeyPem"] = releaseSigningPublicKeyPem }) .Build(); @@ -281,4 +488,34 @@ public Task SolicitarActualizacionAsync(string? sourcePath, string? target public Task GetEstadoAsync(CancellationToken cancellationToken) => Task.FromResult(new WatchdogStateResponse()); } + + private static byte[] CreateReleaseZipBytes(string version) + { + using var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + AddZipEntry(archive, "VERSION", version); + AddZipEntry(archive, "api/GestionCaja.API.exe", "api"); + AddZipEntry(archive, "watchdog/GestionCaja.Watchdog.exe", "watchdog"); + } + + return stream.ToArray(); + } + + private static void AddZipEntry(ZipArchive archive, string path, string content) + { + var entry = archive.CreateEntry(path); + using var writer = new StreamWriter(entry.Open()); + writer.Write(content); + } + + private static string Sha256Digest(byte[] bytes) + { + return $"sha256:{Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant()}"; + } + + private static byte[] SignZipBytes(byte[] bytes, RSA rsa) + { + return rsa.SignData(bytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + } } diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/AuthServiceTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/AuthServiceTests.cs index 97c3409..7c08b72 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/AuthServiceTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/AuthServiceTests.cs @@ -17,7 +17,18 @@ public class AuthServiceTests { ["JwtSettings:Secret"] = "test-secret-key-minimum-32-characters-long", ["JwtSettings:AccessTokenExpMinutes"] = "60", - ["JwtSettings:RefreshTokenExpDays"] = "7" + ["JwtSettings:RefreshTokenExpDays"] = "7", + ["Security:RequireMfaForWebUsers"] = "false" + }) + .Build(); + + private static IConfiguration BuildMfaConfig() => new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["JwtSettings:Secret"] = "test-secret-key-minimum-32-characters-long", + ["JwtSettings:AccessTokenExpMinutes"] = "60", + ["JwtSettings:RefreshTokenExpDays"] = "7", + ["Security:RequireMfaForWebUsers"] = "true" }) .Build(); @@ -30,7 +41,7 @@ private static AppDbContext BuildDbContext() } [Fact] - public async Task Login_Should_Throttle_Client_Before_Global_Account_Lock() + public async Task Login_Should_Lock_Account_On_Fifth_Bad_Password() { await using var db = BuildDbContext(); var user = new Usuario @@ -57,13 +68,13 @@ public async Task Login_Should_Throttle_Client_Before_Global_Account_Lock() } Func fifthAttempt = () => sut.LoginAsync(user.Email, "BadPass!", "127.0.0.1", CancellationToken.None); - var throttled = await fifthAttempt.Should().ThrowAsync(); - throttled.Which.StatusCode.Should().Be(StatusCodes.Status429TooManyRequests); - throttled.Which.Message.Should().Be("Demasiados intentos. Espera unos minutos."); + var locked = await fifthAttempt.Should().ThrowAsync(); + locked.Which.StatusCode.Should().Be(StatusCodes.Status401Unauthorized); + locked.Which.Message.Should().Be("Credenciales inválidas"); var persisted = await db.Usuarios.FirstAsync(x => x.Id == user.Id); - persisted.FailedLoginAttempts.Should().Be(4); - persisted.LockedUntil.Should().BeNull(); + persisted.FailedLoginAttempts.Should().Be(5); + persisted.LockedUntil.Should().BeAfter(DateTime.UtcNow); } [Fact] @@ -130,6 +141,177 @@ public async Task Login_Should_Return_Tokens_And_Reset_Lock_Counters_When_Passwo (await db.RefreshTokens.AnyAsync(x => x.TokenHash == tokenHash)).Should().BeTrue(); } + [Fact] + public async Task Login_Should_Require_Mfa_Setup_When_Mfa_Is_Enabled() + { + await using var db = BuildDbContext(); + var user = new Usuario + { + Id = Guid.NewGuid(), + Email = "mfa-setup@test.local", + PasswordHash = BCrypt.Net.BCrypt.HashPassword("Valid1234!Ab", workFactor: 12), + NombreCompleto = "Mfa Setup", + Rol = RolUsuario.ADMIN, + Activo = true, + PrimerLogin = false, + FechaCreacion = DateTime.UtcNow + }; + db.Usuarios.Add(user); + await db.SaveChangesAsync(); + + var sut = new AuthService(db, BuildMfaConfig(), new AuditService(db), secretProtector: new PlainTextSecretProtector()); + + var result = await sut.LoginAsync(user.Email, "Valid1234!Ab", "127.0.0.1", CancellationToken.None); + + result.AccessToken.Should().BeNull(); + result.RefreshToken.Should().BeNull(); + result.MfaRequired.Should().BeTrue(); + result.MfaSetupRequired.Should().BeTrue(); + result.MfaChallengeId.Should().NotBeNullOrWhiteSpace(); + result.MfaSecret.Should().NotBeNullOrWhiteSpace(); + result.MfaOtpAuthUri.Should().Contain("otpauth://totp/"); + (await db.Auditorias.AnyAsync(x => x.TipoAccion == GestionCaja.API.Constants.AuditActions.LoginMfaRequired)).Should().BeTrue(); + } + + [Fact] + public async Task VerifyMfa_Should_Enable_Mfa_And_Issue_Tokens() + { + await using var db = BuildDbContext(); + var user = new Usuario + { + Id = Guid.NewGuid(), + Email = "mfa-verify@test.local", + PasswordHash = BCrypt.Net.BCrypt.HashPassword("Valid1234!Ab", workFactor: 12), + NombreCompleto = "Mfa Verify", + Rol = RolUsuario.ADMIN, + Activo = true, + PrimerLogin = false, + FechaCreacion = DateTime.UtcNow + }; + db.Usuarios.Add(user); + await db.SaveChangesAsync(); + + var sut = new AuthService(db, BuildMfaConfig(), new AuditService(db), secretProtector: new PlainTextSecretProtector()); + var login = await sut.LoginAsync(user.Email, "Valid1234!Ab", "127.0.0.1", CancellationToken.None); + var code = TotpService.GenerateCode(login.MfaSecret!, DateTime.UtcNow); + + var result = await sut.VerifyMfaAsync(login.MfaChallengeId!, code, "127.0.0.1", CancellationToken.None); + + result.AccessToken.Should().NotBeNullOrWhiteSpace(); + result.RefreshToken.Should().NotBeNullOrWhiteSpace(); + result.Usuario.MfaEnabled.Should().BeTrue(); + + var persisted = await db.Usuarios.SingleAsync(x => x.Id == user.Id); + persisted.MfaEnabled.Should().BeTrue(); + persisted.MfaSecret.Should().NotBeNullOrWhiteSpace(); + persisted.MfaEnabledAt.Should().NotBeNull(); + persisted.MfaLastAcceptedStep.Should().NotBeNull(); + (await db.Auditorias.AnyAsync(x => x.TipoAccion == GestionCaja.API.Constants.AuditActions.MfaVerified)).Should().BeTrue(); + } + + [Fact] + public async Task VerifyMfa_Should_Lock_User_Across_New_Challenges_After_Repeated_Failures() + { + await using var db = BuildDbContext(); + var user = new Usuario + { + Id = Guid.NewGuid(), + Email = "mfa-lock@test.local", + PasswordHash = BCrypt.Net.BCrypt.HashPassword("Valid1234!Ab", workFactor: 12), + NombreCompleto = "Mfa Lock", + Rol = RolUsuario.ADMIN, + Activo = true, + PrimerLogin = false, + MfaEnabled = true, + MfaSecret = TotpService.GenerateSecret(), + FechaCreacion = DateTime.UtcNow + }; + db.Usuarios.Add(user); + await db.SaveChangesAsync(); + + var sut = new AuthService(db, BuildMfaConfig(), new AuditService(db), secretProtector: new PlainTextSecretProtector()); + + for (var i = 1; i <= 5; i++) + { + var login = await sut.LoginAsync(user.Email, "Valid1234!Ab", "127.0.0.1", CancellationToken.None); + Func invalidMfa = () => sut.VerifyMfaAsync(login.MfaChallengeId!, "not-code", "127.0.0.1", CancellationToken.None); + var exception = await invalidMfa.Should().ThrowAsync(); + exception.Which.StatusCode.Should().Be(StatusCodes.Status401Unauthorized); + } + + var persisted = await db.Usuarios.SingleAsync(x => x.Id == user.Id); + persisted.LockedUntil.Should().BeAfter(DateTime.UtcNow); + persisted.FailedLoginAttempts.Should().Be(5); + + Func lockedLogin = () => sut.LoginAsync(user.Email, "Valid1234!Ab", "127.0.0.1", CancellationToken.None); + var locked = await lockedLogin.Should().ThrowAsync(); + locked.Which.StatusCode.Should().Be(StatusCodes.Status401Unauthorized); + } + + [Fact] + public async Task Login_Should_Not_Require_Mfa_Again_When_Trusted_Mfa_Cookie_Is_Valid() + { + await using var db = BuildDbContext(); + var user = new Usuario + { + Id = Guid.NewGuid(), + Email = "mfa-trusted@test.local", + PasswordHash = BCrypt.Net.BCrypt.HashPassword("Valid1234!Ab", workFactor: 12), + NombreCompleto = "Mfa Trusted", + Rol = RolUsuario.ADMIN, + Activo = true, + PrimerLogin = false, + FechaCreacion = DateTime.UtcNow + }; + db.Usuarios.Add(user); + await db.SaveChangesAsync(); + + var sut = new AuthService(db, BuildMfaConfig(), new AuditService(db), secretProtector: new PlainTextSecretProtector()); + var login = await sut.LoginAsync(user.Email, "Valid1234!Ab", "127.0.0.1", CancellationToken.None); + var code = TotpService.GenerateCode(login.MfaSecret!, DateTime.UtcNow); + var verified = await sut.VerifyMfaAsync(login.MfaChallengeId!, code, "127.0.0.1", CancellationToken.None); + + var trustedLogin = await sut.LoginAsync(user.Email, "Valid1234!Ab", "127.0.0.1", CancellationToken.None, verified.TrustedMfaToken); + + verified.TrustedMfaToken.Should().NotBeNullOrWhiteSpace(); + verified.TrustedMfaTokenExpiresAt.Should().BeAfter(DateTime.UtcNow.AddDays(89)); + trustedLogin.MfaRequired.Should().BeFalse(); + trustedLogin.AccessToken.Should().NotBeNullOrWhiteSpace(); + trustedLogin.RefreshToken.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public async Task Login_Should_Require_Mfa_When_Trusted_Mfa_Cookie_Is_Expired() + { + await using var db = BuildDbContext(); + var user = new Usuario + { + Id = Guid.NewGuid(), + Email = "mfa-expired@test.local", + PasswordHash = BCrypt.Net.BCrypt.HashPassword("Valid1234!Ab", workFactor: 12), + NombreCompleto = "Mfa Expired", + Rol = RolUsuario.ADMIN, + Activo = true, + PrimerLogin = false, + MfaEnabled = true, + MfaSecret = TotpService.GenerateSecret(), + SecurityStamp = Guid.NewGuid().ToString("N"), + FechaCreacion = DateTime.UtcNow + }; + db.Usuarios.Add(user); + await db.SaveChangesAsync(); + + var expiredTrustedToken = BuildTrustedMfaTokenForTest(user, DateTime.UtcNow.AddSeconds(-1)); + var sut = new AuthService(db, BuildMfaConfig(), new AuditService(db), secretProtector: new PlainTextSecretProtector()); + + var result = await sut.LoginAsync(user.Email, "Valid1234!Ab", "127.0.0.1", CancellationToken.None, expiredTrustedToken); + + result.MfaRequired.Should().BeTrue(); + result.MfaSetupRequired.Should().BeFalse(); + result.AccessToken.Should().BeNull(); + result.RefreshToken.Should().BeNull(); + } + [Fact] public async Task ChangePassword_Should_Update_Hash_And_Clear_PrimerLogin() { @@ -285,4 +467,27 @@ public async Task Logout_Should_Revoke_Refresh_Token_And_Return_UserId() revokedUserId.Should().Be(user.Id); (await db.RefreshTokens.SingleAsync()).RevocadoEn.Should().NotBeNull(); } + + private static string BuildTrustedMfaTokenForTest(Usuario usuario, DateTime expiresAtUtc) + { + var payload = System.Text.Json.JsonSerializer.Serialize(new + { + Version = "v1", + UserId = usuario.Id, + SecurityStamp = usuario.SecurityStamp, + ExpiresAtUnix = new DateTimeOffset(expiresAtUtc).ToUnixTimeSeconds() + }); + var payloadBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payload)); + using var hmac = new System.Security.Cryptography.HMACSHA256(Encoding.UTF8.GetBytes("test-secret-key-minimum-32-characters-long")); + var signature = Base64UrlEncode(hmac.ComputeHash(Encoding.UTF8.GetBytes(payloadBase64))); + return $"{payloadBase64}.{signature}"; + } + + private static string Base64UrlEncode(byte[] bytes) + { + return Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } } diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/CuentasControllerTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/CuentasControllerTests.cs index f470bce..8777011 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/CuentasControllerTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/CuentasControllerTests.cs @@ -142,13 +142,67 @@ public async Task Resumen_Should_Expose_PlazoFijo_Metadata() summary.Notas.Should().Be("Notas cuenta"); } - private static CuentasController BuildController(AppDbContext db, Guid userId) + [Fact] + public async Task Resumen_Should_Hide_PlazoFijo_Reference_Account_When_User_Cannot_Access_It() + { + await using var db = BuildDbContext(); + var userId = Guid.NewGuid(); + var titularId = Guid.NewGuid(); + var cuentaId = Guid.NewGuid(); + var referenciaId = Guid.NewGuid(); + + db.Usuarios.Add(new Usuario + { + Id = userId, + Email = "gerente.plazo.resumen@test.local", + PasswordHash = "hash", + NombreCompleto = "Gerente Plazo", + Rol = RolUsuario.GERENTE, + Activo = true, + PrimerLogin = false + }); + db.Titulares.Add(new Titular { Id = titularId, Nombre = "Titular Plazo", Tipo = TipoTitular.EMPRESA }); + db.Cuentas.AddRange( + new Cuenta { Id = referenciaId, TitularId = titularId, Nombre = "Cuenta Referencia Privada", Divisa = "EUR", TipoCuenta = TipoCuenta.NORMAL, Activa = true }, + new Cuenta { Id = cuentaId, TitularId = titularId, Nombre = "Deposito Gerente", Divisa = "EUR", TipoCuenta = TipoCuenta.PLAZO_FIJO, Activa = true }); + db.PermisosUsuario.Add(new PermisoUsuario + { + Id = Guid.NewGuid(), + UsuarioId = userId, + CuentaId = cuentaId, + PuedeVerCuentas = true + }); + db.PlazosFijos.Add(new PlazoFijo + { + Id = Guid.NewGuid(), + CuentaId = cuentaId, + CuentaReferenciaId = referenciaId, + FechaInicio = new DateOnly(2026, 4, 25), + FechaVencimiento = new DateOnly(2026, 10, 25), + Renovable = true, + Estado = EstadoPlazoFijo.ACTIVO, + FechaCreacion = DateTime.UtcNow + }); + await db.SaveChangesAsync(); + + var controller = BuildController(db, userId, RolUsuario.GERENTE); + + var result = await controller.Resumen(cuentaId, "1m", CancellationToken.None); + + var summary = result.Should().BeOfType().Subject.Value + .Should().BeOfType().Subject; + summary.PlazoFijo.Should().NotBeNull(); + summary.PlazoFijo!.CuentaReferenciaId.Should().BeNull(); + summary.PlazoFijo.CuentaReferenciaNombre.Should().BeNull(); + } + + private static CuentasController BuildController(AppDbContext db, Guid userId, RolUsuario role = RolUsuario.ADMIN) { var controller = new CuentasController(db, new UserAccessService(db), new AuditService(db), new NoOpPlazoFijoService()); var identity = new ClaimsIdentity( [ new Claim(ClaimTypes.NameIdentifier, userId.ToString()), - new Claim(ClaimTypes.Role, nameof(RolUsuario.ADMIN)) + new Claim(ClaimTypes.Role, role.ToString()) ], "TestAuth"); controller.ControllerContext = new ControllerContext { @@ -214,6 +268,61 @@ public async Task Crear_Should_Create_PlazoFijo_With_Metadata() plazo.Estado.Should().Be(EstadoPlazoFijo.ACTIVO); } + [Fact] + public async Task Crear_Should_Keep_Formato_For_Efectivo() + { + await using var db = BuildDbContext(); + var userId = Guid.NewGuid(); + var titularId = Guid.NewGuid(); + var formatoId = Guid.NewGuid(); + + db.Usuarios.Add(new Usuario + { + Id = userId, + Email = "admin.efectivo@test.local", + PasswordHash = "hash", + NombreCompleto = "Admin Efectivo", + Rol = RolUsuario.ADMIN, + Activo = true, + PrimerLogin = false + }); + db.Titulares.Add(new Titular { Id = titularId, Nombre = "Caja Central", Tipo = TipoTitular.EMPRESA }); + db.DivisasActivas.Add(new DivisaActiva { Codigo = "EUR", Activa = true, EsBase = true }); + db.FormatosImportacion.Add(new FormatoImportacion + { + Id = formatoId, + Nombre = "Caja EUR", + BancoNombre = "Caja", + Divisa = "EUR", + Activo = true, + MapeoJson = "{\"tipo_monto\":\"una_columna\",\"fecha\":0,\"concepto\":1,\"monto\":2,\"saldo\":3,\"columnas_extra\":[]}" + }); + await db.SaveChangesAsync(); + + var controller = BuildController(db, userId); + + var result = await controller.Crear(new SaveCuentaRequest + { + TitularId = titularId, + Nombre = "Caja Oficina", + Divisa = "EUR", + TipoCuenta = TipoCuenta.EFECTIVO, + FormatoId = formatoId, + BancoNombre = "No deberia persistir", + NumeroCuenta = "123", + Iban = "ES00" + }, CancellationToken.None); + + result.Should().BeOfType(); + var cuenta = await db.Cuentas.SingleAsync(c => c.Nombre == "Caja Oficina"); + cuenta.TipoCuenta.Should().Be(TipoCuenta.EFECTIVO); + cuenta.EsEfectivo.Should().BeTrue(); + cuenta.FormatoId.Should().Be(formatoId); + cuenta.BancoNombre.Should().BeNull(); + cuenta.NumeroCuenta.Should().BeNull(); + cuenta.Iban.Should().BeNull(); + } + [Fact] public async Task Listar_Should_Filter_By_TipoTitular_And_TipoCuenta() { diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/DashboardServiceTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/DashboardServiceTests.cs index 21fcef7..7685782 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/DashboardServiceTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/DashboardServiceTests.cs @@ -60,8 +60,9 @@ public async Task GetPrincipalAsync_Should_Aggregate_CurrentBalances_And_PeriodF { await using var db = BuildDbContext(); var adminId = Guid.NewGuid(); - var monthStart = new DateOnly(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1); - var periodStart = DateOnly.FromDateTime(DateTime.UtcNow.Date).AddMonths(-1); + var today = DateOnly.FromDateTime(DateTime.UtcNow.Date); + var monthStart = new DateOnly(today.Year, today.Month, 1); + var periodStart = today.AddMonths(-1); db.Usuarios.Add(new Usuario { @@ -121,7 +122,7 @@ public async Task GetPrincipalAsync_Should_Aggregate_CurrentBalances_And_PeriodF { Id = Guid.NewGuid(), CuentaId = cuentaEurId, - Fecha = monthStart.AddDays(1), + Fecha = today, Monto = -40m, Saldo = 60m, FilaNumero = 2 @@ -130,7 +131,7 @@ public async Task GetPrincipalAsync_Should_Aggregate_CurrentBalances_And_PeriodF { Id = Guid.NewGuid(), CuentaId = cuentaUsdId, - Fecha = monthStart.AddDays(2), + Fecha = today, Monto = 120m, Saldo = 120m, FilaNumero = 1 @@ -235,6 +236,129 @@ public async Task GetTitularAsync_Should_Reject_Manager_Without_Access_To_Reques exception.Which.Message.Should().Contain("No tienes permisos"); } + [Fact] + public async Task GetPrincipalAsync_Should_Not_Grant_All_Accounts_For_DashboardOnly_GlobalPermission() + { + await using var db = BuildDbContext(); + var adminId = Guid.NewGuid(); + var managerId = Guid.NewGuid(); + var titularId = Guid.NewGuid(); + var cuentaId = Guid.NewGuid(); + + db.Usuarios.AddRange( + new Usuario + { + Id = adminId, + Email = "admin.dashboard-global@test.local", + PasswordHash = "hash", + NombreCompleto = "Admin Dashboard Global", + Rol = RolUsuario.ADMIN, + Activo = true, + PrimerLogin = false + }, + new Usuario + { + Id = managerId, + Email = "manager.dashboard-only-global@test.local", + PasswordHash = "hash", + NombreCompleto = "Manager Dashboard Only Global", + Rol = RolUsuario.GERENTE, + Activo = true, + PrimerLogin = false + }); + + SeedDashboardConfig(db, adminId); + db.Titulares.Add(new Titular { Id = titularId, Nombre = "Titular Global", Tipo = TipoTitular.EMPRESA }); + db.Cuentas.Add(new Cuenta { Id = cuentaId, TitularId = titularId, Nombre = "Cuenta Global", Divisa = "EUR", Activa = true }); + db.PermisosUsuario.Add(new PermisoUsuario + { + Id = Guid.NewGuid(), + UsuarioId = managerId, + CuentaId = null, + TitularId = null, + PuedeVerDashboard = true + }); + db.Extractos.Add(new Extracto + { + Id = Guid.NewGuid(), + CuentaId = cuentaId, + Fecha = DateOnly.FromDateTime(DateTime.UtcNow.Date), + Monto = 50m, + Saldo = 50m, + FilaNumero = 1 + }); + await db.SaveChangesAsync(); + + var sut = BuildService(db); + + var act = async () => await sut.GetPrincipalAsync(managerId, "EUR", CancellationToken.None); + + var exception = await act.Should().ThrowAsync(); + exception.Which.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + } + + [Fact] + public async Task GetPrincipalAsync_Should_Allow_GlobalDashboard_When_GlobalDataPermission_Exists() + { + await using var db = BuildDbContext(); + var adminId = Guid.NewGuid(); + var managerId = Guid.NewGuid(); + var titularId = Guid.NewGuid(); + var cuentaId = Guid.NewGuid(); + + db.Usuarios.AddRange( + new Usuario + { + Id = adminId, + Email = "admin.dashboard-data@test.local", + PasswordHash = "hash", + NombreCompleto = "Admin Dashboard Data", + Rol = RolUsuario.ADMIN, + Activo = true, + PrimerLogin = false + }, + new Usuario + { + Id = managerId, + Email = "manager.dashboard-data@test.local", + PasswordHash = "hash", + NombreCompleto = "Manager Dashboard Data", + Rol = RolUsuario.GERENTE, + Activo = true, + PrimerLogin = false + }); + + SeedDashboardConfig(db, adminId); + db.Titulares.Add(new Titular { Id = titularId, Nombre = "Titular Data", Tipo = TipoTitular.EMPRESA }); + db.Cuentas.Add(new Cuenta { Id = cuentaId, TitularId = titularId, Nombre = "Cuenta Data", Divisa = "EUR", Activa = true }); + db.PermisosUsuario.Add(new PermisoUsuario + { + Id = Guid.NewGuid(), + UsuarioId = managerId, + CuentaId = null, + TitularId = null, + PuedeVerCuentas = true, + PuedeVerDashboard = true + }); + db.Extractos.Add(new Extracto + { + Id = Guid.NewGuid(), + CuentaId = cuentaId, + Fecha = DateOnly.FromDateTime(DateTime.UtcNow.Date), + Monto = 50m, + Saldo = 50m, + FilaNumero = 1 + }); + await db.SaveChangesAsync(); + + var sut = BuildService(db); + + var result = await sut.GetPrincipalAsync(managerId, "EUR", CancellationToken.None); + + result.TotalConvertido.Should().Be(50m); + result.SaldosPorTitular.Should().ContainSingle(x => x.TitularId == titularId); + } + [Fact] public async Task GetPrincipalAsync_Should_Prioritize_Active_Base_Currency_Over_Stale_Config_Default() { diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/ExtractosControllerTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/ExtractosControllerTests.cs index c883630..610fa00 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/ExtractosControllerTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/ExtractosControllerTests.cs @@ -154,7 +154,7 @@ public async Task Listar_Should_Not_Return_Deleted_Rows_To_NonAdmin_Even_When_Re Id = Guid.NewGuid(), UsuarioId = userId, CuentaId = cuentaId, - PuedeVerDashboard = true + PuedeVerCuentas = true }); db.Extractos.AddRange( new Extracto @@ -264,6 +264,56 @@ public async Task Listar_Should_Return_Empty_For_DashboardOnly_GlobalPermission( page.Data.Should().BeEmpty(); } + [Fact] + public async Task Listar_Should_Return_Empty_For_DashboardOnly_ScopedPermission() + { + await using var db = BuildDbContext(); + var userId = Guid.NewGuid(); + var titularId = Guid.NewGuid(); + var cuentaId = Guid.NewGuid(); + + db.Usuarios.Add(new Usuario + { + Id = userId, + Email = "gerente.dashboard-scoped@test.local", + PasswordHash = "hash", + NombreCompleto = "Gerente Dashboard Scoped", + Rol = RolUsuario.GERENTE, + Activo = true, + PrimerLogin = false + }); + db.Titulares.Add(new Titular { Id = titularId, Nombre = "Titular Scoped", Tipo = TipoTitular.EMPRESA }); + db.Cuentas.Add(new Cuenta { Id = cuentaId, TitularId = titularId, Nombre = "Cuenta Scoped", Divisa = "EUR", Activa = true }); + db.PermisosUsuario.Add(new PermisoUsuario + { + Id = Guid.NewGuid(), + UsuarioId = userId, + CuentaId = cuentaId, + TitularId = titularId, + PuedeVerDashboard = true + }); + db.Extractos.Add(new Extracto + { + Id = Guid.NewGuid(), + CuentaId = cuentaId, + Fecha = DateOnly.FromDateTime(DateTime.UtcNow.Date), + Concepto = "No visible", + Monto = 10m, + Saldo = 10m, + FilaNumero = 1 + }); + await db.SaveChangesAsync(); + + var controller = BuildController(db, userId, RolUsuario.GERENTE); + + var result = await controller.Listar(ct: CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var page = ok.Value.Should().BeOfType>().Subject; + page.Total.Should().Be(0); + page.Data.Should().BeEmpty(); + } + [Fact] public async Task Listar_Should_Return_All_Rows_For_ViewAccounts_GlobalPermission() { @@ -331,6 +381,174 @@ public async Task Listar_Should_Return_All_Rows_For_ViewAccounts_GlobalPermissio page.Data.Select(row => row.Concepto).Should().BeEquivalentTo("Cuenta A", "Cuenta B"); } + [Fact] + public async Task Restaurar_Should_Require_DeletePermission() + { + await using var db = BuildDbContext(); + var userId = Guid.NewGuid(); + var titularId = Guid.NewGuid(); + var cuentaId = Guid.NewGuid(); + var extractoId = Guid.NewGuid(); + + db.Usuarios.Add(new Usuario + { + Id = userId, + Email = "gerente.restore@test.local", + PasswordHash = "hash", + NombreCompleto = "Gerente Restore", + Rol = RolUsuario.GERENTE, + Activo = true, + PrimerLogin = false + }); + db.Titulares.Add(new Titular { Id = titularId, Nombre = "Titular Restore", Tipo = TipoTitular.EMPRESA }); + db.Cuentas.Add(new Cuenta { Id = cuentaId, TitularId = titularId, Nombre = "Cuenta Restore", Divisa = "EUR", Activa = true }); + db.PermisosUsuario.Add(new PermisoUsuario + { + Id = Guid.NewGuid(), + UsuarioId = userId, + CuentaId = cuentaId, + PuedeVerCuentas = true, + PuedeAgregarLineas = true, + PuedeEliminarLineas = false + }); + db.Extractos.Add(new Extracto + { + Id = extractoId, + CuentaId = cuentaId, + Fecha = DateOnly.FromDateTime(DateTime.UtcNow.Date), + Concepto = "Eliminado", + Monto = 10m, + Saldo = 10m, + FilaNumero = 1, + DeletedAt = DateTime.UtcNow + }); + await db.SaveChangesAsync(); + + var controller = BuildController(db, userId, RolUsuario.GERENTE); + + var result = await controller.Restaurar(extractoId, CancellationToken.None); + + result.Should().BeOfType(); + (await db.Extractos.IgnoreQueryFilters().SingleAsync(x => x.Id == extractoId)).DeletedAt.Should().NotBeNull(); + } + + [Fact] + public async Task ToggleFlag_Should_Require_Flagged_EditPermission_When_Flag_Changes() + { + await using var db = BuildDbContext(); + var userId = Guid.NewGuid(); + var titularId = Guid.NewGuid(); + var cuentaId = Guid.NewGuid(); + var extractoId = Guid.NewGuid(); + + db.Usuarios.Add(new Usuario + { + Id = userId, + Email = "gerente.flag-note@test.local", + PasswordHash = "hash", + NombreCompleto = "Gerente Nota Flag", + Rol = RolUsuario.GERENTE, + Activo = true, + PrimerLogin = false + }); + db.Titulares.Add(new Titular { Id = titularId, Nombre = "Titular Flag", Tipo = TipoTitular.EMPRESA }); + db.Cuentas.Add(new Cuenta { Id = cuentaId, TitularId = titularId, Nombre = "Cuenta Flag", Divisa = "EUR", Activa = true }); + db.PermisosUsuario.Add(new PermisoUsuario + { + Id = Guid.NewGuid(), + UsuarioId = userId, + CuentaId = cuentaId, + PuedeEditarLineas = true + }); + db.PreferenciasUsuarioCuenta.Add(new PreferenciaUsuarioCuenta + { + Id = Guid.NewGuid(), + UsuarioId = userId, + CuentaId = cuentaId, + ColumnasEditables = """["flagged_nota"]""" + }); + db.Extractos.Add(new Extracto + { + Id = extractoId, + CuentaId = cuentaId, + Fecha = DateOnly.FromDateTime(DateTime.UtcNow.Date), + Concepto = "Flag", + Monto = 10m, + Saldo = 10m, + FilaNumero = 1, + Flagged = false + }); + await db.SaveChangesAsync(); + + var controller = BuildController(db, userId, RolUsuario.GERENTE); + + var result = await controller.ToggleFlag(extractoId, new ToggleFlagRequest { Flagged = true, Nota = "No autorizada" }, CancellationToken.None); + + result.Should().BeOfType(); + var extracto = await db.Extractos.SingleAsync(x => x.Id == extractoId); + extracto.Flagged.Should().BeFalse(); + extracto.FlaggedNota.Should().BeNull(); + } + + [Fact] + public async Task ToggleFlag_Should_Allow_Note_Edit_When_Flag_Does_Not_Change() + { + await using var db = BuildDbContext(); + var userId = Guid.NewGuid(); + var titularId = Guid.NewGuid(); + var cuentaId = Guid.NewGuid(); + var extractoId = Guid.NewGuid(); + + db.Usuarios.Add(new Usuario + { + Id = userId, + Email = "gerente.flag-note-ok@test.local", + PasswordHash = "hash", + NombreCompleto = "Gerente Nota Flag OK", + Rol = RolUsuario.GERENTE, + Activo = true, + PrimerLogin = false + }); + db.Titulares.Add(new Titular { Id = titularId, Nombre = "Titular Flag OK", Tipo = TipoTitular.EMPRESA }); + db.Cuentas.Add(new Cuenta { Id = cuentaId, TitularId = titularId, Nombre = "Cuenta Flag OK", Divisa = "EUR", Activa = true }); + db.PermisosUsuario.Add(new PermisoUsuario + { + Id = Guid.NewGuid(), + UsuarioId = userId, + CuentaId = cuentaId, + PuedeEditarLineas = true + }); + db.PreferenciasUsuarioCuenta.Add(new PreferenciaUsuarioCuenta + { + Id = Guid.NewGuid(), + UsuarioId = userId, + CuentaId = cuentaId, + ColumnasEditables = """["flagged_nota"]""" + }); + db.Extractos.Add(new Extracto + { + Id = extractoId, + CuentaId = cuentaId, + Fecha = DateOnly.FromDateTime(DateTime.UtcNow.Date), + Concepto = "Flag", + Monto = 10m, + Saldo = 10m, + FilaNumero = 1, + Flagged = true, + FlaggedNota = "Anterior" + }); + await db.SaveChangesAsync(); + + var controller = BuildController(db, userId, RolUsuario.GERENTE); + + var result = await controller.ToggleFlag(extractoId, new ToggleFlagRequest { Flagged = true, Nota = "Nueva" }, CancellationToken.None); + + result.Should().BeOfType(); + var extracto = await db.Extractos.SingleAsync(x => x.Id == extractoId); + extracto.Flagged.Should().BeTrue(); + extracto.FlaggedNota.Should().Be("Nueva"); + } + [Fact] public async Task GetCuentasTitular_Should_Forbid_Unauthorized_Titular() { diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/ImportacionServiceTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/ImportacionServiceTests.cs index d485734..bc80add 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/ImportacionServiceTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/ImportacionServiceTests.cs @@ -339,7 +339,7 @@ public async Task ConfirmarAsync_Should_Normalize_Ingreso_Egreso_Columns_To_Sign result.FilasImportadas.Should().Be(2); var extractos = await db.Extractos - .OrderBy(e => e.FilaNumero) + .OrderByDescending(e => e.FilaNumero) .ToListAsync(); extractos.Select(e => e.Monto).Should().Equal(1000.50m, -250.25m); @@ -397,10 +397,10 @@ public async Task ConfirmarAsync_Should_Accept_BbvaMx_Signed_Egreso_Column() result.FilasImportadas.Should().Be(3); var extractos = await db.Extractos - .OrderBy(e => e.FilaNumero) + .OrderByDescending(e => e.FilaNumero) .ToListAsync(); - extractos.Select(e => e.Monto).Should().Equal(-74.00m, -74.00m, 3150.00m); + extractos.Select(e => e.Monto).Should().Equal(3150.00m, -74.00m, -74.00m); } [Fact] @@ -503,7 +503,7 @@ public async Task ConfirmarAsync_Should_Use_Ingreso_Egreso_And_Validate_Monto_Wh result.FilasImportadas.Should().Be(2); var extractos = await db.Extractos - .OrderBy(e => e.FilaNumero) + .OrderByDescending(e => e.FilaNumero) .ToListAsync(); extractos.Select(e => e.Monto).Should().Equal(1000.50m, -250.25m); @@ -655,7 +655,7 @@ public async Task ValidarAsync_Should_Parse_Thousands_Separators_Without_Treatin result.FilasImportadas.Should().Be(2); var extractos = await db.Extractos - .OrderBy(e => e.FilaNumero) + .OrderByDescending(e => e.FilaNumero) .ToListAsync(); extractos.Should().HaveCount(2); @@ -833,6 +833,38 @@ public async Task ValidarAsync_Should_Allow_Concept_Rows_With_Missing_Amount_Dat ]); } + [Fact] + public async Task ValidarAsync_Should_Allow_Concept_Rows_With_Missing_Amount_And_Date_When_Balance_Is_Present() + { + await using var db = BuildDbContext(); + var (userId, cuentaId) = await SeedImportableCuentaAsync(db); + + var request = new ImportacionValidarRequest + { + CuentaId = cuentaId, + RawData = string.Join('\n', [ + "07/04/2026\tREMESA RECIBOS\t3180,00\t54018,20", + "\tOlivares Palomares, Sergio\t\t815,00" + ]), + Separador = "tab", + Mapeo = DefaultMapeo() + }; + + var service = new ImportacionService(db, new AuditService(db)); + var result = await service.ValidarAsync(userId, RolUsuario.EMPLEADO.ToString(), request, CancellationToken.None); + + result.FilasOk.Should().Be(2); + result.FilasError.Should().Be(0); + result.Filas[1].Valida.Should().BeTrue(); + result.Filas[1].Datos["fecha"].Should().Be("07/04/2026"); + result.Filas[1].Datos["monto"].Should().Be("0"); + result.Filas[1].Datos["saldo"].Should().Be("815,00"); + result.Filas[1].Advertencias.Should().BeEquivalentTo([ + "Monto vacio; se importara como 0.", + "Fecha vacia; se usara la fecha anterior (07/04/2026)." + ]); + } + [Fact] public async Task ConfirmarAsync_Should_Import_Concept_Rows_With_Warning_Fallbacks() { @@ -883,13 +915,49 @@ public async Task ConfirmarAsync_Should_Import_Concept_Rows_With_Warning_Fallbac result.FilasImportadas.Should().Be(2); result.FilasConError.Should().Be(0); - var imported = await db.Extractos.OrderBy(e => e.FilaNumero).ToListAsync(); + var imported = await db.Extractos.OrderByDescending(e => e.FilaNumero).ToListAsync(); imported[1].Fecha.Should().Be(new DateOnly(2026, 4, 22)); imported[1].Concepto.Should().Be("EGARARECYCLING"); imported[1].Monto.Should().Be(0m); imported[1].Saldo.Should().Be(500m); } + [Fact] + public async Task ConfirmarAsync_Should_Import_Concept_Rows_With_Provided_Balance_And_Missing_Amount_Date() + { + await using var db = BuildDbContext(); + var (userId, cuentaId) = await SeedImportableCuentaAsync(db); + + var request = new ImportacionConfirmarRequest + { + CuentaId = cuentaId, + RawData = string.Join('\n', [ + "07/04/2026\tREMESA RECIBOS\t3180,00\t54018,20", + "\tOlivares Palomares, Sergio\t\t815,00" + ]), + Separador = "tab", + FilasAImportar = [1, 2], + Mapeo = DefaultMapeo() + }; + + var service = new ImportacionService(db, new AuditService(db)); + var result = await service.ConfirmarAsync( + userId, + RolUsuario.EMPLEADO.ToString(), + request, + new DefaultHttpContext(), + CancellationToken.None); + + result.FilasImportadas.Should().Be(2); + result.FilasConError.Should().Be(0); + + var imported = await db.Extractos.OrderByDescending(e => e.FilaNumero).ToListAsync(); + imported[1].Fecha.Should().Be(new DateOnly(2026, 4, 7)); + imported[1].Concepto.Should().Be("Olivares Palomares, Sergio"); + imported[1].Monto.Should().Be(0m); + imported[1].Saldo.Should().Be(815m); + } + [Fact] public async Task ConfirmarAsync_Should_Import_Only_Selected_Valid_Rows_And_Audit_The_Batch() { @@ -974,7 +1042,7 @@ public async Task ConfirmarAsync_Should_Import_Only_Selected_Valid_Rows_And_Audi } [Fact] - public async Task ConfirmarAsync_Should_Assign_FilaNumero_From_Oldest_To_Newest_When_Input_Is_ReverseChronological() + public async Task ConfirmarAsync_Should_Preserve_Pasted_Order_When_Viewing_FilaNumero_Descending() { await using var db = BuildDbContext(); @@ -998,8 +1066,8 @@ public async Task ConfirmarAsync_Should_Assign_FilaNumero_From_Oldest_To_Newest_ { CuentaId = cuenta.Id, RawData = string.Join('\n', [ - "2026-04-03\tMovimiento reciente\t10\t110", - "2026-04-01\tMovimiento antiguo\t5\t100" + "2026-04-01\tLinea superior\t5\t100", + "2026-04-03\tLinea inferior\t10\t110" ]), Separador = "tab", Mapeo = new MapeoColumnasRequest @@ -1022,14 +1090,14 @@ public async Task ConfirmarAsync_Should_Assign_FilaNumero_From_Oldest_To_Newest_ result.FilasImportadas.Should().Be(2); var orderedByFila = await db.Extractos - .OrderBy(e => e.FilaNumero) + .OrderByDescending(e => e.FilaNumero) .ToListAsync(); orderedByFila.Should().HaveCount(2); - orderedByFila[0].Concepto.Should().Be("Movimiento antiguo"); - orderedByFila[0].FilaNumero.Should().Be(1); - orderedByFila[1].Concepto.Should().Be("Movimiento reciente"); - orderedByFila[1].FilaNumero.Should().Be(2); + orderedByFila[0].Concepto.Should().Be("Linea superior"); + orderedByFila[0].FilaNumero.Should().Be(2); + orderedByFila[1].Concepto.Should().Be("Linea inferior"); + orderedByFila[1].FilaNumero.Should().Be(1); } [Fact] @@ -1136,6 +1204,74 @@ public async Task ValidarAsync_Should_Reject_RawData_Over_Row_Limit() ex.Which.StatusCode.Should().Be(StatusCodes.Status413PayloadTooLarge); } + [Fact] + public async Task ValidarAsync_Should_Reject_Too_Many_Extra_Columns() + { + await using var db = BuildDbContext(); + var (userId, cuentaId) = await SeedImportableCuentaAsync(db); + var service = new ImportacionService(db, new AuditService(db)); + var request = new ImportacionValidarRequest + { + CuentaId = cuentaId, + RawData = "01/04/2026\tMovimiento\t1\t1", + Separador = "tab", + Mapeo = new MapeoColumnasRequest + { + Fecha = 0, + Concepto = 1, + Monto = 2, + Saldo = 3, + ColumnasExtra = Enumerable.Range(0, 65) + .Select(i => new MapeoColumnaExtraRequest { Nombre = $"Extra{i}", Indice = i + 4 }) + .ToList() + } + }; + + var act = () => service.ValidarAsync(userId, RolUsuario.EMPLEADO.ToString(), request, CancellationToken.None); + + var ex = await act.Should().ThrowAsync(); + ex.Which.StatusCode.Should().Be(StatusCodes.Status400BadRequest); + ex.Which.Message.Should().Contain("64"); + } + + [Fact] + public async Task ConfirmarAsync_Should_Not_Persist_Blank_Extra_Column_Values() + { + await using var db = BuildDbContext(); + var (userId, cuentaId) = await SeedImportableCuentaAsync(db); + var service = new ImportacionService(db, new AuditService(db)); + var request = new ImportacionConfirmarRequest + { + CuentaId = cuentaId, + RawData = "01/04/2026\tMovimiento\t1\t1\tREF-1\t", + Separador = "tab", + Mapeo = new MapeoColumnasRequest + { + Fecha = 0, + Concepto = 1, + Monto = 2, + Saldo = 3, + ColumnasExtra = + [ + new MapeoColumnaExtraRequest { Nombre = "Referencia", Indice = 4 }, + new MapeoColumnaExtraRequest { Nombre = "Vacia", Indice = 5 } + ] + } + }; + + var result = await service.ConfirmarAsync( + userId, + RolUsuario.EMPLEADO.ToString(), + request, + new DefaultHttpContext(), + CancellationToken.None); + + result.FilasImportadas.Should().Be(1); + var extra = await db.ExtractosColumnasExtra.SingleAsync(); + extra.NombreColumna.Should().Be("Referencia"); + extra.Valor.Should().Be("REF-1"); + } + private static async Task<(Guid UserId, Guid CuentaId)> SeedImportableCuentaAsync(AppDbContext db) { var userId = Guid.NewGuid(); diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/IntegrationAuthMiddlewareTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/IntegrationAuthMiddlewareTests.cs index 942bfaa..9260b8e 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/IntegrationAuthMiddlewareTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/IntegrationAuthMiddlewareTests.cs @@ -3,6 +3,7 @@ using GestionCaja.API.Middleware; using GestionCaja.API.Models; using GestionCaja.API.Services; +using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; @@ -105,6 +106,53 @@ public async Task IntegrationAudit_Should_Persist_Even_If_Client_Cancels() logs[0].CodigoRespuesta.Should().Be(StatusCodes.Status500InternalServerError); } + [Fact] + public async Task IntegrationAudit_Should_Redact_Secret_Like_Query_Keys_And_Token_Values() + { + await using var db = BuildDbContext(); + var clock = new FakeClock(new DateTime(2026, 4, 18, 12, 15, 0, DateTimeKind.Utc)); + var cache = new MemoryCache(new MemoryCacheOptions()); + var tokenService = new IntegrationTokenService(db); + var plainToken = "sk_test_redaction"; + + var token = new IntegrationToken + { + Id = Guid.NewGuid(), + Nombre = "redaction", + TokenHash = tokenService.ComputeSha256(plainToken), + Estado = EstadoTokenIntegracion.Activo, + PermisoLectura = true, + UsuarioCreadorId = Guid.NewGuid() + }; + db.IntegrationTokens.Add(token); + await db.SaveChangesAsync(); + + var middleware = new IntegrationAuthMiddleware( + context => + { + context.Response.StatusCode = StatusCodes.Status200OK; + return Task.CompletedTask; + }, + cache, + clock); + + var context = new DefaultHttpContext(); + context.Request.Path = "/api/integration/openclaw/saldos"; + context.Request.Method = HttpMethods.Get; + context.Request.QueryString = new QueryString("?client_secret=leaked&x-api-key=key-123&visible=ok&safe=sk_gestion_caja_should_not_land"); + context.Request.Headers.Authorization = $"Bearer {plainToken}"; + + await middleware.InvokeAsync(context, db, tokenService); + + var audit = await db.AuditoriaIntegraciones.SingleAsync(row => row.TokenId == token.Id); + var parametros = JsonSerializer.Deserialize>(audit.Parametros!); + parametros.Should().NotBeNull(); + parametros!["client_secret"].Should().Be("[REDACTED]"); + parametros["x-api-key"].Should().Be("[REDACTED]"); + parametros["safe"].Should().Be("[REDACTED]"); + parametros["visible"].Should().Be("ok"); + } + [Fact] public async Task InvalidBearer_Should_RateLimit_Before_Revalidating_Token() { diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/IntegrationOpenClawControllerTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/IntegrationOpenClawControllerTests.cs index 060001b..230de87 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/IntegrationOpenClawControllerTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/IntegrationOpenClawControllerTests.cs @@ -156,6 +156,90 @@ public async Task Extractos_Should_Return_Derived_TipoMovimiento_Inside_Wrapped_ payload.Should().Contain("EGRESO"); } + [Fact] + public async Task Auditoria_Should_Not_Return_Values_For_SoftDeleted_Extractos() + { + await using var db = BuildDbContext(); + var titular = new Titular { Id = Guid.NewGuid(), Nombre = "Empresa", Tipo = TipoTitular.EMPRESA }; + var cuenta = new Cuenta { Id = Guid.NewGuid(), TitularId = titular.Id, Nombre = "Cuenta", Divisa = "EUR" }; + var activeExtractoId = Guid.NewGuid(); + var deletedExtractoId = Guid.NewGuid(); + var token = new IntegrationToken + { + Id = Guid.NewGuid(), + Nombre = "token", + TokenHash = "hash", + PermisoLectura = true, + Estado = EstadoTokenIntegracion.Activo, + UsuarioCreadorId = Guid.NewGuid() + }; + + db.Titulares.Add(titular); + db.Cuentas.Add(cuenta); + db.IntegrationTokens.Add(token); + db.IntegrationPermissions.Add(new IntegrationPermission + { + Id = Guid.NewGuid(), + TokenId = token.Id, + CuentaId = cuenta.Id, + AccesoTipo = "lectura" + }); + db.Extractos.AddRange( + new Extracto + { + Id = activeExtractoId, + CuentaId = cuenta.Id, + Fecha = new DateOnly(2026, 5, 1), + Concepto = "Visible", + Monto = 10m, + Saldo = 10m, + FilaNumero = 1 + }, + new Extracto + { + Id = deletedExtractoId, + CuentaId = cuenta.Id, + Fecha = new DateOnly(2026, 5, 2), + Concepto = "Eliminado", + Monto = 20m, + Saldo = 30m, + FilaNumero = 2, + DeletedAt = DateTime.UtcNow + }); + db.Auditorias.AddRange( + new Auditoria + { + Id = Guid.NewGuid(), + TipoAccion = "extracto_update", + EntidadTipo = "EXTRACTOS", + EntidadId = activeExtractoId, + ValorAnterior = "VisibleAnterior", + ValorNuevo = "VisibleNuevo", + Timestamp = DateTime.UtcNow + }, + new Auditoria + { + Id = Guid.NewGuid(), + TipoAccion = "extracto_update", + EntidadTipo = "EXTRACTOS", + EntidadId = deletedExtractoId, + ValorAnterior = "DeletedAnterior", + ValorNuevo = "DeletedNuevo", + Timestamp = DateTime.UtcNow + }); + await db.SaveChangesAsync(); + + var controller = BuildController(db, token); + + var result = await controller.Auditoria("full", cuenta.Id, null, null, null, "all", 100, 1, CancellationToken.None); + + var okResult = result.Should().BeOfType().Subject; + var payload = JsonSerializer.Serialize(okResult.Value); + payload.Should().Contain("VisibleNuevo"); + payload.Should().NotContain("DeletedNuevo"); + payload.Should().NotContain("DeletedAnterior"); + } + private sealed class TiposCambioServiceStub : ITiposCambioService { public Task ConvertAsync(decimal amount, string divisaOrigen, string divisaDestino, CancellationToken cancellationToken) diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/RowLevelSecurityTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/RowLevelSecurityTests.cs new file mode 100644 index 0000000..d4ff5a8 --- /dev/null +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/RowLevelSecurityTests.cs @@ -0,0 +1,500 @@ +using FluentAssertions; +using GestionCaja.API.Data; +using GestionCaja.API.Models; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Xunit; + +namespace GestionCaja.API.Tests; + +[Collection(PostgresCollection.Name)] +public sealed class RowLevelSecurityTests +{ + private const string RlsContextSecret = "test-rls-context-secret-with-more-than-32-characters"; + private readonly PostgresFixture _fixture; + + public RowLevelSecurityTests(PostgresFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task CoreFinancialTables_Should_Enforce_Rls_By_User_And_IntegrationScope() + { + var (migrationConnectionString, runtimeConnectionString) = await CreateRoleConnectionStringsAsync(); + var migrationOptions = new DbContextOptionsBuilder() + .UseNpgsql(migrationConnectionString) + .UseSnakeCaseNamingConvention() + .Options; + var options = new DbContextOptionsBuilder() + .UseNpgsql(runtimeConnectionString) + .UseSnakeCaseNamingConvention() + .Options; + + var adminId = Guid.NewGuid(); + var readerId = Guid.NewGuid(); + var writerId = Guid.NewGuid(); + var titularPermitidoId = Guid.NewGuid(); + var titularBloqueadoId = Guid.NewGuid(); + var cuentaPermitidaId = Guid.NewGuid(); + var cuentaBloqueadaId = Guid.NewGuid(); + var extractoPermitidoId = Guid.NewGuid(); + var extractoBloqueadoId = Guid.NewGuid(); + var integrationTokenId = Guid.NewGuid(); + + await using (var db = new AppDbContext(migrationOptions)) + { + await db.Database.MigrateAsync(); + } + + await ConfigureRlsRuntimeAsync(migrationConnectionString, runtimeConnectionString); + + await using (var db = new AppDbContext(options)) + { + await db.Database.OpenConnectionAsync(); + await SetRlsContextAsync( + (NpgsqlConnection)db.Database.GetDbConnection(), + "system", + null, + null, + isAdmin: true, + isSystem: true, + "system"); + + db.Usuarios.AddRange( + new Usuario + { + Id = adminId, + Email = $"admin-{Guid.NewGuid():N}@atlas.local", + NombreCompleto = "Admin RLS", + PasswordHash = "test", + Rol = RolUsuario.ADMIN, + Activo = true + }, + new Usuario + { + Id = readerId, + Email = $"reader-{Guid.NewGuid():N}@atlas.local", + NombreCompleto = "Reader RLS", + PasswordHash = "test", + Rol = RolUsuario.GERENTE, + Activo = true + }, + new Usuario + { + Id = writerId, + Email = $"writer-{Guid.NewGuid():N}@atlas.local", + NombreCompleto = "Writer RLS", + PasswordHash = "test", + Rol = RolUsuario.GERENTE, + Activo = true + }); + + db.Titulares.AddRange( + new Titular { Id = titularPermitidoId, Nombre = "Titular permitido", Tipo = TipoTitular.EMPRESA }, + new Titular { Id = titularBloqueadoId, Nombre = "Titular bloqueado", Tipo = TipoTitular.EMPRESA }); + + db.Cuentas.AddRange( + new Cuenta + { + Id = cuentaPermitidaId, + TitularId = titularPermitidoId, + Nombre = "Cuenta permitida", + Divisa = "EUR", + Activa = true + }, + new Cuenta + { + Id = cuentaBloqueadaId, + TitularId = titularBloqueadoId, + Nombre = "Cuenta bloqueada", + Divisa = "EUR", + Activa = true + }); + + db.Extractos.AddRange( + new Extracto + { + Id = extractoPermitidoId, + CuentaId = cuentaPermitidaId, + Fecha = new DateOnly(2026, 5, 1), + Concepto = "Permitido", + Monto = 10, + Saldo = 10, + FilaNumero = 1 + }, + new Extracto + { + Id = extractoBloqueadoId, + CuentaId = cuentaBloqueadaId, + Fecha = new DateOnly(2026, 5, 1), + Concepto = "Bloqueado", + Monto = 20, + Saldo = 20, + FilaNumero = 1 + }); + + db.PermisosUsuario.AddRange( + new PermisoUsuario + { + Id = Guid.NewGuid(), + UsuarioId = readerId, + CuentaId = cuentaPermitidaId, + PuedeVerCuentas = true + }, + new PermisoUsuario + { + Id = Guid.NewGuid(), + UsuarioId = writerId, + CuentaId = cuentaPermitidaId, + PuedeImportar = true + }); + + db.IntegrationTokens.Add(new IntegrationToken + { + Id = integrationTokenId, + Nombre = "RLS integration", + TokenHash = Guid.NewGuid().ToString("N"), + Tipo = "openclaw", + Estado = EstadoTokenIntegracion.Activo, + PermisoLectura = true, + UsuarioCreadorId = adminId + }); + db.IntegrationPermissions.Add(new IntegrationPermission + { + Id = Guid.NewGuid(), + TokenId = integrationTokenId, + CuentaId = cuentaBloqueadaId, + AccesoTipo = "lectura" + }); + + await db.SaveChangesAsync(); + await db.Database.CloseConnectionAsync(); + } + + NpgsqlConnection.ClearAllPools(); + + await using var connection = new NpgsqlConnection(runtimeConnectionString); + await connection.OpenAsync(); + + var tableFailures = await ExecuteScalarAsync( + connection, + """ + SELECT count(*) + FROM ( + SELECT c.relname, c.relrowsecurity, c.relforcerowsecurity, coalesce(p.policy_count, 0) AS policy_count + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + LEFT JOIN ( + SELECT polrelid, count(*) AS policy_count + FROM pg_policy + GROUP BY polrelid + ) p ON p.polrelid = c.oid + WHERE n.nspname = 'public' + AND c.relname IN ( + 'TITULARES', + 'CUENTAS', + 'PLAZOS_FIJOS', + 'EXTRACTOS', + 'EXTRACTOS_COLUMNAS_EXTRA', + 'EXPORTACIONES', + 'PREFERENCIAS_USUARIO_CUENTA', + 'AUDITORIAS', + 'AUDITORIA_INTEGRACIONES', + 'BACKUPS', + 'NOTIFICACIONES_ADMIN' + ) + ) r + WHERE NOT r.relrowsecurity OR NOT r.relforcerowsecurity OR r.policy_count = 0 + """); + tableFailures.Should().Be(0); + + var roleFlags = await ExecuteScalarAsync( + connection, + "SELECT rolsuper::text || '|' || rolbypassrls::text FROM pg_roles WHERE rolname = current_user"); + roleFlags.Should().Be("false|false"); + + var runtimeOwnedTables = await ExecuteScalarAsync( + connection, + """ + SELECT count(*) + FROM pg_tables + WHERE schemaname = 'public' + AND tableowner = current_user + AND tablename IN ( + 'TITULARES', + 'CUENTAS', + 'PLAZOS_FIJOS', + 'EXTRACTOS', + 'EXTRACTOS_COLUMNAS_EXTRA', + 'EXPORTACIONES', + 'PREFERENCIAS_USUARIO_CUENTA', + 'AUDITORIAS', + 'AUDITORIA_INTEGRACIONES', + 'BACKUPS', + 'NOTIFICACIONES_ADMIN' + ) + """); + runtimeOwnedTables.Should().Be(0); + + await SetRlsContextAsync(connection, "anonymous", null, null, isAdmin: false, isSystem: false, "anonymous"); + (await CountByIdsAsync(connection, "CUENTAS", cuentaPermitidaId, cuentaBloqueadaId)).Should().Be(0); + (await CountByIdsAsync(connection, "EXTRACTOS", extractoPermitidoId, extractoBloqueadoId)).Should().Be(0); + + await SetUnsignedRlsContextAsync(connection, "user", readerId, null, isAdmin: false, isSystem: false, "data"); + (await CountByIdsAsync(connection, "CUENTAS", cuentaPermitidaId, cuentaBloqueadaId)).Should().Be(0); + await SetUnsignedRlsContextAsync(connection, "system", null, null, isAdmin: true, isSystem: true, "system"); + (await CountByIdsAsync(connection, "CUENTAS", cuentaPermitidaId, cuentaBloqueadaId)).Should().Be(0); + + await SetRlsContextAsync(connection, "user", readerId, null, isAdmin: false, isSystem: false, "data"); + (await CountByIdsAsync(connection, "CUENTAS", cuentaPermitidaId, cuentaBloqueadaId)).Should().Be(1); + (await CountByIdsAsync(connection, "TITULARES", titularPermitidoId, titularBloqueadoId)).Should().Be(1); + (await CountByIdsAsync(connection, "EXTRACTOS", extractoPermitidoId, extractoBloqueadoId)).Should().Be(1); + + var deniedInsert = async () => await InsertExtractoAsync(connection, cuentaPermitidaId); + await deniedInsert.Should().ThrowAsync() + .Where(ex => ex.SqlState == PostgresErrorCodes.InsufficientPrivilege); + var deniedExport = async () => await InsertExportacionAsync(connection, cuentaPermitidaId); + await deniedExport.Should().ThrowAsync() + .Where(ex => ex.SqlState == PostgresErrorCodes.InsufficientPrivilege); + + await SetRlsContextAsync(connection, "user", writerId, null, isAdmin: false, isSystem: false, "data"); + await InsertExtractoAsync(connection, cuentaPermitidaId); + await InsertExportacionAsync(connection, cuentaPermitidaId); + + await SetRlsContextAsync(connection, "integration", null, integrationTokenId, isAdmin: false, isSystem: false, "integration"); + (await CountByIdsAsync(connection, "CUENTAS", cuentaPermitidaId, cuentaBloqueadaId)).Should().Be(1); + (await CountByIdsAsync(connection, "EXTRACTOS", extractoPermitidoId, extractoBloqueadoId)).Should().Be(1); + + await SetRlsContextAsync(connection, "user", adminId, null, isAdmin: true, isSystem: false, "data"); + (await CountByIdsAsync(connection, "CUENTAS", cuentaPermitidaId, cuentaBloqueadaId)).Should().Be(2); + (await CountByIdsAsync(connection, "EXTRACTOS", extractoPermitidoId, extractoBloqueadoId)).Should().Be(2); + } + + private async Task<(string MigrationConnectionString, string RuntimeConnectionString)> CreateRoleConnectionStringsAsync() + { + var builder = new NpgsqlConnectionStringBuilder(_fixture.ConnectionString); + var ownerRole = $"rls_owner_{Guid.NewGuid():N}"[..26]; + var runtimeRole = $"rls_app_{Guid.NewGuid():N}"[..24]; + var ownerPassword = $"test-{Guid.NewGuid():N}"; + var runtimePassword = $"test-{Guid.NewGuid():N}"; + var escapedOwnerPassword = ownerPassword.Replace("'", "''", StringComparison.Ordinal); + var escapedPassword = runtimePassword.Replace("'", "''", StringComparison.Ordinal); + var escapedDatabase = builder.Database?.Replace("\"", "\"\"", StringComparison.Ordinal) ?? "gestion_caja_tests"; + + await using var connection = new NpgsqlConnection(_fixture.ConnectionString); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = $""" + CREATE EXTENSION IF NOT EXISTS pgcrypto; + CREATE ROLE "{ownerRole}" WITH LOGIN PASSWORD '{escapedOwnerPassword}' NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS; + CREATE ROLE "{runtimeRole}" WITH LOGIN PASSWORD '{escapedPassword}' NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS; + ALTER DATABASE "{escapedDatabase}" OWNER TO "{ownerRole}"; + ALTER SCHEMA public OWNER TO "{ownerRole}"; + GRANT CONNECT ON DATABASE "{escapedDatabase}" TO "{ownerRole}"; + GRANT CONNECT ON DATABASE "{escapedDatabase}" TO "{runtimeRole}"; + GRANT USAGE, CREATE ON SCHEMA public TO "{ownerRole}"; + DO $$ + DECLARE + ns record; + obj record; + BEGIN + FOR ns IN + SELECT n.nspname + FROM pg_namespace n + WHERE n.nspname IN ('public', 'atlas_security') + LOOP + EXECUTE format('ALTER SCHEMA %I OWNER TO %I', ns.nspname, '{ownerRole}'); + EXECUTE format('GRANT USAGE, CREATE ON SCHEMA %I TO %I', ns.nspname, '{ownerRole}'); + FOR obj IN + SELECT c.relkind, c.relname + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = ns.nspname + AND c.relkind IN ('r','S','v','m','p') + LOOP + EXECUTE format('ALTER %s %I.%I OWNER TO %I', + CASE obj.relkind + WHEN 'r' THEN 'TABLE' + WHEN 'p' THEN 'TABLE' + WHEN 'S' THEN 'SEQUENCE' + WHEN 'v' THEN 'VIEW' + WHEN 'm' THEN 'MATERIALIZED VIEW' + END, + ns.nspname, obj.relname, '{ownerRole}'); + END LOOP; + FOR obj IN + SELECT p.proname, pg_get_function_identity_arguments(p.oid) AS args + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = ns.nspname + LOOP + EXECUTE format('ALTER FUNCTION %I.%I(%s) OWNER TO %I', + ns.nspname, obj.proname, obj.args, '{ownerRole}'); + END LOOP; + END LOOP; + END + $$; + """; + await command.ExecuteNonQueryAsync(); + + var migrationBuilder = new NpgsqlConnectionStringBuilder(builder.ConnectionString) + { + Username = ownerRole, + Password = ownerPassword + }; + var runtimeBuilder = new NpgsqlConnectionStringBuilder(builder.ConnectionString) + { + Username = runtimeRole, + Password = runtimePassword + }; + return (migrationBuilder.ConnectionString, runtimeBuilder.ConnectionString); + } + + private static async Task ConfigureRlsRuntimeAsync(string migrationConnectionString, string runtimeConnectionString) + { + var runtimeBuilder = new NpgsqlConnectionStringBuilder(runtimeConnectionString); + var runtimeUsername = runtimeBuilder.Username + ?? throw new InvalidOperationException("Runtime username is required for RLS test grants."); + var runtimeRole = QuoteIdentifier(runtimeUsername); + + await using var connection = new NpgsqlConnection(migrationConnectionString); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = $$""" + INSERT INTO atlas_security.rls_context_secret (id, secret, updated_at) + VALUES (true, @secret, now()) + ON CONFLICT (id) DO UPDATE + SET secret = EXCLUDED.secret, + updated_at = now(); + + REVOKE ALL ON TABLE atlas_security.rls_context_secret FROM PUBLIC; + REVOKE ALL ON TABLE atlas_security.rls_context_secret FROM {{runtimeRole}}; + GRANT USAGE ON SCHEMA public TO {{runtimeRole}}; + GRANT USAGE ON SCHEMA atlas_security TO {{runtimeRole}}; + GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO {{runtimeRole}}; + GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO {{runtimeRole}}; + GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA atlas_security TO {{runtimeRole}}; + """; + command.Parameters.AddWithValue("secret", RlsContextSecret); + await command.ExecuteNonQueryAsync(); + } + + private static async Task SetRlsContextAsync( + NpgsqlConnection connection, + string authMode, + Guid? userId, + Guid? integrationTokenId, + bool isAdmin, + bool isSystem, + string requestScope) + { + await using var command = connection.CreateCommand(); + var isAdminText = isAdmin ? "true" : "false"; + var isSystemText = isSystem ? "true" : "false"; + var signature = RlsContextSigner.Sign( + RlsContextSecret, + authMode, + userId?.ToString() ?? string.Empty, + integrationTokenId?.ToString() ?? string.Empty, + isAdminText, + isSystemText, + requestScope); + command.CommandText = """ + SELECT + set_config('atlas.auth_mode', @auth_mode, false), + set_config('atlas.user_id', @user_id, false), + set_config('atlas.integration_token_id', @integration_token_id, false), + set_config('atlas.is_admin', @is_admin, false), + set_config('atlas.system', @system, false), + set_config('atlas.request_scope', @request_scope, false), + set_config('atlas.context_signature', @context_signature, false) + """; + command.Parameters.AddWithValue("auth_mode", authMode); + command.Parameters.AddWithValue("user_id", userId?.ToString() ?? string.Empty); + command.Parameters.AddWithValue("integration_token_id", integrationTokenId?.ToString() ?? string.Empty); + command.Parameters.AddWithValue("is_admin", isAdminText); + command.Parameters.AddWithValue("system", isSystemText); + command.Parameters.AddWithValue("request_scope", requestScope); + command.Parameters.AddWithValue("context_signature", signature); + await command.ExecuteNonQueryAsync(); + } + + private static async Task SetUnsignedRlsContextAsync( + NpgsqlConnection connection, + string authMode, + Guid? userId, + Guid? integrationTokenId, + bool isAdmin, + bool isSystem, + string requestScope) + { + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT + set_config('atlas.auth_mode', @auth_mode, false), + set_config('atlas.user_id', @user_id, false), + set_config('atlas.integration_token_id', @integration_token_id, false), + set_config('atlas.is_admin', @is_admin, false), + set_config('atlas.system', @system, false), + set_config('atlas.request_scope', @request_scope, false), + set_config('atlas.context_signature', 'invalid-signature', false) + """; + command.Parameters.AddWithValue("auth_mode", authMode); + command.Parameters.AddWithValue("user_id", userId?.ToString() ?? string.Empty); + command.Parameters.AddWithValue("integration_token_id", integrationTokenId?.ToString() ?? string.Empty); + command.Parameters.AddWithValue("is_admin", isAdmin ? "true" : "false"); + command.Parameters.AddWithValue("system", isSystem ? "true" : "false"); + command.Parameters.AddWithValue("request_scope", requestScope); + await command.ExecuteNonQueryAsync(); + } + + private static async Task CountByIdsAsync(NpgsqlConnection connection, string table, params Guid[] ids) + { + await using var command = connection.CreateCommand(); + command.CommandText = $"""SELECT count(*) FROM "{table}" WHERE id = ANY(@ids)"""; + command.Parameters.AddWithValue("ids", ids); + return (long)(await command.ExecuteScalarAsync() ?? 0L); + } + + private static async Task ExecuteScalarAsync(NpgsqlConnection connection, string sql) + { + await using var command = connection.CreateCommand(); + command.CommandText = sql; + var result = await command.ExecuteScalarAsync(); + result.Should().NotBeNull(); + return (T)result!; + } + + private static async Task InsertExtractoAsync(NpgsqlConnection connection, Guid cuentaId) + { + await using var command = connection.CreateCommand(); + command.CommandText = """ + INSERT INTO "EXTRACTOS" + (id, cuenta_id, fecha, concepto, monto, saldo, fila_numero, checked, flagged, fecha_creacion) + VALUES + (@id, @cuenta_id, DATE '2026-05-02', 'RLS insert', 1, 1, @fila_numero, false, false, now()) + """; + command.Parameters.AddWithValue("id", Guid.NewGuid()); + command.Parameters.AddWithValue("cuenta_id", cuentaId); + command.Parameters.AddWithValue("fila_numero", Random.Shared.Next(1000, 1000000)); + await command.ExecuteNonQueryAsync(); + } + + private static async Task InsertExportacionAsync(NpgsqlConnection connection, Guid cuentaId) + { + await using var command = connection.CreateCommand(); + command.CommandText = """ + INSERT INTO "EXPORTACIONES" + (id, cuenta_id, fecha_exportacion, estado, tipo) + VALUES + (@id, @cuenta_id, now(), 1, 1) + """; + command.Parameters.AddWithValue("id", Guid.NewGuid()); + command.Parameters.AddWithValue("cuenta_id", cuentaId); + await command.ExecuteNonQueryAsync(); + } + + private static string QuoteIdentifier(string value) => + "\"" + value.Replace("\"", "\"\"", StringComparison.Ordinal) + "\""; +} diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/SeedDataTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/SeedDataTests.cs index 4392020..835c551 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/SeedDataTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/SeedDataTests.cs @@ -81,6 +81,37 @@ public void Initialize_Should_Add_Default_Bank_Formats_Without_Duplicating_Exist .Be(adminId); } + [Fact] + public void Initialize_Should_Not_Duplicate_Default_Format_When_Fixed_Id_Already_Exists() + { + using var db = BuildDbContext(); + db.Usuarios.Add(new Usuario + { + Id = Guid.NewGuid(), + Email = "admin.seed@test.local", + PasswordHash = "hash", + NombreCompleto = "Admin Seed", + Rol = RolUsuario.ADMIN, + Activo = true, + PrimerLogin = false + }); + db.FormatosImportacion.Add(new FormatoImportacion + { + Id = Guid.Parse("e1b2cba0-60bd-4854-9b24-d2e88763fa5d"), + Nombre = "Formato legado", + BancoNombre = null, + Divisa = null, + MapeoJson = "{}", + Activo = true + }); + db.SaveChanges(); + + var act = () => SeedData.Initialize(db); + + act.Should().NotThrow(); + db.FormatosImportacion.IgnoreQueryFilters().Should().HaveCount(8); + } + [Fact] public void Initialize_Should_Reject_Default_Admin_Password_In_Production() { diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/UserAccessServiceTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/UserAccessServiceTests.cs index b0c0541..d807875 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/UserAccessServiceTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/UserAccessServiceTests.cs @@ -121,6 +121,42 @@ public async Task GetScopeAsync_Should_Not_Grant_Global_Data_Access_For_Dashboar scope.HasGlobalAccess.Should().BeFalse(); } + [Fact] + public async Task GetScopeAsync_Should_Not_Grant_Scoped_Data_Access_For_DashboardOnly_Permission() + { + await using var db = BuildDbContext(); + var userId = Guid.NewGuid(); + var titularId = Guid.NewGuid(); + var cuentaId = Guid.NewGuid(); + db.PermisosUsuario.Add(new PermisoUsuario + { + Id = Guid.NewGuid(), + UsuarioId = userId, + CuentaId = cuentaId, + TitularId = titularId, + PuedeAgregarLineas = false, + PuedeEditarLineas = false, + PuedeEliminarLineas = false, + PuedeImportar = false, + PuedeVerDashboard = true + }); + await db.SaveChangesAsync(); + + var identity = new ClaimsIdentity( + [ + new Claim(ClaimTypes.NameIdentifier, userId.ToString()), + new Claim(ClaimTypes.Role, nameof(RolUsuario.GERENTE)) + ], "TestAuth"); + + var principal = new ClaimsPrincipal(identity); + var service = new UserAccessService(db); + var scope = await service.GetScopeAsync(principal, CancellationToken.None); + + scope.HasPermissions.Should().BeTrue(); + scope.CuentaIds.Should().BeEmpty(); + scope.TitularIds.Should().BeEmpty(); + } + [Fact] public async Task GetScopeAsync_Should_Grant_Global_Access_For_ViewAccounts_GlobalPermission() { diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/UsuariosControllerTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/UsuariosControllerTests.cs index c2fa46b..6962eba 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/UsuariosControllerTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/UsuariosControllerTests.cs @@ -147,6 +147,55 @@ public async Task Actualizar_Should_Revoke_Sessions_When_Admin_Resets_Password() (await db.Auditorias.AnyAsync(x => x.EntidadId == user.Id && x.TipoAccion == AuditActions.PasswordReset)).Should().BeTrue(); } + [Fact] + public async Task GuardarPermisos_Should_Revoke_Target_User_Sessions() + { + await using var db = BuildDbContext(); + var audit = new AuditService(db); + var controller = new UsuariosController(db, audit); + controller.ControllerContext = BuildControllerContext(Guid.NewGuid()); + + var user = new Usuario + { + Id = Guid.NewGuid(), + Email = "perms.target@atlasbalance.local", + NombreCompleto = "Perms Target", + PasswordHash = BCrypt.Net.BCrypt.HashPassword("OldPass123!", workFactor: 12), + Rol = RolUsuario.EMPLEADO, + Activo = true, + PrimerLogin = false, + FechaCreacion = DateTime.UtcNow, + SecurityStamp = "old-stamp" + }; + db.Usuarios.Add(user); + db.RefreshTokens.Add(new RefreshToken + { + Id = Guid.NewGuid(), + UsuarioId = user.Id, + TokenHash = "permissions-token-hash", + ExpiraEn = DateTime.UtcNow.AddDays(1), + CreadoEn = DateTime.UtcNow + }); + await db.SaveChangesAsync(); + + var request = new[] + { + new SavePermisoUsuarioRequest + { + PuedeVerCuentas = true, + PuedeVerDashboard = true + } + }; + + var result = await controller.GuardarPermisos(user.Id, request, CancellationToken.None); + + result.Should().BeOfType(); + var persisted = await db.Usuarios.SingleAsync(x => x.Id == user.Id); + persisted.SecurityStamp.Should().NotBe("old-stamp"); + (await db.RefreshTokens.SingleAsync(x => x.UsuarioId == user.Id)).RevocadoEn.Should().NotBeNull(); + (await db.Auditorias.AnyAsync(x => x.EntidadId == user.Id && x.TipoAccion == AuditActions.CambioPermisos)).Should().BeTrue(); + } + private static ControllerContext BuildControllerContext(Guid adminId) { var identity = new ClaimsIdentity(new[] diff --git a/Atlas Balance/backend/tests/GestionCaja.API.Tests/WatchdogOperationsServiceTests.cs b/Atlas Balance/backend/tests/GestionCaja.API.Tests/WatchdogOperationsServiceTests.cs index 0b30450..f8bfb1d 100644 --- a/Atlas Balance/backend/tests/GestionCaja.API.Tests/WatchdogOperationsServiceTests.cs +++ b/Atlas Balance/backend/tests/GestionCaja.API.Tests/WatchdogOperationsServiceTests.cs @@ -126,7 +126,8 @@ private static WatchdogOperationsService CreateService( ["WatchdogSettings:ApiServiceName"] = $"FakeService-{Guid.NewGuid():N}", ["WatchdogSettings:UpdateSourceRoot"] = updateSourceRoot, ["WatchdogSettings:UpdateTargetPath"] = updateTargetPath, - ["WatchdogSettings:BackupPath"] = backupPathRoot + ["WatchdogSettings:BackupPath"] = backupPathRoot, + ["WatchdogSettings:RequireDatabaseBackupBeforeUpdate"] = "false" }) .Build(); diff --git a/Atlas Balance/docker-compose.yml b/Atlas Balance/docker-compose.yml index 3703ce4..c684753 100644 --- a/Atlas Balance/docker-compose.yml +++ b/Atlas Balance/docker-compose.yml @@ -1,16 +1,19 @@ services: postgres: - image: postgres:16-alpine + image: postgres:16-alpine@sha256:4e6e670bb069649261c9c18031f0aded7bb249a5b6664ddec29c013a89310d50 container_name: atlas_balance_db restart: unless-stopped environment: POSTGRES_DB: atlas_balance - POSTGRES_USER: app_user - POSTGRES_PASSWORD: ${ATLAS_BALANCE_POSTGRES_PASSWORD:?Set ATLAS_BALANCE_POSTGRES_PASSWORD in a local .env file or environment variable} + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${ATLAS_BALANCE_POSTGRES_OWNER_PASSWORD:?Set ATLAS_BALANCE_POSTGRES_OWNER_PASSWORD in a local .env file or environment variable} + ATLAS_BALANCE_POSTGRES_OWNER_PASSWORD: ${ATLAS_BALANCE_POSTGRES_OWNER_PASSWORD:?Set ATLAS_BALANCE_POSTGRES_OWNER_PASSWORD in a local .env file or environment variable} + ATLAS_BALANCE_POSTGRES_APP_PASSWORD: ${ATLAS_BALANCE_POSTGRES_APP_PASSWORD:?Set ATLAS_BALANCE_POSTGRES_APP_PASSWORD in a local .env file or environment variable} ports: - "5433:5432" volumes: - pgdata:/var/lib/postgresql/data + - ./scripts/postgres-init:/docker-entrypoint-initdb.d:ro volumes: pgdata: diff --git a/Atlas Balance/frontend/package-lock.json b/Atlas Balance/frontend/package-lock.json index 7a01e21..5edec8e 100644 --- a/Atlas Balance/frontend/package-lock.json +++ b/Atlas Balance/frontend/package-lock.json @@ -1,17 +1,18 @@ { "name": "atlas-balance-frontend", - "version": "1.4.0", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "atlas-balance-frontend", - "version": "1.4.0", + "version": "1.5.0", "dependencies": { "@fontsource-variable/geist": "^5.2.8", "@tanstack/react-virtual": "^3.11.2", "axios": "^1.15.2", "lucide-react": "^1.11.0", + "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.54.1", @@ -22,6 +23,7 @@ "devDependencies": { "@playwright/test": "^1.59.1", "@types/node": "^20.17.10", + "@types/qrcode": "^1.5.6", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@typescript-eslint/eslint-plugin": "^7.18.0", @@ -727,6 +729,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", @@ -760,9 +772,9 @@ "@typescript-eslint/type-utils": "7.18.0", "@typescript-eslint/utils": "7.18.0", "@typescript-eslint/visitor-keys": "7.18.0", - "graphemer": "^1.4.0", + "graphemer": "^1.5.0", "ignore": "^5.3.1", - "natural-compare": "^1.4.0", + "natural-compare": "^1.5.0", "ts-api-utils": "^1.3.0" }, "engines": { @@ -1018,7 +1030,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1028,7 +1039,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -1127,6 +1137,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1144,6 +1163,17 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1157,7 +1187,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -1170,7 +1199,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -1352,6 +1380,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -1384,6 +1421,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -1434,6 +1477,12 @@ "node": ">= 0.4" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1524,7 +1573,7 @@ "find-up": "^5.0.0", "glob-parent": "^6.0.2", "globals": "^13.19.0", - "graphemer": "^1.4.0", + "graphemer": "^1.5.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", @@ -1534,7 +1583,7 @@ "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", + "natural-compare": "^1.5.0", "optionator": "^0.9.3", "strip-ansi": "^6.0.1", "text-table": "^0.2.0" @@ -1898,6 +1947,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -2044,8 +2102,8 @@ } }, "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.5.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true, "license": "MIT" @@ -2174,6 +2232,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -2581,8 +2648,8 @@ "license": "MIT" }, "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.5.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", "dependencies": { @@ -2698,8 +2765,8 @@ } }, "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.5.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, "license": "MIT" @@ -2714,8 +2781,8 @@ } }, "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.5.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, "license": "ISC", @@ -2773,6 +2840,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -2790,7 +2866,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2893,6 +2968,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", @@ -2938,7 +3022,7 @@ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", "dependencies": { - "loose-envify": "^1.4.0", + "loose-envify": "^1.5.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } @@ -2968,6 +3052,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -3091,7 +3192,7 @@ "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", + "loose-envify": "^1.5.0", "prop-types": "^15.6.2" }, "peerDependencies": { @@ -3131,6 +3232,21 @@ "decimal.js-light": "^2.4.1" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3256,6 +3372,12 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3299,11 +3421,24 @@ "node": ">=0.10.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -3628,6 +3763,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -3638,6 +3779,20 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -3645,6 +3800,99 @@ "dev": true, "license": "ISC" }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/Atlas Balance/frontend/package.json b/Atlas Balance/frontend/package.json index 5bcc02b..4d60adb 100644 --- a/Atlas Balance/frontend/package.json +++ b/Atlas Balance/frontend/package.json @@ -1,8 +1,8 @@ { "name": "atlas-balance-frontend", "private": true, - "version": "1.4.0", - "appVersion": "V-01.04", + "version": "1.5.0", + "appVersion": "V-01.05", "type": "module", "scripts": { "dev": "vite", @@ -16,6 +16,7 @@ "@tanstack/react-virtual": "^3.11.2", "axios": "^1.15.2", "lucide-react": "^1.11.0", + "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.54.1", @@ -26,6 +27,7 @@ "devDependencies": { "@playwright/test": "^1.59.1", "@types/node": "^20.17.10", + "@types/qrcode": "^1.5.6", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@typescript-eslint/eslint-plugin": "^7.18.0", diff --git a/Atlas Balance/frontend/src/components/dashboard/EvolucionChart.tsx b/Atlas Balance/frontend/src/components/dashboard/EvolucionChart.tsx index 618664d..bc47f4c 100644 --- a/Atlas Balance/frontend/src/components/dashboard/EvolucionChart.tsx +++ b/Atlas Balance/frontend/src/components/dashboard/EvolucionChart.tsx @@ -17,14 +17,21 @@ interface EvolucionChartProps { points: DashboardPuntoEvolucion[]; divisa: string; colors: DashboardChartColors; + height?: number; } -export function EvolucionChart({ points, divisa, colors }: EvolucionChartProps) { +const EVOLUTION_AXIS_MIN_WIDTH = 44; +const EVOLUTION_AXIS_MAX_WIDTH = 72; +const EVOLUTION_AXIS_CHAR_WIDTH = 6.2; +const EVOLUTION_AXIS_PADDING = 14; + +export function EvolucionChart({ points, divisa, colors, height = 320 }: EvolucionChartProps) { if (points.length === 0) { return

No hay datos de evolucion para este periodo.

; } const lastPoint = points[points.length - 1]; + const axisWidth = getEvolutionAxisWidth(points, divisa); return (
Ingresos Egresos
- - + + formatDate(value)} axisLine={false} tickLine={false} + tickMargin={10} minTickGap={28} /> formatCompactCurrency(value, divisa)} - width={116} + width={axisWidth} axisLine={false} tickLine={false} + tickMargin={10} /> } cursor={{ stroke: 'var(--chart-grid)' }} /> { + const labels = [ + formatCompactCurrency(point.ingresos, divisa), + formatCompactCurrency(point.egresos, divisa), + formatCompactCurrency(point.saldo, divisa), + ]; + + return Math.max(maxLength, ...labels.map((label) => label.length)); + }, 0); + + const estimatedWidth = Math.ceil(maxLabelLength * EVOLUTION_AXIS_CHAR_WIDTH + EVOLUTION_AXIS_PADDING); + return Math.min(EVOLUTION_AXIS_MAX_WIDTH, Math.max(EVOLUTION_AXIS_MIN_WIDTH, estimatedWidth)); +} + function DashboardTooltip({ active, payload, label, divisa }: TooltipProps & { divisa: string }) { if (!active || !payload?.length) { return null; diff --git a/Atlas Balance/frontend/src/components/dashboard/SaldoPorDivisaCard.tsx b/Atlas Balance/frontend/src/components/dashboard/SaldoPorDivisaCard.tsx index 8781b76..4ddece7 100644 --- a/Atlas Balance/frontend/src/components/dashboard/SaldoPorDivisaCard.tsx +++ b/Atlas Balance/frontend/src/components/dashboard/SaldoPorDivisaCard.tsx @@ -5,11 +5,17 @@ import { formatCurrency } from '@/utils/formatters'; interface SaldoPorDivisaCardProps { items: DashboardSaldoDivisa[]; divisaPrincipal: string; + className?: string; } -export function SaldoPorDivisaCard({ items, divisaPrincipal }: SaldoPorDivisaCardProps) { +export function SaldoPorDivisaCard({ items, divisaPrincipal, className }: SaldoPorDivisaCardProps) { + const orderedItems = [ + ...items.filter((item) => item.divisa === divisaPrincipal), + ...items.filter((item) => item.divisa !== divisaPrincipal), + ]; + return ( -
+

Saldos por divisa

@@ -18,26 +24,35 @@ export function SaldoPorDivisaCard({ items, divisaPrincipal }: SaldoPorDivisaCar

No hay saldos disponibles.

) : (
- {items.map((item) => ( + {orderedItems.map((item) => (
-

{item.divisa}

-

+

+

{item.divisa}

+ {item.divisa === divisaPrincipal ? Base : null} +
+

{formatCurrency(item.saldo_total ?? item.saldo, item.divisa)}

- - Disponible:{' '} - - {formatCurrency(item.saldo_disponible ?? item.saldo, item.divisa)} - - - - Inmovilizado:{' '} - - {formatCurrency(item.saldo_inmovilizado ?? 0, item.divisa)} - - +
+
+
Disponible
+
+ + {formatCurrency(item.saldo_disponible ?? item.saldo, item.divisa)} + +
+
+
+
Inmovilizado
+
+ + {formatCurrency(item.saldo_inmovilizado ?? 0, item.divisa)} + +
+
+
{item.divisa !== divisaPrincipal ? ( Equivale a{' '} diff --git a/Atlas Balance/frontend/src/components/extractos/ExtractoTable.tsx b/Atlas Balance/frontend/src/components/extractos/ExtractoTable.tsx index e7b6206..9f3fe78 100644 --- a/Atlas Balance/frontend/src/components/extractos/ExtractoTable.tsx +++ b/Atlas Balance/frontend/src/components/extractos/ExtractoTable.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useId } from 'react'; import { AppSelect } from '@/components/common/AppSelect'; @@ -76,18 +76,28 @@ export default function ExtractoTable({ }); }, [rows, filters, activeColumns]); + const headerOffset = density === 'compact' ? 40 : 46; const rowVirtualizer = useVirtualizer({ count: filteredRows.length, getScrollElement: () => parentRef.current, - estimateSize: () => 42, + estimateSize: () => (density === 'compact' ? 34 : 42), overscan: 15, + scrollMargin: headerOffset, + scrollPaddingStart: headerOffset, getItemKey: (index) => filteredRows[index]?.id ?? index }); + useEffect(() => { + rowVirtualizer.measure(); + }, [density, rowVirtualizer]); + const gridTemplateColumns = activeColumns.length > 0 ? activeColumns.map(getColumnTrack).join(' ') : '1fr'; return ( -
+
{filteredRows.length.toLocaleString('es-ES')} filas @@ -132,95 +142,112 @@ export default function ExtractoTable({ checked={visibleColumns ? visibleColumns.includes(column) : true} onChange={() => onToggleColumn(column)} /> - {column} + {getColumnLabel(column)} ))}
) : null} -
- {activeColumns.map((column) => ( -
- - {showFilters ? ( - setFilters((prev) => ({ ...prev, [column]: e.target.value }))} + + {showFilters ? ( + setFilters((prev) => ({ ...prev, [column]: e.target.value }))} + /> + ) : null} +
+ ))} +
+ +
+ {loading ? ( +
+ +
+ ) : filteredRows.length === 0 ? ( +
+ - ) : null} -
- ))} -
-
- {loading ? ( -
- -
- ) : filteredRows.length === 0 ? ( -
- -
- ) : ( -
- {rowVirtualizer.getVirtualItems().map((virtualRow) => { - const row = filteredRows[virtualRow.index]; - return ( -
- {activeColumns.map((column) => ( -
{ - e.preventDefault(); - onOpenAudit(row, column); - }} - > - {renderCell({ - row, - column, - canEdit: canEditCell(row, column), - amountClassName: getAmountClassName(row, column), - note: flagNotes[row.id] ?? row.flagged_nota ?? '', - onNoteChange: (next) => setFlagNotes((prev) => ({ ...prev, [row.id]: next })), - onSaveCell, - onToggleCheck, - onToggleFlag - })} - {!ACTION_COLUMNS.has(column) ? ( - - ) : null} -
- ))} -
- ); - })} -
- )} +
+ ) : ( +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const row = filteredRows[virtualRow.index]; + return ( +
+ {activeColumns.map((column) => ( +
{ + e.preventDefault(); + onOpenAudit(row, column); + }} + > + {renderCell({ + row, + column, + canEdit: canEditCell(row, column), + amountClassName: getAmountClassName(row, column), + note: flagNotes[row.id] ?? row.flagged_nota ?? '', + onNoteChange: (next) => setFlagNotes((prev) => ({ ...prev, [row.id]: next })), + onSaveCell, + onToggleCheck, + onToggleFlag + })} + {!ACTION_COLUMNS.has(column) ? ( + + ) : null} +
+ ))} +
+ ); + })} +
+ )} +
); @@ -322,14 +349,14 @@ function getAmountClassName(row: Extracto, column: string): string { } function getColumnTrack(column: string): string { - if (column === 'fila_numero') return '84px'; - if (column === 'checked') return '108px'; - if (column === 'flagged') return '220px'; - if (column === 'fecha') return '132px'; - if (column === 'concepto') return 'minmax(260px, 2fr)'; - if (column === 'comentarios') return 'minmax(220px, 1.4fr)'; - if (AMOUNT_COLUMNS.has(column)) return 'minmax(148px, 168px)'; - return 'minmax(150px, 1fr)'; + if (column === 'fila_numero') return '72px'; + if (column === 'checked') return '92px'; + if (column === 'flagged') return '210px'; + if (column === 'fecha') return '124px'; + if (column === 'concepto') return 'minmax(320px, 2fr)'; + if (column === 'comentarios') return 'minmax(260px, 1.35fr)'; + if (AMOUNT_COLUMNS.has(column)) return 'minmax(142px, 164px)'; + return 'minmax(156px, 1fr)'; } function getColumnClassName(column: string): string { @@ -340,3 +367,26 @@ function getColumnClassName(column: string): string { return classes.join(' '); } + +function getColumnLabel(column: string): string { + switch (column) { + case 'fila_numero': + return 'Fila'; + case 'checked': + return 'Rev.'; + case 'flagged': + return 'Alerta'; + case 'fecha': + return 'Fecha'; + case 'concepto': + return 'Concepto'; + case 'comentarios': + return 'Comentarios'; + case 'monto': + return 'Importe'; + case 'saldo': + return 'Saldo'; + default: + return column.replace(/_/g, ' '); + } +} diff --git a/Atlas Balance/frontend/src/components/layout/BottomNav.tsx b/Atlas Balance/frontend/src/components/layout/BottomNav.tsx index ba8fb1c..c2c9914 100644 --- a/Atlas Balance/frontend/src/components/layout/BottomNav.tsx +++ b/Atlas Balance/frontend/src/components/layout/BottomNav.tsx @@ -1,13 +1,14 @@ import { useEffect, useMemo, useState } from 'react'; import { NavLink, useLocation } from 'react-router-dom'; import { IconMenu } from '@/components/Icons'; -import { getVisibleNavigationItems } from '@/utils/navigation'; +import { getVisibleNavigationItems, navigationGroups, type NavigationGroup } from '@/utils/navigation'; import { useAlertCount } from '@/stores/alertasStore'; import { useAuthStore } from '@/stores/authStore'; import { useNotificacionesAdminStore } from '@/stores/notificacionesAdminStore'; import { useUpdateStore } from '@/stores/updateStore'; -const PRIMARY_ITEM_PATHS = ['/dashboard', '/titulares', '/cuentas', '/extractos']; +const PRIMARY_ITEM_PATHS = ['/dashboard', '/titulares', '/cuentas', '/importacion']; +const SECONDARY_GROUP_ORDER: NavigationGroup[] = ['operacion', 'control', 'sistema']; export function BottomNav() { const location = useLocation(); @@ -29,6 +30,16 @@ export function BottomNav() { const hiddenBadgeCount = alertCount + exportacionesPendientes + (updateAvailable ? 1 : 0); const secondaryActive = secondaryItems.some((item) => location.pathname.startsWith(item.to)); + const secondaryGroups = useMemo( + () => + SECONDARY_GROUP_ORDER + .map((group) => ({ + group, + items: secondaryItems.filter((item) => item.group === group), + })) + .filter((item) => item.items.length > 0), + [secondaryItems] + ); useEffect(() => { setMenuOpen(false); @@ -75,7 +86,7 @@ export function BottomNav() { onClick={() => setMenuOpen((current) => !current)} > - Menu + Mas {hiddenBadgeCount > 0 ? {hiddenBadgeCount} : null} @@ -92,8 +103,8 @@ export function BottomNav() { >
- Menu -

Accesos secundarios y herramientas de administracion.

+ Mas +

Extractos, control y sistema.

-
- {secondaryItems.map((item) => { - const badge = - item.to === '/alertas' - ? alertCount - : item.to === '/exportaciones' && usuario?.rol === 'ADMIN' - ? exportacionesPendientes - : item.to === '/configuracion' && updateAvailable - ? 1 - : 0; +
+ {secondaryGroups.map(({ group, items }) => ( +
+

{navigationGroups[group].label}

+
+ {items.map((item) => { + const badge = + item.to === '/alertas' + ? alertCount + : item.to === '/exportaciones' && usuario?.rol === 'ADMIN' + ? exportacionesPendientes + : item.to === '/configuracion' && updateAvailable + ? 1 + : 0; - return ( - - isActive ? 'bottom-nav-sheet-link bottom-nav-sheet-link--active' : 'bottom-nav-sheet-link' - } - > - {item.icon} - {item.label} - {badge > 0 ? ( - - {item.to === '/configuracion' ? 'Update' : badge} - - ) : null} - - ); - })} + return ( + + isActive ? 'bottom-nav-sheet-link bottom-nav-sheet-link--active' : 'bottom-nav-sheet-link' + } + > + {item.icon} + {item.label} + {badge > 0 ? ( + + {item.to === '/configuracion' ? 'Update' : badge} + + ) : null} + + ); + })} +
+
+ ))}
diff --git a/Atlas Balance/frontend/src/components/layout/Sidebar.tsx b/Atlas Balance/frontend/src/components/layout/Sidebar.tsx index 43c105b..8388223 100644 --- a/Atlas Balance/frontend/src/components/layout/Sidebar.tsx +++ b/Atlas Balance/frontend/src/components/layout/Sidebar.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { NavLink, useLocation } from 'react-router-dom'; -import { getVisibleNavigationItems } from '@/utils/navigation'; +import { getVisibleNavigationItems, navigationGroups, type NavigationGroup } from '@/utils/navigation'; import { useAlertCount } from '@/stores/alertasStore'; import { useAuthStore } from '@/stores/authStore'; import { useNotificacionesAdminStore } from '@/stores/notificacionesAdminStore'; @@ -29,6 +29,23 @@ export function Sidebar() { }, [checkUpdate, clearNotificaciones, loadResumen, location.pathname, usuario?.rol]); const visibleNavItems = getVisibleNavigationItems(usuario?.rol); + const groupOrder: NavigationGroup[] = ['operacion', 'control', 'sistema']; + + const getBadge = (to: string) => { + if (to === '/alertas' && alertCount > 0) { + return {alertCount}; + } + + if (to === '/exportaciones' && usuario?.rol === 'ADMIN' && exportacionesPendientes > 0) { + return {exportacionesPendientes}; + } + + if (to === '/configuracion' && updateAvailable) { + return !; + } + + return null; + }; return ( ); diff --git a/Atlas Balance/frontend/src/pages/ConfiguracionPage.tsx b/Atlas Balance/frontend/src/pages/ConfiguracionPage.tsx index 5e0fa06..6d7acc3 100644 --- a/Atlas Balance/frontend/src/pages/ConfiguracionPage.tsx +++ b/Atlas Balance/frontend/src/pages/ConfiguracionPage.tsx @@ -657,16 +657,16 @@ export default function ConfiguracionPage() {

Sistema y actualizacion

-

Endpoint JSON con version, source_path y target_path.

+

Usa el repo oficial por HTTPS. Atlas Balance consulta el ultimo GitHub Release, descarga el ZIP win-x64 y lo prepara en la carpeta segura de actualizaciones.

Version actual

{currentVersion ?? 'N/D'}

Version disponible

{availableVersion ?? 'Ninguna'}

@@ -674,7 +674,7 @@ export default function ConfiguracionPage() {
{updateMessage ?

{updateMessage}

: null}
- +
diff --git a/Atlas Balance/frontend/src/pages/CuentaDetailPage.tsx b/Atlas Balance/frontend/src/pages/CuentaDetailPage.tsx index d690639..8ba7be0 100644 --- a/Atlas Balance/frontend/src/pages/CuentaDetailPage.tsx +++ b/Atlas Balance/frontend/src/pages/CuentaDetailPage.tsx @@ -22,6 +22,8 @@ interface DeleteCandidate { concepto: string | null; } +const BULK_DELETE_PREVIEW_LIMIT = 6; + interface UpdateExtractoPayload { fecha?: string; concepto?: string; @@ -88,6 +90,8 @@ export default function CuentaDetailPage() { const [periodo, setPeriodo] = useState('1m'); const [isImportModalOpen, setIsImportModalOpen] = useState(false); const [deleteCandidate, setDeleteCandidate] = useState(null); + const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); + const [selectedRowIds, setSelectedRowIds] = useState>(new Set()); const [actionLoading, setActionLoading] = useState(false); const [notesDraft, setNotesDraft] = useState(''); const [notesSaving, setNotesSaving] = useState(false); @@ -161,6 +165,36 @@ export default function CuentaDetailPage() { setNotesStatus(null); }, [summary?.cuenta_id, summary?.notas]); + useEffect(() => { + setSelectedRowIds((current) => { + if (current.size === 0) { + return current; + } + + const validIds = new Set(rows.map((row) => row.id)); + const next = new Set(); + current.forEach((rowId) => { + if (validIds.has(rowId)) { + next.add(rowId); + } + }); + return next; + }); + }, [rows]); + + useEffect(() => { + if (!canDeleteRows) { + setSelectedRowIds(new Set()); + setBulkDeleteOpen(false); + } + }, [canDeleteRows]); + + useEffect(() => { + if (bulkDeleteOpen && rows.every((row) => !selectedRowIds.has(row.id))) { + setBulkDeleteOpen(false); + } + }, [bulkDeleteOpen, rows, selectedRowIds]); + useEffect(() => { if (!id || !allowedDashboard) { return; @@ -203,6 +237,15 @@ export default function CuentaDetailPage() { () => [...new Set(rows.flatMap((row) => Object.keys(row.columnas_extra ?? {})))], [rows] ); + const selectedRows = useMemo( + () => rows.filter((row) => selectedRowIds.has(row.id)), + [rows, selectedRowIds] + ); + const selectedRowsCount = selectedRows.length; + const allRowsSelected = rows.length > 0 && selectedRowsCount === rows.length; + const bulkDeleteConfirmLabel = `Eliminar ${selectedRowsCount} ${selectedRowsCount === 1 ? 'linea' : 'lineas'}`; + const selectedRowsPreview = selectedRows.slice(0, BULK_DELETE_PREVIEW_LIMIT).map((row) => row.fila_numero).join(', '); + const hiddenSelectedRows = Math.max(0, selectedRowsCount - BULK_DELETE_PREVIEW_LIMIT); const importUrl = useMemo(() => { if (!summary) { @@ -239,6 +282,62 @@ export default function CuentaDetailPage() { } }; + const toggleRowSelection = (rowId: string, selected: boolean) => { + setSelectedRowIds((current) => { + const next = new Set(current); + if (selected) { + next.add(rowId); + } else { + next.delete(rowId); + } + return next; + }); + }; + + const toggleSelectAllRows = (selected: boolean) => { + if (!selected) { + setSelectedRowIds(new Set()); + return; + } + + setSelectedRowIds(new Set(rows.map((row) => row.id))); + }; + + const confirmDeleteSelectedRows = async () => { + if (selectedRowsCount === 0) { + return; + } + + setActionLoading(true); + setError(null); + + let deletedCount = 0; + + for (const row of selectedRows) { + try { + await api.delete(`/extractos/${row.id}`); + deletedCount += 1; + } catch (err) { + setError( + extractErrorMessage( + err, + `Se eliminaron ${deletedCount} de ${selectedRowsCount} lineas. Revisa permisos o vuelve a intentarlo.` + ) + ); + break; + } + } + + try { + if (deletedCount > 0) { + setBulkDeleteOpen(false); + await loadCuentaData(); + } + } finally { + setActionLoading(false); + } + }; + const saveCell = async (row: Extracto, column: string, value: string) => { if (!canEditCell(column)) { return; @@ -442,6 +541,34 @@ export default function CuentaDetailPage() { {summary.ultima_actualizacion ? formatDateTime(summary.ultima_actualizacion) : 'Sin movimientos'} + {canDeleteRows && rows.length > 0 ? ( +
+ +
+ + {selectedRowsCount > 0 + ? `${selectedRowsCount} ${selectedRowsCount === 1 ? 'linea seleccionada' : 'lineas seleccionadas'}` + : 'Selecciona lineas para borrarlas en lote'} + + +
+
+ ) : null} {rows.length === 0 ? ( @@ -461,6 +588,7 @@ export default function CuentaDetailPage() { {extraColumns.map((column) => ( {column} ))} + {canDeleteRows ? Seleccion : null} {canDeleteRows ? Acciones : null} @@ -535,6 +663,17 @@ export default function CuentaDetailPage() { /> ))} + {canDeleteRows ? ( + + toggleRowSelection(row.id, event.target.checked)} + /> + + ) : null} {canDeleteRows ? ( } @@ -537,12 +545,15 @@ export default function CuentasPage() { ) : ( - - + + formatCurrency(Number(value), principal.divisa_principal)} + width={72} + axisLine={false} + tickLine={false} + tickMargin={10} + tickFormatter={(value) => formatCompactCurrency(Number(value), principal.divisa_principal)} /> formatCurrency(value, principal.divisa_principal)} @@ -561,6 +572,20 @@ export default function CuentasPage() { )} + {evolucion ? ( +
+
+

Evolucion

+ Ultimo punto: {evolucion.puntos.length ? formatDate(evolucion.puntos[evolucion.puntos.length - 1].fecha) : 'N/A'} +
+ +
+ ) : null} +
- {evolucion ? ( -
-
-

Evolucion

- Ultimo punto: {evolucion.puntos.length ? formatDate(evolucion.puntos[evolucion.puntos.length - 1].fecha) : 'N/A'} -
- -
- ) : null} - {saldosDivisa ? (
@@ -907,24 +918,28 @@ export default function CuentasPage() { - {form.tipo_cuenta === 'NORMAL' ? ( + {form.tipo_cuenta !== 'PLAZO_FIJO' ? (
-

Datos bancarios

+

{form.tipo_cuenta === 'NORMAL' ? 'Datos bancarios' : 'Importacion'}

- - - - - + {form.tipo_cuenta === 'NORMAL' ? ( + <> + + + + + + + ) : null}
) : ( -

Las cuentas de efectivo y plazo fijo no usan datos bancarios ni formato de importacion.

+

Las cuentas de plazo fijo no usan datos bancarios ni formato de importacion.

)} {form.tipo_cuenta === 'PLAZO_FIJO' || renewingId ? ( diff --git a/Atlas Balance/frontend/src/pages/DashboardPage.tsx b/Atlas Balance/frontend/src/pages/DashboardPage.tsx index 0d0fca9..54e713d 100644 --- a/Atlas Balance/frontend/src/pages/DashboardPage.tsx +++ b/Atlas Balance/frontend/src/pages/DashboardPage.tsx @@ -79,14 +79,12 @@ export default function DashboardPage() { }, [evolucion, principal?.egresos_mes, principal?.ingresos_mes]); const saldosPorTipo = useMemo( () => - TIPO_TITULAR_ORDER - .map((tipo) => ({ - tipo, - items: (principal?.saldos_por_titular ?? []) - .filter((item) => item.tipo_titular === tipo) - .sort((a, b) => b.total_convertido - a.total_convertido), - })) - .filter((group) => group.items.length > 0), + TIPO_TITULAR_ORDER.map((tipo) => ({ + tipo, + items: (principal?.saldos_por_titular ?? []) + .filter((item) => item.tipo_titular === tipo) + .sort((a, b) => b.total_convertido - a.total_convertido), + })), [principal?.saldos_por_titular], ); @@ -161,6 +159,8 @@ export default function DashboardPage() { ); } + const lastEvolutionPoint = evolucion.puntos.length > 0 ? evolucion.puntos[evolucion.puntos.length - 1] : null; + return (
@@ -174,124 +174,140 @@ export default function DashboardPage() {
-
- - {formatCurrency(principal.total_convertido, principal.divisa_principal)} - - } - /> - - {formatCurrency(periodTotals.ingresos, principal.divisa_principal)} - - } - /> - - {formatCurrency(periodTotals.egresos, principal.divisa_principal)} - - } - /> -
- -
-
-

Plazos fijos

- {principal.plazos_fijos.total_cuentas} cuentas -
-
-
- Monto total - - - {formatCurrency(principal.plazos_fijos.monto_total_convertido, principal.divisa_principal)} - - -
-
- Intereses aprox. - - - {formatCurrency(principal.plazos_fijos.intereses_previstos_convertidos, principal.divisa_principal)} - - +
+
+
+ + {formatCurrency(principal.total_convertido, principal.divisa_principal)} + + } + /> + + {formatCurrency(periodTotals.ingresos, principal.divisa_principal)} + + } + /> + + {formatCurrency(periodTotals.egresos, principal.divisa_principal)} + + } + />
-
- Proximo vencimiento - - {principal.plazos_fijos.dias_hasta_proximo_vencimiento === null - ? 'Sin fecha' - : `${principal.plazos_fijos.dias_hasta_proximo_vencimiento} dias`} - - {principal.plazos_fijos.proximo_vencimiento ? ( - {formatDate(principal.plazos_fijos.proximo_vencimiento)} - ) : null} -
-
-
-
- - -
-
-

Saldos por titular

-
- - {principal.saldos_por_titular.length === 0 ? ( - - ) : ( -
- {saldosPorTipo.map((group) => ( -
-

{TIPO_TITULAR_LABELS[group.tipo]}

-
- {group.items.map((item) => ( - - {item.titular_nombre} - - - {formatCurrency(item.total_convertido, principal.divisa_principal)} - - - - Disponible {formatCurrency(item.saldo_disponible_convertido ?? item.total_convertido, principal.divisa_principal)} - {' · '} - Inmovilizado {formatCurrency(item.saldo_inmovilizado_convertido ?? 0, principal.divisa_principal)} - - - ))} -
-
- ))} +
+
+

Plazos fijos

+ {principal.plazos_fijos.total_cuentas} cuentas +
+
+
+ Monto total + + + {formatCurrency(principal.plazos_fijos.monto_total_convertido, principal.divisa_principal)} + + +
+
+ Intereses aprox. + + + {formatCurrency(principal.plazos_fijos.intereses_previstos_convertidos, principal.divisa_principal)} + + +
+
+ Proximo vencimiento + + {principal.plazos_fijos.dias_hasta_proximo_vencimiento === null + ? 'Sin fecha' + : `${principal.plazos_fijos.dias_hasta_proximo_vencimiento} dias`} + + {principal.plazos_fijos.proximo_vencimiento ? ( + {formatDate(principal.plazos_fijos.proximo_vencimiento)} + ) : null} +
- )} -
+
+
+ +
-
-
-

Evolución

+
+
+
+

Evolución

+

Saldo, ingresos y egresos del periodo seleccionado.

+
+ {lastEvolutionPoint ? ( + + Saldo final {formatCurrency(lastEvolutionPoint.saldo, principal.divisa_principal)} + + ) : null}
+ +
+
+

Saldos por titular

+
+ + {principal.saldos_por_titular.length === 0 ? ( + + ) : ( +
+ {saldosPorTipo.map((group) => ( +
+

{TIPO_TITULAR_LABELS[group.tipo]}

+
+ {group.items.map((item) => ( + + {item.titular_nombre} + + + {formatCurrency(item.total_convertido, principal.divisa_principal)} + + + + Disponible {formatCurrency(item.saldo_disponible_convertido ?? item.total_convertido, principal.divisa_principal)} + {' · '} + Inmovilizado {formatCurrency(item.saldo_inmovilizado_convertido ?? 0, principal.divisa_principal)} + + + ))} + {group.items.length === 0 ?
Sin saldos
: null} +
+
+ ))} +
+ )} +
); } diff --git a/Atlas Balance/frontend/src/pages/ImportacionPage.tsx b/Atlas Balance/frontend/src/pages/ImportacionPage.tsx index 4b1fca4..971c218 100644 --- a/Atlas Balance/frontend/src/pages/ImportacionPage.tsx +++ b/Atlas Balance/frontend/src/pages/ImportacionPage.tsx @@ -23,6 +23,7 @@ const VALID_MARKER = '\u2713'; const INVALID_MARKER = '\u2717'; const WARNING_MARKER = '!'; const PLAZO_FIJO_MARKER = '\u2022 Plazo fijo'; +const DEFAULT_RETURN_TO = '/dashboard'; type ImportStep = 1 | 2; type PlazoFijoMovimiento = 'INGRESO' | 'EGRESO'; @@ -41,6 +42,15 @@ function getApiErrorMessage(error: unknown, fallback: string): string { return fallback; } +function normalizeReturnTo(value: string | null): string { + const candidate = value?.trim(); + if (!candidate || !candidate.startsWith('/') || candidate.startsWith('//') || candidate.includes('\\')) { + return DEFAULT_RETURN_TO; + } + + return candidate; +} + function detectSeparator(lines: string[]): 'tab' | 'comma' | 'semicolon' { const sample = lines.slice(0, 5); const candidates: Array<{ key: 'tab' | 'comma' | 'semicolon'; char: string }> = [ @@ -98,7 +108,7 @@ export default function ImportacionPage() { const preselectedCuentaId = searchParams.get('cuentaId'); const autoCloseOnSuccess = searchParams.get('autoClose') === '1'; const isEmbedded = searchParams.get('embedded') === '1'; - const returnTo = searchParams.get('returnTo') || '/dashboard'; + const returnTo = normalizeReturnTo(searchParams.get('returnTo')); const usuario = useAuthStore((state) => state.usuario); const [step, setStep] = useState(1); const [contexto, setContexto] = useState([]); @@ -415,7 +425,7 @@ export default function ImportacionPage() {

Importacion de Extractos

-

Las cuentas normales usan formato bancario. Las de plazo fijo solo permiten anadir o sacar dinero.

+

Las cuentas normales y de efectivo usan formato de importacion. Las de plazo fijo solo permiten anadir o sacar dinero.

{canManageFormatos && (
Gestionar formatos de importacion diff --git a/Atlas Balance/frontend/src/pages/LoginPage.tsx b/Atlas Balance/frontend/src/pages/LoginPage.tsx index af85c47..57e28cc 100644 --- a/Atlas Balance/frontend/src/pages/LoginPage.tsx +++ b/Atlas Balance/frontend/src/pages/LoginPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; +import QRCode from 'qrcode'; import api from '@/services/api'; import { useAlertasStore } from '@/stores/alertasStore'; import { useAuthStore } from '@/stores/authStore'; @@ -11,6 +12,14 @@ import { extractErrorMessage } from '@/utils/errorMessage'; interface LoginForm { email: string; password: string; + mfaCode: string; +} + +interface MfaChallenge { + id: string; + setupRequired: boolean; + secret: string | null; + otpAuthUri: string | null; } export default function LoginPage() { @@ -18,10 +27,12 @@ export default function LoginPage() { const setUsuario = useAuthStore((state) => state.setUsuario); const setPermisos = usePermisosStore((state) => state.setPermisos); const loadAlertasActivas = useAlertasStore((state) => state.loadAlertasActivas); - const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm(); + const { register, handleSubmit, formState: { errors, isSubmitting }, setFocus } = useForm(); const [error, setError] = useState(null); const [postUpdateMessage, setPostUpdateMessage] = useState(null); const [showPassword, setShowPassword] = useState(false); + const [mfaChallenge, setMfaChallenge] = useState(null); + const [mfaQrCode, setMfaQrCode] = useState(null); useEffect(() => { const message = sessionStorage.getItem('atlas_balance_update_message'); @@ -33,18 +44,84 @@ export default function LoginPage() { sessionStorage.removeItem('atlas_balance_update_message'); }, []); + useEffect(() => { + if (mfaChallenge) { + setFocus('mfaCode'); + } + }, [mfaChallenge, setFocus]); + + useEffect(() => { + let cancelled = false; + + if (!mfaChallenge?.setupRequired || !mfaChallenge.otpAuthUri) { + setMfaQrCode(null); + return; + } + + QRCode.toDataURL(mfaChallenge.otpAuthUri, { + errorCorrectionLevel: 'M', + margin: 2, + width: 208, + }) + .then((dataUrl) => { + if (!cancelled) { + setMfaQrCode(dataUrl); + } + }) + .catch(() => { + if (!cancelled) { + setMfaQrCode(null); + } + }); + + return () => { + cancelled = true; + }; + }, [mfaChallenge]); + + const completeLogin = async (data: LoginResponse) => { + if (!data.usuario) { + setError('Respuesta de login invalida'); + return; + } + + setUsuario(data.usuario, data.csrf_token); + setPermisos(data.permisos ?? []); + + if (!data.usuario.primer_login) { + await loadAlertasActivas(); + } + + navigate(data.usuario.primer_login ? '/cambiar-password' : '/dashboard', { replace: true }); + }; + const onSubmit = handleSubmit(async (values) => { setError(null); try { - const { data } = await api.post('/auth/login', values); - setUsuario(data.usuario, data.csrf_token); - setPermisos(data.permisos); + if (mfaChallenge) { + const { data } = await api.post('/auth/mfa/verify', { + challenge_id: mfaChallenge.id, + code: values.mfaCode, + }); + await completeLogin(data); + return; + } - if (!data.usuario.primer_login) { - await loadAlertasActivas(); + const { data } = await api.post('/auth/login', { + email: values.email, + password: values.password, + }); + if (data.mfa_required && data.mfa_challenge_id) { + setMfaChallenge({ + id: data.mfa_challenge_id, + setupRequired: !!data.mfa_setup_required, + secret: data.mfa_secret ?? null, + otpAuthUri: data.mfa_otp_auth_uri ?? null, + }); + return; } - navigate(data.usuario.primer_login ? '/cambiar-password' : '/dashboard', { replace: true }); + await completeLogin(data); } catch (err) { setError( extractErrorMessage( @@ -71,45 +148,84 @@ export default function LoginPage() {
-

Iniciar sesion

-

Acceso privado para operar saldos, extractos y alertas.

- -
- - - {errors.email &&

{errors.email.message}

} -
+

{mfaChallenge ? 'Verificar acceso' : 'Iniciar sesion'}

+

+ {mfaChallenge ? 'Introduce el codigo temporal de tu app de autenticacion.' : 'Acceso privado para operar saldos, extractos y alertas.'} +

-
- -
- - -
- {errors.password &&

{errors.password.message}

} -
+ {!mfaChallenge && ( + <> +
+ + + {errors.email &&

{errors.email.message}

} +
+ +
+ +
+ + +
+ {errors.password &&

{errors.password.message}

} +
+ + )} + + {mfaChallenge && ( + <> + {mfaChallenge.setupRequired && mfaChallenge.secret && ( +
+ Escanea este QR con Google Authenticator + {mfaQrCode && ( + QR para configurar Google Authenticator + )} + {mfaChallenge.secret} +

Si el QR falla, introduce la clave manualmente y confirma el primer codigo.

+
+ )} + +
+ + + {errors.mfaCode &&

{errors.mfaCode.message}

} +
+ + )} {postUpdateMessage &&

{postUpdateMessage}

} {error &&

{error}

} @@ -119,7 +235,7 @@ export default function LoginPage() { disabled={isSubmitting} className="auth-button" > - {isSubmitting ? 'Entrando...' : 'Entrar'} + {isSubmitting ? 'Validando...' : (mfaChallenge ? 'Verificar' : 'Entrar')}
diff --git a/Atlas Balance/frontend/src/pages/TitularesPage.tsx b/Atlas Balance/frontend/src/pages/TitularesPage.tsx index c0c23b7..246865d 100644 --- a/Atlas Balance/frontend/src/pages/TitularesPage.tsx +++ b/Atlas Balance/frontend/src/pages/TitularesPage.tsx @@ -32,7 +32,7 @@ import type { Titular, } from '@/types'; import { extractErrorMessage } from '@/utils/errorMessage'; -import { formatCurrency, formatDate } from '@/utils/formatters'; +import { formatCompactCurrency, formatCurrency, formatDate } from '@/utils/formatters'; interface TitularCard extends Titular { cuentas_count: number; @@ -310,7 +310,7 @@ export default function TitularesPage() { }; return ( -
+

Titulares

{isAdmin && } @@ -339,12 +339,15 @@ export default function TitularesPage() { ) : ( - - + + formatCurrency(Number(value), principal.divisa_principal)} + width={72} + axisLine={false} + tickLine={false} + tickMargin={10} + tickFormatter={(value) => formatCompactCurrency(Number(value), principal.divisa_principal)} /> formatCurrency(value, principal.divisa_principal)} diff --git a/Atlas Balance/frontend/src/styles/auth.css b/Atlas Balance/frontend/src/styles/auth.css index b05f308..29b19d2 100644 --- a/Atlas Balance/frontend/src/styles/auth.css +++ b/Atlas Balance/frontend/src/styles/auth.css @@ -15,11 +15,11 @@ } .auth-logo-container { - width: min(100%, 1120px); + width: min(calc(100% - 2rem), 430px); margin: 0 auto; display: flex; align-items: center; - justify-content: flex-start; + justify-content: center; gap: var(--space-3); text-align: left; } @@ -165,6 +165,38 @@ color: var(--success-text); } +.auth-mfa-setup { + display: grid; + gap: var(--space-2); + padding: var(--space-3); + border: 1px solid var(--surface-border); + border-radius: var(--radius-control); + background: var(--bg-surface-soft); +} + +.auth-secret { + display: block; + overflow-wrap: anywhere; + padding: var(--space-3); + border-radius: var(--radius-control); + background: var(--control-bg); + color: var(--text-primary); + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + line-height: 1.5; + user-select: all; +} + +.auth-mfa-qr { + width: min(100%, 13rem); + aspect-ratio: 1; + justify-self: center; + border-radius: var(--radius-control); + border: 1px solid var(--surface-border); + background: #fff; + padding: var(--space-2); +} + .auth-button { width: 100%; min-height: 3rem; @@ -238,6 +270,7 @@ } .auth-logo-container { + width: min(calc(100% - 1.5rem), 430px); justify-content: center; text-align: center; } diff --git a/Atlas Balance/frontend/src/styles/global.css b/Atlas Balance/frontend/src/styles/global.css index 03ce955..9e98c3d 100644 --- a/Atlas Balance/frontend/src/styles/global.css +++ b/Atlas Balance/frontend/src/styles/global.css @@ -1,5 +1,3 @@ -@import "@fontsource-variable/geist"; - *, *::before, *::after { diff --git a/Atlas Balance/frontend/src/styles/layout/dashboard.css b/Atlas Balance/frontend/src/styles/layout/dashboard.css index f17c654..215647a 100644 --- a/Atlas Balance/frontend/src/styles/layout/dashboard.css +++ b/Atlas Balance/frontend/src/styles/layout/dashboard.css @@ -31,6 +31,19 @@ background: color-mix(in srgb, var(--bg-surface) 72%, transparent); } +.dashboard-overview-grid { + display: grid; + grid-template-columns: minmax(30rem, 0.95fr) minmax(24rem, 1.05fr); + gap: var(--space-4); + align-items: stretch; +} + +.dashboard-overview-primary { + display: grid; + gap: var(--space-4); + min-width: 0; +} + .dashboard-select-control { display: grid; gap: var(--space-1); @@ -51,6 +64,15 @@ gap: var(--space-3); } +.dashboard-kpi-grid--overview { + grid-template-columns: minmax(0, 1.45fr) repeat(2, minmax(0, 0.85fr)); + align-content: stretch; +} + +.dashboard-kpi-grid--overview .dashboard-kpi { + padding: var(--space-4); +} + .dashboard-kpi { border: 1px solid var(--border-soft); border-radius: var(--radius-card); @@ -80,6 +102,7 @@ font-size: 1.55rem; font-weight: var(--font-weight-bold); line-height: 1.15; + white-space: nowrap; } .dashboard-kpi--featured { @@ -92,6 +115,10 @@ font-weight: var(--font-weight-heavy); } +.dashboard-kpi-grid--overview .dashboard-kpi--featured p { + font-size: clamp(1.35rem, 1.5vw, 1.65rem); +} + .dashboard-kpi-helper { display: block; margin-top: var(--space-3); @@ -119,6 +146,13 @@ background: var(--bg-surface); box-shadow: var(--shadow-card); padding: var(--space-5); + min-width: 0; +} + +.dashboard-evolution-card { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + padding: var(--space-5) var(--space-6) var(--space-6); } .dashboard-card-header { @@ -128,6 +162,17 @@ margin-bottom: var(--space-3); } +.dashboard-card-header--chart { + align-items: flex-start; + gap: var(--space-4); + margin-bottom: var(--space-4); +} + +.dashboard-card-header--chart p { + margin-top: var(--space-1); + color: var(--color-text-secondary); +} + .dashboard-card-meta { color: var(--color-text-secondary); font-size: var(--font-size-sm); @@ -137,6 +182,10 @@ padding: var(--space-4) var(--space-5); } +.dashboard-overview-primary .dashboard-plazo-card { + align-self: stretch; +} + .dashboard-plazo-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -167,37 +216,95 @@ .dashboard-divisa-list { display: grid; - grid-template-columns: repeat(2, minmax(120px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: var(--space-2); } +.dashboard-divisa-card .dashboard-divisa-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + .dashboard-divisa-item { border: 1px solid var(--border-soft); border-radius: var(--radius-control); background: var(--bg-surface-soft); padding: var(--space-3); + display: grid; + gap: var(--space-2); + min-width: 0; +} + +.dashboard-divisa-item-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); } .dashboard-divisa-item h3 { font-size: var(--font-size-sm); color: var(--color-text-secondary); - margin-bottom: var(--space-1); } -.dashboard-divisa-item p { +.dashboard-divisa-item-header span { + border-radius: var(--radius-pill); + background: var(--accent-primary-soft); + color: var(--color-sidebar-active-text); + padding: 0.1rem var(--space-2); + font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); } +.dashboard-divisa-total { + font-family: var(--font-family-mono); + font-size: 1.28rem; + font-weight: var(--font-weight-heavy); + line-height: 1.12; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dashboard-divisa-breakdown { + display: grid; + gap: var(--space-1); +} + +.dashboard-divisa-breakdown div { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-2); + min-width: 0; +} + +.dashboard-divisa-breakdown dt, .dashboard-divisa-converted { - display: block; - margin-top: var(--space-1); color: var(--color-text-secondary); font-size: var(--font-size-sm); } +.dashboard-divisa-breakdown dd { + min-width: 0; + color: var(--color-text-primary); + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + text-align: right; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dashboard-divisa-converted { + display: block; + padding-top: var(--space-1); + border-top: 1px solid var(--border-soft); +} + .dashboard-titular-groups { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); } @@ -229,9 +336,20 @@ color: var(--color-text-secondary); } +.dashboard-titular-empty { + display: grid; + min-height: 5.75rem; + place-items: center; + border: 1px dashed var(--border-soft); + border-radius: var(--radius-control); + color: var(--color-text-secondary); + background: var(--bg-surface-soft); + font-size: var(--font-size-sm); +} + .dashboard-chart-wrapper { width: 100%; - min-height: 320px; + min-height: 420px; } .dashboard-chart-legend { @@ -298,6 +416,33 @@ overflow: auto; } +.dashboard-bulk-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + flex-wrap: wrap; + margin-bottom: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--border-soft); + border-radius: var(--radius-control); + background: var(--bg-surface-soft); +} + +.dashboard-bulk-selection { + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-weight: var(--font-weight-semibold); +} + +.dashboard-bulk-actions-buttons { + display: inline-flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} + .dashboard-table-wrap tbody tr.dashboard-row-flagged, .dashboard-table-wrap tbody tr[data-flagged="true"] { background: var(--color-row-flagged); @@ -360,17 +505,15 @@ } @media (max-width: 1200px) { + .dashboard-overview-grid, .dashboard-grid { grid-template-columns: 1fr; } - - .dashboard-titular-groups { - grid-template-columns: 1fr; - } } @media (max-width: 900px) { - .dashboard-kpi-grid { + .dashboard-kpi-grid, + .dashboard-kpi-grid--overview { grid-template-columns: 1fr; } @@ -381,4 +524,24 @@ .dashboard-toolbar { flex-direction: column; } + + .dashboard-evolution-card { + padding: var(--space-4); + } + + .dashboard-card-header--chart { + flex-direction: column; + } + + .dashboard-divisa-card .dashboard-divisa-list { + grid-template-columns: 1fr; + } + + .dashboard-titular-groups { + grid-template-columns: 1fr; + } + + .dashboard-chart-wrapper { + min-height: 340px; + } } diff --git a/Atlas Balance/frontend/src/styles/layout/entities.css b/Atlas Balance/frontend/src/styles/layout/entities.css index 3f7b34a..df3ab21 100644 --- a/Atlas Balance/frontend/src/styles/layout/entities.css +++ b/Atlas Balance/frontend/src/styles/layout/entities.css @@ -79,6 +79,14 @@ align-items: start; } +.titulares-page .phase2-cards { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.cuentas-page .phase2-cards { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + .phase2-cards > .import-muted, .phase2-cards > .empty-state, .phase2-cards > .users-pagination { @@ -135,6 +143,22 @@ white-space: nowrap; } +.titulares-page .titular-card { + min-height: 100%; +} + +.titulares-page .titular-card-title { + align-items: flex-start; +} + +.titulares-page .titular-card-title h3 { + display: -webkit-box; + line-height: 1.2; + white-space: normal; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + .pill { display: inline-block; flex: 0 0 auto; @@ -202,6 +226,24 @@ gap: var(--space-2); } +.cuentas-page .cuenta-card { + min-height: 100%; +} + +.cuentas-page .cuenta-card .titular-card-title { + align-items: flex-start; + flex-wrap: wrap; +} + +.cuentas-page .cuenta-card .titular-card-title h3 { + display: -webkit-box; + flex: 1 1 100%; + line-height: 1.2; + white-space: normal; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + .cuenta-card .pill { padding: 1px 8px; font-size: var(--font-size-xs); @@ -213,6 +255,12 @@ gap: var(--space-3); } +.cuentas-page .cuenta-card-meta { + grid-template-columns: minmax(0, 1fr) minmax(9rem, auto); + gap: var(--space-3); + align-items: start; +} + .titular-card-meta { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(6.75rem, 0.7fr) minmax(9rem, 0.9fr); @@ -220,6 +268,23 @@ align-items: start; } +.titulares-page .titular-card-meta { + grid-template-columns: minmax(0, 1fr) minmax(8.5rem, auto); + gap: var(--space-3); +} + +.titulares-page .titular-card-notes { + grid-column: 1 / -1; +} + +.titulares-page .titular-card-notes .cuenta-card-meta-value { + display: -webkit-box; + min-height: 2.4em; + white-space: normal; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + .cuenta-card-meta-item, .titular-card-meta-item { display: grid; @@ -248,12 +313,22 @@ text-align: right; } +.titulares-page .titular-card-balance { + align-self: start; +} + .cuenta-card-balance { grid-column: 3; grid-row: 1 / span 2; align-self: center; } +.cuentas-page .cuenta-card-balance { + grid-column: 2; + grid-row: 1 / span 2; + align-self: start; +} + .cuenta-card-notes { grid-column: 1 / -1; } @@ -262,6 +337,12 @@ white-space: normal; } +.cuentas-page .cuenta-card-notes .cuenta-card-meta-value { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + .cuenta-card-balance .signed-amount, .titular-card-balance .signed-amount { font-size: var(--font-size-sm); @@ -279,6 +360,14 @@ padding: 0.25rem 0.7rem; } +.cuentas-page .phase2-row-actions { + margin-top: auto; +} + +.titulares-page .phase2-row-actions { + margin-top: auto; +} + .titulares-dashboard-card { display: grid; gap: var(--space-3); @@ -584,6 +673,14 @@ grid-template-columns: 1fr; } + .titulares-page .phase2-cards { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .cuentas-page .phase2-cards { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .titulares-dashboard-header { flex-direction: column; } @@ -593,6 +690,14 @@ .phase2-cards { grid-template-columns: 1fr; } + + .titulares-page .phase2-cards { + grid-template-columns: 1fr; + } + + .cuentas-page .phase2-cards { + grid-template-columns: 1fr; + } } @media (max-width: 768px) { @@ -674,4 +779,15 @@ justify-items: start; text-align: left; } + + .cuentas-page .cuenta-card-meta { + grid-template-columns: 1fr; + } + + .cuentas-page .cuenta-card-balance { + grid-column: auto; + grid-row: auto; + justify-items: start; + text-align: left; + } } diff --git a/Atlas Balance/frontend/src/styles/layout/extractos.css b/Atlas Balance/frontend/src/styles/layout/extractos.css index f2bbd50..dcd3ef9 100644 --- a/Atlas Balance/frontend/src/styles/layout/extractos.css +++ b/Atlas Balance/frontend/src/styles/layout/extractos.css @@ -33,10 +33,16 @@ } .extracto-table-section { + --sheet-grid: color-mix(in srgb, var(--border-soft) 78%, var(--text-secondary) 22%); + --sheet-grid-strong: color-mix(in srgb, var(--border-soft) 55%, var(--text-secondary) 45%); + --sheet-head-bg: color-mix(in srgb, var(--surface-bg-sunken) 88%, var(--bg-surface) 12%); + --sheet-row-hover: color-mix(in srgb, var(--accent-primary) 6%, var(--bg-surface) 94%); + --sheet-selected: color-mix(in srgb, var(--accent-primary) 72%, var(--bg-surface) 28%); + --sheet-row-height: 42px; border: 1px solid var(--border-soft); - border-radius: var(--radius-card); + border-radius: calc(var(--radius-card) - 2px); background: var(--bg-surface); - box-shadow: var(--shadow-card); + box-shadow: var(--shadow-sm); overflow: hidden; } @@ -45,9 +51,9 @@ align-items: center; justify-content: space-between; gap: var(--space-3); - padding: var(--space-3) var(--space-4); - border-bottom: 1px solid var(--border-soft); - background: var(--bg-surface); + padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--sheet-grid-strong); + background: var(--sheet-head-bg); } .extracto-table-toolbar > div:first-child { @@ -72,34 +78,68 @@ justify-content: flex-end; } +.extracto-table-actions > button { + min-height: 2.25rem; + border-radius: calc(var(--radius-control) - 3px); +} + .extracto-density-control { width: 10rem; } .extracto-density-control .app-select-trigger { - min-height: 2.5rem; + min-height: 2.25rem; + border-radius: calc(var(--radius-control) - 3px); } .column-visibility-panel { display: flex; flex-wrap: wrap; - gap: var(--space-2); + gap: 1px; padding: var(--space-2) var(--space-3); - border-bottom: 1px solid var(--border-soft); - background: var(--bg-surface-soft); + border-bottom: 1px solid var(--sheet-grid-strong); + background: var(--sheet-grid); +} + +.column-visibility-panel label { + display: inline-flex; + align-items: center; + gap: var(--space-1); + min-height: 2rem; + padding: 0 var(--space-2); + background: var(--bg-surface); + color: var(--text-primary); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + text-transform: none; +} + +.extracto-table-viewport { + height: 560px; + overflow: auto; + position: relative; + background: + linear-gradient(var(--sheet-grid) 1px, transparent 1px), + linear-gradient(90deg, var(--sheet-grid) 1px, transparent 1px), + var(--bg-surface); + background-size: 100% var(--sheet-row-height), 120px 100%; + scrollbar-gutter: stable; } .extracto-table-head { display: grid; grid-template-columns: repeat(7, minmax(100px, 1fr)); - border-bottom: 1px solid var(--border-soft); + border-bottom: 1px solid var(--sheet-grid-strong); min-width: max-content; + position: sticky; + top: 0; + z-index: 6; + box-shadow: 0 1px 0 var(--sheet-grid-strong); } .extracto-table-body { - height: 520px; - overflow: auto; position: relative; + min-width: max-content; } .extracto-row { @@ -109,15 +149,22 @@ width: 100%; display: grid; grid-template-columns: repeat(7, minmax(100px, 1fr)); - min-height: 42px; - border-bottom: 1px solid var(--border-soft); + min-height: var(--sheet-row-height); + border-bottom: 1px solid var(--sheet-grid); background: var(--bg-surface); min-width: max-content; + transition: background-color var(--transition-fast); +} + +.extracto-row:hover { + background: var(--sheet-row-hover); } .extracto-row.flagged { background: var(--color-row-flagged); - box-shadow: inset 0 0 0 1px var(--color-row-flagged-border); + box-shadow: + inset 0 1px 0 var(--color-row-flagged-border), + inset 0 -1px 0 var(--color-row-flagged-border); } .extracto-row.flagged .cell { @@ -125,35 +172,68 @@ } .cell { - padding: var(--space-1) var(--space-2); - border-right: 1px solid var(--border-soft); + min-height: var(--sheet-row-height); + padding: 0 var(--space-2); + border-right: 1px solid var(--sheet-grid); display: flex; align-items: center; gap: var(--space-1); min-width: 0; position: relative; + background: inherit; + color: var(--text-primary); + font-variant-numeric: tabular-nums; + line-height: 1.25; +} + +.cell:focus-within { + z-index: 4; + box-shadow: inset 0 0 0 2px var(--sheet-selected); } .cell.head { flex-direction: column; align-items: stretch; - background: var(--bg-surface-soft); - position: sticky; - top: 0; - z-index: 1; + justify-content: center; + min-height: 46px; + background: var(--sheet-head-bg); + border-right-color: var(--sheet-grid-strong); + color: var(--text-secondary); } .cell.head button { - padding: var(--space-1); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-1); + padding: 0; background: transparent; - color: var(--color-text-secondary); + color: inherit; text-align: left; - min-height: 2.5rem; + min-height: 1.8rem; border: 0; + border-radius: 0; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + letter-spacing: 0.015em; + text-transform: uppercase; +} + +.cell.head button:hover { + color: var(--text-primary); +} + +.cell.head button small { + color: var(--text-link); + font-size: 0.65rem; + font-weight: var(--font-weight-semibold); } .cell.head input { width: 100%; + min-height: 1.85rem; + border-radius: 0; + font-size: var(--font-size-xs); } .cell-edit-button { @@ -163,7 +243,20 @@ color: var(--color-text-primary); padding: 0; border: 0; - min-height: 2.5rem; + border-radius: 0; + min-height: calc(var(--sheet-row-height) - 2px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: inherit; +} + +.cell-edit-button:hover { + color: var(--text-primary); +} + +.cell-edit-button:focus-visible { + outline: 0; } .cell-edit-shell { @@ -190,18 +283,27 @@ .cell-audit-button { opacity: 0; pointer-events: none; - min-height: 2.5rem; - padding: 0 var(--space-2); - border: 1px solid var(--border-soft); - background: var(--bg-surface); + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); + min-height: 1.55rem; + padding: 0 var(--space-1); + border: 1px solid var(--sheet-grid-strong); + border-radius: calc(var(--radius-control) - 5px); + background: var(--surface-bg-raised); color: var(--text-link); font-size: var(--font-size-xs); + transition: + opacity var(--transition-fast), + transform var(--transition-fast); } .cell:hover .cell-audit-button, .cell:focus-within .cell-audit-button { opacity: 1; pointer-events: auto; + transform: translateY(-50%) translateX(-1px); } .cell--amount, @@ -213,17 +315,23 @@ } .cell--comentarios .cell-edit-button { - white-space: normal; + white-space: nowrap; } .cell--fila_numero { position: sticky; left: 0; - z-index: 2; - background: inherit; - box-shadow: 1px 0 0 var(--border-soft); + z-index: 5; + background: var(--sheet-head-bg); + box-shadow: 1px 0 0 var(--sheet-grid-strong); font-family: var(--font-family-mono); font-weight: var(--font-weight-semibold); + color: var(--text-secondary); + justify-content: flex-end; +} + +.cell.head.cell--fila_numero { + z-index: 8; } .cell-edit-button.signed-amount--positive { @@ -236,7 +344,7 @@ .flag-cell { display: grid; - grid-template-columns: 20px auto minmax(72px, 1fr); + grid-template-columns: 18px auto minmax(70px, 1fr); align-items: center; gap: var(--space-1); width: 100%; @@ -248,12 +356,37 @@ font-weight: var(--font-weight-semibold); } -.extracto-table-section--compact .extracto-row { - min-height: 34px; +.extracto-table-section--compact { + --sheet-row-height: 34px; } .extracto-table-section--compact .cell { - padding-block: 0.15rem; + min-height: var(--sheet-row-height); +} + +.extracto-table-section--compact .cell.head { + min-height: 40px; +} + +.extracto-table-section .cell input:not([type="checkbox"]) { + width: 100%; + min-width: 0; + border-radius: 0; +} + +.extracto-table-section .cell > input:not([type="checkbox"]), +.extracto-table-section .cell .flag-cell input:not([type="checkbox"]) { + min-height: calc(var(--sheet-row-height) - 8px); + border-color: transparent; + background: transparent; + box-shadow: none; +} + +.extracto-table-section .cell > input:not([type="checkbox"]):focus, +.extracto-table-section .cell .flag-cell input:not([type="checkbox"]):focus { + border-color: transparent; + background: var(--bg-surface); + box-shadow: inset 0 0 0 2px var(--sheet-selected); } .extracto-empty { diff --git a/Atlas Balance/frontend/src/styles/layout/shell.css b/Atlas Balance/frontend/src/styles/layout/shell.css index 366b79a..3646ceb 100644 --- a/Atlas Balance/frontend/src/styles/layout/shell.css +++ b/Atlas Balance/frontend/src/styles/layout/shell.css @@ -111,10 +111,54 @@ .app-nav { display: flex; flex-direction: column; - gap: var(--space-1); + gap: var(--space-3); overflow-x: hidden; } +.app-nav-section { + display: grid; + gap: var(--space-1); + min-width: 0; + padding-top: var(--space-3); + border-top: 1px solid var(--border-soft); +} + +.app-nav-section:first-child { + padding-top: 0; + border-top: 0; +} + +.app-nav-section-label { + min-height: 1.45rem; + padding: 0 var(--space-3); + color: var(--text-muted); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + letter-spacing: 0.03em; + text-transform: uppercase; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: + opacity var(--duration-fast) var(--ease-premium), + max-height var(--transition-normal), + padding var(--transition-normal); +} + +.app-sidebar--collapsed .app-nav { + gap: var(--space-2); +} + +.app-sidebar--collapsed .app-nav-section { + padding-top: var(--space-2); +} + +.app-sidebar--collapsed .app-nav-section-label { + max-height: 0; + padding: 0; + opacity: 0; +} + .app-nav-link { position: relative; color: var(--color-sidebar-text); @@ -186,12 +230,29 @@ .app-nav-link--active { background: var(--color-sidebar-active-bg); color: var(--color-sidebar-active-text); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-primary) 22%, transparent); } .app-main { min-width: 0; display: grid; - grid-template-rows: var(--topbar-height) 1fr; + grid-template-rows: var(--topbar-height) auto minmax(0, 1fr); +} + +.app-main > .app-topbar { + grid-row: 1; +} + +.app-main > .alert-banner { + grid-row: 2; + align-self: start; + min-height: 0; + height: auto; +} + +.app-main > .app-content { + grid-row: 3; + min-height: 0; } .app-topbar { @@ -330,6 +391,7 @@ background: var(--color-bg-warning); padding: var(--space-2) var(--space-3); display: flex; + align-self: start; align-items: center; justify-content: space-between; gap: var(--space-3); @@ -600,7 +662,7 @@ } .app-main { - grid-template-rows: var(--topbar-height) 1fr; + grid-template-rows: var(--topbar-height) auto minmax(0, 1fr); padding-bottom: calc(78px + env(safe-area-inset-bottom)); } @@ -750,6 +812,25 @@ gap: var(--space-2); } + .bottom-nav-sheet-sections { + display: grid; + gap: var(--space-4); + } + + .bottom-nav-sheet-section { + display: grid; + gap: var(--space-2); + min-width: 0; + } + + .bottom-nav-sheet-section h3 { + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + letter-spacing: 0.03em; + text-transform: uppercase; + } + .bottom-nav-sheet-link { min-height: 3rem; display: flex; diff --git a/Atlas Balance/frontend/src/types/index.ts b/Atlas Balance/frontend/src/types/index.ts index 14871d4..6b7db8e 100644 --- a/Atlas Balance/frontend/src/types/index.ts +++ b/Atlas Balance/frontend/src/types/index.ts @@ -16,6 +16,7 @@ export interface Usuario { rol: RolUsuario; activo: boolean; primer_login: boolean; + mfa_enabled: boolean; fecha_creacion: string; fecha_ultima_login: string | null; } @@ -432,8 +433,13 @@ export interface ApiResponse { export interface LoginResponse { csrf_token: string; - usuario: Usuario; - permisos: PermisoUsuario[]; + usuario?: Usuario; + permisos?: PermisoUsuario[]; + mfa_required?: boolean; + mfa_setup_required?: boolean; + mfa_challenge_id?: string; + mfa_secret?: string | null; + mfa_otp_auth_uri?: string | null; } export interface DashboardPrincipal { diff --git a/Atlas Balance/frontend/src/utils/navigation.ts b/Atlas Balance/frontend/src/utils/navigation.ts index 1b116d5..fc15b61 100644 --- a/Atlas Balance/frontend/src/utils/navigation.ts +++ b/Atlas Balance/frontend/src/utils/navigation.ts @@ -1,20 +1,34 @@ import { createElement } from 'react'; import type { ReactNode } from 'react'; import { - IconDashboard, - IconTitulares, - IconCuentas, - IconExtractos, - IconImportacion, - IconFormatos, - IconAlertas, - IconExportaciones, - IconUsuarios, - IconAuditoria, - IconConfiguracion, - IconBackups, - IconPapelera, -} from '@/components/Icons'; + BellRing, + Building2, + ClipboardList, + DatabaseBackup, + DownloadCloud, + FileCog, + LayoutDashboard, + Settings, + TableProperties, + Trash2, + Upload, + UsersRound, + WalletCards, +} from 'lucide-react'; + +export type NavigationGroup = 'operacion' | 'control' | 'sistema'; + +export const navigationGroups: Record = { + operacion: { label: 'Operacion' }, + control: { label: 'Control' }, + sistema: { label: 'Sistema' }, +}; + +const iconProps = { + size: 20, + strokeWidth: 1.9, + 'aria-hidden': true, +} as const; export interface NavigationItem { to: string; @@ -22,23 +36,24 @@ export interface NavigationItem { /** Etiqueta corta para el menu inferior movil */ short: string; icon: ReactNode; + group: NavigationGroup; adminOnly?: boolean; } export const navigationItems: NavigationItem[] = [ - { to: '/dashboard', label: 'Dashboard', short: 'Inicio', icon: createElement(IconDashboard) }, - { to: '/titulares', label: 'Titulares', short: 'Titulares', icon: createElement(IconTitulares) }, - { to: '/cuentas', label: 'Cuentas', short: 'Cuentas', icon: createElement(IconCuentas) }, - { to: '/extractos', label: 'Extractos', short: 'Extractos', icon: createElement(IconExtractos) }, - { to: '/importacion', label: 'Importacion', short: 'Importar', icon: createElement(IconImportacion) }, - { to: '/formatos-importacion', label: 'Formatos', short: 'Formatos', icon: createElement(IconFormatos), adminOnly: true }, - { to: '/alertas', label: 'Alertas', short: 'Alertas', icon: createElement(IconAlertas) }, - { to: '/exportaciones', label: 'Exportaciones', short: 'Exportar', icon: createElement(IconExportaciones) }, - { to: '/usuarios', label: 'Usuarios', short: 'Usuarios', icon: createElement(IconUsuarios), adminOnly: true }, - { to: '/auditoria', label: 'Auditoria', short: 'Auditoria', icon: createElement(IconAuditoria), adminOnly: true }, - { to: '/configuracion', label: 'Configuracion', short: 'Ajustes', icon: createElement(IconConfiguracion), adminOnly: true }, - { to: '/backups', label: 'Backups', short: 'Backups', icon: createElement(IconBackups), adminOnly: true }, - { to: '/papelera', label: 'Papelera', short: 'Papelera', icon: createElement(IconPapelera), adminOnly: true }, + { to: '/dashboard', label: 'Dashboard', short: 'Inicio', icon: createElement(LayoutDashboard, iconProps), group: 'operacion' }, + { to: '/titulares', label: 'Titulares', short: 'Titulares', icon: createElement(Building2, iconProps), group: 'operacion' }, + { to: '/cuentas', label: 'Cuentas', short: 'Cuentas', icon: createElement(WalletCards, iconProps), group: 'operacion' }, + { to: '/extractos', label: 'Extractos', short: 'Extractos', icon: createElement(TableProperties, iconProps), group: 'operacion' }, + { to: '/importacion', label: 'Importacion', short: 'Importar', icon: createElement(Upload, iconProps), group: 'operacion' }, + { to: '/alertas', label: 'Alertas', short: 'Alertas', icon: createElement(BellRing, iconProps), group: 'control' }, + { to: '/exportaciones', label: 'Exportaciones', short: 'Exportar', icon: createElement(DownloadCloud, iconProps), group: 'control' }, + { to: '/usuarios', label: 'Usuarios', short: 'Usuarios', icon: createElement(UsersRound, iconProps), group: 'sistema', adminOnly: true }, + { to: '/auditoria', label: 'Auditoria', short: 'Auditoria', icon: createElement(ClipboardList, iconProps), group: 'sistema', adminOnly: true }, + { to: '/formatos-importacion', label: 'Formatos', short: 'Formatos', icon: createElement(FileCog, iconProps), group: 'sistema', adminOnly: true }, + { to: '/backups', label: 'Backups', short: 'Backups', icon: createElement(DatabaseBackup, iconProps), group: 'sistema', adminOnly: true }, + { to: '/configuracion', label: 'Configuracion', short: 'Ajustes', icon: createElement(Settings, iconProps), group: 'sistema', adminOnly: true }, + { to: '/papelera', label: 'Papelera', short: 'Papelera', icon: createElement(Trash2, iconProps), group: 'sistema', adminOnly: true }, ]; export function getVisibleNavigationItems(role?: string | null) { diff --git a/Atlas Balance/scripts/Build-Release.ps1 b/Atlas Balance/scripts/Build-Release.ps1 index 416f5cd..05007dd 100644 --- a/Atlas Balance/scripts/Build-Release.ps1 +++ b/Atlas Balance/scripts/Build-Release.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "V-01.04", + [string]$Version = "V-01.05", [string]$Runtime = "win-x64", [string]$Configuration = "Release", [switch]$CleanNpmInstall @@ -136,5 +136,26 @@ $zipPath = Join-Path $releaseRoot "$packageName.zip" Remove-Item -LiteralPath $zipPath -Force -ErrorAction SilentlyContinue Compress-Archive -Path (Join-Path $packageRoot "*") -DestinationPath $zipPath -Force +$signaturePath = "$zipPath.sig" +Remove-Item -LiteralPath $signaturePath -Force -ErrorAction SilentlyContinue +if (-not [string]::IsNullOrWhiteSpace($env:ATLAS_RELEASE_SIGNING_PRIVATE_KEY_PEM)) { + $rsa = [System.Security.Cryptography.RSA]::Create() + try { + $privateKeyPem = $env:ATLAS_RELEASE_SIGNING_PRIVATE_KEY_PEM -replace "\\n", "`n" + $rsa.ImportFromPem($privateKeyPem) + $zipBytes = [System.IO.File]::ReadAllBytes($zipPath) + $signature = $rsa.SignData( + $zipBytes, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) + [System.IO.File]::WriteAllBytes($signaturePath, $signature) + Write-Host "Firma generada: $signaturePath" -ForegroundColor Green + } finally { + $rsa.Dispose() + } +} else { + Write-Warning "ATLAS_RELEASE_SIGNING_PRIVATE_KEY_PEM no definido; el actualizador online rechazara este ZIP sin su asset .sig." +} + Write-Host "Release generado: $packageRoot" -ForegroundColor Green Write-Host "ZIP generado: $zipPath" -ForegroundColor Green diff --git a/Atlas Balance/scripts/Instalar-AtlasBalance.ps1 b/Atlas Balance/scripts/Instalar-AtlasBalance.ps1 index 1ebb15d..950afa8 100644 --- a/Atlas Balance/scripts/Instalar-AtlasBalance.ps1 +++ b/Atlas Balance/scripts/Instalar-AtlasBalance.ps1 @@ -1,4 +1,4 @@ -param( +param( [string]$InstallPath = "C:\AtlasBalance", [string]$ServerName = $env:COMPUTERNAME, [int]$ApiPort = 443, @@ -6,6 +6,8 @@ [string]$DbHost = "localhost", [int]$DbPort = 5432, [string]$DbName = "atlas_balance", + [string]$DbOwnerUser = "atlas_balance_owner", + [string]$DbOwnerPassword = "", [string]$DbUser = "atlas_balance_app", [string]$DbPassword = "", [string]$PostgresAdminUser = "postgres", @@ -22,12 +24,13 @@ ) $ErrorActionPreference = "Stop" -$AppVersion = "V-01.04" +$AppVersion = "V-01.05" $ApiServiceName = "AtlasBalance.API" $WatchdogServiceName = "AtlasBalance.Watchdog" $ManagedPostgres = $false $GeneratedPostgresAdminPassword = "" $ExistingUsersDetected = $false +$ReleaseSigningPublicKeyPem = if ([string]::IsNullOrWhiteSpace($env:ATLAS_RELEASE_SIGNING_PUBLIC_KEY_PEM)) { "" } else { $env:ATLAS_RELEASE_SIGNING_PUBLIC_KEY_PEM -replace "\\n", "`n" } function Test-IsAdmin { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() @@ -69,6 +72,32 @@ function New-RandomSecret { return -join $chars } +function Protect-SecretDirectory { + param([string]$Path) + + New-Item -ItemType Directory -Path $Path -Force | Out-Null + & icacls.exe $Path /inheritance:r /grant:r "*S-1-5-32-544:(OI)(CI)F" "*S-1-5-18:(OI)(CI)F" | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "No se pudo restringir ACL en $Path. No se escribiran credenciales en claro." + } +} + +function Write-SecretFile { + param( + [string]$Path, + [string[]]$Lines + ) + + $directory = Split-Path -Parent $Path + Protect-SecretDirectory -Path $directory + Set-Content -LiteralPath $Path -Value $Lines -Encoding UTF8 + & icacls.exe $Path /inheritance:r /grant:r "*S-1-5-32-544:F" "*S-1-5-18:F" | Out-Null + if ($LASTEXITCODE -ne 0) { + Remove-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue + throw "No se pudo restringir ACL en $Path. El archivo de credenciales se elimino." + } +} + function Escape-SqlLiteral { param([string]$Value) return $Value.Replace("'", "''") @@ -254,23 +283,34 @@ function Ensure-Database { throw "PostgresAdminPassword no esta configurada. Usa install.cmd para preparar PostgreSQL automaticamente o pasa -PostgresAdminPassword si usas una instancia existente." } + $ownerRoleName = Escape-SqlLiteral $DbOwnerUser + $ownerRolePassword = Escape-SqlLiteral $DbOwnerPassword $roleName = Escape-SqlLiteral $DbUser $rolePassword = Escape-SqlLiteral $DbPassword $dbNameLiteral = Escape-SqlLiteral $DbName + $ownerRoleExists = Invoke-Psql -PsqlExe $psql -Scalar -Sql "SELECT 1 FROM pg_roles WHERE rolname = '$ownerRoleName';" + if ($ownerRoleExists -eq "1") { + Invoke-Psql -PsqlExe $psql -Sql "ALTER ROLE `"$DbOwnerUser`" WITH LOGIN PASSWORD '$ownerRolePassword' NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;" | Out-Null + } else { + Invoke-Psql -PsqlExe $psql -Sql "CREATE ROLE `"$DbOwnerUser`" WITH LOGIN PASSWORD '$ownerRolePassword' NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;" | Out-Null + } + $roleExists = Invoke-Psql -PsqlExe $psql -Scalar -Sql "SELECT 1 FROM pg_roles WHERE rolname = '$roleName';" if ($roleExists -eq "1") { - Invoke-Psql -PsqlExe $psql -Sql "ALTER ROLE `"$DbUser`" WITH LOGIN PASSWORD '$rolePassword';" | Out-Null + Invoke-Psql -PsqlExe $psql -Sql "ALTER ROLE `"$DbUser`" WITH LOGIN PASSWORD '$rolePassword' NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;" | Out-Null } else { - Invoke-Psql -PsqlExe $psql -Sql "CREATE ROLE `"$DbUser`" WITH LOGIN PASSWORD '$rolePassword';" | Out-Null + Invoke-Psql -PsqlExe $psql -Sql "CREATE ROLE `"$DbUser`" WITH LOGIN PASSWORD '$rolePassword' NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;" | Out-Null } $dbExists = Invoke-Psql -PsqlExe $psql -Scalar -Sql "SELECT 1 FROM pg_database WHERE datname = '$dbNameLiteral';" if ($dbExists -ne "1") { - Invoke-Psql -PsqlExe $psql -Sql "CREATE DATABASE `"$DbName`" OWNER `"$DbUser`" ENCODING 'UTF8';" | Out-Null + Invoke-Psql -PsqlExe $psql -Sql "CREATE DATABASE `"$DbName`" OWNER `"$DbOwnerUser`" ENCODING 'UTF8';" | Out-Null + } else { + Invoke-Psql -PsqlExe $psql -Sql "ALTER DATABASE `"$DbName`" OWNER TO `"$DbOwnerUser`";" | Out-Null } - Invoke-Psql -PsqlExe $psql -Database $DbName -Sql "GRANT ALL PRIVILEGES ON DATABASE `"$DbName`" TO `"$DbUser`";" | Out-Null + Invoke-Psql -PsqlExe $psql -Database $DbName -Sql "ALTER SCHEMA public OWNER TO `"$DbOwnerUser`"; GRANT CONNECT ON DATABASE `"$DbName`" TO `"$DbUser`"; GRANT USAGE ON SCHEMA public TO `"$DbUser`";" | Out-Null } function Test-ExistingApplicationUsers { @@ -396,6 +436,7 @@ function Write-AppSettings { $apiTarget = Join-Path $InstallPath "api" $dataProtectionKeysPath = Join-Path $env:ProgramData "AtlasBalance\keys" $connection = "Host=$DbHost;Port=$DbPort;Database=$DbName;Username=$DbUser;Password=$DbPassword" + $migrationConnection = "Host=$DbHost;Port=$DbPort;Database=$DbName;Username=$DbOwnerUser;Password=$DbOwnerPassword" $url = "https://0.0.0.0:$ApiPort" $seedAdminPassword = if ($ExistingUsersDetected) { "" } else { $AdminPassword } @@ -403,6 +444,7 @@ function Write-AppSettings { $apiConfig = [ordered]@{ ConnectionStrings = [ordered]@{ DefaultConnection = $connection + MigrationConnection = $migrationConnection } JwtSettings = [ordered]@{ Secret = $JwtSecret @@ -421,10 +463,16 @@ function Write-AppSettings { DockerPostgresContainer = "atlas_balance_db" UpdateSourceRoot = $updateRoot UpdateTargetPath = $apiTarget + RequireDatabaseBackupBeforeUpdate = $true + RequireHealthCheckAfterUpdate = $true + ApiHealthUrl = if ($ApiPort -eq 443) { "https://localhost/api/health" } else { "https://localhost`:$ApiPort/api/health" } } GitHubSettings = [ordered]@{ UpdateToken = "" } + UpdateSecurity = [ordered]@{ + ReleaseSigningPublicKeyPem = $ReleaseSigningPublicKeyPem + } DataProtection = [ordered]@{ KeysPath = $dataProtectionKeysPath } @@ -462,6 +510,8 @@ function Write-AppSettings { DbHost = $DbHost DbPort = [string]$DbPort DbName = $DbName + DbOwnerUser = $DbOwnerUser + DbOwnerPassword = $DbOwnerPassword DbUser = $DbUser DbPassword = $DbPassword DockerPostgresContainer = "atlas_balance_db" @@ -613,7 +663,7 @@ function Write-RuntimeAndCredentials { Write-JsonFile -Value $runtime -Path (Join-Path $InstallPath "atlas-balance.runtime.json") Set-Content -LiteralPath (Join-Path $InstallPath "VERSION") -Value $AppVersion -Encoding UTF8 - $credentialsPath = Join-Path $InstallPath "INSTALL_CREDENTIALS_ONCE.txt" + $credentialsPath = Join-Path (Join-Path $InstallPath "config") "INSTALL_CREDENTIALS_ONCE.txt" if ($ExistingUsersDetected) { $lines = @( "Atlas Balance $AppVersion", @@ -624,6 +674,8 @@ function Write-RuntimeAndCredentials { "Base de datos: $DbName", "Usuario DB app: $DbUser", "Password DB app: $DbPassword", + "Usuario DB migracion/owner: $DbOwnerUser", + "Password DB migracion/owner: $DbOwnerPassword", "PostgreSQL gestionado por Atlas: $ManagedPostgres", "", "Guarda esto en un gestor de passwords y borra este archivo.", @@ -639,6 +691,8 @@ function Write-RuntimeAndCredentials { "Base de datos: $DbName", "Usuario DB app: $DbUser", "Password DB app: $DbPassword", + "Usuario DB migracion/owner: $DbOwnerUser", + "Password DB migracion/owner: $DbOwnerPassword", "PostgreSQL gestionado por Atlas: $ManagedPostgres", "", "Guarda esto en un gestor de passwords y borra este archivo.", @@ -655,8 +709,7 @@ function Write-RuntimeAndCredentials { $lines[7..($lines.Count - 1)] ) | ForEach-Object { $_ } } - Set-Content -LiteralPath $credentialsPath -Value $lines -Encoding UTF8 - & icacls.exe $credentialsPath /inheritance:r /grant:r "*S-1-5-32-544:F" "*S-1-5-18:F" | Out-Null + Write-SecretFile -Path $credentialsPath -Lines $lines Register-CredentialsCleanupTask -CredentialsPath $credentialsPath } @@ -674,6 +727,7 @@ if (-not (Test-IsAdmin)) { } if ([string]::IsNullOrWhiteSpace($DbPassword)) { $DbPassword = New-RandomSecret 40 } +if ([string]::IsNullOrWhiteSpace($DbOwnerPassword)) { $DbOwnerPassword = New-RandomSecret 40 } if ([string]::IsNullOrWhiteSpace($AdminPassword)) { $AdminPassword = New-RandomSecret 24 } if ([string]::IsNullOrWhiteSpace($PostgresInstallPath)) { $PostgresInstallPath = Join-Path $InstallPath "postgresql\16" } if ([string]::IsNullOrWhiteSpace($PostgresDataPath)) { $PostgresDataPath = Join-Path $InstallPath "postgres-data" } @@ -682,7 +736,7 @@ $watchdogSecret = New-RandomSecret 64 $certPassword = New-RandomSecret 40 New-Item -ItemType Directory -Path $InstallPath -Force | Out-Null -foreach ($dir in @("api", "watchdog", "scripts", "backups", "exports", "logs", "certs", "updates")) { +foreach ($dir in @("api", "watchdog", "scripts", "backups", "exports", "logs", "certs", "updates", "config")) { New-Item -ItemType Directory -Path (Join-Path $InstallPath $dir) -Force | Out-Null } @@ -796,5 +850,5 @@ try { Write-Host "" Write-Host "Atlas Balance $AppVersion instalado." -ForegroundColor Green Write-Host "URL: $appUrl" -ForegroundColor Cyan -Write-Host "Credenciales iniciales: $InstallPath\INSTALL_CREDENTIALS_ONCE.txt" -ForegroundColor Yellow +Write-Host "Credenciales iniciales: $InstallPath\config\INSTALL_CREDENTIALS_ONCE.txt" -ForegroundColor Yellow Write-Host "Atajo creado: Atlas Balance" -ForegroundColor Cyan diff --git a/Atlas Balance/scripts/Reset-AdminPassword.ps1 b/Atlas Balance/scripts/Reset-AdminPassword.ps1 index ca71eba..75f68dd 100644 --- a/Atlas Balance/scripts/Reset-AdminPassword.ps1 +++ b/Atlas Balance/scripts/Reset-AdminPassword.ps1 @@ -23,6 +23,12 @@ function Convert-SecureStringToPlain { } } +function Test-IsAdmin { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + function New-RandomSecret { param([int]$Length = 24) @@ -42,6 +48,32 @@ function New-RandomSecret { return -join $chars } +function Protect-SecretDirectory { + param([string]$Path) + + New-Item -ItemType Directory -Path $Path -Force | Out-Null + & icacls.exe $Path /inheritance:r /grant:r "*S-1-5-32-544:(OI)(CI)F" "*S-1-5-18:(OI)(CI)F" | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "No se pudo restringir ACL en $Path. No se escribiran credenciales en claro." + } +} + +function Write-SecretFile { + param( + [string]$Path, + [string]$Body + ) + + $directory = Split-Path -Parent $Path + Protect-SecretDirectory -Path $directory + Set-Content -LiteralPath $Path -Value $Body -Encoding UTF8 + & icacls.exe $Path /inheritance:r /grant:r "*S-1-5-32-544:F" "*S-1-5-18:F" | Out-Null + if ($LASTEXITCODE -ne 0) { + Remove-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue + throw "No se pudo restringir ACL en $Path. El archivo de credenciales se elimino." + } +} + function Find-PostgresBin { param( [string]$PreferredPath, @@ -120,6 +152,10 @@ function Get-PasswordHash { throw "No se encontro BCrypt.Net en $apiPath. Ejecuta este script desde una instalacion completa de Atlas Balance." } +if (-not (Test-IsAdmin)) { + throw "Ejecuta este script como Administrador." +} + $apiConfigPath = Join-Path $InstallPath "api\appsettings.Production.json" if (-not (Test-Path $apiConfigPath)) { throw "No se encontro $apiConfigPath. Indica -InstallPath con la instalacion real." @@ -239,8 +275,19 @@ if ($parts.Count -lt 2 -or [int]$parts[0] -lt 1) { Write-Host "Password admin reseteada para $AdminEmail." -ForegroundColor Green Write-Host "Login bloqueado limpiado, primer_login activado y refresh tokens revocados: $($parts[1])." -ForegroundColor Green if ($passwordWasGenerated) { - Write-Host "Password temporal generada: $plainPassword" -ForegroundColor Yellow - Write-Host "Guardala en un gestor de passwords y cambiala en el primer login." -ForegroundColor Yellow + $credentialsDir = Join-Path $InstallPath "config" + $credentialsFile = Join-Path $credentialsDir "RESET_ADMIN_CREDENTIALS_ONCE.txt" + $credentialsBody = @( + "Atlas Balance - reset de password admin" + "Fecha: $(Get-Date -Format o)" + "Email: $AdminEmail" + "Password temporal: $plainPassword" + "" + "Borra este archivo despues de iniciar sesion y cambiar la password." + ) -join [Environment]::NewLine + Write-SecretFile -Path $credentialsFile -Body $credentialsBody + Write-Host "Password temporal escrita en: $credentialsFile" -ForegroundColor Yellow + Write-Host "Acceso restringido a Administrators. Borra el archivo tras iniciar sesion." -ForegroundColor Yellow } else { Write-Host "Usa la password temporal introducida y cambiala en el primer login." -ForegroundColor Yellow } diff --git a/Atlas Balance/scripts/install.ps1 b/Atlas Balance/scripts/install.ps1 index 32bcad0..3114d02 100644 --- a/Atlas Balance/scripts/install.ps1 +++ b/Atlas Balance/scripts/install.ps1 @@ -31,7 +31,7 @@ $packageRoot = Split-Path -Parent $PSScriptRoot $apiExe = Join-Path $packageRoot "api\GestionCaja.API.exe" $watchdogExe = Join-Path $packageRoot "watchdog\GestionCaja.Watchdog.exe" if (-not (Test-Path $apiExe) -or -not (Test-Path $watchdogExe)) { - throw "Esta carpeta no es el paquete instalable. Genera o descarga AtlasBalance-V-01.04-win-x64.zip. Ejecuta el instalador desde la carpeta descomprimida que contiene api\GestionCaja.API.exe, watchdog\GestionCaja.Watchdog.exe, scripts e install.cmd." + throw "Esta carpeta no es el paquete instalable. Genera o descarga AtlasBalance-V-01.05-win-x64.zip. Ejecuta el instalador desde la carpeta descomprimida que contiene api\GestionCaja.API.exe, watchdog\GestionCaja.Watchdog.exe, scripts e install.cmd." } $forwardArgs = @() diff --git a/Atlas Balance/scripts/postgres-init/001-create-app-user.sh b/Atlas Balance/scripts/postgres-init/001-create-app-user.sh new file mode 100644 index 0000000..85437fc --- /dev/null +++ b/Atlas Balance/scripts/postgres-init/001-create-app-user.sh @@ -0,0 +1,28 @@ +#!/bin/sh +set -eu + +OWNER_PASSWORD_SQL=$(printf "%s" "$ATLAS_BALANCE_POSTGRES_OWNER_PASSWORD" | sed "s/'/''/g") +APP_PASSWORD_SQL=$(printf "%s" "$ATLAS_BALANCE_POSTGRES_APP_PASSWORD" | sed "s/'/''/g") + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL +DO \$\$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'atlas_owner') THEN + EXECUTE 'CREATE ROLE atlas_owner WITH LOGIN PASSWORD ''' || '$OWNER_PASSWORD_SQL' || ''' NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS'; + ELSE + EXECUTE 'ALTER ROLE atlas_owner WITH LOGIN PASSWORD ''' || '$OWNER_PASSWORD_SQL' || ''' NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'app_user') THEN + EXECUTE 'CREATE ROLE app_user WITH LOGIN PASSWORD ''' || '$APP_PASSWORD_SQL' || ''' NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS'; + ELSE + EXECUTE 'ALTER ROLE app_user WITH LOGIN PASSWORD ''' || '$APP_PASSWORD_SQL' || ''' NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS'; + END IF; +END +\$\$; + +ALTER DATABASE atlas_balance OWNER TO atlas_owner; +ALTER SCHEMA public OWNER TO atlas_owner; +GRANT CONNECT ON DATABASE atlas_balance TO app_user; +GRANT USAGE ON SCHEMA public TO app_user; +EOSQL diff --git a/Atlas Balance/scripts/update.ps1 b/Atlas Balance/scripts/update.ps1 index 9d66d02..cf62142 100644 --- a/Atlas Balance/scripts/update.ps1 +++ b/Atlas Balance/scripts/update.ps1 @@ -1,7 +1,7 @@ param( [string]$PackagePath = "", - [Parameter(ValueFromRemainingArguments = $true)] - [string[]]$RemainingArgs + [string]$InstallPath = "C:\AtlasBalance", + [switch]$SkipBackup ) $ErrorActionPreference = "Stop" @@ -45,11 +45,20 @@ if (-not (Test-IsAdmin)) { "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", (Quote-Argument $scriptPath), - "-PackagePath", (Quote-Argument $packageRoot) - ) + ($RemainingArgs | ForEach-Object { Quote-Argument $_ }) + "-PackagePath", (Quote-Argument $packageRoot), + "-InstallPath", (Quote-Argument $InstallPath) + ) + if ($SkipBackup) { + $argumentList += "-SkipBackup" + } Start-Process -FilePath "powershell.exe" -ArgumentList ($argumentList -join " ") -Verb RunAs | Out-Null exit 0 } -& $updater @RemainingArgs +$updaterArgs = @("-InstallPath", $InstallPath) +if ($SkipBackup) { + $updaterArgs += "-SkipBackup" +} + +& $updater @updaterArgs diff --git a/CLAUDE.md b/CLAUDE.md index 707d447..97b7b65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,7 @@ ## Que es este proyecto +Este proyecto pertenece a la empresa Atlas Labs y la aplicacion se llama Atlas Balance. Aplicacion web on-premise para gestion de tesoreria multi-banco, multi-titular, multi-divisa. Corre en Windows Server, accesible por 4-8 usuarios en red local via navegador. **Stack:** @@ -139,51 +140,51 @@ Guardar toda la documentacion en `Documentacion`. ``` Atlas Balance/ -├── CLAUDE.md -├── AGENTS.md -├── .github/ -├── .gitignore -├── .gitattributes -├── Atlas Balance/ -│ ├── AGENTS.md -│ ├── CLAUDE.md -│ ├── VERSION -│ ├── Directory.Build.props -│ ├── docker-compose.yml -│ ├── Atlas Balance Release/ -│ ├── backend/ -│ │ ├── GestionCaja.sln -│ │ ├── src/ -│ │ │ ├── GestionCaja.API/ -│ │ │ │ ├── Program.cs -│ │ │ │ ├── appsettings.json -│ │ │ │ ├── appsettings.Development.json.template -│ │ │ │ ├── Constants/ -│ │ │ │ ├── Models/ -│ │ │ │ ├── Data/ -│ │ │ │ ├── DTOs/ -│ │ │ │ ├── Services/ -│ │ │ │ ├── Controllers/ -│ │ │ │ ├── Middleware/ -│ │ │ │ ├── Jobs/ -│ │ │ │ ├── Migrations/ -│ │ │ │ └── wwwroot/ -│ │ │ └── GestionCaja.Watchdog/ -│ │ └── tests/ -│ ├── frontend/ -│ │ ├── package.json -│ │ ├── vite.config.ts -│ │ ├── tsconfig.json -│ │ ├── index.html -│ │ └── src/ -│ └── scripts/ -├── Documentacion/ -│ ├── Versiones/ -│ ├── Diseno/ -│ ├── SPEC.md -│ ├── documentacion.md -│ └── DOCUMENTACION_CAMBIOS.md -└── Otros/ ++-- CLAUDE.md ++-- AGENTS.md ++-- .github/ ++-- .gitignore ++-- .gitattributes ++-- Atlas Balance/ +¦ +-- AGENTS.md +¦ +-- CLAUDE.md +¦ +-- VERSION +¦ +-- Directory.Build.props +¦ +-- docker-compose.yml +¦ +-- Atlas Balance Release/ +¦ +-- backend/ +¦ ¦ +-- GestionCaja.sln +¦ ¦ +-- src/ +¦ ¦ ¦ +-- GestionCaja.API/ +¦ ¦ ¦ ¦ +-- Program.cs +¦ ¦ ¦ ¦ +-- appsettings.json +¦ ¦ ¦ ¦ +-- appsettings.Development.json.template +¦ ¦ ¦ ¦ +-- Constants/ +¦ ¦ ¦ ¦ +-- Models/ +¦ ¦ ¦ ¦ +-- Data/ +¦ ¦ ¦ ¦ +-- DTOs/ +¦ ¦ ¦ ¦ +-- Services/ +¦ ¦ ¦ ¦ +-- Controllers/ +¦ ¦ ¦ ¦ +-- Middleware/ +¦ ¦ ¦ ¦ +-- Jobs/ +¦ ¦ ¦ ¦ +-- Migrations/ +¦ ¦ ¦ ¦ +-- wwwroot/ +¦ ¦ ¦ +-- GestionCaja.Watchdog/ +¦ ¦ +-- tests/ +¦ +-- frontend/ +¦ ¦ +-- package.json +¦ ¦ +-- vite.config.ts +¦ ¦ +-- tsconfig.json +¦ ¦ +-- index.html +¦ ¦ +-- src/ +¦ +-- scripts/ ++-- Documentacion/ +¦ +-- Versiones/ +¦ +-- Diseno/ +¦ +-- SPEC.md +¦ +-- documentacion.md +¦ +-- DOCUMENTACION_CAMBIOS.md ++-- Otros/ ``` ## Esquema de BD corregido @@ -227,7 +228,7 @@ npm run build # Release Windows x64 cd "Atlas Balance" -powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.04 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.05 # Conectar a PostgreSQL psql -h localhost -p 5433 -U app_user -d atlas_balance diff --git a/Documentacion/AUDITORIA_USO_BUGS_SEGURIDAD_V-01.04_2026-04-25.md b/Documentacion/AUDITORIA_USO_BUGS_SEGURIDAD_V-01.05_2026-04-25.md similarity index 99% rename from Documentacion/AUDITORIA_USO_BUGS_SEGURIDAD_V-01.04_2026-04-25.md rename to Documentacion/AUDITORIA_USO_BUGS_SEGURIDAD_V-01.05_2026-04-25.md index 92b6199..8a3724f 100644 --- a/Documentacion/AUDITORIA_USO_BUGS_SEGURIDAD_V-01.04_2026-04-25.md +++ b/Documentacion/AUDITORIA_USO_BUGS_SEGURIDAD_V-01.05_2026-04-25.md @@ -1,4 +1,4 @@ -# Auditoria de uso, bugs y seguridad - V-01.04 +# Auditoria de uso, bugs y seguridad - V-01.05 Fecha: 2026-04-25 diff --git a/Documentacion/DOCUMENTACION_CAMBIOS.md b/Documentacion/DOCUMENTACION_CAMBIOS.md index ab8d983..65ce21d 100644 --- a/Documentacion/DOCUMENTACION_CAMBIOS.md +++ b/Documentacion/DOCUMENTACION_CAMBIOS.md @@ -8,4754 +8,6845 @@ Regla de trabajo desde ahora: - No cerrar una tarea sin dejar evidencia de verificacion. --- -## 2026-04-25 - Publicacion release V-01.04 +## 2026-05-02 - Generacion de paquete release V-01.05 -**Version:** V-01.04 +**Version:** V-01.05 -**Trabajo realizado:** Regenerar el paquete Windows x64 final y publicarlo en GitHub junto con la rama de version. +**Trabajo realizado:** +- Generado el paquete Windows x64 `AtlasBalance-V-01.05-win-x64` en `Atlas Balance/Atlas Balance Release`. +- Generado el ZIP `AtlasBalance-V-01.05-win-x64.zip`. +- Sincronizado el build frontend servido por la API durante el empaquetado. +- Confirmado que los artefactos de release quedan ignorados por Git y deben publicarse como asset de GitHub Release, no como archivos versionados. **Archivos tocados:** - `Atlas Balance/backend/src/GestionCaja.API/wwwroot` -- `Atlas Balance/Atlas Balance Release/AtlasBalance-V-01.04-win-x64` -- `Atlas Balance/Atlas Balance Release/AtlasBalance-V-01.04-win-x64.zip` +- `Atlas Balance/Atlas Balance Release/AtlasBalance-V-01.05-win-x64` +- `Atlas Balance/Atlas Balance Release/AtlasBalance-V-01.05-win-x64.zip` - `Documentacion/DOCUMENTACION_CAMBIOS.md` - `Documentacion/DOCUMENTACION_TECNICA.md` -- `Documentacion/Versiones/v-01.04.md` - -**Cambios implementados:** -- Regenerado el paquete `AtlasBalance-V-01.04-win-x64.zip` desde `scripts/Build-Release.ps1`. -- Sincronizado `wwwroot` desde el build frontend incluido en el paquete. -- Verificado que el paquete no incluye artefactos de desarrollo, `.env`, `node_modules`, `obj`, `bin/Debug` ni `.bak-iframe-fix`. -- Preparada publicacion como asset de GitHub Release, sin versionar el ZIP en Git. +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/Versiones/v-01.05.md` **Comandos ejecutados:** -- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.04` -- `Get-FileHash -Algorithm SHA256` -- `npm.cmd run lint` -- `npm.cmd audit --audit-level=moderate` -- `dotnet test "Atlas Balance\backend\tests\GestionCaja.API.Tests\GestionCaja.API.Tests.csproj" -c Release` -- `dotnet list "Atlas Balance\backend\src\GestionCaja.API\GestionCaja.API.csproj" package --vulnerable --include-transitive` +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\\scripts\\Build-Release.ps1" -Version V-01.05` +- `Get-FileHash -Algorithm SHA256 "Atlas Balance\\Atlas Balance Release\\AtlasBalance-V-01.05-win-x64.zip"` **Resultado de verificacion:** -- Frontend build OK dentro del script de release. -- Frontend lint OK. -- `npm audit`: 0 vulnerabilidades. -- Backend tests Release: 108/108 OK. -- NuGet vulnerable: sin hallazgos. -- Paquete generado: `Atlas Balance/Atlas Balance Release/AtlasBalance-V-01.04-win-x64.zip`. -- Tamano ZIP: `102360418` bytes. -- SHA256 final: `B5ABC5525CBD49F2BD0A5ADC5B930A2113AF323F99C1337087B8E0D7875E6A10`. +- `npm.cmd run build`: OK dentro de `Build-Release.ps1`. +- `dotnet publish GestionCaja.API -c Release -r win-x64 --self-contained true`: OK. +- `dotnet publish GestionCaja.Watchdog -c Release -r win-x64 --self-contained true`: OK. +- ZIP generado: `102350978` bytes. +- SHA256: `3E7A3ED22EFC4D18A161EA9D8D15CD9C12B3D51BDEF9AE38863767EC5CEAE299`. **Pendientes:** -- Validacion manual en Windows Server 2019 real tras descargar el asset publicado. +- No se genero `.zip.sig` porque `ATLAS_RELEASE_SIGNING_PRIVATE_KEY_PEM` no estaba definido. Publicar este ZIP como release final/latest romperia el actualizador online, que exige firma detached. --- -## 2026-04-25 - Correccion de hallazgos de auditoria de uso, bugs y seguridad +## 2026-05-02 - Cierre de hallazgos residuales del escaneo repo-wide -**Version:** V-01.04 +**Version:** V-01.05 -**Trabajo realizado:** Arreglar los hallazgos abiertos por la auditoria: stack frontend violado por Tailwind/shadcn, contrato duplicado de resumen de cuenta, accesibilidad de controles propios y decoracion visual innecesaria. +**Trabajo realizado:** +- Corregidos los hallazgos residuales del escaneo repo-wide: ACL fail-open de credenciales de instalacion/reset, bypass de columnas en `ToggleFlag`, scope global `dashboard-only`, auditoria OpenClaw de extractos eliminados, `returnTo` externo en importacion, RLS de exportaciones con permiso de lectura y tag Docker mutable de PostgreSQL. +- El instalador escribe credenciales en `C:\AtlasBalance\config\INSTALL_CREDENTIALS_ONCE.txt`, con el directorio `config` restringido antes de volcar secretos. +- `Reset-AdminPassword.ps1` exige Administrador y escribe `RESET_ADMIN_CREDENTIALS_ONCE.txt` solo despues de proteger ACL. +- Se agrego cobertura de regresion para `ExtractosController`, `DashboardService`, `IntegrationOpenClawController` y RLS. +- Se sincronizo `frontend/dist` hacia `backend/src/GestionCaja.API/wwwroot`. **Archivos tocados:** -- `Atlas Balance/frontend/package.json` -- `Atlas Balance/frontend/package-lock.json` -- `Atlas Balance/frontend/vite.config.ts` -- `Atlas Balance/frontend/src/styles/global.css` -- `Atlas Balance/frontend/src/styles/auth.css` -- `Atlas Balance/frontend/src/styles/layout/admin.css` -- `Atlas Balance/frontend/src/styles/layout/dashboard.css` -- `Atlas Balance/frontend/src/styles/layout/entities.css` -- `Atlas Balance/frontend/src/styles/layout/shell.css` -- `Atlas Balance/frontend/src/styles/layout/system-coherence.css` -- `Atlas Balance/frontend/src/components/common/DatePickerField.tsx` -- `Atlas Balance/frontend/src/components/common/ConfirmDialog.tsx` -- `Atlas Balance/frontend/src/components/common/AppSelect.tsx` -- `Atlas Balance/backend/src/GestionCaja.API/Controllers/CuentasController.cs` -- `Atlas Balance/backend/src/GestionCaja.API/DTOs/CuentasDtos.cs` -- `Atlas Balance/backend/src/GestionCaja.API/wwwroot` -- `Atlas Balance/backend/tests/GestionCaja.API.Tests/CuentasControllerTests.cs` -- `Documentacion/REGISTRO_BUGS.md` -- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` -- `Documentacion/Versiones/v-01.04.md` -- `Documentacion/DOCUMENTACION_TECNICA.md` -- `Documentacion/AUDITORIA_USO_BUGS_SEGURIDAD_V-01.04_2026-04-25.md` -- `Documentacion/DOCUMENTACION_CAMBIOS.md` - -**Cambios implementados:** -- Eliminadas dependencias y configuracion Tailwind/shadcn (`@tailwindcss/vite`, `tailwindcss`, `shadcn`, `tw-animate-css`, `tailwind-merge`, `class-variance-authority`, `radix-ui`, `components.json`, boton shadcn y utilidades asociadas). -- `CuentasController.Resumen` expone ahora un contrato rico con titular, cuenta, divisa, tipo, notas, ultima actualizacion y metadatos de plazo fijo. -- Agregado test de regresion para resumen de cuenta `PLAZO_FIJO`. -- `DatePickerField` incorpora etiquetas de fecha completa y navegacion con flechas/Home/End. -- `ConfirmDialog` atrapa Tab dentro del modal. -- `AppSelect` abre/cierra con Enter y Espacio. -- Retirados fondos decorativos con radial/linear gradients de superficies principales. +- `.github/workflows/ci.yml` +- `Atlas Balance/docker-compose.yml` +- `Atlas Balance/scripts/Instalar-AtlasBalance.ps1` +- `Atlas Balance/scripts/Reset-AdminPassword.ps1` +- `Atlas Balance/backend/src/GestionCaja.API/Controllers/ExtractosController.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Controllers/IntegrationOpenClawController.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Services/DashboardService.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501120000_EnableRowLevelSecurity.cs` +- `Atlas Balance/frontend/src/pages/ImportacionPage.tsx` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/ExtractosControllerTests.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/DashboardServiceTests.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/IntegrationOpenClawControllerTests.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/RowLevelSecurityTests.cs` +- `Documentacion/*` **Comandos ejecutados:** -- `npm.cmd uninstall @tailwindcss/vite tailwindcss shadcn tw-animate-css tailwind-merge class-variance-authority clsx radix-ui` +- `dotnet test "Atlas Balance/backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj" -c Release --filter "ExtractosControllerTests|DashboardServiceTests|IntegrationOpenClawControllerTests|RowLevelSecurityTests" --no-restore` - `npm.cmd run lint` - `npm.cmd run build` -- `npm.cmd audit --audit-level=moderate` -- `dotnet test ".\\Atlas Balance\\backend\\tests\\GestionCaja.API.Tests\\GestionCaja.API.Tests.csproj" -c Release --filter CuentasControllerTests` -- `dotnet test ".\\Atlas Balance\\backend\\tests\\GestionCaja.API.Tests\\GestionCaja.API.Tests.csproj" -c Release` -- `dotnet list ".\\Atlas Balance\\backend\\src\\GestionCaja.API\\GestionCaja.API.csproj" package --vulnerable --include-transitive` -- Busquedas `Select-String` para restos de Tailwind/shadcn y degradados decorativos. -- `robocopy dist ..\\backend\\src\\GestionCaja.API\\wwwroot /MIR` +- Parser PowerShell de scripts de instalacion/reset/install/update +- `robocopy frontend/dist backend/src/GestionCaja.API/wwwroot /MIR` **Resultado de verificacion:** -- Sin restos directos de Tailwind/shadcn en codigo/configuracion versionable. -- `npm audit`: 0 vulnerabilidades. -- Frontend lint OK. -- Frontend build OK. -- Backend tests: 108/108 OK. -- NuGet vulnerable: sin hallazgos. -- `wwwroot` sincronizado; `robocopy` devolvio codigo `1`, copia correcta con archivos actualizados y limpieza de bundles antiguos. +- Tests focalizados backend: 20/20 OK. +- Frontend lint/build: OK. +- Parser PowerShell: OK. +- `wwwroot` sincronizado, `robocopyExit=3`. **Pendientes:** -- Ejecutar Playwright E2E con `E2E_ADMIN_PASSWORD` en una base disposable. -- El estado Git local sigue sucio y no sirve como base fina de revision sin limpieza previa. +- Ejecutar suite completa antes de publicar release. ---- -## 2026-04-25 - Pasada extra de auditoria y endurecimiento defensivo +## 2026-05-02 - Revision repo-wide post-hardening de bugs y seguridad -**Version:** V-01.04 +**Version:** V-01.05 -**Trabajo realizado:** Repaso completo de bugs documentados, revision de seguridad (auth, permisos, CSRF, security stamp, integracion OpenClaw, secretos, rate limit, cabeceras, dependencias) y aplicacion de guardias de entrada en endpoints nuevos de V-01.04. +**Trabajo realizado:** +- Repaso completo de `REGISTRO_BUGS.md`, `LOG_ERRORES_INCIDENCIAS.md`, `SEGURIDAD_AUDITORIA_V-01.05.md` y `SEGURIDAD_CHECKLIST_APP_V-01.05_2026-05-01.md` para confirmar que los hallazgos previos siguen cerrados. +- Revision de codigo dirigida (subagente) sobre backend, frontend, scripts y Watchdog buscando hallazgos nuevos no cubiertos. +- Cierre del bug abierto `Harness RLS local sin permiso sobre __EFMigrationsHistory`: `RowLevelSecurityTests.CreateRoleConnectionStringsAsync` reasigna ownership de tablas/secuencias/vistas/materializadas/funciones de `public` y `atlas_security` al rol owner creado por el test. +- `IntegrationOpenClawController`: el endpoint de extractos enviaba el email del creador al socio externo. Sustituido por `nombre_completo` (mantiene `usuario-eliminado` para borrados). +- `IntegrationOpenClawController.Auditoria`: eliminado `ip_address` del payload enviado a OpenClaw. +- `scripts/Reset-AdminPassword.ps1`: con `-GeneratePassword` ya no imprime la password temporal en consola; la vuelca en `C:\AtlasBalance\config\RESET_ADMIN_CREDENTIALS_ONCE.txt` con ACL restringida a Administrators. +- `ActualizacionService.DownloadAndPreparePackageAsync`: extraccion ZIP con validacion entrada-por-entrada contra `packageRoot` (defensa en profundidad sobre digest+firma). **Archivos tocados:** -- `Atlas Balance/backend/src/GestionCaja.API/Controllers/AlertasController.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Controllers/CuentasController.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Controllers/ImportacionController.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Controllers/IntegrationOpenClawController.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Services/ActualizacionService.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/RowLevelSecurityTests.cs` +- `Atlas Balance/scripts/Reset-AdminPassword.ps1` - `Documentacion/REGISTRO_BUGS.md` - `Documentacion/LOG_ERRORES_INCIDENCIAS.md` - `Documentacion/DOCUMENTACION_CAMBIOS.md` -**Cambios implementados:** -- Endpoints `POST /api/alertas`, `PUT /api/alertas/{id}`, `POST /api/cuentas/{id}/plazo-fijo/renovar` y `POST /api/importacion/plazo-fijo/movimiento`: validacion de body nulo y normalizacion de listas de destinatarios para que un cuerpo malformado devuelva `400` en lugar de `500`. -- Verificadas auditorias V-01.02/V-01.03/V-01.04: incidencias previas siguen cerradas, `npm audit` y NuGet sin vulnerabilidades. -- Bugs abiertos pre-existentes (Tailwind/shadcn introducido, `CuentaResumenResponse` duplicado, accesibilidad de controles propios, estado Git local) confirmados: requieren decision de producto, no se tocan en esta pasada. - **Comandos ejecutados:** - `dotnet build "Atlas Balance/backend/GestionCaja.sln" -c Release` -- `dotnet test "Atlas Balance/backend/GestionCaja.sln" -c Release --no-build` +- `dotnet test "Atlas Balance/backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj" -c Release --no-build` - `dotnet list "Atlas Balance/backend/GestionCaja.sln" package --vulnerable --include-transitive` -- `npm.cmd audit --audit-level=moderate` -- `npm.cmd run lint` -- `npm.cmd run build` +- `npm.cmd audit --audit-level=moderate` (frontend) +- `npm.cmd run lint` y `npm.cmd run build` (frontend) +- Parser PowerShell sobre `scripts/Reset-AdminPassword.ps1` **Resultado de verificacion:** -- Backend Release build OK, 0 warnings. -- Backend tests: 107/107 OK. -- NuGet vulnerable: sin paquetes vulnerables. -- `npm audit`: 0 vulnerabilidades. -- Frontend lint OK. -- Frontend build OK. +- Backend: build Release sin warnings ni errores, suite 129/129 OK (incluye `RowLevelSecurityTests` que antes dejaba la suite en 127/128). +- NuGet: sin paquetes vulnerables. +- Frontend: lint OK, build OK, npm audit 0 vulnerabilidades. +- PowerShell: parser sin errores en `Reset-AdminPassword.ps1`. **Pendientes:** -- Decision sobre eliminar Tailwind/shadcn vs adoptarlo oficialmente en el stack canonico. -- Eliminar o alinear `CuentasController.Resumen` con el resumen rico que devuelve `ExtractosController`. -- Cerrar contrato de accesibilidad de teclado en `DatePickerField`, `ConfirmDialog`, `AppSelect`. -- Estado Git local sigue listado como abierto. +- Bug abierto: estado Git local poco fiable (`git status` lista todo como untracked); requiere decision explicita para no romper la copia. +- Operativo: firma de binarios Windows, pentest pre-prod, branch protection en GitHub. Estos quedan fuera del alcance de codigo. ---- -## 2026-04-25 - Auditoria general de bugs y seguridad +## 2026-05-02 - Verificacion de vaciado de titulares y cuentas -**Version:** V-01.04 +**Version:** V-01.05 -**Trabajo realizado:** Revision completa razonable de bugs documentados, problemas de seguridad conocidos, dependencias, configuracion y verificaciones automaticas. +**Trabajo realizado:** +- Se reviso la base local `atlas_balance` en el contenedor Docker `atlas_balance_db`. +- Las tablas principales de titulares, cuentas y extractos ya estaban vacias antes de ejecutar ningun borrado. +- Se verificaron tambien tablas dependientes scopeadas por cuenta/titular para confirmar que no quedaban restos operativos. +- No se tocaron usuarios, configuracion, migraciones ni credenciales. **Archivos tocados:** -- `Atlas Balance/frontend/package.json` -- `Atlas Balance/frontend/package-lock.json` -- `Atlas Balance/backend/src/GestionCaja.API/wwwroot` -- `Documentacion/SEGURIDAD_AUDITORIA_V-01.04.md` -- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` -- `Documentacion/REGISTRO_BUGS.md` -- `Documentacion/DOCUMENTACION_TECNICA.md` - `Documentacion/DOCUMENTACION_CAMBIOS.md` -- `Documentacion/Versiones/v-01.04.md` -**Cambios implementados:** -- Revisadas incidencias previas de auth, permisos, rutas, secretos, exportaciones, OpenClaw, cabeceras y CI/CD. -- Confirmado que `npm audit` y NuGet no reportan vulnerabilidades. -- Verificado con advisories recientes que el lockfile ya resolvia versiones seguras, pero el manifiesto mantenia rangos minimos antiguos. -- Actualizado `axios` a `^1.15.2` y `react-router-dom` a `^6.30.3`. -- Recompilado frontend y sincronizado `wwwroot`. -- Creado informe `SEGURIDAD_AUDITORIA_V-01.04.md`. +**Comandos ejecutados:** +- `docker ps --format "{{.Names}}\t{{.Status}}\t{{.Ports}}"` +- `docker exec -i -e PGPASSWORD=... atlas_balance_db psql -U app_user -d atlas_balance` + +**Resultado de verificacion:** +- `TITULARES`: 0 registros. +- `CUENTAS`: 0 registros. +- `EXTRACTOS`: 0 registros. +- `PLAZOS_FIJOS`, `EXTRACTOS_COLUMNAS_EXTRA`, permisos/preferencias scopeados, alertas scopeadas, exportaciones e integration permissions scopeadas: 0 registros. + +**Pendientes:** +- Ninguno. + +## 2026-05-02 - Escaneo de seguridad completo y correcciones + +**Version:** V-01.05 + +**Trabajo realizado:** +- Se ejecuto un escaneo repo-wide con `codex-security` y subagentes sobre auth/MFA, autorizacion, integraciones, importacion, frontend, CI, dependencias, Watchdog y actualizaciones. +- `AuthService` bloquea la cuenta al quinto fallo real de password y acumula fallos MFA por usuario, no por challenge descartable. +- Las auditorias de integracion redactan claves query normalizadas (`client_secret`, `x-api-key`, bearer/token-like values) antes de persistir. +- Importacion limita columnas extra a 64, limita nombres a 80 caracteres, rechaza indices extra inexistentes y no persiste valores extra vacios. +- Los permisos `PuedeVerDashboard` ya no conceden acceso app-layer a cuentas/extractos; restaurar extractos exige permiso de eliminacion. +- Las respuestas de plazo fijo ocultan cuenta de referencia si el usuario no puede verla o si queda fuera de filtros de borrado. +- El actualizador online exige firma detached `.zip.sig` RSA/SHA-256 verificada con `UpdateSecurity:ReleaseSigningPublicKeyPem` o `ATLAS_RELEASE_SIGNING_PUBLIC_KEY_PEM`; el script de release genera la firma si recibe `ATLAS_RELEASE_SIGNING_PRIVATE_KEY_PEM`. + +**Archivos tocados principales:** +- `Atlas Balance/backend/src/GestionCaja.API/Services/AuthService.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Middleware/IntegrationAuthMiddleware.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Services/ImportacionService.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Services/UserAccessService.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Controllers/ExtractosController.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Controllers/CuentasController.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Services/ActualizacionService.cs` +- `Atlas Balance/scripts/Build-Release.ps1` +- Tests focalizados de auth, integraciones, importacion, permisos, extractos, cuentas y actualizaciones. +- Artefactos de scan en `C:\tmp\codex-security-scans\Atlas Balance Dev\6ad0b10_20260502005342`. **Comandos ejecutados:** -- `Get-Content` sobre instrucciones, version actual, log, bugs, auditorias y skill local `cyber-neo`. -- `Get-Command semgrep,trivy,gitleaks,npm.cmd,dotnet` +- `dotnet test 'Atlas Balance/backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj' -c Release --filter "AuthServiceTests|IntegrationAuthMiddlewareTests|ImportacionServiceTests|UserAccessServiceTests|ExtractosControllerTests|CuentasControllerTests|ActualizacionServiceTests"` +- `dotnet test 'Atlas Balance/backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj' -c Release` +- `dotnet list 'Atlas Balance/backend/GestionCaja.sln' package --vulnerable --include-transitive` - `npm.cmd audit --audit-level=moderate` -- `npm.cmd ls axios react-router react-router-dom --depth=0` -- `npm.cmd view axios version` -- `npm.cmd install axios@^1.15.2 react-router-dom@^6.30.3` -- `npm.cmd run lint` -- `npm.cmd run build` -- `robocopy .\dist ..\backend\src\GestionCaja.API\wwwroot /MIR` -- `dotnet build ".\Atlas Balance\backend\GestionCaja.sln" -c Release --no-restore` -- `dotnet test ".\Atlas Balance\backend\GestionCaja.sln" -c Release --no-build` -- `dotnet list ".\Atlas Balance\backend\GestionCaja.sln" package --vulnerable --include-transitive` -- `dotnet list ".\Atlas Balance\backend\GestionCaja.sln" package --deprecated` -- `git diff --check -- ...` +- Parser PowerShell para `Build-Release.ps1` e `Instalar-AtlasBalance.ps1` +- `git diff --check` **Resultado de verificacion:** -- Frontend lint OK. -- Frontend build OK. -- Backend Release build OK. -- Backend tests: 107/107 OK. -- `npm audit`: 0 vulnerabilidades. +- Tests focalizados: OK, 72/72. +- Suite backend completa: 127/128 OK; falla `RowLevelSecurityTests.CoreFinancialTables_Should_Enforce_Rls_By_User_And_IntegrationScope` por permisos locales de PostgreSQL sobre `__EFMigrationsHistory`. - NuGet vulnerable: sin paquetes vulnerables. -- `wwwroot`: sincronizado y sin sourcemaps, plantillas Development ni `.env`. +- npm audit: 0 vulnerabilidades. +- Parser PowerShell: OK. +- `git diff --check`: sin errores de whitespace; solo avisos de line endings. **Pendientes:** -- Instalar `semgrep`, `trivy` y `gitleaks` si se quiere una auditoria automatizada SAST/secrets externa ademas de la revision manual. -- El bug abierto de estado Git local sigue sin tocarse. +- Reparar el setup local de PostgreSQL usado por `RowLevelSecurityTests` para que el rol de test pueda consultar/aplicar migraciones sin romper el modelo RLS. +- Configurar clave publica de firma de releases antes de usar actualizaciones online; sin clave y `.zip.sig`, el actualizador rechaza el paquete a proposito. -## 2026-04-25 - Importacion simple de plazo fijo y resumen en dashboard +--- +## 2026-05-02 - Alineacion dinamica de todas las graficas de evolucion -**Version:** V-01.04 +**Version:** V-01.05 -**Trabajo realizado:** Ajustado el flujo de plazos fijos para que la importacion no use formatos bancarios y el dashboard muestre sus datos clave. +**Trabajo realizado:** +- `EvolucionChart` deja de usar un ancho fijo de `72px` para el eje Y. +- El ancho del eje ahora se calcula segun la etiqueta compacta mas larga de saldo, ingresos y egresos. +- El ancho queda limitado entre `44px` y `72px`, evitando hueco inutil con importes pequenos sin romper etiquetas largas. +- El cambio aplica automaticamente a las cuatro vistas que usan `EvolucionChart`: dashboard principal, dashboard por titular, `Titulares` y `Cuentas`. +- Se sincronizo `frontend/dist` con `backend/src/GestionCaja.API/wwwroot`. **Archivos tocados:** -- `Atlas Balance/backend/src/GestionCaja.API/DTOs/ImportacionDtos.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Controllers/ImportacionController.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Services/ImportacionService.cs` -- `Atlas Balance/backend/src/GestionCaja.API/DTOs/DashboardDtos.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Services/DashboardService.cs` -- `Atlas Balance/backend/tests/GestionCaja.API.Tests/ImportacionServiceTests.cs` -- `Atlas Balance/backend/tests/GestionCaja.API.Tests/DashboardServiceTests.cs` -- `Atlas Balance/frontend/src/pages/ImportacionPage.tsx` -- `Atlas Balance/frontend/src/pages/DashboardPage.tsx` -- `Atlas Balance/frontend/src/styles/layout/importacion.css` -- `Atlas Balance/frontend/src/styles/layout/dashboard.css` -- `Atlas Balance/frontend/src/types/index.ts` +- `Atlas Balance/frontend/src/components/dashboard/EvolucionChart.tsx` - `Atlas Balance/backend/src/GestionCaja.API/wwwroot` -- Documentacion de `V-01.04`. - -**Cambios implementados:** -- El contexto de importacion expone `tipo_cuenta`. -- Las cuentas `PLAZO_FIJO` ya no aceptan importacion con mapeo/formato bancario. -- Nuevo endpoint `POST /api/importacion/plazo-fijo/movimiento` para registrar solo entrada o salida de dinero. -- El movimiento calcula saldo actual como ultimo saldo + monto firmado y audita la operacion. -- La pantalla de importacion muestra un formulario simple para plazo fijo: movimiento, fecha, monto y concepto. -- El dashboard principal muestra resumen de plazos fijos: monto total, intereses previstos aproximados y dias hasta el proximo vencimiento. +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/Versiones/v-01.05.md` -**Decisiones visuales:** -- El plazo fijo usa un formulario compacto dentro de la pantalla de importacion existente, sin wizard ni tabla: pedir formato aqui seria hacer trabajar al usuario para nada. -- El dashboard agrega una banda de metricas sobria, consistente con las cards existentes y responsive a una columna en movil. +**Decisiones visuales tomadas:** +- Resolverlo en el componente compartido para no duplicar ajustes por pantalla. +- Mantener el eje visible y legible, pero sin reservar espacio fijo cuando los importes son cortos. **Comandos ejecutados:** - `npm.cmd run lint` - `npm.cmd run build` -- `robocopy dist ..\\backend\\src\\GestionCaja.API\\wwwroot /MIR` -- `dotnet test "Atlas Balance/backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj" --filter "ImportacionServiceTests|DashboardServiceTests"` -- `dotnet build "Atlas Balance/backend/src/GestionCaja.API/GestionCaja.API.csproj" -c Release` +- `robocopy "C:\Proyectos\Atlas Balance Dev\Atlas Balance\frontend\dist" "C:\Proyectos\Atlas Balance Dev\Atlas Balance\backend\src\GestionCaja.API\wwwroot" /MIR` +- Playwright headless con APIs mockeadas sobre `/dashboard`, `/dashboard/titular/titular-1`, `/titulares` y `/cuentas` **Resultado de verificacion:** - `npm.cmd run lint`: OK. - `npm.cmd run build`: OK. -- `robocopy /MIR`: OK. -- Tests focalizados importacion/dashboard: 28/28 OK. -- Backend Release build: OK, 0 warnings, 0 errores. -- Primer intento de tests quedo bloqueado por una `GestionCaja.API.exe` local en Debug; se detuvo ese proceso y se repitio correctamente. +- `robocopy`: OK, codigo `1` esperado por copia con cambios. +- Playwright headless: OK; `gridStartX=45px`, `yAxisWidth=39px` y sin errores de pagina en las cuatro rutas probadas. + +**Pendientes de diseno abiertos:** +- Ninguno para este ajuste puntual. **Pendientes:** -- Validacion manual con datos reales: crear plazo fijo, registrar entrada/salida desde importacion y revisar dashboard tras refrescar. +- Ninguno. -## 2026-04-25 - Actualizaciones post-instalacion +--- +## 2026-05-02 - Listado de cuentas en tres columnas -**Version:** V-01.04 +**Version:** V-01.05 -**Trabajo realizado:** Endurecido el flujo de actualizacion para instalaciones ya existentes. +**Trabajo realizado:** +- El listado inferior de `Cuentas` pasa de dos a tres columnas en desktop. +- Se acota el cambio a `CuentasPage` mediante la clase `cuentas-page`, sin cambiar la grilla global compartida. +- Las tarjetas de cuenta ajustan titulo, badges, metadatos, saldo, notas y acciones para funcionar mejor en tres columnas. +- El responsive queda en tres columnas desktop, dos columnas tablet y una columna mobile. +- Se sincronizo `frontend/dist` con `backend/src/GestionCaja.API/wwwroot`. **Archivos tocados:** -- `Atlas Balance/update.cmd` -- `Atlas Balance/Actualizar Atlas Balance.cmd` -- `Atlas Balance/scripts/update.ps1` -- `Atlas Balance/scripts/Actualizar-AtlasBalance.ps1` -- `Atlas Balance/README_RELEASE.md` -- `Documentacion/documentacion.md` -- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Atlas Balance/frontend/src/pages/CuentasPage.tsx` +- `Atlas Balance/frontend/src/styles/layout/entities.css` +- `Atlas Balance/backend/src/GestionCaja.API/wwwroot` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` - `Documentacion/DOCUMENTACION_TECNICA.md` -- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` -- `Documentacion/REGISTRO_BUGS.md` -- `Documentacion/Versiones/v-01.04.md` +- `Documentacion/Versiones/v-01.05.md` -**Cambios implementados:** -- `update.ps1` valida paquete antes de autoelevar y soporta `-PackagePath`. -- El actualizador actualiza scripts/wrappers instalados, `VERSION` y `atlas-balance.runtime.json`. -- El flujo conserva configuracion, backup previo, rollback de binarios y ahora valida `/api/health` con `curl.exe -k`. -- Documentado uso desde paquete nuevo y desde instalacion existente con `-PackagePath`. +**Decisiones visuales tomadas:** +- No tocar `.phase2-cards` globalmente: seria demasiado amplio para un cambio de listado. +- Forzar titulo de cuenta y notas a dos lineas maximo para evitar tarjetas descompensadas. +- Mantener el saldo en una columna derecha dentro de la tarjeta cuando hay espacio, y apilarlo en mobile. **Comandos ejecutados:** -- `Get-Content` sobre version actual, version `V-01.04`, log y scripts de actualizacion. -- `Select-String` sobre servicios de actualizacion API/Watchdog. -- Parser PowerShell sobre `update.ps1` y `Actualizar-AtlasBalance.ps1`. -- Ejecucion de update desde carpeta fuente para validar fallo claro. -- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.04`. -- Parser PowerShell sobre scripts empaquetados. -- `dotnet test ".\backend\GestionCaja.sln" -c Release --no-restore --filter "FullyQualifiedName!~ExtractosConcurrencyTests"`. -- `Get-FileHash -Algorithm SHA256` sobre el ZIP regenerado. +- `npm.cmd run lint` +- `npm.cmd run build` +- Playwright headless con APIs mockeadas en `/cuentas` +- `robocopy dist "..\\backend\\src\\GestionCaja.API\\wwwroot" /MIR` **Resultado de verificacion:** -- Parser PowerShell OK. -- Update desde carpeta fuente falla con mensaje de paquete invalido. -- Actualizador empaquetado desde paquete valido y `InstallPath` inexistente falla con mensaje claro de instalacion inexistente. -- Paquete regenerado: `AtlasBalance-V-01.04-win-x64.zip`. -- SHA256: `42994915A8AFD014EF807D99E6335944302662FAA21927206ACAF1B8FDE46304`. -- Scripts empaquetados parsean correctamente. -- Paquete sin `*Development*`, `*.template`, `.env`, `node_modules` ni `.bak-iframe-fix`. -- Backend tests filtrados sin Testcontainers: 95/95 OK. +- `npm.cmd run lint`: OK. +- `npm.cmd run build`: OK. +- Playwright: desktop `3` columnas, tablet `2`, mobile `1`, sin overflow horizontal. +- `robocopy`: OK. **Pendientes:** -- Probar actualizacion real desde una instalacion `V-01.03`/`V-01.04` en Windows Server 2019. +- Ninguno. -## 2026-04-25 - Cierre incidencias instalacion Windows Server 2019 +--- +## 2026-05-02 - Ajuste de tamano del saldo total en dashboard principal -**Version:** V-01.04 +**Version:** V-01.05 -**Trabajo realizado:** Corregidas las incidencias operativas del documento `INCIDENCIAS_INSTALACION_WINDOWS_SERVER_2019_V-01.04.txt`. +**Trabajo realizado:** +- Se reduce la escala del numero destacado de `Saldo total` en el resumen superior del dashboard. +- La grilla de KPIs superiores da mas ancho relativo al KPI principal frente a ingresos y egresos. +- Se evita el salto de linea en importes KPI para que `1.000.000,00 €` no se parta en dos. +- Se sincroniza `frontend/dist` con `backend/src/GestionCaja.API/wwwroot`. **Archivos tocados:** -- `Atlas Balance/install.cmd` -- `Atlas Balance/Instalar Atlas Balance.cmd` -- `Atlas Balance/README_RELEASE.md` -- `Atlas Balance/scripts/install.ps1` -- `Atlas Balance/scripts/Instalar-AtlasBalance.ps1` -- `Atlas Balance/scripts/Reset-AdminPassword.ps1` -- `Atlas Balance/scripts/Build-Release.ps1` -- `Documentacion/documentacion.md` -- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Atlas Balance/frontend/src/styles/layout/dashboard.css` +- `Atlas Balance/backend/src/GestionCaja.API/wwwroot` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` - `Documentacion/DOCUMENTACION_TECNICA.md` - `Documentacion/LOG_ERRORES_INCIDENCIAS.md` -- `Documentacion/REGISTRO_BUGS.md` -- `Documentacion/Versiones/v-01.04.md` +- `Documentacion/Versiones/v-01.05.md` -**Cambios implementados:** -- Validacion temprana de paquete release para evitar instalar desde carpeta fuente o ZIP `main`. -- Fallback operativo cuando `winget` falla en Windows Server 2019 y documentacion de PostgreSQL 17 como valido. -- Deteccion de usuarios existentes para no generar credenciales admin falsas en reinstalaciones. -- Script oficial `Reset-AdminPassword.ps1` con bcrypt 12, limpieza de bloqueo, `primer_login`, rotacion de `security_stamp` y revocacion de refresh tokens. -- Health check post-instalacion con `curl.exe -k`. -- Inclusion de scripts de reset/certificado cliente en el paquete release. +**Decisiones visuales tomadas:** +- Dar prioridad visual real al saldo total: menos escala teatral y mas legibilidad. Un KPI que no aguanta un millon de euros es un KPI de juguete. +- No usar `overflow: hidden` ni puntos suspensivos; el importe debe verse completo. **Comandos ejecutados:** -- `Get-Content` sobre instrucciones, version actual, version `V-01.04`, incidencias, log y catalogo de skills. -- `Select-String`/`Get-ChildItem` para localizar scripts, cabeceras, instalador y documentacion. -- Parser PowerShell con `[System.Management.Automation.Language.Parser]::ParseFile(...)`. -- Ejecucion de `Instalar-AtlasBalance.ps1` e `install.ps1` desde carpeta fuente para validar fallo claro. -- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.04`. -- `dotnet test ".\backend\GestionCaja.sln" -c Release --no-restore --filter "FullyQualifiedName!~ExtractosConcurrencyTests"`. -- `Get-FileHash -Algorithm SHA256` sobre `AtlasBalance-V-01.04-win-x64.zip`. +- `npm.cmd run lint` +- `npm.cmd run build` +- Playwright headless con APIs mockeadas sobre `http://127.0.0.1:5186/dashboard` +- `robocopy "C:\Proyectos\Atlas Balance Dev\Atlas Balance\frontend\dist" "C:\Proyectos\Atlas Balance Dev\Atlas Balance\backend\src\GestionCaja.API\wwwroot" /MIR` **Resultado de verificacion:** -- Parser PowerShell OK en scripts modificados. -- Ejecutar el instalador desde carpeta fuente falla con mensaje de paquete invalido. -- Ejecutar `scripts\install.ps1` desde carpeta fuente falla con el mismo mensaje antes de autoelevar. -- Paquete generado: `Atlas Balance/Atlas Balance Release/AtlasBalance-V-01.04-win-x64.zip`. -- SHA256: `42994915A8AFD014EF807D99E6335944302662FAA21927206ACAF1B8FDE46304`. -- Scripts nuevos incluidos en paquete y parser OK en scripts empaquetados. -- Paquete sin `*Development*`, `*.template`, `.env`, `node_modules` ni `.bak-iframe-fix`. -- Backend tests filtrados sin Testcontainers: 95/95 OK. +- `npm.cmd run lint`: OK. +- `npm.cmd run build`: OK. +- Playwright headless: OK; `1.000.000,00 €` queda en una sola linea, `wraps=false`, `overflows=false`. +- `robocopy`: OK, bundle servido actualizado. + +**Pendientes de diseno abiertos:** +- Si se esperan importes de ocho cifras o mas, habra que pasar el KPI principal a ancho completo o usar formato compacto configurable; fingir que cabe todo en una mini tarjeta seria mala idea. **Pendientes:** -- Probar el ZIP en Windows Server 2019 real con PostgreSQL 17 antes de publicarlo. +- Ninguno. -## 2026-04-25 - Documento incidencias instalacion Windows Server 2019 +--- +## 2026-05-02 - Divisa base primero en saldos por divisa -**Version:** V-01.04 +**Version:** V-01.05 -**Trabajo realizado:** Generado un documento TXT de traspaso con errores, bugs, incidencias y soluciones detectadas durante la instalacion real en Windows Server 2019. +**Trabajo realizado:** +- `Saldos por divisa` muestra siempre la divisa base como primera tarjeta. +- El resto de divisas conserva el orden recibido de la API. +- Se sincroniza `frontend/dist` con `backend/src/GestionCaja.API/wwwroot`. **Archivos tocados:** -- `Documentacion/INCIDENCIAS_INSTALACION_WINDOWS_SERVER_2019_V-01.04.txt` +- `Atlas Balance/frontend/src/components/dashboard/SaldoPorDivisaCard.tsx` +- `Atlas Balance/backend/src/GestionCaja.API/wwwroot` - `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/Versiones/v-01.05.md` -**Cambios implementados:** -- Registradas incidencias de instalacion desde carpeta fuente, paquete V-01.03 vs V-01.04, PostgreSQL 17, `winget`, wrapper `install.cmd`, certificado PFX, health check PowerShell, certificado cliente, credenciales iniciales, reset admin, bloqueo login, SQL con tablas en mayusculas, modal de importacion anti-frame y parche temporal del bundle. -- Incluido checklist para cerrar `V-01.04` sin documentar passwords ni secretos reales. +**Decisiones visuales tomadas:** +- La divisa base es la referencia de lectura y debe ir delante aunque el backend entregue otro orden. Hacer depender la jerarquia visual del orden del array era una tonteria evitable. **Comandos ejecutados:** -- `Get-Content` sobre version actual, `v-01.04.md` y bitacora. -- Creacion del TXT con `apply_patch`. +- `npm.cmd run lint` +- `npm.cmd run build` +- `robocopy "C:\Proyectos\Atlas Balance Dev\Atlas Balance\frontend\dist" "C:\Proyectos\Atlas Balance Dev\Atlas Balance\backend\src\GestionCaja.API\wwwroot" /MIR` +- Playwright headless con APIs mockeadas sobre `http://127.0.0.1:5184/dashboard` **Resultado de verificacion:** -- Documento creado en `Documentacion`. -- No se incluyeron passwords reales. +- `npm.cmd run lint`: OK. +- `npm.cmd run build`: OK. +- `robocopy`: OK, bundle servido actualizado. +- Playwright headless: OK; API mockeada devuelve `USD` antes que `EUR`, pero la primera tarjeta renderizada es `EUR` porque es la divisa base. **Pendientes:** -- Convertir las soluciones pendientes en cambios de codigo/scripts antes de publicar `V-01.04`. +- Ninguno. -## 2026-04-25 - Fix modal importacion bloqueado por anti-frame +--- +## 2026-05-02 - Listado de titulares en tres columnas -**Version:** V-01.04 +**Version:** V-01.05 -**Trabajo realizado:** Corregido el bloqueo del modal `Importar movimientos` en produccion. +**Trabajo realizado:** +- El listado inferior de `Titulares` pasa de dos a tres columnas en desktop. +- Se acota el cambio a `TitularesPage` mediante la clase `titulares-page`, evitando afectar el listado de `Cuentas`. +- Las tarjetas de titular ajustan titulo, notas, estado, saldo y acciones para soportar mejor el ancho de tres columnas. +- El responsive queda en tres columnas desktop, dos columnas tablet y una columna mobile. +- Se sincronizo `frontend/dist` con `backend/src/GestionCaja.API/wwwroot`. **Archivos tocados:** -- `Atlas Balance/backend/src/GestionCaja.API/Program.cs` -- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` -- `Documentacion/REGISTRO_BUGS.md` +- `Atlas Balance/frontend/src/pages/TitularesPage.tsx` +- `Atlas Balance/frontend/src/styles/layout/entities.css` +- `Atlas Balance/backend/src/GestionCaja.API/wwwroot` - `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/Versiones/v-01.05.md` -**Cambios implementados:** -- `X-Frame-Options` pasa de `DENY` a `SAMEORIGIN`. -- `Content-Security-Policy frame-ancestors` pasa de `'none'` a `'self'`. -- La app sigue bloqueando embebidos externos, pero permite su propia ruta `/importacion` dentro del modal. +**Decisiones visuales tomadas:** +- No cambiar `.phase2-cards` globalmente, porque tambien lo usa `CuentasPage`. +- Mantener tarjetas densas pero legibles: titulo y notas con maximo dos lineas, saldo alineado y acciones ancladas abajo. +- Usar 3/2/1 columnas segun viewport para evitar overflow horizontal. **Comandos ejecutados:** -- `Select-String` sobre frontend, bundle generado y `Program.cs`. -- `Get-Content` sobre `CuentaDetailPage.tsx`, `ImportacionPage.tsx` y cabeceras de produccion. +- `npm.cmd run lint` +- `npm.cmd run build` +- Playwright headless con APIs mockeadas en `/titulares` +- `robocopy dist "..\\backend\\src\\GestionCaja.API\\wwwroot" /MIR` **Resultado de verificacion:** -- Causa identificada: iframe same-origin bloqueado por cabeceras HTTP de la API. -- Correccion aplicada en fuente `V-01.04`. +- `npm.cmd run lint`: OK. +- `npm.cmd run build`: OK. +- Playwright: desktop `3` columnas, tablet `2`, mobile `1`, sin overflow horizontal. +- `robocopy`: OK, codigo `1` esperado por copia con cambios. **Pendientes:** -- Publicar/regenerar paquete para llevar la correccion al servidor. En `V-01.03` instalado puede mitigarse navegando a `/importacion` en pagina completa. +- Ninguno. -## 2026-04-25 - Fix reinstalacion certificado HTTPS +## 2026-05-02 - Formato de importacion en cuentas de efectivo -**Version:** V-01.04 +**Version:** V-01.05 -**Trabajo realizado:** Diagnosticado y corregido un fallo de reinstalacion en Windows Server donde la API no arrancaba al cargar el certificado HTTPS. +**Trabajo realizado:** +- Se permite seleccionar `Formato de importacion` en cuentas de tipo `EFECTIVO`. +- `CuentasPage` conserva el selector de formato para cuentas normales y de efectivo, pero sigue limpiando datos bancarios en efectivo. +- `CuentasController` valida y persiste `formato_id` para `NORMAL` y `EFECTIVO`; solo lo descarta en `PLAZO_FIJO`. +- `ImportacionPage` actualiza el texto de ayuda para indicar que efectivo tambien usa formatos de importacion. +- Se agrego una regresion backend para asegurar que una cuenta de efectivo conserva su formato y no guarda banco/IBAN/numero. +- Se sincronizo `frontend/dist` con `backend/src/GestionCaja.API/wwwroot`. **Archivos tocados:** -- `Atlas Balance/scripts/Instalar-AtlasBalance.ps1` +- `Atlas Balance/frontend/src/pages/CuentasPage.tsx` +- `Atlas Balance/frontend/src/pages/ImportacionPage.tsx` +- `Atlas Balance/backend/src/GestionCaja.API/Controllers/CuentasController.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/CuentasControllerTests.cs` +- `Atlas Balance/backend/src/GestionCaja.API/wwwroot` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` - `Documentacion/LOG_ERRORES_INCIDENCIAS.md` - `Documentacion/REGISTRO_BUGS.md` -- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/Versiones/v-01.05.md` -**Cambios implementados:** -- `New-AtlasCertificate` ya no reutiliza `atlas-balance.pfx` existente durante instalacion; elimina PFX/CER previos y genera un par nuevo con la password que se escribe en `appsettings.Production.json`. -- Registrada la incidencia y la mitigacion operativa para instalaciones afectadas. +**Decisiones visuales tomadas:** +- Mantener una sola seccion compacta para importacion en efectivo, sin mostrar campos bancarios que no aplican. +- No crear un flujo nuevo de importacion: efectivo usa el mismo selector y motor que una cuenta normal. **Comandos ejecutados:** -- `Get-Service AtlasBalance.API,AtlasBalance.Watchdog` en servidor afectado, reportado por usuario. -- `Get-EventLog -LogName Application -Newest 50`, reportado por usuario. -- `netstat -ano | findstr :443`, reportado por usuario. -- `Select-String` y `Get-Content` sobre `Instalar-AtlasBalance.ps1` para revisar generacion de certificado y configuracion. +- `dotnet test "Atlas Balance\\backend\\tests\\GestionCaja.API.Tests\\GestionCaja.API.Tests.csproj" -c Release --filter CuentasControllerTests` +- `npm.cmd run lint` +- `npm.cmd run build` +- `robocopy dist "..\\backend\\src\\GestionCaja.API\\wwwroot" /MIR` **Resultado de verificacion:** -- Causa identificada en el flujo de instalacion: PFX existente + password nueva. -- Correccion aplicada en script para `V-01.04`. +- `CuentasControllerTests`: 5/5 OK. +- Primer `npm.cmd run lint`: fallo por dependencia faltante del `useEffect`; corregido. +- `npm.cmd run lint`: OK. +- `npm.cmd run build`: OK. +- `robocopy`: OK, codigo `1` esperado por copia con cambios. **Pendientes:** -- Regenerar paquete `V-01.04` antes de publicar una release nueva. +- Validacion manual en navegador con una cuenta de efectivo real si hay datos de usuario disponibles. -## 2026-04-25 - Apertura version V-01.04 +## 2026-05-02 - Alineacion de graficas en dashboards de cuentas y titulares -**Version:** V-01.04 +**Version:** V-01.05 -**Trabajo realizado:** Apertura de la nueva linea de trabajo posterior a la publicacion de `V-01.03`, con rama propia y fuentes de version alineadas. +**Trabajo realizado:** +- Se ajustaron las graficas de barras embebidas en `CuentasPage` y `TitularesPage`. +- El eje Y deja de reservar `120px` y pasa a `72px`, igualando el criterio ya aplicado a `EvolucionChart`. +- Los ticks del eje Y usan formato compacto para evitar que etiquetas largas empujen el area de trazado hacia la derecha. +- Se definieron margenes explicitos y se ocultaron lineas de eje innecesarias para alinear mejor la grafica con el borde izquierdo util. +- Se sincronizo `frontend/dist` con `backend/src/GestionCaja.API/wwwroot`. **Archivos tocados:** -- `CLAUDE.md` -- `Atlas Balance/AGENTS.md` -- `Atlas Balance/CLAUDE.md` -- `Atlas Balance/VERSION` -- `Atlas Balance/Directory.Build.props` -- `Atlas Balance/frontend/package.json` -- `Atlas Balance/frontend/package-lock.json` -- `Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs` -- `Atlas Balance/scripts/Build-Release.ps1` -- `Atlas Balance/scripts/Instalar-AtlasBalance.ps1` -- `Atlas Balance/README_RELEASE.md` -- `Documentacion/documentacion.md` +- `Atlas Balance/frontend/src/pages/CuentasPage.tsx` +- `Atlas Balance/frontend/src/pages/TitularesPage.tsx` +- `Atlas Balance/backend/src/GestionCaja.API/wwwroot` - `Documentacion/DOCUMENTACION_CAMBIOS.md` - `Documentacion/DOCUMENTACION_TECNICA.md` -- `Documentacion/Versiones/version_actual.md` -- `Documentacion/Versiones/v-01.03.md` -- `Documentacion/Versiones/v-01.04.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/Versiones/v-01.05.md` -**Cambios implementados:** -- Creada rama local `V-01.04` desde `V-01.03`. -- Marcada `V-01.04` como version actual del proyecto. -- Cerrada `V-01.03` como version publicada/base anterior. -- Actualizadas fuentes runtime backend/frontend a `1.4.0` y `V-01.04`. -- Actualizados scripts y documentacion viva para generar paquetes `AtlasBalance-V-01.04-win-x64`. +**Decisiones visuales tomadas:** +- Corregir la geometria interna de Recharts, no compensar con padding externo en la tarjeta. +- Mantener tooltip con importe completo y usar formato compacto solo en el eje, donde el espacio manda. **Comandos ejecutados:** -- `git status --short --branch` -- `Get-Content` sobre `CLAUDE.md`, `Documentacion/Versiones/version_actual.md`, archivos `v-*` y fuentes runtime. -- `git branch --list V-01.04` -- `git ls-remote --heads origin V-01.04` -- `git switch -c V-01.04` -- `git switch V-01.04` -- `Select-String` para localizar referencias vivas a `V-01.03` y `1.3.0`. -- `git diff --check` -- `dotnet build '.\Atlas Balance\backend\GestionCaja.sln' -c Release --no-restore` +- `Get-Date -Format 'yyyy-MM-dd HH:mm:ss K'` +- `npm.cmd run lint` - `npm.cmd run build` +- `robocopy "C:\Proyectos\Atlas Balance Dev\Atlas Balance\frontend\dist" "C:\Proyectos\Atlas Balance Dev\Atlas Balance\backend\src\GestionCaja.API\wwwroot" /MIR` +- Playwright headless con APIs mockeadas sobre `/titulares` y `/cuentas` **Resultado de verificacion:** -- Rama activa confirmada: `V-01.04`. -- `git diff --check`: OK; solo avisos esperados de normalizacion LF/CRLF. -- Backend build Release: OK, 0 warnings, 0 errores. -- Frontend build: OK con `atlas-balance-frontend@1.4.0`. -- Busqueda de referencias activas: sin restos de `V-01.03` en codigo/configuracion viva. +- `npm.cmd run lint`: OK. +- `npm.cmd run build`: OK. +- `robocopy`: OK, codigo `1` esperado por copia con cambios. +- Playwright headless: OK; `gridStartX=72px` y `yAxisWidth=69px` en `/titulares` y `/cuentas`, sin errores de pagina. + +**Pendientes de diseno abiertos:** +- Ninguno para este ajuste puntual. **Pendientes:** - Ninguno. -## 2026-04-25 - Publicacion asset GitHub Release V-01.03 +--- +## 2026-05-02 - Reorden de plazos fijos y saldos por titular en dashboard principal -**Version:** V-01.03 +**Version:** V-01.05 -**Trabajo realizado:** Publicacion del ZIP instalable `AtlasBalance-V-01.03-win-x64.zip` como asset de GitHub Release, sin meter el paquete generado en Git. +**Trabajo realizado:** +- Se mueve `Plazos fijos` al bloque superior del dashboard, justo debajo de `Saldo total`, `Ingresos periodo` y `Egresos periodo`. +- `Saldos por titular` pasa a ocupar toda la parte inferior del dashboard. +- Los saldos por titular se muestran en tres columnas fijas: Empresa, Autonomo y Particular. +- Se mantiene cada columna aunque un tipo no tenga saldos, mostrando un estado compacto `Sin saldos`. +- Se sincroniza `frontend/dist` con `backend/src/GestionCaja.API/wwwroot`. **Archivos tocados:** +- `Atlas Balance/frontend/src/pages/DashboardPage.tsx` +- `Atlas Balance/frontend/src/styles/layout/dashboard.css` +- `Atlas Balance/backend/src/GestionCaja.API/wwwroot` - `Documentacion/DOCUMENTACION_CAMBIOS.md` -- `Documentacion/Versiones/v-01.03.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/Versiones/v-01.05.md` -**Cambios implementados:** -- Creado el release publico `V-01.03-win-x64` en `AtlasLabs797/AtlasBalance`. -- Subido el asset `AtlasBalance-V-01.03-win-x64.zip`. -- Asociado el tag `V-01.03-win-x64` al commit `8df640d86912eb39b900a59ea0fd8ba769cacc96` (`origin/V-01.03`). -- Marcado `V-01.03-win-x64` como ultimo release publicado. +**Decisiones visuales tomadas:** +- `Plazos fijos` pertenece al resumen financiero superior porque explica la parte inmovilizada del saldo total; dejarlo abajo junto a titulares mezclaba conceptos. +- `Saldos por titular` necesita ancho completo para comparar Empresa, Autonomo y Particular sin apretar tarjetas. La grilla de dos columnas anterior era una mala lectura para tres categorias. +- En mobile las columnas vuelven a una sola columna para no crear una tabla ilegible en pantallas estrechas. **Comandos ejecutados:** -- `gh auth status` -- `gh release list --repo AtlasLabs797/AtlasBalance --limit 20` -- `Get-FileHash -Algorithm SHA256` sobre el ZIP de release. -- `gh release create V-01.03-win-x64 ... --draft` -- `gh release edit V-01.03-win-x64 --draft=false --latest` -- `gh release view V-01.03-win-x64 --json tagName,name,isDraft,isImmutable,isPrerelease,url,assets,publishedAt,targetCommitish` -- `git ls-remote --tags origin V-01.03-win-x64` +- `npm.cmd run lint` +- `npm.cmd run build` +- Playwright headless con APIs mockeadas sobre `http://127.0.0.1:5183/dashboard` +- `robocopy "C:\Proyectos\Atlas Balance Dev\Atlas Balance\frontend\dist" "C:\Proyectos\Atlas Balance Dev\Atlas Balance\backend\src\GestionCaja.API\wwwroot" /MIR` **Resultado de verificacion:** -- Release publicado: `https://github.com/AtlasLabs797/AtlasBalance/releases/tag/V-01.03-win-x64`. -- Asset publicado: `AtlasBalance-V-01.03-win-x64.zip`. -- Tamano del asset: `102249107` bytes. -- SHA256 verificado por GitHub y local: `71E51F49CF740D358E056F256B70B3352EE23E61BD6FFFF0F048627AA07FDFA2`. -- Release no queda en draft y no es prerelease. +- `npm.cmd run lint`: OK. +- `npm.cmd run build`: OK. +- Playwright headless: OK; `plazoDebajoKpis=true`, `titularesFullWidth=1140`, columnas `Empresa|Autonomo|Particular`, sin overflow horizontal. +- `robocopy`: OK, bundle servido actualizado desde `frontend/dist`. + +**Pendientes de diseno abiertos:** +- Validar con nombres reales muy largos si algun titular necesita truncado adicional dentro de cada columna. **Pendientes:** -- Ninguno para la publicacion del asset de release. +- Ninguno. -## 2026-04-25 - Publicacion GitHub V-01.03 +--- +## 2026-05-01 - Rediseño del dashboard principal con gráfica a ancho completo -**Version:** V-01.03 +**Version:** V-01.05 -**Trabajo realizado:** Publicacion del contenido versionable de `V-01.03` en GitHub, excluyendo `Otros/`, `Skills/` y paquetes generados de `Atlas Balance/Atlas Balance Release`. +**Trabajo realizado:** +- Se reestructura el dashboard principal para que `Evolución` deje de competir en una grilla de tres columnas. +- Los KPIs y `Saldos por divisa` quedan como resumen superior compacto. +- La gráfica de evolución pasa a una tarjeta propia de ancho completo y mayor altura útil. +- `EvolucionChart` acepta altura configurable para usar una gráfica más grande en el dashboard principal sin romper otros usos. +- Se sincroniza `frontend/dist` con `backend/src/GestionCaja.API/wwwroot`. **Archivos tocados:** +- `Atlas Balance/frontend/src/pages/DashboardPage.tsx` +- `Atlas Balance/frontend/src/components/dashboard/EvolucionChart.tsx` +- `Atlas Balance/frontend/src/styles/layout/dashboard.css` +- `Atlas Balance/backend/src/GestionCaja.API/wwwroot` +- `Documentacion/Diseno/DESIGN.md` - `Documentacion/DOCUMENTACION_CAMBIOS.md` -- `Documentacion/Versiones/v-01.03.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/Versiones/v-01.05.md` -**Cambios implementados:** -- Validada la rama local `V-01.03` contra la version actual. -- Confirmado remoto oficial `https://github.com/AtlasLabs797/AtlasBalance.git`. -- Staged del contenido versionable del proyecto sin incluir directorios excluidos. -- Commit principal creado: `1155bac` (`Publica V-01.03`). -- Push realizado a `origin/V-01.03`. +**Decisiones visuales tomadas:** +- La gráfica temporal es el bloque principal de análisis, no una tarjeta lateral. El layout anterior era demasiado democrático: todo parecía igual de importante, que en un dashboard financiero es una mala señal. +- Mantener sobriedad: más ancho, más altura y mejor jerarquía; nada de efectos nuevos ni dependencia visual externa. +- Los saldos por divisa siguen arriba porque dan contexto inmediato, pero no roban espacio horizontal a la gráfica. **Comandos ejecutados:** -- `Get-Content` sobre `CLAUDE.md`, `Documentacion/Versiones/version_actual.md` y `Documentacion/Versiones/v-01.03.md`. -- `git status --short --branch` -- `git remote -v` -- `gh --version` -- `gh auth status` -- `git ls-remote --heads origin V-01.03` -- `git diff --check` -- `dotnet test ".\Atlas Balance\backend\GestionCaja.sln" -c Release --no-restore` - `npm.cmd run lint` - `npm.cmd run build` -- `npm.cmd audit --audit-level=low` -- `dotnet list ".\Atlas Balance\backend\GestionCaja.sln" package --vulnerable --include-transitive` -- `git add -A -- .` -- `git config user.name "Codex"` -- `git config user.email "codex@atlasbalance.local"` -- `git commit -m "Publica V-01.03"` -- `git push -u origin V-01.03` +- Playwright headless con APIs mockeadas sobre `http://127.0.0.1:5177/dashboard` +- `robocopy frontend/dist -> backend/src/GestionCaja.API/wwwroot /MIR` **Resultado de verificacion:** -- `git diff --check`: OK. -- Tests backend Release: 94/94 OK. -- Frontend lint: OK. -- Frontend build: OK. -- `npm audit`: 0 vulnerabilidades. -- NuGet vulnerable: sin paquetes vulnerables. -- `Otros/`, `Skills/` y paquetes de release quedaron fuera del commit. -- Rama remota creada correctamente: `origin/V-01.03`. -- `gh` no estaba autenticado durante esta publicacion de codigo; no se creo PR desde esa sesion. -- Asset de release publicado posteriormente en `V-01.03-win-x64`. +- `npm.cmd run lint`: OK. +- `npm.cmd run build`: OK. +- Playwright headless final: OK; `chartWidthRatio=0.960`, `svgHeight=420`, sin errores de pagina, sin respuestas API 500 y sin overflow horizontal. Durante la verificacion se corrigieron dos fallos del script mock (`puntos` mal nombrado y rutas auxiliares no mockeadas); no eran fallos del producto. +- `robocopy`: OK. + +**Pendientes de diseno abiertos:** +- Validar con datos reales si titulares con nombres muy largos necesitan truncado más agresivo. **Pendientes:** -- Crear PR si se quiere revisar/mergear desde GitHub. +- Ninguno. -## 2026-04-25 - Generacion release Windows x64 V-01.03 +--- +## 2026-05-01 - Alineacion de grafica de Evolucion en dashboard principal -**Version:** V-01.03 +**Version:** V-01.05 -**Trabajo realizado:** Generacion del paquete instalable Windows x64 de la version actual, equivalente al release previo pero con runtime, frontend, API, Watchdog, scripts y manifiesto alineados a `V-01.03`. +**Trabajo realizado:** +- Se ajusto el `LineChart` de `EvolucionChart` para que el area de trazado no quede desplazada a la derecha. +- El eje Y deja de reservar `116px` y pasa a una anchura mas proporcionada (`72px`) con margenes explicitos del chart. +- Se agrega `tickMargin` a ambos ejes para conservar lectura sin inflar el carril del eje Y. +- Se sincroniza `wwwroot` con el build frontend actualizado. **Archivos tocados:** +- `Atlas Balance/frontend/src/components/dashboard/EvolucionChart.tsx` - `Atlas Balance/backend/src/GestionCaja.API/wwwroot` -- `Atlas Balance/Atlas Balance Release/AtlasBalance-V-01.03-win-x64` -- `Atlas Balance/Atlas Balance Release/AtlasBalance-V-01.03-win-x64.zip` - `Documentacion/DOCUMENTACION_CAMBIOS.md` - `Documentacion/DOCUMENTACION_TECNICA.md` -- `Documentacion/Versiones/v-01.03.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/Versiones/v-01.05.md` -**Cambios implementados:** -- Ejecutado `scripts/Build-Release.ps1 -Version V-01.03`. -- Recompilado frontend React/Vite y sincronizado en `GestionCaja.API/wwwroot`. -- Publicada API ASP.NET Core y Watchdog como self-contained `win-x64`. -- Copiados scripts operativos `install/update/uninstall/start`, wrappers historicos, `VERSION`, `README.md`, `.gitignore`, `documentacion.md` y `version.json`. -- Generados carpeta y ZIP finales `AtlasBalance-V-01.03-win-x64`. +**Decisiones visuales tomadas:** +- Corregir la geometria del chart en Recharts, no compensar el problema con padding externo en la tarjeta. +- Mantener suficiente espacio para importes compactos tipo `4 EUR` sin que el eje Y coma media grafica. **Comandos ejecutados:** -- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.03` -- `Get-ChildItem` sobre `Atlas Balance/Atlas Balance Release`. -- `Get-Content` sobre `version.json` y `VERSION` empaquetados. -- Barrido de `api` empaquetada para detectar `*Development*`, `*.template` o `.env`. +- `npm.cmd run lint` +- `npm.cmd run build` +- `robocopy dist ..\\backend\\src\\GestionCaja.API\\wwwroot /MIR` +- Playwright headless con APIs mockeadas sobre `/dashboard` **Resultado de verificacion:** -- `npm.cmd run build`: OK dentro del build de release. -- `dotnet publish` API `win-x64`: OK. -- `dotnet publish` Watchdog `win-x64`: OK. -- `version.json` empaquetado apunta a `V-01.03`. -- `VERSION` empaquetado contiene `V-01.03`. -- No se detectaron `appsettings.Development`, plantillas ni `.env` dentro de `api`. +- `npm.cmd run lint`: OK. +- `npm.cmd run build`: OK. +- `robocopy ... /MIR`: OK. +- Playwright headless: OK; `YAxis <= 86px` y `plotInsetFromLegend=72px`. + +**Pendientes de diseno abiertos:** +- Ninguno para este ajuste puntual. **Pendientes:** -- Ninguno. Si se publica en GitHub, este ZIP debe ir como asset de GitHub Release, no como archivo versionado. +- Validacion visual final con datos reales del servidor si aparece otro caso extremo de importes largos. -## 2026-04-25 - Auditoria profunda de seguridad y hardening +--- +## 2026-05-02 - Regresion MFA cada 90 dias -**Version:** V-01.03 +**Version:** V-01.05 -**Trabajo realizado:** Analisis de seguridad sobre backend, frontend, configuracion, scripts, dependencias y Watchdog; remediacion directa de hallazgos de sesion, SSRF, path traversal, rate limiting y dependencias. +**Trabajo realizado:** +- Agregada prueba de regresion para confirmar que una cookie `mfa_trusted` expirada vuelve a exigir Google Authenticator. +- Confirmado que la ventana recordada es de 90 dias y no se renueva en cada login con cookie valida. **Archivos tocados:** -- `Atlas Balance/backend/src/GestionCaja.API/Constants/AuthClaimNames.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Constants/SecurityPolicy.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Constants/AuditActions.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Models/Entities.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Data/AppDbContext.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Migrations/20260425081244_UserSessionHardening.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Middleware/UserStateMiddleware.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Middleware/IntegrationAuthMiddleware.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Services/AuthService.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Services/UserSessionState.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Controllers/UsuariosController.cs` -- `Atlas Balance/backend/src/GestionCaja.API/ConfigurationDefaults.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Controllers/ConfiguracionController.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Services/ActualizacionService.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Services/BackupService.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Services/ExportacionService.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Controllers/ExportacionesController.cs` -- `Atlas Balance/backend/src/GestionCaja.Watchdog/Services/WatchdogOperationsService.cs` -- `Atlas Balance/frontend/package-lock.json` -- `Atlas Balance/frontend/src/components/usuarios/UsuarioModal.tsx` -- `Atlas Balance/frontend/src/pages/ChangePasswordPage.tsx` -- `Atlas Balance/scripts/Instalar-AtlasBalance.ps1` -- Tests backend asociados y documentacion V-01.03. - -**Cambios implementados:** -- `postcss` actualizado de `8.5.9` a `8.5.10` para cerrar vulnerabilidad moderada reportada por `npm audit`. -- `SecurityStamp`/`PasswordChangedAt` en usuarios; los access tokens se invalidan si el stamp ya no coincide. -- Reset/cambio/delete de usuario y reuse de refresh token revocan refresh tokens activos. -- Login limita intentos por cliente/email y evita revelar bloqueo de cuenta. -- Integracion OpenClaw limita bearer invalido antes de consultar tokens activos. -- `app_update_check_url` solo acepta HTTPS del repositorio oficial de Atlas Balance en GitHub. -- Rutas de backup/export/Watchdog se validan como absolutas antes de normalizar. -- Password minimo sube a 12 caracteres con bloqueo de passwords comunes; frontend actualizado. -- `INSTALL_CREDENTIALS_ONCE.txt` queda con borrado automatico a 24 horas. -- Informe `Documentacion/SEGURIDAD_AUDITORIA_V-01.03.md` actualizado. +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/AuthServiceTests.cs` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` **Comandos ejecutados:** -- `npm.cmd update postcss` -- `npm.cmd audit --audit-level=moderate` -- `dotnet list '.\Atlas Balance\backend\GestionCaja.sln' package --vulnerable --include-transitive` -- `dotnet ef migrations add UserSessionHardening` -- `dotnet test "GestionCaja.sln" --filter "FullyQualifiedName~AuthServiceTests|FullyQualifiedName~UserStateMiddlewareTests|FullyQualifiedName~IntegrationAuthMiddlewareTests|FullyQualifiedName~UsuariosControllerTests|FullyQualifiedName~SeedDataTests|FullyQualifiedName~ConfiguracionControllerTests|FullyQualifiedName~ActualizacionServiceTests"` -- `dotnet test "GestionCaja.sln"` -- `dotnet build "GestionCaja.sln" -c Release --no-restore` -- `dotnet test "GestionCaja.sln" -c Release --no-build` -- `npm.cmd run lint` -- `npm.cmd run build` -- Parser PowerShell sobre `Instalar-AtlasBalance.ps1`. +- `dotnet test "C:\Proyectos\Atlas Balance Dev\Atlas Balance\backend\tests\GestionCaja.API.Tests\GestionCaja.API.Tests.csproj" -c Release --filter AuthServiceTests` **Resultado de verificacion:** -- Backend Release build: OK, 0 warnings, 0 errores. -- Suite backend completa: 94/94 OK. -- Frontend lint/build: OK. -- `npm audit`: 0 vulnerabilidades. -- NuGet vulnerable: sin paquetes vulnerables. -- Parser PowerShell instalador: OK. +- `AuthServiceTests`: OK, 13/13. **Pendientes:** -- Ninguno de los hallazgos corregidos queda abierto. El estado Git local sigue sucio por trabajo previo y no se ha limpiado porque no corresponde a esta tarea. +- Ninguno. -## 2026-04-20 - Apertura version V-01.03 +--- +## 2026-05-01 - MFA recordado 90 dias y QR de enrolamiento -**Version:** V-01.03 +**Version:** V-01.05 -**Trabajo realizado:** Apertura de la nueva linea de trabajo posterior a la publicacion de `V-01.02`, con rama propia y fuentes de version alineadas. +**Trabajo realizado:** +- Login valida una cookie `mfa_trusted` firmada para no pedir Google Authenticator en cada entrada. +- La cookie se emite solo despues de verificar MFA y caduca a los 90 dias. +- El token recordado queda ligado al usuario y a su `security_stamp`, asi que cambios sensibles de cuenta lo invalidan. +- El primer enrolamiento de MFA muestra QR escaneable y conserva la clave manual como fallback. +- Se agrega `qrcode` al frontend y se sincroniza `wwwroot`. **Archivos tocados:** -- `CLAUDE.md` -- `Atlas Balance/AGENTS.md` -- `Atlas Balance/CLAUDE.md` -- `Atlas Balance/VERSION` -- `Atlas Balance/Directory.Build.props` +- `Atlas Balance/backend/src/GestionCaja.API/Services/AuthService.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Controllers/AuthController.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/AuthServiceTests.cs` - `Atlas Balance/frontend/package.json` - `Atlas Balance/frontend/package-lock.json` -- `Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs` -- `Atlas Balance/scripts/Build-Release.ps1` -- `Atlas Balance/scripts/Instalar-AtlasBalance.ps1` -- `Atlas Balance/README_RELEASE.md` -- `Documentacion/documentacion.md` +- `Atlas Balance/frontend/src/pages/LoginPage.tsx` +- `Atlas Balance/frontend/src/styles/auth.css` +- `Atlas Balance/backend/src/GestionCaja.API/wwwroot` - `Documentacion/DOCUMENTACION_CAMBIOS.md` - `Documentacion/DOCUMENTACION_TECNICA.md` -- `Documentacion/Versiones/version_actual.md` -- `Documentacion/Versiones/v-01.02.md` -- `Documentacion/Versiones/v-01.03.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/Versiones/v-01.05.md` -**Cambios implementados:** -- Creada rama local `V-01.03` desde `V-01.02`. -- Marcada `V-01.03` como version actual del proyecto. -- Cerrada `V-01.02` como version publicada/base anterior. -- Actualizadas fuentes runtime backend/frontend a `1.3.0` y `V-01.03`. -- Actualizados scripts y documentacion viva para generar paquetes `AtlasBalance-V-01.03-win-x64`. +**Decisiones visuales tomadas:** +- El QR se muestra dentro del bloque MFA existente, centrado y con fondo blanco para mantener lectura fiable en modo claro y oscuro. +- La clave manual queda debajo del QR como fallback, no como opcion principal. **Comandos ejecutados:** -- `git status --short --branch` -- `Get-Content` sobre `CLAUDE.md`, `Documentacion/Versiones/version_actual.md`, `Documentacion/Versiones/v-01.02.md` y fuentes runtime. -- `git branch --list V-01.03` -- `git switch -c V-01.03` -- `Select-String` para localizar referencias vivas a `V-01.02` y `1.2.0`. -- `git diff --check` -- `dotnet build '.\Atlas Balance\backend\GestionCaja.sln' -c Release --no-restore` +- `npm.cmd install qrcode @types/qrcode` +- `npm.cmd install -D @types/qrcode` +- `npm.cmd run lint` - `npm.cmd run build` +- `dotnet test "C:\Proyectos\Atlas Balance Dev\Atlas Balance\backend\tests\GestionCaja.API.Tests\GestionCaja.API.Tests.csproj" --filter AuthServiceTests` +- `dotnet test "C:\Proyectos\Atlas Balance Dev\Atlas Balance\backend\tests\GestionCaja.API.Tests\GestionCaja.API.Tests.csproj" -c Release --filter AuthServiceTests` +- `robocopy dist "..\backend\src\GestionCaja.API\wwwroot" /MIR` **Resultado de verificacion:** -- `git diff --check`: OK; solo avisos esperados de normalizacion LF/CRLF. -- Backend build Release: OK, 0 warnings, 0 errores. -- Frontend build: OK con `atlas-balance-frontend@1.3.0`. +- `npm.cmd install`: OK, 0 vulnerabilidades. +- `npm.cmd run lint`: OK. +- `npm.cmd run build`: OK. +- `dotnet test --filter AuthServiceTests` en Debug: bloqueado por `GestionCaja.API.exe` en uso, PID `35456`. +- `dotnet test -c Release --filter AuthServiceTests`: OK, 11/11. +- `robocopy`: OK, codigo `1` esperado por copia con cambios. + +**Pendientes de diseno abiertos:** +- Ninguno para este ajuste puntual. **Pendientes:** - Ninguno. -## 2026-04-20 - Publicacion GitHub V-01.02 +--- +## 2026-05-01 - Alineacion del logo en login -**Version:** V-01.02 +**Version:** V-01.05 -**Trabajo realizado:** Preparacion automatizada de la version actual para publicacion en GitHub siguiendo el flujo del proyecto: rama `V-01.02`, tag de distribucion `V-01.02-win-x64`, paquete Windows x64 como asset de GitHub Release y codigo/documentacion como contenido Git versionable. +**Trabajo realizado:** +- Alineado el bloque de marca superior del login con la misma columna visual del formulario. +- Centrado el contenido de marca dentro de esa columna para que el bloque `Atlas Balance` quede en el medio, no anclado al borde izquierdo. +- Se mantiene centrado en mobile para conservar una lectura compacta. +- Se sincroniza `frontend/dist` con `backend/src/GestionCaja.API/wwwroot`. **Archivos tocados:** -- Contenido versionable del proyecto preparado para el commit de publicacion (`Atlas Balance/`, `.github/`, raiz y `Documentacion/`). +- `Atlas Balance/frontend/src/styles/auth.css` +- `Atlas Balance/backend/src/GestionCaja.API/wwwroot` - `Documentacion/DOCUMENTACION_CAMBIOS.md` -- `Documentacion/Versiones/v-01.02.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/Versiones/v-01.05.md` -**Cambios implementados:** -- Confirmado que `Documentacion/Versiones/version_actual.md` declara `V-01.02`. -- Confirmado que la version runtime coincide: `Atlas Balance/VERSION`, `Atlas Balance/Directory.Build.props` y `Atlas Balance/frontend/package.json`. -- Rama local `V-01.02` sincronizada por fast-forward sobre `origin/main` antes de crear el commit de version. -- Regenerado el paquete oficial con `Atlas Balance/scripts/Build-Release.ps1`. -- Verificado que `AtlasBalance-V-01.02-win-x64.zip` contiene `VERSION=V-01.02` y no contiene archivos prohibidos como `.env`, `appsettings.Development.json`, plantillas de configuracion, `node_modules`, `frontend/dist` suelto ni sourcemaps. -- Calculado SHA256 del ZIP para trazabilidad: `F2BDC7BAF0168631C6E11E2E802B4019A5A88BA944CA8426CFD3B5353D865386`. -- Limpieza mecanica de espacios finales y lineas extra al final de archivo para que el indice pase `git diff --cached --check`. +**Decisiones visuales tomadas:** +- El login debe leerse como una sola columna centrada: marca, formulario y footer. Alinear la marca al borde izquierdo de la tarjeta seguia viendose desplazado; el bloque de marca completo debe centrarse sobre la tarjeta. **Comandos ejecutados:** -- `git fetch origin --prune --tags` -- `git merge --ff-only origin/main` -- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.02` - `npm.cmd run lint` -- `dotnet test ".\backend\GestionCaja.sln" -c Release --no-restore` -- `dotnet test ".\backend\GestionCaja.sln" -c Release --no-restore --filter "FullyQualifiedName!~ExtractosConcurrencyTests"` -- Inspeccion automatizada del ZIP con `System.IO.Compression.ZipFile`. -- `Get-FileHash -Algorithm SHA256` -- `git add -A` -- `git diff --cached --check` -- Validacion de que el indice no incluye `Otros/`, `Skills/`, `.env`, `appsettings.Development.json`, `bin/`, `obj/`, `node_modules/`, `frontend/dist/`, `wwwroot/` ni paquetes de `Atlas Balance Release`. +- `npm.cmd run build` +- `robocopy "C:\Proyectos\Atlas Balance Dev\Atlas Balance\frontend\dist" "C:\Proyectos\Atlas Balance Dev\Atlas Balance\backend\src\GestionCaja.API\wwwroot" /MIR` +- Verificacion visual con Edge headless sobre `http://127.0.0.1:5176/login` via CDP. **Resultado de verificacion:** -- Build de release: OK. -- Frontend lint: OK. -- Backend suite completa: 82/83 OK; falla solo `ExtractosConcurrencyTests` porque Docker/Testcontainers no esta disponible en este entorno, incidencia ya documentada. -- Backend suite filtrada sin Testcontainers: 82/82 OK. -- `git diff --cached --check`: OK. -- Archivos prohibidos en indice Git: 0. -- ZIP oficial generado: `Atlas Balance/Atlas Balance Release/AtlasBalance-V-01.02-win-x64.zip`. -- Flujo de publicacion objetivo: rama `V-01.02`, tag `V-01.02-win-x64`, GitHub Release `Atlas Balance V-01.02 Windows x64`. +- `npm.cmd run lint`: OK. +- `npm.cmd run build`: OK. +- `robocopy`: OK, codigo `1` esperado por copia con cambios. +- Edge headless: centro del bloque de marca y centro de la tarjeta coinciden; `brandDeltaCard=0px`. + +**Pendientes de diseno abiertos:** +- Ninguno para este ajuste puntual. **Pendientes:** -- Ninguno dentro del flujo automatizado de publicacion. +- Ninguno. -## 2026-04-20 - Release funcional autonoma V-01.02 +--- +## 2026-05-01 - Aplicacion de guia UI/UX en shell y dashboard -**Version:** V-01.02 +**Version:** V-01.05 -**Trabajo realizado:** Analisis de estructura real del proyecto y generacion de release Windows x64 funcional en `Atlas Balance/Atlas Balance Release`, con scripts obligatorios `install`, `update`, `uninstall` y `start`. +**Trabajo realizado:** +- Aplicadas las nuevas reglas de `Documentacion/Diseno/DESIGN.md` al shell principal y dashboard. +- La navegacion lateral queda agrupada por intencion: Operacion, Control y Sistema. +- El menu inferior movil queda en 5 destinos: Inicio, Titulares, Cuentas, Importar y Mas. +- Los iconos de navegacion pasan a `lucide-react`, respetando el peso visual definido. +- El dashboard principal prioriza saldo total, saldos por divisa y evolucion en la primera lectura. +- `Saldos por divisa` muestra total, disponible e inmovilizado con jerarquia numerica clara. +- Se elimina la carga de Geist desde CSS y se corrige el token tipografico roto de MFA (`--font-family-mono`). +- Se sincroniza `frontend/dist` con `backend/src/GestionCaja.API/wwwroot`. **Archivos tocados:** -- `Atlas Balance/scripts/Instalar-AtlasBalance.ps1` -- `Atlas Balance/scripts/Actualizar-AtlasBalance.ps1` -- `Atlas Balance/scripts/Launch-AtlasBalance.ps1` -- `Atlas Balance/scripts/Build-Release.ps1` -- `Atlas Balance/scripts/setup-https.ps1` -- `Atlas Balance/scripts/install.ps1` -- `Atlas Balance/scripts/update.ps1` -- `Atlas Balance/scripts/start.ps1` -- `Atlas Balance/scripts/uninstall.ps1` -- `Atlas Balance/install.cmd` -- `Atlas Balance/update.cmd` -- `Atlas Balance/start.cmd` -- `Atlas Balance/uninstall.cmd` -- `Atlas Balance/README_RELEASE.md` -- `Atlas Balance/RELEASE.gitignore` -- `Atlas Balance/Atlas Balance Release/AtlasBalance-V-01.02-win-x64/**` -- `Atlas Balance/Atlas Balance Release/AtlasBalance-V-01.02-win-x64.zip` -- `Documentacion/documentacion.md` -- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Atlas Balance/frontend/src/utils/navigation.ts` +- `Atlas Balance/frontend/src/components/layout/Sidebar.tsx` +- `Atlas Balance/frontend/src/components/layout/BottomNav.tsx` +- `Atlas Balance/frontend/src/components/dashboard/SaldoPorDivisaCard.tsx` +- `Atlas Balance/frontend/src/pages/DashboardPage.tsx` +- `Atlas Balance/frontend/src/styles/global.css` +- `Atlas Balance/frontend/src/styles/auth.css` +- `Atlas Balance/frontend/src/styles/layout/shell.css` +- `Atlas Balance/frontend/src/styles/layout/dashboard.css` +- `Atlas Balance/backend/src/GestionCaja.API/wwwroot` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` - `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` - `Documentacion/LOG_ERRORES_INCIDENCIAS.md` - `Documentacion/REGISTRO_BUGS.md` -- `Documentacion/Versiones/v-01.02.md` +- `Documentacion/Versiones/v-01.05.md` -**Cambios implementados:** -- Detectado flujo real: frontend React/Vite se compila a `dist`, se copia a `GestionCaja.API/wwwroot`, la API ASP.NET Core 8 sirve API + SPA y aplica migraciones EF Core al arrancar. -- Confirmado que produccion no necesita Node ni .NET Runtime en servidor por paquete self-contained; si necesita PostgreSQL. -- Creados scripts one-click `install.cmd`, `update.cmd`, `uninstall.cmd` y `start.cmd`. -- Creados wrappers PowerShell `install.ps1`, `update.ps1`, `start.ps1` y `uninstall.ps1`. -- El instalador puede preparar PostgreSQL 16 gestionado con `winget`, servicio `AtlasBalance.PostgreSQL`, password generada y puerto local libre si `5432` esta ocupado. -- El runtime instalado registra si PostgreSQL es gestionado; `start` y `update` arrancan la base antes de Watchdog/API. -- Desinstalador completo para servicios, firewall, atajos, carpeta instalada, Data Protection y PostgreSQL gestionado. -- `Build-Release.ps1` copia scripts obligatorios, README de release y `.gitignore` preventivo al paquete. -- Reescrito `setup-https.ps1` en ASCII porque no parseaba por codificacion rota. +**Decisiones visuales tomadas:** +- Priorizar arquitectura de informacion sobre decoracion: agrupar menus reduce coste cognitivo sin ocultar funciones. +- Mantener el estilo financiero sobrio: superficies planas, bordes suaves, numeros mono/tabulares y estados discretos. +- No introducir Tailwind, shadcn ni librerias nuevas; se usa CSS variables propias y Lucide ya instalado. +- En dashboard, el dato financiero principal debe aparecer antes de secciones administrativas o secundarias. **Comandos ejecutados:** -- Lectura de `CLAUDE.md`, `AGENTS.md`, `Documentacion/Versiones/*`, `LOG_ERRORES_INCIDENCIAS.md`, `SKILLS_LOCALES.md`, scripts, csproj, package.json, appsettings, Program.cs y servicios. -- `rg --files` (fallo conocido por acceso denegado; se uso PowerShell). -- Parser PowerShell sobre scripts fuente y empaquetados. -- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.02` - `npm.cmd run lint` -- `dotnet test .\backend\GestionCaja.sln -c Release --no-restore` -- `dotnet test .\backend\GestionCaja.sln -c Release --no-restore --filter "FullyQualifiedName!~ExtractosConcurrencyTests"` -- Scanner local de secretos `cyber-neo` sobre el paquete generado. -- Inspeccion de ZIP con `System.IO.Compression.ZipFile`. -- `winget search PostgreSQL.PostgreSQL --source winget` +- `npm.cmd run build` +- Verificacion Playwright con APIs mockeadas en `http://127.0.0.1:5175/dashboard` para desktop y mobile. +- `robocopy 'C:\Proyectos\Atlas Balance Dev\Atlas Balance\frontend\dist' 'C:\Proyectos\Atlas Balance Dev\Atlas Balance\backend\src\GestionCaja.API\wwwroot' /MIR` **Resultado de verificacion:** -- Release generado: `Atlas Balance/Atlas Balance Release/AtlasBalance-V-01.02-win-x64`. -- ZIP generado: `Atlas Balance/Atlas Balance Release/AtlasBalance-V-01.02-win-x64.zip`. -- Frontend build: OK. -- Frontend lint: OK. -- Parser PowerShell scripts fuente/paquete: OK. -- Backend tests filtrando Testcontainers: 82/82 OK. -- Backend suite completa: 82/83 OK; falla solo `ExtractosConcurrencyTests` porque Docker/Testcontainers no esta disponible en este entorno. -- Scanner de secretos sobre paquete: 0 hallazgos. -- Paquete verificado sin `appsettings.Development.json`, plantillas, source maps, `node_modules` ni `frontend/dist` suelto. -- `winget` local lista `PostgreSQL.PostgreSQL.16`, usado por el instalador automatico. +- `npm.cmd run lint`: OK. +- `npm.cmd run build`: OK. +- Playwright desktop/mobile: sin overflow horizontal; grupos de menu correctos; bottom nav correcto; se corrigio solapamiento inicial del KPI principal. +- `robocopy`: OK, con borrado de assets Geist antiguos del bundle servido. + +**Pendientes de diseno abiertos:** +- Aplicar la guia pantalla por pantalla en Titulares, Cuentas, Alertas, Configuracion y flujos de importacion. +- Revisar formularios largos para reducir modales cuando una edicion inline o panel sea mejor. **Pendientes:** -- Validar `install.cmd` en un Windows Server limpio con `winget` disponible antes de distribuir fuera de esta maquina. -- Ejecutar suite completa con Docker activo para cubrir `ExtractosConcurrencyTests`. +- Validacion visual manual con datos reales del usuario final. -## 2026-04-20 - Auditoria tecnica profunda y hardening V-01.02 +## 2026-05-01 - Guia UI/UX Atlas Balance -**Version:** V-01.02 +**Version:** V-01.05 -**Trabajo realizado:** Auditoria tecnica sobre backend, frontend, base de datos, configuracion, scripts, dependencias, artefactos publicos/runtime, logs, temporales y auxiliares ignorados. Se corrigieron los riesgos reales encontrados, no solo se listaron. +**Trabajo realizado:** +- Reescrito `Documentacion/Diseno/DESIGN.md` como sistema de diseno operativo para Atlas Balance. +- Se adapta el formato de referencia de Atlas Connect al producto real: tesoreria multi-banco, tablas densas, dashboards financieros, menus por permisos, dark/light mode y CSS variables propias. +- Se mantienen los colores actuales del frontend y se documentan reglas mas estrictas para menus, iconos, tablas, charts, formularios, responsive, motion, accesibilidad y anti-patrones. **Archivos tocados:** -- `.gitignore` -- `Atlas Balance/.gitignore` +- `Documentacion/Diseno/DESIGN.md` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/Versiones/v-01.05.md` + +**Comandos ejecutados:** +- Lectura de `CLAUDE.md`, `Documentacion/Versiones/version_actual.md`, `Documentacion/Versiones/v-01.05.md`, `Documentacion/SKILLS_LOCALES.md`, `Documentacion/LOG_ERRORES_INCIDENCIAS.md`, `Atlas Connect Dev/docs/DESIGN.md` y estilos frontend actuales. + +**Resultado de verificacion:** +- Cambio documental. No requiere build ni tests de frontend/backend. + +**Pendientes:** +- Aplicar la guia en codigo: reorganizar navegacion, migrar iconos nuevos a `lucide-react`, revisar topbar/bottom nav y reforzar tablas/charts pantalla por pantalla. + +## 2026-05-01 - Activacion de Row Level Security en PostgreSQL + +**Version:** V-01.05 + +**Trabajo realizado:** Activar Row Level Security real en PostgreSQL y conectarlo con el contexto de autorizacion del backend. + +**Archivos tocados:** +- `Atlas Balance/backend/src/GestionCaja.API/Data/RlsDbCommandInterceptor.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Data/RlsContextSigner.cs` - `Atlas Balance/backend/src/GestionCaja.API/Program.cs` -- `Atlas Balance/backend/src/GestionCaja.API/appsettings.json` +- `Atlas Balance/backend/src/GestionCaja.API/Middleware/IntegrationAuthMiddleware.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501120000_EnableRowLevelSecurity.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501120000_EnableRowLevelSecurity.Designer.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501133000_SignRowLevelSecurityContext.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Migrations/20260501133000_SignRowLevelSecurityContext.Designer.cs` +- `Atlas Balance/backend/src/GestionCaja.API/appsettings.Development.json.template` - `Atlas Balance/backend/src/GestionCaja.API/appsettings.Production.json.template` -- `Atlas Balance/backend/src/GestionCaja.API/Services/SecretProtector.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Controllers/ConfiguracionController.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Services/EmailService.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Services/TiposCambioService.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Services/UserAccessService.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Controllers/ExportacionesController.cs` -- `Atlas Balance/backend/src/GestionCaja.Watchdog/Program.cs` -- `Atlas Balance/backend/tests/GestionCaja.API.Tests/PlainTextSecretProtector.cs` -- `Atlas Balance/backend/tests/GestionCaja.API.Tests/ConfiguracionControllerTests.cs` -- `Atlas Balance/backend/tests/GestionCaja.API.Tests/DashboardServiceTests.cs` -- `Atlas Balance/backend/tests/GestionCaja.API.Tests/TiposCambioServiceTests.cs` -- `Atlas Balance/backend/tests/GestionCaja.API.Tests/UserAccessServiceTests.cs` -- `Atlas Balance/scripts/backup-manual.ps1` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/RowLevelSecurityTests.cs` +- `Atlas Balance/docker-compose.yml` - `Atlas Balance/scripts/Instalar-AtlasBalance.ps1` -- `Atlas Balance/scripts/restore-backup.ps1` -- `Atlas Balance/scripts/install-services.ps1` -- `Atlas Balance/scripts/uninstall-services.ps1` +- `Atlas Balance/scripts/postgres-init/001-create-app-user.sh` - `Documentacion/DOCUMENTACION_CAMBIOS.md` - `Documentacion/DOCUMENTACION_TECNICA.md` - `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/documentacion.md` - `Documentacion/LOG_ERRORES_INCIDENCIAS.md` - `Documentacion/REGISTRO_BUGS.md` -- `Documentacion/SEGURIDAD_AUDITORIA_V-01.02.md` -- `Documentacion/Versiones/v-01.02.md` - -**Artefactos eliminados:** -- Logs runtime temporales de API en `backend/src/GestionCaja.API`. -- JSON/cookies/cabeceras de smoke/login en `Otros/Auxiliares/artifacts`. -- Captura auxiliar de login rellenado en `Otros/Auxiliares/artifacts/phase4-visual`. +- `Documentacion/Versiones/v-01.05.md` **Cambios implementados:** -- Los secretos de configuracion en BD (`smtp_password`, `exchange_rate_api_key`) ahora se guardan protegidos con ASP.NET Core Data Protection y prefijo `enc:v1:`. -- Los secretos legacy que ya existan en claro se migran automaticamente en el siguiente arranque. -- En produccion, las claves de Data Protection se persisten fuera de rutas publicas, por defecto en `%ProgramData%/AtlasBalance/keys`, y en Windows se protegen con DPAPI de maquina. -- El endpoint de configuracion sigue sin devolver passwords/API keys al frontend; auditoria de cambios redacta valores sensibles. -- El servicio SMTP y la sincronizacion de tipos de cambio descifran secretos solo al usarlos. -- Corregido bug de autorizacion: `PuedeVerDashboard` global ya no concede acceso global a cuentas, titulares, exportaciones o extractos. -- Endurecida descarga de exportaciones: solo permite `.xlsx` dentro de la ruta `export_path` configurada. -- Watchdog queda forzado a `localhost:5001` mediante Kestrel, reduciendo exposicion accidental. -- Cualquier wildcard en `AllowedHosts` queda rechazado fuera de Development; la plantilla obliga a definir host real y el instalador usa `$ServerName;localhost`. -- La configuracion base versionable baja `AllowedHosts` a `localhost`. -- Scripts de backup/restore manual usan usuario `atlas_balance_app`, restauran `PGPASSWORD` anterior, limpian `SecureString` con `ZeroFreeBSTR` y validan backups `.dump`. -- Scripts de servicios usan nombres `AtlasBalance.API` y `AtlasBalance.Watchdog`. -- `.gitignore` ignora keyrings locales de Data Protection. -- La plantilla/instalador de produccion declaran `DataProtection:KeysPath` en `%ProgramData%/AtlasBalance/keys`. +- Nueva migracion EF Core que crea helpers `atlas_security`, activa `ENABLE ROW LEVEL SECURITY` y `FORCE ROW LEVEL SECURITY`, y define politicas sobre tablas sensibles de datos financieros, auditoria, backups y notificaciones admin. +- El runtime de EF Core fija contexto PostgreSQL por comando mediante variables de sesion `atlas.*`: modo de autenticacion, usuario, token de integracion, admin, sistema, alcance de dashboard y firma HMAC. +- La migracion `SignRowLevelSecurityContext` exige que el contexto `atlas.*` este firmado contra un secreto guardado en `atlas_security.rls_context_secret`; un `SET atlas.system=true` manual sin firma ya no sirve. +- Las politicas usan `PERMISOS_USUARIO` e `INTEGRATION_PERMISSIONS` como fuente de alcance por cuenta. +- El middleware de integraciones fija el token validado en `HttpContext.Items` antes de escribir auditoria/rate limit, para que las politicas puedan autorizar tambien esos inserts. +- Docker nuevo deja de crear la base con `app_user` como superusuario; crea `atlas_owner` para migraciones/ownership y `app_user` como runtime sin `BYPASSRLS`. +- El instalador crea/separa `atlas_balance_owner` y `atlas_balance_app`; `MigrationConnection` aplica migraciones/grants y `DefaultConnection` queda para runtime. +- Se agrega test de integracion con PostgreSQL real que valida catalogo RLS, rol runtime endurecido, runtime sin ownership de tablas, rechazo de contexto sin firma, aislamiento anonimo/usuario/integracion/admin y bloqueo de escritura sin permiso. +- La base local `atlas_balance_db` queda migrada y con el rol `app_user` endurecido. **Comandos ejecutados:** -- `Get-Content` y `Select-String` sobre instrucciones, version, errores, skills, configuracion, scripts, backend, frontend, docs y auxiliares. -- `Get-ChildItem` para localizar logs, backups, temporales, artefactos, certificados, dumps y archivos sensibles por nombre. -- Barrido de patrones sensibles (`password`, `secret`, `token`, `api_key`, `connectionstring`, `PGPASSWORD`, `csrf`) con salida redactada. -- `git check-ignore` sobre `.env`, `appsettings.Development.json`, logs y artefactos de `Otros`. -- `dotnet list "Atlas Balance/backend/GestionCaja.sln" package --vulnerable --include-transitive` -- `npm.cmd audit --audit-level=moderate` -- `npm.cmd run lint` -- `npm.cmd run build` -- `dotnet build "Atlas Balance/backend/GestionCaja.sln" -c Release --no-restore` -- `dotnet test "Atlas Balance/backend/GestionCaja.sln" -c Release --no-build` +- `dotnet build '.\Atlas Balance\backend\src\GestionCaja.API\GestionCaja.API.csproj' -c Release --no-restore` +- `dotnet test '.\Atlas Balance\backend\tests\GestionCaja.API.Tests\GestionCaja.API.Tests.csproj' -c Release --no-restore --filter RowLevelSecurityTests` +- `dotnet test '.\Atlas Balance\backend\tests\GestionCaja.API.Tests\GestionCaja.API.Tests.csproj' -c Release --no-restore --filter "FullyQualifiedName~RowLevelSecurityTests|FullyQualifiedName~UserAccessServiceTests|FullyQualifiedName~IntegrationAuthorizationServiceTests|FullyQualifiedName~IntegrationAuthMiddlewareTests|FullyQualifiedName~IntegrationTokenServiceTests"` +- `docker start atlas_balance_db` +- `dotnet ef database update` +- Consultas `psql` a `pg_class`, `pg_policy`, `pg_policies` y `pg_roles`. +- Consulta `psql` a `atlas_security.context_is_valid()` con firma invalida. +- Siembra local del secreto RLS desde configuracion de desarrollo sin imprimir el secreto. +- Consulta `psql` a `atlas_security.context_is_valid()` con firma valida calculada localmente. +- `git diff --check` **Resultado de verificacion:** -- Backend build Release: OK, 0 warnings, 0 errores. -- Backend tests Release completos: 83/83 OK. -- Frontend lint: OK. -- Frontend build: OK. -- NuGet audit: sin paquetes vulnerables conocidos. -- npm audit: 0 vulnerabilidades. -- Barrido final de artefactos de login/cookies/cabeceras en `Otros/Auxiliares/artifacts`: sin restos. +- `dotnet build`: OK. +- `RowLevelSecurityTests`: OK. +- Tests focalizados RLS/permisos/integraciones: 15/15 OK. +- En `atlas_balance_db`, las 11 tablas objetivo tienen `relrowsecurity=true`, `relforcerowsecurity=true` y politicas en `pg_policies`. +- En `atlas_balance_db`, `__EFMigrationsHistory` contiene `20260501120000_EnableRowLevelSecurity` y `20260501133000_SignRowLevelSecurityContext`. +- `pg_policies` devuelve 20 politicas en schema `public`. +- El rol local `app_user` queda con `rolsuper=false` y `rolbypassrls=false`. +- `atlas_security.context_is_valid()` devuelve `false` con contexto `system` falsificado y firma invalida. +- `atlas_security.rls_context_secret` contiene secreto local y `atlas_security.context_is_valid()` devuelve `true` con firma valida. +- `git diff --check`: OK; solo avisos de conversion LF/CRLF ya presentes en el arbol. **Pendientes:** -- `.env` y `appsettings.Development.json` siguen existiendo localmente e ignorados; si esos secretos salieron alguna vez de esta maquina, hay que rotarlos. -- El estado Git local no permite diff fino porque la copia aparece practicamente entera como `untracked`; no se ha reparado porque no era parte segura de este cambio. +- Ninguno para instalaciones nuevas. En bases legacy creadas antes de separar owner/runtime, conviene migrar manualmente ownership a un rol owner si se quiere que la credencial SQL de runtime sea una frontera fuerte ante acceso directo a PostgreSQL. -## 2026-04-20 - Verificacion y cierre de bugs reportados V-01.02 +--- +## 2026-05-01 - Comprobacion de Row Level Security en PostgreSQL -**Version:** V-01.02 +**Version:** V-01.05 -**Trabajo realizado:** Contraste punto por punto de la revision V-01.02 y correccion de restos reales que seguian activos en configuracion, scripts, frontend y documentacion. +**Trabajo realizado:** Verificar si Row Level Security esta configurado en codigo, migraciones y base local. **Archivos tocados:** -- `Atlas Balance/AGENTS.md` -- `Atlas Balance/backend/src/GestionCaja.API/appsettings.json` -- `Atlas Balance/backend/src/GestionCaja.API/appsettings.Development.json.template` -- `Atlas Balance/backend/src/GestionCaja.API/appsettings.Production.json.template` -- `Atlas Balance/backend/src/GestionCaja.API/wwwroot/*` (bundle generado, ignorado por Git) -- `Atlas Balance/backend/tests/GestionCaja.API.Tests/ActualizacionServiceTests.cs` -- `Atlas Balance/backend/tests/GestionCaja.API.Tests/ExportacionServiceTests.cs` -- `Atlas Balance/backend/tests/GestionCaja.API.Tests/UsuariosControllerTests.cs` -- `Atlas Balance/backend/tests/GestionCaja.API.Tests/WatchdogOperationsServiceTests.cs` -- `Atlas Balance/frontend/e2e/README.md` -- `Atlas Balance/frontend/e2e/admin-smoke.spec.ts` -- `Atlas Balance/frontend/src/components/usuarios/UsuarioModal.tsx` -- `Atlas Balance/frontend/src/pages/ConfiguracionPage.tsx` -- `Atlas Balance/frontend/src/pages/CuentaDetailPage.tsx` -- `Atlas Balance/frontend/src/pages/ImportacionPage.tsx` -- `Atlas Balance/frontend/src/utils/appEvents.ts` -- `Atlas Balance/scripts/Instalar-AtlasBalance.ps1` -- `Atlas Balance/scripts/backup-manual.ps1` -- `Atlas Balance/scripts/install-cert-client.ps1` -- `Atlas Balance/scripts/install-services.ps1` -- `Atlas Balance/scripts/setup-https.ps1` - `Documentacion/DOCUMENTACION_CAMBIOS.md` -- `Documentacion/DOCUMENTACION_TECNICA.md` -- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` - `Documentacion/REGISTRO_BUGS.md` -- `Documentacion/SPEC.md` -- `Documentacion/Versiones/v-01.02.md` -- `Documentacion/documentacion.md` **Cambios implementados:** -- Confirmado que `App.tsx` ya evita CSRF vacio (`""`) y devuelve `null` si la cookie esta ausente o vacia. -- Confirmado que `useSessionTimeout.ts` ya limita `remainingSeconds` a cero con `Math.max`. -- Confirmado que `api.ts` ya marca `_retry` tambien en requests encoladas durante refresh y evita el logout prematuro por 401 concurrentes dentro de la misma pestana. -- Corregidos restos `atlasbalnace` en `SeedAdmin:Email`, plantillas, placeholders UI, tests E2E y scripts. -- Corregidos restos `atlas-blance` en rutas por defecto, placeholders, tests y evento interno de importacion. -- Creada constante compartida `IMPORTACION_COMPLETADA_EVENT` para que importacion y cuenta no repitan el string del evento. -- Corregido `Instalar-AtlasBalance.ps1`, que seguia escribiendo `V-01.01` en runtime. -- Actualizada documentacion de instalacion y SPEC a `V-01.02` y rutas `C:/AtlasBalance`. -- Recompilado el frontend y sincronizado `frontend/dist` con `backend/src/GestionCaja.API/wwwroot`. - -**Decisiones visuales:** -- No hubo cambios de diseno visual. Solo se corrigieron placeholders de ejemplo y nombres internos. +- No se modifica codigo ni esquema. +- Se registra bug abierto porque RLS no esta configurado en migraciones ni en la base local verificada. **Comandos ejecutados:** -- `Get-Content` sobre instrucciones, version, log de errores y archivos afectados. -- `Get-ChildItem ... | Select-String -Pattern 'atlasbalnace|atlas-blance|V-01\.01'` -- `dotnet test "Atlas Balance\backend\GestionCaja.sln" -c Release --no-restore --filter "FullyQualifiedName!~ExtractosConcurrencyTests"` -- `npm.cmd run lint` -- `docker compose ps` -- `docker ps --filter "name=atlas_balance_db" --format "{{.Names}}\t{{.Status}}\t{{.Ports}}"` -- `docker compose ps -a` -- `npm.cmd run build` -- Limpieza segura de `backend/src/GestionCaja.API/wwwroot` y copia de `frontend/dist`. -- `dotnet test "Atlas Balance\backend\GestionCaja.sln" -c Release --no-restore` +- `Get-Content` de instrucciones, version actual y log de incidencias. +- Busquedas PowerShell con `Select-String` sobre migraciones, scripts, configuracion y documentacion. +- `docker start atlas_balance_db` +- Consultas `psql` a `pg_class`, `pg_policy`, `pg_policies` y `pg_roles`. +- `docker stop atlas_balance_db` **Resultado de verificacion:** -- Backend tests Release sin Docker/Testcontainers: 81/81 OK. -- Backend tests Release completos con Docker disponible: 82/82 OK. -- Frontend lint: OK. -- Frontend build: OK. -- `atlas_balance_db`: contenedor Docker activo, puerto `5433->5432`. -- `docker compose ps` en esta carpeta no lista servicios porque el contenedor activo no pertenece al proyecto Compose actual. -- Barrido final en codigo activo y `wwwroot`: 0 coincidencias de `atlasbalnace`, `atlas-blance` o `V-01.01`. +- Sin apariciones versionables de `ENABLE ROW LEVEL SECURITY`, `FORCE ROW LEVEL SECURITY`, `CREATE POLICY`, `BYPASSRLS` o `NOBYPASSRLS`. +- Todas las tablas `public` de `atlas_balance_db` tienen `relrowsecurity=false`, `relforcerowsecurity=false` y `0` politicas. +- `pg_policies` no devuelve ninguna politica. +- El rol local `app_user` aparece como superusuario con `BYPASSRLS`, por lo que no es valido para probar aislamiento por RLS. +- El contenedor se dejo parado, como estaba antes de la comprobacion. **Pendientes:** -- Ninguno sobre los bugs revisados. Si se quiere que `docker compose ps` muestre `atlas_balance_db`, hay que levantarlo desde este compose concreto o alinear el nombre de proyecto Compose. +- Disenar e implementar RLS real si se quiere defensa en profundidad a nivel PostgreSQL: roles separados owner/runtime, runtime sin `BYPASSRLS`, politicas por tablas sensibles y contexto de usuario seguro por transaccion. -## 2026-04-20 - Auditoria de seguridad y bugs V-01.02 +--- +## 2026-04-26 - Reorden en dashboard de titulares (evolucion antes del listado) -**Version:** V-01.02 +**Version:** V-01.05 -**Trabajo realizado:** Revision completa de seguridad y bugs usando la skill local `cyber-neo`, auditoria manual de auth/config/permisos/supply chain, limpieza de secretos versionables y verificacion de backend/frontend. +**Trabajo realizado:** Reordenar el bloque de dashboard en `Cuentas` para que la tarjeta `Evolucion` se renderice antes del listado de cuentas/titulares. **Archivos tocados:** -- `.github/workflows/ci.yml` -- `Atlas Balance/.env.example` -- `Atlas Balance/.gitignore` -- `Atlas Balance/docker-compose.yml` -- `Atlas Balance/backend/src/GestionCaja.API/Program.cs` -- `Atlas Balance/backend/src/GestionCaja.API/appsettings.json` -- `Atlas Balance/backend/src/GestionCaja.API/appsettings.Development.json.template` -- `Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Services/ActualizacionService.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Services/EmailService.cs` -- `Atlas Balance/backend/src/GestionCaja.API/Services/ImportacionService.cs` -- `Atlas Balance/backend/src/GestionCaja.Watchdog/appsettings.json` -- `Atlas Balance/backend/src/GestionCaja.Watchdog/appsettings.Development.json.template` -- `Atlas Balance/backend/src/GestionCaja.Watchdog/appsettings.Production.json.template` -- `Atlas Balance/backend/src/GestionCaja.Watchdog/Services/WatchdogOperationsService.cs` -- `Atlas Balance/backend/tests/GestionCaja.API.Tests/ImportacionServiceTests.cs` -- `Atlas Balance/backend/tests/GestionCaja.API.Tests/PostgresFixture.cs` -- `Atlas Balance/backend/tests/GestionCaja.API.Tests/SeedDataTests.cs` -- `Atlas Balance/backend/tests/GestionCaja.API.Tests/UsuariosControllerTests.cs` -- `Atlas Balance/frontend/e2e/README.md` +- `Atlas Balance/frontend/src/pages/CuentasPage.tsx` - `Documentacion/DOCUMENTACION_CAMBIOS.md` - `Documentacion/DOCUMENTACION_TECNICA.md` - `Documentacion/DOCUMENTACION_USUARIO.md` -- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` -- `Documentacion/REGISTRO_BUGS.md` -- `Documentacion/SEGURIDAD_AUDITORIA_V-01.02.md` -- `Documentacion/Versiones/v-01.02.md` -- `Documentacion/documentacion.md` +- `Documentacion/Versiones/v-01.05.md` **Cambios implementados:** -- Eliminados secretos/defaults de desarrollo de configuracion versionable. -- `SeedAdmin:Password` ahora es obligatorio antes del primer arranque con BD vacia. -- JWT en Development genera clave efimera si no hay secreto configurado; fuera de Development sigue exigiendo secreto real. -- Watchdog ya no usa password de BD por defecto para restauraciones. -- `docker-compose.yml` exige `ATLAS_BALANCE_POSTGRES_PASSWORD` desde `.env` local o entorno. -- Añadidas plantillas API/Watchdog y `.env.example` sin secretos reales. -- Corregida version residual `V-01.01` en seed y User-Agent de actualizaciones. -- Corregidos textos mojibake en importacion y SMTP. -- CI endurecido con actions fijadas a SHAs. -- Añadido `.gitignore` dentro de `Atlas Balance` para proteger la app si se usa como raiz independiente. +- Se mueve el bloque `titulares-evolucion-card` por encima de `cuentas-balance-list` dentro del dashboard de titulares en la pagina de `Cuentas`. +- No se modifica carga de datos, filtros, permisos ni endpoints; solo cambia el orden visual. + +**Decisiones visuales tomadas:** +- Priorizar contexto temporal (tendencia) antes del detalle tabular para lectura mas rapida del estado de titulares. **Comandos ejecutados:** -- `Get-Content` / `Get-ChildItem` / `Select-String` para inspeccion estatica. -- `python Skills/Seguridad/cyber-neo-main/skills/cyber-neo/scripts/scan_secrets.py "Atlas Balance" --json` -- `python Skills/Seguridad/cyber-neo-main/skills/cyber-neo/scripts/check_lockfiles.py "Atlas Balance/frontend"` -- `dotnet list "Atlas Balance/backend/GestionCaja.sln" package --vulnerable --include-transitive` -- `npm.cmd audit --json` -- `npm.cmd ci` -- `dotnet test "Atlas Balance/backend/GestionCaja.sln" -c Release --no-restore --filter "FullyQualifiedName!~ExtractosConcurrencyTests"` - `npm.cmd run lint` - `npm.cmd run build` **Resultado de verificacion:** -- Scanner de secretos local: 0 hallazgos. -- NuGet audit: sin paquetes vulnerables. -- npm audit: 0 vulnerabilidades. -- Backend tests Release sin Docker/Testcontainers: 81/81 OK. -- Frontend lint: OK. -- Frontend build: OK. +- `npm.cmd run lint`: OK. +- `npm.cmd run build`: OK. + +**Pendientes de diseno abiertos:** +- Ninguno para este ajuste puntual. **Pendientes:** -- Ejecutar `ExtractosConcurrencyTests` con Docker activo. -- Reparar la metadata Git local; `git status` falla porque `.git` apunta a un worktree inexistente. -- Revisar valores productivos reales de `AllowedHosts`, secretos y rutas antes de release. +- Ninguno. -## 2026-04-20 - Auditoria y limpieza estructural del proyecto +--- +## 2026-04-26 - Reorden de dashboard principal (grafica antes de saldos) -**Version:** V-01.02 +**Version:** V-01.05 -**Trabajo realizado:** Auditoria completa del proyecto. Correccion de todos los problemas encontrados: git, configuracion, estructura de carpetas y documentacion. +**Trabajo realizado:** Reordenar el dashboard principal para que la grafica de evolucion aparezca antes de los bloques de `Saldo por divisa` y `Saldos por titular`. **Archivos tocados:** -- `.gitignore` — añadidos: `wwwroot/assets/`, `wwwroot/index.html`, `wwwroot/fonts/`, `wwwroot/logos/`, `appsettings.Development.json` -- `Atlas Balance/docker-compose.yml` — postgres actualizado de 14 a 16 -- `Atlas Balance/backend/src/GestionCaja.API/appsettings.Development.json` — reducido a solo los overrides reales (Kestrel, Serilog, paths watchdog dev) -- `Atlas Balance/backend/src/GestionCaja.API/appsettings.Development.json.template` — creado para nuevos devs -- `Atlas Balance/backend/src/GestionCaja.API/Constants/AuditActions.cs` — creado (movido desde Services/) -- `Atlas Balance/backend/src/GestionCaja.API/Services/AuditActions.cs` — eliminado -- `Atlas Balance/backend/src/GestionCaja.API/Services/{ExportacionService,BackupService,AuthService,AlertaService}.cs` — añadido `using GestionCaja.API.Constants` -- `Atlas Balance/backend/src/GestionCaja.API/Controllers/{AlertasController,UsuariosController,AuthController,IntegracionesController,ConfiguracionController}.cs` — añadido `using GestionCaja.API.Constants` -- `Atlas Balance/backend/tests/GestionCaja.API.Tests/{AlertaServiceTests,UsuariosControllerTests,ConfiguracionControllerTests}.cs` — añadido `using GestionCaja.API.Constants` -- `Atlas Balance/frontend/src/utils/navigation.ts` — creado (movido desde components/layout/) -- `Atlas Balance/frontend/src/components/layout/navigation.ts` — eliminado -- `Atlas Balance/frontend/src/components/layout/{TopBar,Sidebar,BottomNav}.tsx` — actualizado import de navigation -- `Atlas Balance/frontend/src/pages/PlaceholderPage.tsx` — eliminado (sin uso) -- `CLAUDE.md` y `Atlas Balance/CLAUDE.md` — corregidos: Vite 5→8, PostgreSQL 14→16, V-01.01→V-01.02, estructura de directorios actualizada +- `Atlas Balance/frontend/src/pages/DashboardPage.tsx` +- `Atlas Balance/backend/src/GestionCaja.API/wwwroot` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/Versiones/v-01.05.md` + +**Cambios implementados:** +- Se mueve el bloque `Evolución` por encima del grid de saldos. +- No cambia ninguna logica de carga de datos ni calculos; solo cambia el orden visual en el dashboard principal. +- Se sincroniza `wwwroot` con el build frontend actualizado. **Comandos ejecutados:** -- `git rm --cached` sobre 18 archivos de wwwroot y appsettings.Development.json -- `dotnet restore GestionCaja.sln` + `dotnet build GestionCaja.sln -c Release --no-restore` +- `npm.cmd run lint` +- `npm.cmd run build` +- `robocopy dist ..\\backend\\src\\GestionCaja.API\\wwwroot /MIR` **Resultado de verificacion:** -- Backend: `Compilación correcta. 0 Advertencias, 0 Errores` -- Frontend: node_modules no instalados en esta maquina; cambios son solo actualizaciones de ruta de import, sin cambios de logica +- `npm.cmd run lint`: OK. +- `npm.cmd run build`: OK. +- `robocopy ... /MIR`: OK (codigo `1` esperado por copia con cambios). **Pendientes:** -- Verificar `npm run build` del frontend en entorno con node_modules instalados -- La duplicacion de CLAUDE.md entre raiz y Atlas Balance/ sigue siendo un punto de fallo; considerar usar un symlink o script de sincronizacion -- `design-tokens.css` en Documentacion/ y `variables.css` en frontend/styles pueden desincronizarse; sin mecanismo de sync automatico +- Ninguno en este cambio. --- -## 2026-04-20 - Apertura version V-01.02 +## 2026-04-26 - Orden de lineas preservado en importacion -**Fase:** Control de versiones +**Version:** V-01.05 + +**Trabajo realizado:** Corregir la importacion de extractos para que no reordene las lineas por fecha antes de guardarlas. **Archivos tocados:** -- `Atlas Balance/VERSION` -- `Atlas Balance/Directory.Build.props` -- `Atlas Balance/frontend/package.json` -- `Atlas Balance/frontend/package-lock.json` -- `Atlas Balance/scripts/Build-Release.ps1` -- `Documentacion/Versiones/version_actual.md` -- `Documentacion/Versiones/v-01.01.md` -- `Documentacion/Versiones/v-01.02.md` +- `Atlas Balance/backend/src/GestionCaja.API/Services/ImportacionService.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/ImportacionServiceTests.cs` - `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/Versiones/v-01.05.md` **Cambios implementados:** -- Creada rama `V-01.02` desde `V-01.01`. -- Creado worktree separado en `C:\Proyectos\Atlas Balance Dev V-01.02` para no mezclar cambios pendientes de la carpeta principal. -- Actualizada la version runtime backend a `1.2.0` con `InformationalVersion` `V-01.02`. -- Actualizada la version frontend a `1.2.0` y `appVersion` `V-01.02`. -- Actualizado el script de release para generar `V-01.02` por defecto. -- Marcada `V-01.02` como version actual de trabajo y `V-01.01` como base anterior. +- Eliminado el ordenamiento interno por fecha durante `ConfirmarAsync`. +- La numeracion `fila_numero` se asigna desde abajo hacia arriba, para que la linea superior del extracto pegado quede como la ultima/mas alta. +- El registro de auditoria conserva las primeras filas segun el orden original del pegado. +- Actualizadas regresiones de importacion para validar el orden visible descendente por `fila_numero`. **Comandos ejecutados:** -- `git branch V-01.02 V-01.01` -- `git worktree add 'C:\Proyectos\Atlas Balance Dev V-01.02' V-01.02` -- `git status --short --branch` +- `dotnet test ".\\Atlas Balance\\backend\\tests\\GestionCaja.API.Tests\\GestionCaja.API.Tests.csproj" --filter ImportacionServiceTests --no-restore` +- `dotnet build ".\\Atlas Balance\\backend\\src\\GestionCaja.API\\GestionCaja.API.csproj" -c Release --no-restore` **Resultado de verificacion:** -- La rama `V-01.02` queda abierta desde `V-01.01`. -- La carpeta original `C:\Proyectos\Atlas Balance Dev` queda intacta con sus cambios pendientes. +- `ImportacionServiceTests`: 26/26 OK. +- Backend `GestionCaja.API` Release build OK, 0 warnings, 0 errores. **Pendientes:** -- Definir tickets concretos para bugs y funciones de `V-01.02`. -- Ejecutar build/tests cuando empiecen los cambios de codigo. +- Ninguno en este cambio. -## 2026-04-20 - Version V-01.01 - PR y release GitHub +--- +## 2026-04-26 - Borrado multiple de extractos en dashboard de cuenta -**Version:** V-01.01 +**Version:** V-01.05 -**Trabajo realizado:** -- Ajustada la politica para publicar paquetes pesados como assets de GitHub Releases. -- `Atlas Balance/Atlas Balance Release` queda versionada solo con `.gitkeep`. -- Se preparo la rama `V-01.01` para abrir PR sin binarios generados en el diff final. -- Se publico el paquete local `AtlasBalance-V-01.01-win-x64.zip` como asset del release `V-01.01-win-x64`. -- Se fusiono `origin/main` para que el PR tenga historia comun con `main`. -- Se creo el PR draft `https://github.com/AtlasLabs797/AtlasBalance/pull/1`. -- Se elimino el draft untagged que quedo del primer intento de release. -- Se elimino el tag remoto accidental `V-01.01` para evitar ambiguedad con la rama `V-01.01`. +**Trabajo realizado:** Permitir seleccionar varias lineas del desglose de una cuenta y enviarlas a papelera en una sola accion. **Archivos tocados:** -- `LICENSE` -- `.gitignore` -- `CLAUDE.md` -- `AGENTS.md` -- `Atlas Balance/CLAUDE.md` -- `Atlas Balance/AGENTS.md` -- `Atlas Balance/Atlas Balance Release/.gitkeep` +- `Atlas Balance/frontend/src/pages/CuentaDetailPage.tsx` +- `Atlas Balance/frontend/src/styles/layout/dashboard.css` +- `Atlas Balance/backend/src/GestionCaja.API/wwwroot` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` - `Documentacion/DOCUMENTACION_TECNICA.md` - `Documentacion/DOCUMENTACION_USUARIO.md` -- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` -- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/Versiones/v-01.05.md` + +**Cambios implementados:** +- Se agrega seleccion por fila en la tabla de extractos del dashboard de cuenta. +- Se agrega selector global `Seleccionar todas` y contador de filas seleccionadas. +- Se agrega accion `Eliminar seleccionadas` con confirmacion unica y detalle de filas afectadas. +- El borrado en lote reutiliza `DELETE /api/extractos/{id}` para mantener permisos y auditoria existentes. **Comandos ejecutados:** -- `gh --version` -- `gh auth status` -- `git status --short --branch --untracked-files=all` -- `git rm -r --cached -- Atlas Balance/Atlas Balance Release` -- `git push -u origin HEAD:refs/heads/V-01.01` -- `git tag V-01.01-win-x64` -- `git push origin V-01.01-win-x64` -- GitHub REST API para crear release, subir asset y crear PR. -- `git merge --allow-unrelated-histories --no-edit origin/main` -- `git commit -m "docs: record release and PR setup"` -- `git push` -- GitHub REST API para crear PR draft. -- GitHub REST API para eliminar el draft untagged del primer intento. -- `git push origin :refs/tags/V-01.01` +- `npm.cmd run build` +- `npm.cmd run lint` +- `robocopy dist ..\\backend\\src\\GestionCaja.API\\wwwroot /MIR` **Resultado de verificacion:** -- Release `V-01.01-win-x64` publicado con asset Windows x64. -- El intento inicial de PR fallo por falta de historia comun y se corrigio fusionando `origin/main`. -- PR draft creado: `https://github.com/AtlasLabs797/AtlasBalance/pull/1`. -- Releases restantes: `V-01.01-win-x64` publicado, 1 asset. -- Tags remotos restantes de release: `V-01.01-win-x64`. +- Build frontend OK. +- Lint frontend OK. +- `wwwroot` sincronizado (`robocopy` codigo `1` esperado). **Pendientes:** -- Revisar y marcar el PR como listo cuando se quiera mergear a `main`. +- Ninguno en este cambio. --- -## 2026-04-20 - Version V-01.01 - Politica GitHub sin Otros ni Skills +## 2026-04-26 - Actualizacion post-instalacion endurecida -**Version:** V-01.01 +**Version:** V-01.05 -**Trabajo realizado:** -- Anadida regla para subir a GitHub todo lo versionable excepto `Otros/` y `Skills/`. -- Anadida exclusion `Skills/` en `.gitignore`. -- Mantenida exclusion de basura local, dependencias generadas y secretos. -- Permitido que `Atlas Balance/Atlas Balance Release` pueda entrar en Git si se sube todo el proyecto versionable. -- Creado commit `0d08ffe` y publicado en `origin/V-01.01`. -- Documentada la advertencia de GitHub por el ZIP de release grande. +**Trabajo realizado:** Corregir los dos fallos detectados al actualizar una instalacion real desde `V-01.03` con paquete `V-01.04`: reenvio roto de `-InstallPath` y arranque bloqueado por formatos de importacion duplicados. **Archivos tocados:** -- `.gitignore` -- `CLAUDE.md` -- `AGENTS.md` -- `Atlas Balance/CLAUDE.md` -- `Atlas Balance/AGENTS.md` +- `Atlas Balance/scripts/update.ps1` +- `Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/SeedDataTests.cs` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` - `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` - `Documentacion/LOG_ERRORES_INCIDENCIAS.md` -- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/Versiones/v-01.05.md` + +**Cambios implementados:** +- `update.ps1` declara explicitamente `InstallPath` y `SkipBackup`, y los reenvia a `Actualizar-AtlasBalance.ps1` sin depender de argumentos residuales. +- `SeedData` comprueba IDs fijos existentes antes de insertar formatos de importacion por defecto. +- Agregado test de regresion para una fila legacy de `FORMATOS_IMPORTACION` con ID fijo ya existente pero datos de banco/divisa incompletos. **Comandos ejecutados:** -- `Get-Content` sobre version e incidencias. -- `git status --short --untracked-files=all` -- `Get-ChildItem` para revisar tamanos de release. -- `git check-ignore` para verificar que `Otros/` y `Skills/` quedan fuera. -- `git diff --cached --check` para validar whitespace antes del commit. -- `git commit -m "chore: publish V-01.01 project layout"` -- `git push -u origin V-01.01` +- Parser PowerShell sobre `Atlas Balance/scripts/update.ps1` y `Atlas Balance/scripts/Actualizar-AtlasBalance.ps1`. +- `dotnet test "Atlas Balance/backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj" --filter SeedDataTests` +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.05` +- `Get-FileHash -Algorithm SHA256` **Resultado de verificacion:** -- `Skills/` queda ignorada. -- `Otros/` queda ignorada. -- `Atlas Balance/Atlas Balance Release` deja de estar ignorada. -- `git diff --cached --check` quedo limpio tras corregir espacios finales detectados. -- Push correcto a `https://github.com/AtlasLabs797/AtlasBalance`, rama `V-01.01`. -- GitHub acepto el ZIP de release, pero aviso que 97.49 MiB supera el maximo recomendado de 50 MiB. +- Parser PowerShell OK para `update.ps1` y `Actualizar-AtlasBalance.ps1`. +- `SeedDataTests`: 5/5 OK. +- Paquete regenerado: `Atlas Balance/Atlas Balance Release/AtlasBalance-V-01.05-win-x64.zip`. +- Tamano ZIP: `102360688` bytes. +- SHA256: `482189BB4B6F731CEB02ECA214A550B1CE9DB33C71F0DBF4E057761E8FD002C3`. **Pendientes:** -- Considerar GitHub Releases o Git LFS para paquetes de release futuros si superan 50 MiB. +- Publicar/subir el ZIP corregido como asset si esta version se distribuye desde GitHub Releases. --- -## 2026-04-20 - Version V-01.01 - Catalogo de skills locales +## 2026-04-25 - Publicacion release V-01.05 -**Version:** V-01.01 +**Version:** V-01.05 -**Trabajo realizado:** -- Analizada la carpeta `Skills`. -- Identificados paquetes de construccion, diseno, escritura y seguridad. -- Separadas skills reales de duplicados por agente (`.agents`, `.codex`, `.claude`, `.cursor`, etc.). -- Creado `Documentacion/SKILLS_LOCALES.md` con rutas canonicas, casos de uso y forma de aplicar cada skill. -- Actualizadas instrucciones para agentes para consultar el catalogo antes de usar skills locales. -- Documentada la regla de adaptar cualquier skill al stack real de Atlas Balance y no introducir dependencias ajenas sin motivo. +**Trabajo realizado:** Regenerar el paquete Windows x64 final y publicarlo en GitHub junto con la rama de version. **Archivos tocados:** -- `CLAUDE.md` -- `AGENTS.md` -- `Atlas Balance/CLAUDE.md` -- `Atlas Balance/AGENTS.md` -- `Documentacion/SKILLS_LOCALES.md` -- `Documentacion/DOCUMENTACION_TECNICA.md` -- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Atlas Balance/backend/src/GestionCaja.API/wwwroot` +- `Atlas Balance/Atlas Balance Release/AtlasBalance-V-01.05-win-x64` +- `Atlas Balance/Atlas Balance Release/AtlasBalance-V-01.05-win-x64.zip` - `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/Versiones/v-01.05.md` + +**Cambios implementados:** +- Regenerado el paquete `AtlasBalance-V-01.05-win-x64.zip` desde `scripts/Build-Release.ps1`. +- Sincronizado `wwwroot` desde el build frontend incluido en el paquete. +- Verificado que el paquete no incluye artefactos de desarrollo, `.env`, `node_modules`, `obj`, `bin/Debug` ni `.bak-iframe-fix`. +- Preparada publicacion como asset de GitHub Release, sin versionar el ZIP en Git. **Comandos ejecutados:** -- `Get-Content` sobre archivos de version y log de incidencias. -- `Get-ChildItem -Recurse -Filter SKILL.md` para inventario. -- Lectura puntual de `README.md`, `CLAUDE.md` y `SKILL.md` canonicos. -- `Select-String` para verificacion de referencias. +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.05` +- `Get-FileHash -Algorithm SHA256` +- `npm.cmd run lint` +- `npm.cmd audit --audit-level=moderate` +- `dotnet test "Atlas Balance\backend\tests\GestionCaja.API.Tests\GestionCaja.API.Tests.csproj" -c Release` +- `dotnet list "Atlas Balance\backend\src\GestionCaja.API\GestionCaja.API.csproj" package --vulnerable --include-transitive` **Resultado de verificacion:** -- Catalogo creado con rutas canonicas. -- Instrucciones principales enlazan a `Documentacion/SKILLS_LOCALES.md`. -- Duplicados documentados como duplicados, no como skills independientes. +- Frontend build OK dentro del script de release. +- Frontend lint OK. +- `npm audit`: 0 vulnerabilidades. +- Backend tests Release: 108/108 OK. +- NuGet vulnerable: sin hallazgos. +- Paquete generado: `Atlas Balance/Atlas Balance Release/AtlasBalance-V-01.05-win-x64.zip`. +- Tamano ZIP: `102360418` bytes. +- SHA256 final: `B5ABC5525CBD49F2BD0A5ADC5B930A2113AF323F99C1337087B8E0D7875E6A10`. **Pendientes:** -- No se ejecuto ningun script o CLI de las skills; solo se analizaron archivos locales. +- Validacion manual en Windows Server 2019 real tras descargar el asset publicado. --- -## 2026-04-20 - Version V-01.01 - Reorganizacion de carpetas y reglas de documentacion +## 2026-04-26 - Actualizacion post-instalacion endurecida -**Version:** V-01.01 +**Version:** V-01.05 -**Trabajo realizado:** -- Reorganizada la raiz en `Atlas Balance`, `Documentacion` y `Otros`. -- Movida la aplicacion a `Atlas Balance`. -- Movidos paquetes existentes a `Atlas Balance/Atlas Balance Release`. -- Movida y centralizada la documentacion en `Documentacion`. -- Movidos duplicados, repos auxiliares de diseno y artefactos temporales a `Otros`. -- Reescritos `CLAUDE.md` y `AGENTS.md` sin secciones de planificacion por fases. -- Anade reglas de GitHub, versiones y documentacion. -- Movido `.git` a la raiz para versionar juntos app y documentacion. -- Ajustado `.github/workflows/ci.yml` para las nuevas rutas bajo `Atlas Balance`. -- Ajustado `Atlas Balance/scripts/Build-Release.ps1` para publicar en `Atlas Balance/Atlas Balance Release` y copiar documentacion desde `Documentacion`. -- Creados documentos base de version, tecnica, usuario, bugs y errores. -- Redactadas credenciales historicas explicitas encontradas en documentacion. +**Trabajo realizado:** Corregir los dos fallos detectados al actualizar una instalacion real desde `V-01.03` con paquete `V-01.04`: reenvio roto de `-InstallPath` y arranque bloqueado por formatos de importacion duplicados. **Archivos tocados:** -- `CLAUDE.md` -- `AGENTS.md` -- `.gitignore` -- `.github/workflows/ci.yml` -- `Atlas Balance/CLAUDE.md` -- `Atlas Balance/AGENTS.md` -- `Atlas Balance/scripts/Build-Release.ps1` -- `Documentacion/documentacion.md` -- `Documentacion/CORRECCIONES.md` -- `Documentacion/SPEC.md` +- `Atlas Balance/scripts/update.ps1` +- `Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/SeedDataTests.cs` - `Documentacion/DOCUMENTACION_CAMBIOS.md` - `Documentacion/DOCUMENTACION_TECNICA.md` - `Documentacion/DOCUMENTACION_USUARIO.md` - `Documentacion/LOG_ERRORES_INCIDENCIAS.md` - `Documentacion/REGISTRO_BUGS.md` -- `Documentacion/Versiones/version_actual.md` -- `Documentacion/Versiones/v-01.01.md` -- `Atlas Balance/**` (movimiento estructural) -- `Otros/**` (material no runtime) +- `Documentacion/Versiones/v-01.05.md` + +**Cambios implementados:** +- `update.ps1` declara explicitamente `InstallPath` y `SkipBackup`, y los reenvia a `Actualizar-AtlasBalance.ps1` sin depender de argumentos residuales. +- `SeedData` comprueba IDs fijos existentes antes de insertar formatos de importacion por defecto. +- Agregado test de regresion para una fila legacy de `FORMATOS_IMPORTACION` con ID fijo ya existente pero datos de banco/divisa incompletos. **Comandos ejecutados:** -- `Get-Content .\CLAUDE.md -Raw` -- `Get-ChildItem -Recurse -Directory` -- `git status --short --untracked-files=all` -- `Move-Item` para app, documentacion, releases y auxiliares. -- `dotnet build '.\Atlas Balance\backend\GestionCaja.sln' --no-restore` -- `npm.cmd run build` en `Atlas Balance/frontend` -- `PSParser` sobre `Atlas Balance/scripts/Build-Release.ps1` +- Parser PowerShell sobre `Atlas Balance/scripts/update.ps1` y `Atlas Balance/scripts/Actualizar-AtlasBalance.ps1`. +- `dotnet test "Atlas Balance/backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj" --filter SeedDataTests` **Resultado de verificacion:** -- Backend build OK: 0 warnings, 0 errores. -- Frontend build OK: `tsc && vite build`. -- `Build-Release.ps1` parse OK. -- Busqueda de secretos historicos exactos redactados sin resultados en instrucciones/documentacion actualizada. -- `CLAUDE.md` y `AGENTS.md` ya no contienen secciones de planificacion por fases. -- Git funciona desde la raiz del proyecto. +- Parser PowerShell OK para `update.ps1` y `Actualizar-AtlasBalance.ps1`. +- `SeedDataTests`: 5/5 OK. **Pendientes:** -- No se ejecuto el empaquetado completo `Build-Release.ps1`; solo se valido sintaxis del script y builds de backend/frontend. -- No se hizo push a GitHub porque no fue solicitado. +- Regenerar paquete `V-01.05` antes de publicarlo o usarlo para actualizar servidores. +## 2026-04-25 - Correccion de hallazgos de auditoria de uso, bugs y seguridad ----## 2026-04-20 - Version V-01.01 e instalador Atlas Balance +**Version:** V-01.05 -**Fase:** Empaquetado, instalacion y actualizaciones. +**Trabajo realizado:** Arreglar los hallazgos abiertos por la auditoria: stack frontend violado por Tailwind/shadcn, contrato duplicado de resumen de cuenta, accesibilidad de controles propios y decoracion visual innecesaria. **Archivos tocados:** -- `.gitignore` -- `Directory.Build.props` -- `VERSION` -- `Instalar Atlas Balance.cmd` -- `Actualizar Atlas Balance.cmd` -- `Atlas Balance.cmd` -- `documentacion.md` -- `frontend/package.json` -- `frontend/package-lock.json` -- `backend/src/GestionCaja.API/Data/SeedData.cs` -- `backend/src/GestionCaja.API/Services/ActualizacionService.cs` -- `backend/src/GestionCaja.API/appsettings.json` -- `backend/src/GestionCaja.API/appsettings.Production.json.template` -- `backend/src/GestionCaja.Watchdog/appsettings.json` -- `scripts/Build-Release.ps1` -- `scripts/Instalar-AtlasBalance.ps1` -- `scripts/Actualizar-AtlasBalance.ps1` -- `scripts/Launch-AtlasBalance.ps1` -- `backend/src/GestionCaja.API/wwwroot/**` (sincronizado desde build frontend) +- `Atlas Balance/frontend/package.json` +- `Atlas Balance/frontend/package-lock.json` +- `Atlas Balance/frontend/vite.config.ts` +- `Atlas Balance/frontend/src/styles/global.css` +- `Atlas Balance/frontend/src/styles/auth.css` +- `Atlas Balance/frontend/src/styles/layout/admin.css` +- `Atlas Balance/frontend/src/styles/layout/dashboard.css` +- `Atlas Balance/frontend/src/styles/layout/entities.css` +- `Atlas Balance/frontend/src/styles/layout/shell.css` +- `Atlas Balance/frontend/src/styles/layout/system-coherence.css` +- `Atlas Balance/frontend/src/components/common/DatePickerField.tsx` +- `Atlas Balance/frontend/src/components/common/ConfirmDialog.tsx` +- `Atlas Balance/frontend/src/components/common/AppSelect.tsx` +- `Atlas Balance/backend/src/GestionCaja.API/Controllers/CuentasController.cs` +- `Atlas Balance/backend/src/GestionCaja.API/DTOs/CuentasDtos.cs` +- `Atlas Balance/backend/src/GestionCaja.API/wwwroot` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/CuentasControllerTests.cs` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/Versiones/v-01.05.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/AUDITORIA_USO_BUGS_SEGURIDAD_V-01.05_2026-04-25.md` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` **Cambios implementados:** -- Fijada la version de backend como `V-01.01` mediante `AssemblyInformationalVersion`. -- Fijada version frontend `1.1.0` y `appVersion=V-01.01`. -- Anadido `VERSION` para trazabilidad del paquete. -- Desactivado el sufijo automatico de hash Git en la version informacional; la version publicada queda exactamente `V-01.01`. -- Creado generador de release self-contained para Windows x64: `scripts/Build-Release.ps1`. -- Creado instalador de servidor: `Instalar Atlas Balance.cmd` -> `scripts/Instalar-AtlasBalance.ps1`. -- Creado actualizador seguro: `Actualizar Atlas Balance.cmd` -> `scripts/Actualizar-AtlasBalance.ps1`. -- Creado lanzador `Atlas Balance.cmd`, que arranca servicios y abre la app; en instalacion crea acceso directo con logo. -- El instalador genera secretos, certificado HTTPS local, `appsettings.Production.json`, servicios Windows, firewall rule, base PostgreSQL y credenciales iniciales. -- El actualizador crea backup PostgreSQL previo, copia rollback de binarios, preserva configuracion y no toca datos. -- Actualizadas rutas por defecto de produccion a `C:\AtlasBalance`. -- Documentado paso a paso el primer despliegue y futuras actualizaciones en `documentacion.md`. +- Eliminadas dependencias y configuracion Tailwind/shadcn (`@tailwindcss/vite`, `tailwindcss`, `shadcn`, `tw-animate-css`, `tailwind-merge`, `class-variance-authority`, `radix-ui`, `components.json`, boton shadcn y utilidades asociadas). +- `CuentasController.Resumen` expone ahora un contrato rico con titular, cuenta, divisa, tipo, notas, ultima actualizacion y metadatos de plazo fijo. +- Agregado test de regresion para resumen de cuenta `PLAZO_FIJO`. +- `DatePickerField` incorpora etiquetas de fecha completa y navegacion con flechas/Home/End. +- `ConfirmDialog` atrapa Tab dentro del modal. +- `AppSelect` abre/cierra con Enter y Espacio. +- Retirados fondos decorativos con radial/linear gradients de superficies principales. **Comandos ejecutados:** -- Parser PowerShell sobre `scripts/Instalar-AtlasBalance.ps1`, `scripts/Actualizar-AtlasBalance.ps1`, `scripts/Build-Release.ps1`, `scripts/Launch-AtlasBalance.ps1`. -- Validacion JSON de `appsettings.json`, `appsettings.Production.json.template` y Watchdog `appsettings.json`. -- `dotnet build backend\GestionCaja.sln -c Release --no-restore` +- `npm.cmd uninstall @tailwindcss/vite tailwindcss shadcn tw-animate-css tailwind-merge class-variance-authority clsx radix-ui` +- `npm.cmd run lint` - `npm.cmd run build` -- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\Build-Release.ps1 -Version V-01.01` -- `npm.cmd install` para reparar `node_modules` tras un intento bloqueado de `npm ci` por un binario de Rolldown en uso. +- `npm.cmd audit --audit-level=moderate` +- `dotnet test ".\\Atlas Balance\\backend\\tests\\GestionCaja.API.Tests\\GestionCaja.API.Tests.csproj" -c Release --filter CuentasControllerTests` +- `dotnet test ".\\Atlas Balance\\backend\\tests\\GestionCaja.API.Tests\\GestionCaja.API.Tests.csproj" -c Release` +- `dotnet list ".\\Atlas Balance\\backend\\src\\GestionCaja.API\\GestionCaja.API.csproj" package --vulnerable --include-transitive` +- Busquedas `Select-String` para restos de Tailwind/shadcn y degradados decorativos. +- `robocopy dist ..\\backend\\src\\GestionCaja.API\\wwwroot /MIR` **Resultado de verificacion:** -- PowerShell parse OK en los 4 scripts nuevos. -- JSON de configuracion OK. -- Backend Release compila: 0 warnings, 0 errores. +- Sin restos directos de Tailwind/shadcn en codigo/configuracion versionable. +- `npm audit`: 0 vulnerabilidades. +- Frontend lint OK. - Frontend build OK. -- Release generado correctamente: - - `release\AtlasBalance-V-01.01-win-x64` - - `release\AtlasBalance-V-01.01-win-x64.zip` -- `GestionCaja.API.exe` publicado muestra: - - `ProductName = Atlas Balance` - - `ProductVersion = V-01.01` - - `FileVersion = 1.1.0.0` -- ZIP contiene instalador, actualizador, lanzador, scripts, `VERSION`, `version.json`, API y Watchdog publicados. +- Backend tests: 108/108 OK. +- NuGet vulnerable: sin hallazgos. +- `wwwroot` sincronizado; `robocopy` devolvio codigo `1`, copia correcta con archivos actualizados y limpieza de bundles antiguos. **Pendientes:** -- No se ejecuto el instalador real en esta maquina porque instalaria servicios Windows y tocaria PostgreSQL local. La validacion hecha fue de build, paquete, sintaxis y estructura. -- En servidor real, PostgreSQL 14+ debe existir o el instalador debe poder usar `winget`. Sin PostgreSQL sano no hay instalacion seria, punto. +- Ejecutar Playwright E2E con `E2E_ADMIN_PASSWORD` en una base disposable. +- El estado Git local sigue sucio y no sirve como base fina de revision sin limpieza previa. -## 2026-04-19 - Fix CI Testcontainers PostgreSQL +--- +## 2026-04-26 - Actualizacion post-instalacion endurecida -**Fase:** Correccion CI +**Version:** V-01.05 -**Archivos tocados:** -- `.github/workflows/ci.yml` -- `backend/tests/GestionCaja.API.Tests/PostgresFixture.cs` -- `DOCUMENTACION_CAMBIOS.md` +**Trabajo realizado:** Corregir los dos fallos detectados al actualizar una instalacion real desde `V-01.03` con paquete `V-01.04`: reenvio roto de `-InstallPath` y arranque bloqueado por formatos de importacion duplicados. -**Problema detectado:** -- GitHub Actions fallaba en `ExtractosConcurrencyTests.Crear_Concurrente_Debe_Generar_FilaNumeros_Unicos`. -- La causa no era la prueba de concurrencia; el runner Windows intentaba crear `postgres:16-alpine` sin imagen disponible para Testcontainers. +**Archivos tocados:** +- `Atlas Balance/scripts/update.ps1` +- `Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/SeedDataTests.cs` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/Versiones/v-01.05.md` **Cambios implementados:** -- CI cambiado de `windows-latest` a `ubuntu-latest`, donde Docker Linux y Testcontainers funcionan de forma natural. -- Rutas del workflow ajustadas a `./backend/GestionCaja.sln`. -- Anadido `docker pull postgres:16-alpine` antes de `dotnet test` para que el fallo sea temprano y claro si Docker Hub o la imagen fallan. -- `PostgresFixture` ahora declara explicitamente `WithImagePullPolicy(PullPolicy.Missing)`. +- `update.ps1` declara explicitamente `InstallPath` y `SkipBackup`, y los reenvia a `Actualizar-AtlasBalance.ps1` sin depender de argumentos residuales. +- `SeedData` comprueba IDs fijos existentes antes de insertar formatos de importacion por defecto. +- Agregado test de regresion para una fila legacy de `FORMATOS_IMPORTACION` con ID fijo ya existente pero datos de banco/divisa incompletos. **Comandos ejecutados:** -- `dotnet test .\GestionCaja.sln -c Release --no-restore` -- `npm.cmd run lint` -- `npm.cmd run build` -- `npm.cmd audit --audit-level=moderate` -- `dotnet list .\GestionCaja.sln package --vulnerable --include-transitive` +- Parser PowerShell sobre `Atlas Balance/scripts/update.ps1` y `Atlas Balance/scripts/Actualizar-AtlasBalance.ps1`. +- `dotnet test "Atlas Balance/backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj" --filter SeedDataTests` **Resultado de verificacion:** -- Backend tests pasan localmente: 75/75. -- Frontend lint pasa. -- Frontend build pasa. -- `npm audit`: 0 vulnerabilidades. -- Auditoria NuGet: 0 vulnerabilidades. +- Parser PowerShell OK para `update.ps1` y `Actualizar-AtlasBalance.ps1`. +- `SeedDataTests`: 5/5 OK. **Pendientes:** -- Esperar nueva ejecucion de GitHub Actions en el PR #1 para confirmar que el runner Linux resuelve el fallo de Testcontainers. +- Regenerar paquete `V-01.05` antes de publicarlo o usarlo para actualizar servidores. +## 2026-04-25 - Pasada extra de auditoria y endurecimiento defensivo -## 2026-04-19 - Push a GitHub y PR inicial +**Version:** V-01.05 -**Fase:** Publicacion GitHub +**Trabajo realizado:** Repaso completo de bugs documentados, revision de seguridad (auth, permisos, CSRF, security stamp, integracion OpenClaw, secretos, rate limit, cabeceras, dependencias) y aplicacion de guardias de entrada en endpoints nuevos de V-01.05. **Archivos tocados:** -- `DOCUMENTACION_CAMBIOS.md` -- `scripts/protect-main-branch.ps1` +- `Atlas Balance/backend/src/GestionCaja.API/Controllers/AlertasController.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Controllers/CuentasController.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Controllers/ImportacionController.cs` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` **Cambios implementados:** -- Configurado remoto `origin` apuntando a `https://github.com/AtlasLabs797/AtlasBalance.git`. -- Detectado que `main` remoto ya existia con un commit de licencia. -- Cambiada la rama local a `codex/initial-project-baseline`. -- Fusionada la base remota para conservar `LICENSE` y evitar historias sin ancestro comun en el PR. -- Push realizado a `origin/codex/initial-project-baseline`. -- Abierto PR draft: `https://github.com/AtlasLabs797/AtlasBalance/pull/1`. -- Instalado GitHub CLI `gh` 2.90.0 con `winget`. -- Anadido script `scripts/protect-main-branch.ps1` para aplicar branch protection tras autenticar `gh`. +- Endpoints `POST /api/alertas`, `PUT /api/alertas/{id}`, `POST /api/cuentas/{id}/plazo-fijo/renovar` y `POST /api/importacion/plazo-fijo/movimiento`: validacion de body nulo y normalizacion de listas de destinatarios para que un cuerpo malformado devuelva `400` en lugar de `500`. +- Verificadas auditorias V-01.02/V-01.03/V-01.05: incidencias previas siguen cerradas, `npm audit` y NuGet sin vulnerabilidades. +- Bugs abiertos pre-existentes (Tailwind/shadcn introducido, `CuentaResumenResponse` duplicado, accesibilidad de controles propios, estado Git local) confirmados: requieren decision de producto, no se tocan en esta pasada. **Comandos ejecutados:** -- `git ls-remote https://github.com/AtlasLabs797/AtlasBalance.git` -- `git remote add origin https://github.com/AtlasLabs797/AtlasBalance.git` -- `git fetch origin main` -- `git branch -M codex/initial-project-baseline` -- `git merge origin/main --allow-unrelated-histories -m "merge remote baseline"` -- `git push -u origin codex/initial-project-baseline` -- `winget install --id GitHub.cli -e --accept-source-agreements --accept-package-agreements --silent` -- `gh --version` -- `gh auth status` +- `dotnet build "Atlas Balance/backend/GestionCaja.sln" -c Release` +- `dotnet test "Atlas Balance/backend/GestionCaja.sln" -c Release --no-build` +- `dotnet list "Atlas Balance/backend/GestionCaja.sln" package --vulnerable --include-transitive` +- `npm.cmd audit --audit-level=moderate` +- `npm.cmd run lint` +- `npm.cmd run build` **Resultado de verificacion:** -- Rama remota creada correctamente. -- PR draft #1 creado correctamente. -- `gh` instalado correctamente. -- `gh auth status` indica que no hay sesion autenticada. -- Script de branch protection versionado y pendiente de ejecucion autenticada. +- Backend Release build OK, 0 warnings. +- Backend tests: 107/107 OK. +- NuGet vulnerable: sin paquetes vulnerables. +- `npm audit`: 0 vulnerabilidades. +- Frontend lint OK. +- Frontend build OK. **Pendientes:** -- Ejecutar `gh auth login` con una cuenta que tenga permisos de administracion sobre el repo. -- Despues de autenticar, ejecutar `.\scripts\protect-main-branch.ps1`. +- Decision sobre eliminar Tailwind/shadcn vs adoptarlo oficialmente en el stack canonico. +- Eliminar o alinear `CuentasController.Resumen` con el resumen rico que devuelve `ExtractosController`. +- Cerrar contrato de accesibilidad de teclado en `DatePickerField`, `ConfirmDialog`, `AppSelect`. +- Estado Git local sigue listado como abierto. -## 2026-04-19 - Primer commit Git limpio +--- +## 2026-04-26 - Actualizacion post-instalacion endurecida -**Fase:** Control de versiones +**Version:** V-01.05 + +**Trabajo realizado:** Corregir los dos fallos detectados al actualizar una instalacion real desde `V-01.03` con paquete `V-01.04`: reenvio roto de `-InstallPath` y arranque bloqueado por formatos de importacion duplicados. **Archivos tocados:** -- `.gitattributes` -- `.gitignore` -- `DOCUMENTACION_CAMBIOS.md` +- `Atlas Balance/scripts/update.ps1` +- `Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/SeedDataTests.cs` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/Versiones/v-01.05.md` **Cambios implementados:** -- Ajustado `.gitignore` para excluir `.claude/`, `artifacts/`, `frontend/.env.*` y `backend/src/GestionCaja.Watchdog/watchdog-state.json`. -- Anadido `.gitattributes` para normalizar finales de linea y marcar binarios. -- Creado commit local inicial en rama `main`: `6876494 initial project baseline`. -- Antes del commit se verifico que no entraran cookies, headers/login JSON, `.env`, `node_modules`, `dist`, `bin/obj`, artefactos ni estado runtime del Watchdog. +- `update.ps1` declara explicitamente `InstallPath` y `SkipBackup`, y los reenvia a `Actualizar-AtlasBalance.ps1` sin depender de argumentos residuales. +- `SeedData` comprueba IDs fijos existentes antes de insertar formatos de importacion por defecto. +- Agregado test de regresion para una fila legacy de `FORMATOS_IMPORTACION` con ID fijo ya existente pero datos de banco/divisa incompletos. **Comandos ejecutados:** -- `git branch -M main` -- `git add -A` -- `git rm --cached -- backend/src/GestionCaja.Watchdog/watchdog-state.json` -- `git commit -m "initial project baseline"` -- `git remote -v` -- `gh --version` +- Parser PowerShell sobre `Atlas Balance/scripts/update.ps1` y `Atlas Balance/scripts/Actualizar-AtlasBalance.ps1`. +- `dotnet test "Atlas Balance/backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj" --filter SeedDataTests` **Resultado de verificacion:** -- Commit local creado correctamente. -- No hay remoto configurado. -- `gh` no esta instalado en esta maquina, por lo que no se pudo crear remoto/push/PR con el flujo seguro de GitHub. +- Parser PowerShell OK para `update.ps1` y `Actualizar-AtlasBalance.ps1`. +- `SeedDataTests`: 5/5 OK. **Pendientes:** -- Instalar/autenticar GitHub CLI (`gh`) o indicar un remoto GitHub existente para ejecutar `git remote add origin ...` y `git push -u origin main`. +- Regenerar paquete `V-01.05` antes de publicarlo o usarlo para actualizar servidores. +## 2026-04-25 - Auditoria general de bugs y seguridad -## 2026-04-19 - Git, CI y seed admin seguro +**Version:** V-01.05 -**Fase:** Hardening operacional +**Trabajo realizado:** Revision completa razonable de bugs documentados, problemas de seguridad conocidos, dependencias, configuracion y verificaciones automaticas. **Archivos tocados:** -- `.github/workflows/ci.yml` -- `backend/src/GestionCaja.API/Program.cs` -- `backend/src/GestionCaja.API/Data/SeedData.cs` -- `backend/src/GestionCaja.API/appsettings.json` -- `backend/src/GestionCaja.API/appsettings.Development.json` -- `backend/src/GestionCaja.API/appsettings.Production.json.template` -- `backend/tests/GestionCaja.API.Tests/SeedDataTests.cs` -- `DOCUMENTACION_CAMBIOS.md` +- `Atlas Balance/frontend/package.json` +- `Atlas Balance/frontend/package-lock.json` +- `Atlas Balance/backend/src/GestionCaja.API/wwwroot` +- `Documentacion/SEGURIDAD_AUDITORIA_V-01.05.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/Versiones/v-01.05.md` **Cambios implementados:** -- Inicializado repositorio Git local en `atlas-blance`. -- Anadido workflow de GitHub Actions con backend tests, auditoria NuGet, `npm audit`, lint y build frontend. -- El seed inicial de admin ya no usa una password fija en produccion. -- `SeedAdmin:Password` es obligatorio antes del primer arranque en produccion y se rechaza si usa passwords por defecto o placeholders tipo `CAMBIAR/AQUI`. -- Desarrollo usa una credencial local de conveniencia, no documentada aqui por higiene de seguridad. -- Anadidos tests para rechazar password seed insegura en produccion y verificar password configurada. +- Revisadas incidencias previas de auth, permisos, rutas, secretos, exportaciones, OpenClaw, cabeceras y CI/CD. +- Confirmado que `npm audit` y NuGet no reportan vulnerabilidades. +- Verificado con advisories recientes que el lockfile ya resolvia versiones seguras, pero el manifiesto mantenia rangos minimos antiguos. +- Actualizado `axios` a `^1.15.2` y `react-router-dom` a `^6.30.3`. +- Recompilado frontend y sincronizado `wwwroot`. +- Creado informe `SEGURIDAD_AUDITORIA_V-01.05.md`. **Comandos ejecutados:** -- `git init` -- `dotnet test .\GestionCaja.sln -c Release` +- `Get-Content` sobre instrucciones, version actual, log, bugs, auditorias y skill local `cyber-neo`. +- `Get-Command semgrep,trivy,gitleaks,npm.cmd,dotnet` +- `npm.cmd audit --audit-level=moderate` +- `npm.cmd ls axios react-router react-router-dom --depth=0` +- `npm.cmd view axios version` +- `npm.cmd install axios@^1.15.2 react-router-dom@^6.30.3` - `npm.cmd run lint` - `npm.cmd run build` -- `npm.cmd audit --audit-level=moderate` -- `dotnet list .\GestionCaja.sln package --vulnerable --include-transitive` -- `git status --short` +- `robocopy .\dist ..\backend\src\GestionCaja.API\wwwroot /MIR` +- `dotnet build ".\Atlas Balance\backend\GestionCaja.sln" -c Release --no-restore` +- `dotnet test ".\Atlas Balance\backend\GestionCaja.sln" -c Release --no-build` +- `dotnet list ".\Atlas Balance\backend\GestionCaja.sln" package --vulnerable --include-transitive` +- `dotnet list ".\Atlas Balance\backend\GestionCaja.sln" package --deprecated` +- `git diff --check -- ...` **Resultado de verificacion:** -- Backend Release compila y tests pasan: 75/75. -- Frontend lint pasa sin warnings. -- Frontend build pasa. -- `npm audit --audit-level=moderate`: 0 vulnerabilidades. -- Auditoria NuGet: 0 vulnerabilidades. -- Git queda inicializado; no se hizo commit automatico. +- Frontend lint OK. +- Frontend build OK. +- Backend Release build OK. +- Backend tests: 107/107 OK. +- `npm audit`: 0 vulnerabilidades. +- NuGet vulnerable: sin paquetes vulnerables. +- `wwwroot`: sincronizado y sin sourcemaps, plantillas Development ni `.env`. **Pendientes:** -- Hacer primer commit intencional despues de revisar que archivos historicos como `artifacts/`, `.claude/` o documentos auxiliares realmente deban versionarse. +- Instalar `semgrep`, `trivy` y `gitleaks` si se quiere una auditoria automatizada SAST/secrets externa ademas de la revision manual. +- El bug abierto de estado Git local sigue sin tocarse. -## 2026-04-19 - Auditoria profunda de bugs y seguridad +## 2026-04-25 - Importacion simple de plazo fijo y resumen en dashboard -**Fase:** Hardening transversal post-Fase 13 +**Version:** V-01.05 -**Archivos tocados:** -- `.gitignore` -- `backend/src/GestionCaja.API/GestionCaja.API.csproj` -- `backend/src/GestionCaja.API/Program.cs` -- `backend/src/GestionCaja.API/Controllers/AuthController.cs` -- `backend/src/GestionCaja.API/Controllers/BackupsController.cs` -- `backend/src/GestionCaja.API/Controllers/ExportacionesController.cs` -- `backend/src/GestionCaja.API/Controllers/ExtractosController.cs` -- `backend/src/GestionCaja.API/Services/AuthService.cs` -- `backend/src/GestionCaja.API/Services/BackupService.cs` -- `backend/src/GestionCaja.API/Services/EmailService.cs` -- `backend/src/GestionCaja.API/Services/ImportacionService.cs` -- `backend/src/GestionCaja.Watchdog/Program.cs` -- `backend/src/GestionCaja.Watchdog/Services/WatchdogOperationsService.cs` -- `backend/tests/GestionCaja.API.Tests/ExtractosControllerTests.cs` -- `backend/tests/GestionCaja.API.Tests/ImportacionServiceTests.cs` -- `DOCUMENTACION_CAMBIOS.md` +**Trabajo realizado:** Ajustado el flujo de plazos fijos para que la importacion no use formatos bancarios y el dashboard muestre sus datos clave. -**Archivos locales eliminados:** -- `backend/src/GestionCaja.API/phase12-login.json` -- `backend/src/GestionCaja.API/phase12.cookies.txt` -- `backend/src/GestionCaja.API/phase12-create-token.json` -- `backend/src/GestionCaja.API/phase12-create-token-rate.json` -- `backend/src/GestionCaja.API/phase12-create-token-write.json` +**Archivos tocados:** +- `Atlas Balance/backend/src/GestionCaja.API/DTOs/ImportacionDtos.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Controllers/ImportacionController.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Services/ImportacionService.cs` +- `Atlas Balance/backend/src/GestionCaja.API/DTOs/DashboardDtos.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Services/DashboardService.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/ImportacionServiceTests.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/DashboardServiceTests.cs` +- `Atlas Balance/frontend/src/pages/ImportacionPage.tsx` +- `Atlas Balance/frontend/src/pages/DashboardPage.tsx` +- `Atlas Balance/frontend/src/styles/layout/importacion.css` +- `Atlas Balance/frontend/src/styles/layout/dashboard.css` +- `Atlas Balance/frontend/src/types/index.ts` +- `Atlas Balance/backend/src/GestionCaja.API/wwwroot` +- Documentacion de `V-01.05`. **Cambios implementados:** -- Eliminadas cookies/JWTs/credenciales de humo local y anadidas reglas `.gitignore` para que no vuelvan a colarse. -- Validacion de produccion endurecida: JWT, secreto Watchdog y connection string ya rechazan placeholders, valores `dev-*`, `CAMBIAR`, `GENERAR`, `AQUI` y defaults conocidos. -- Fallback Docker de `pg_dump` y `pg_restore` migrado a `ProcessStartInfo.ArgumentList`; se elimino el overload con string de argumentos para no reabrir inyeccion por interpolacion. -- Watchdog compara `X-Watchdog-Secret` con `CryptographicOperations.FixedTimeEquals` y rechaza secretos placeholder fuera de Development. -- Restauracion Watchdog limitada a backups `.dump` dentro de `WatchdogSettings:BackupPath`. -- Usuarios no admin ya no pueden forzar `incluirEliminados=true` en extractos. -- `GetCuentasTitular` ya no filtra el nombre de titulares no autorizados. -- Backups/exportaciones devuelven solo nombre de archivo, no rutas absolutas del servidor. -- Email de alerta escapa tambien el `href` generado desde `app_base_url`. -- Importacion rechaza payloads mayores de 5 MB o 50.000 filas para evitar DoS por pegado masivo. -- Auth maneja cuerpo nulo y strings vacios sin caer en 500. -- Vulnerabilidad NuGet alta corregida: Hangfire arrastraba `Newtonsoft.Json 11.0.1`; se fijo `Newtonsoft.Json 13.0.4` sin usar Newtonsoft en codigo de aplicacion. -- Anadidos tests de regresion para soft-deletes no admin, acceso a titulares no autorizados y limites de importacion. +- El contexto de importacion expone `tipo_cuenta`. +- Las cuentas `PLAZO_FIJO` ya no aceptan importacion con mapeo/formato bancario. +- Nuevo endpoint `POST /api/importacion/plazo-fijo/movimiento` para registrar solo entrada o salida de dinero. +- El movimiento calcula saldo actual como ultimo saldo + monto firmado y audita la operacion. +- La pantalla de importacion muestra un formulario simple para plazo fijo: movimiento, fecha, monto y concepto. +- El dashboard principal muestra resumen de plazos fijos: monto total, intereses previstos aproximados y dias hasta el proximo vencimiento. + +**Decisiones visuales:** +- El plazo fijo usa un formulario compacto dentro de la pantalla de importacion existente, sin wizard ni tabla: pedir formato aqui seria hacer trabajar al usuario para nada. +- El dashboard agrega una banda de metricas sobria, consistente con las cards existentes y responsive a una columna en movil. **Comandos ejecutados:** -- `dotnet test .\GestionCaja.sln -c Release` -- `npm.cmd run build` - `npm.cmd run lint` -- `npm.cmd audit --audit-level=moderate` -- `dotnet list .\GestionCaja.sln package --vulnerable --include-transitive` -- `dotnet nuget why .\src\GestionCaja.API\GestionCaja.API.csproj Newtonsoft.Json` -- `dotnet package search Newtonsoft.Json --exact-match --format json` +- `npm.cmd run build` +- `robocopy dist ..\\backend\\src\\GestionCaja.API\\wwwroot /MIR` +- `dotnet test "Atlas Balance/backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj" --filter "ImportacionServiceTests|DashboardServiceTests"` +- `dotnet build "Atlas Balance/backend/src/GestionCaja.API/GestionCaja.API.csproj" -c Release` **Resultado de verificacion:** -- Backend Release compila y tests pasan: 73/73. -- Frontend `tsc && vite build` compila. -- ESLint pasa sin warnings. -- `npm audit --audit-level=moderate`: 0 vulnerabilidades. -- `dotnet list package --vulnerable --include-transitive`: 0 vulnerabilidades en API, Watchdog y tests. -- Escaneo frontend: no se encontraron `dangerouslySetInnerHTML`, `innerHTML`, `eval` ni almacenamiento de tokens; solo preferencias benignas en `localStorage/sessionStorage` y `postMessage` same-origin. +- `npm.cmd run lint`: OK. +- `npm.cmd run build`: OK. +- `robocopy /MIR`: OK. +- Tests focalizados importacion/dashboard: 28/28 OK. +- Backend Release build: OK, 0 warnings, 0 errores. +- Primer intento de tests quedo bloqueado por una `GestionCaja.API.exe` local en Debug; se detuvo ese proceso y se repitio correctamente. **Pendientes:** -- No hay repositorio Git inicializado en esta carpeta; no se pudo producir diff con `git status`. -- Cambiar en despliegue real cualquier password seed inmediatamente en primer login; dejarla viva seria una tonteria. +- Validacion manual con datos reales: crear plazo fijo, registrar entrada/salida desde importacion y revisar dashboard tras refrescar. -## 2026-04-19 - Dashboard cuenta: flags, comentarios y notas +## 2026-04-25 - Actualizaciones post-instalacion -**Fase:** Ajuste funcional post-Fase 13 +**Version:** V-01.05 + +**Trabajo realizado:** Endurecido el flujo de actualizacion para instalaciones ya existentes. **Archivos tocados:** -- `backend/src/GestionCaja.API/Models/Entities.cs` -- `backend/src/GestionCaja.API/DTOs/ExtractosDtos.cs` -- `backend/src/GestionCaja.API/DTOs/CuentasDtos.cs` -- `backend/src/GestionCaja.API/Controllers/ExtractosController.cs` -- `backend/src/GestionCaja.API/Controllers/CuentasController.cs` -- `backend/src/GestionCaja.API/Controllers/IntegrationOpenClawController.cs` -- `backend/src/GestionCaja.API/Migrations/20260419161617_AddCuentaNotasExtractoComentarios.cs` -- `backend/src/GestionCaja.API/Migrations/20260419161617_AddCuentaNotasExtractoComentarios.Designer.cs` -- `backend/src/GestionCaja.API/Migrations/AppDbContextModelSnapshot.cs` -- `frontend/src/types/index.ts` -- `frontend/src/components/extractos/AddRowForm.tsx` -- `frontend/src/components/extractos/ExtractoTable.tsx` -- `frontend/src/pages/ExtractosPage.tsx` -- `frontend/src/pages/CuentaDetailPage.tsx` -- `frontend/src/pages/CuentasPage.tsx` -- `frontend/src/styles/layout.css` -- `frontend/dist/**` -- `backend/src/GestionCaja.API/wwwroot/**` +- `Atlas Balance/update.cmd` +- `Atlas Balance/Actualizar Atlas Balance.cmd` +- `Atlas Balance/scripts/update.ps1` +- `Atlas Balance/scripts/Actualizar-AtlasBalance.ps1` +- `Atlas Balance/README_RELEASE.md` +- `Documentacion/documentacion.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/Versiones/v-01.05.md` **Cambios implementados:** -- `EXTRACTOS` ahora tiene columna `comentarios` para anotaciones libres por linea. -- `CUENTAS` ahora tiene columna `notas` para notas generales por cuenta. -- El dashboard de cuenta muestra una caja de `Notas generales`, editable si el usuario puede editar esa cuenta. -- El desglose del dashboard de cuenta muestra columna `Comentarios` editable por linea. -- Al activar `Flag` en el dashboard de cuenta, la fila queda resaltada con el color de fila marcada. -- La tabla general de extractos incluye `comentarios` como columna base visible por defecto. -- Alta manual de extractos permite cargar comentarios desde el formulario. -- CRUD de cuentas permite editar notas generales desde el modal de cuenta. -- API OpenClaw incluye `comentarios` en la respuesta de extractos. - -**Decisiones visuales tomadas:** -- El resaltado de flag reutiliza los tokens existentes `--color-row-flagged` y `--color-row-flagged-border` para mantener coherencia light/dark. -- Las notas generales van en una seccion propia sobre el desglose para que no compitan con KPIs ni movimientos. -- Los comentarios por linea se muestran como columna estable, no como tooltip oculto, porque una nota que no se ve no sirve. +- `update.ps1` valida paquete antes de autoelevar y soporta `-PackagePath`. +- El actualizador actualiza scripts/wrappers instalados, `VERSION` y `atlas-balance.runtime.json`. +- El flujo conserva configuracion, backup previo, rollback de binarios y ahora valida `/api/health` con `curl.exe -k`. +- Documentado uso desde paquete nuevo y desde instalacion existente con `-PackagePath`. **Comandos ejecutados:** -- `dotnet ef migrations add AddCuentaNotasExtractoComentarios --configuration Release` -- `dotnet build --configuration Release` -- `dotnet ef database update --configuration Release` -- `npm.cmd run build` -- `Copy-Item -Path 'dist\*' -Destination '..\backend\src\GestionCaja.API\wwwroot' -Recurse -Force` -- Restart del backend local en `https://localhost:5000` -- Smoke con Playwright contra `https://localhost:5000` +- `Get-Content` sobre version actual, version `V-01.05`, log y scripts de actualizacion. +- `Select-String` sobre servicios de actualizacion API/Watchdog. +- Parser PowerShell sobre `update.ps1` y `Actualizar-AtlasBalance.ps1`. +- Ejecucion de update desde carpeta fuente para validar fallo claro. +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.05`. +- Parser PowerShell sobre scripts empaquetados. +- `dotnet test ".\backend\GestionCaja.sln" -c Release --no-restore --filter "FullyQualifiedName!~ExtractosConcurrencyTests"`. +- `Get-FileHash -Algorithm SHA256` sobre el ZIP regenerado. **Resultado de verificacion:** -- Backend Release compila sin errores. -- Frontend `tsc && vite build` compila sin errores. -- Migracion aplicada correctamente a PostgreSQL local. -- `GET https://localhost:5000/api/health` devuelve 200. -- Smoke visual abre la app servida por backend y renderiza la pantalla de login sin overlay de Vite. -- Smoke autenticado no ejecutado: la credencial local redactada devuelve 401 en esta BD. +- Parser PowerShell OK. +- Update desde carpeta fuente falla con mensaje de paquete invalido. +- Actualizador empaquetado desde paquete valido y `InstallPath` inexistente falla con mensaje claro de instalacion inexistente. +- Paquete regenerado: `AtlasBalance-V-01.05-win-x64.zip`. +- SHA256: `42994915A8AFD014EF807D99E6335944302662FAA21927206ACAF1B8FDE46304`. +- Scripts empaquetados parsean correctamente. +- Paquete sin `*Development*`, `*.template`, `.env`, `node_modules` ni `.bak-iframe-fix`. +- Backend tests filtrados sin Testcontainers: 95/95 OK. **Pendientes:** -- Probar flujo autenticado real con credenciales validas: editar notas generales, editar comentarios por linea y confirmar persistencia tras recarga. +- Probar actualizacion real desde una instalacion `V-01.03`/`V-01.05` en Windows Server 2019. -### Ajuste posterior: resaltado amarillo de flag +## 2026-04-25 - Cierre incidencias instalacion Windows Server 2019 + +**Version:** V-01.05 + +**Trabajo realizado:** Corregidas las incidencias operativas del documento `INCIDENCIAS_INSTALACION_WINDOWS_SERVER_2019_V-01.05.txt`. **Archivos tocados:** -- `frontend/src/styles/variables.css` -- `frontend/src/styles/layout.css` -- `frontend/src/pages/CuentaDetailPage.tsx` -- `frontend/dist/**` -- `backend/src/GestionCaja.API/wwwroot/**` +- `Atlas Balance/install.cmd` +- `Atlas Balance/Instalar Atlas Balance.cmd` +- `Atlas Balance/README_RELEASE.md` +- `Atlas Balance/scripts/install.ps1` +- `Atlas Balance/scripts/Instalar-AtlasBalance.ps1` +- `Atlas Balance/scripts/Reset-AdminPassword.ps1` +- `Atlas Balance/scripts/Build-Release.ps1` +- `Documentacion/documentacion.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/Versiones/v-01.05.md` **Cambios implementados:** -- Subido el contraste del color flagged a un amarillo visible en light/dark. -- Añadido `data-flagged="true"` y fondo inline en filas flagged del dashboard de cuenta. -- Reforzado el selector CSS para pintar todas las celdas de la fila flagged con `background-color`. -- Añadido borde lateral amarillo en la primera celda para que la marca se lea aunque haya muchas columnas. +- Validacion temprana de paquete release para evitar instalar desde carpeta fuente o ZIP `main`. +- Fallback operativo cuando `winget` falla en Windows Server 2019 y documentacion de PostgreSQL 17 como valido. +- Deteccion de usuarios existentes para no generar credenciales admin falsas en reinstalaciones. +- Script oficial `Reset-AdminPassword.ps1` con bcrypt 12, limpieza de bloqueo, `primer_login`, rotacion de `security_stamp` y revocacion de refresh tokens. +- Health check post-instalacion con `curl.exe -k`. +- Inclusion de scripts de reset/certificado cliente en el paquete release. **Comandos ejecutados:** -- `npm.cmd run build` -- `dotnet build` -- `Copy-Item -Path 'dist\*' -Destination '..\backend\src\GestionCaja.API\wwwroot' -Recurse -Force` -- `curl.exe -k -s -o NUL -w "%{http_code}" https://localhost:5000/api/health` +- `Get-Content` sobre instrucciones, version actual, version `V-01.05`, incidencias, log y catalogo de skills. +- `Select-String`/`Get-ChildItem` para localizar scripts, cabeceras, instalador y documentacion. +- Parser PowerShell con `[System.Management.Automation.Language.Parser]::ParseFile(...)`. +- Ejecucion de `Instalar-AtlasBalance.ps1` e `install.ps1` desde carpeta fuente para validar fallo claro. +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.05`. +- `dotnet test ".\backend\GestionCaja.sln" -c Release --no-restore --filter "FullyQualifiedName!~ExtractosConcurrencyTests"`. +- `Get-FileHash -Algorithm SHA256` sobre `AtlasBalance-V-01.05-win-x64.zip`. **Resultado de verificacion:** -- Frontend compila. -- Backend compila. -- `wwwroot/index.html` apunta a los assets nuevos `index-0g1FU-yq.js` e `index-CMPUqTQ-.css`. -- CSS servido contiene `--row-flagged-bg: #fff2bd` y reglas para `tr[data-flagged=true]`. -- Healthcheck devuelve 200. +- Parser PowerShell OK en scripts modificados. +- Ejecutar el instalador desde carpeta fuente falla con mensaje de paquete invalido. +- Ejecutar `scripts\install.ps1` desde carpeta fuente falla con el mismo mensaje antes de autoelevar. +- Paquete generado: `Atlas Balance/Atlas Balance Release/AtlasBalance-V-01.05-win-x64.zip`. +- SHA256: `42994915A8AFD014EF807D99E6335944302662FAA21927206ACAF1B8FDE46304`. +- Scripts nuevos incluidos en paquete y parser OK en scripts empaquetados. +- Paquete sin `*Development*`, `*.template`, `.env`, `node_modules` ni `.bak-iframe-fix`. +- Backend tests filtrados sin Testcontainers: 95/95 OK. -## 2026-04-13 — Fase 0 (Scaffolding e Infraestructura) +**Pendientes:** +- Probar el ZIP en Windows Server 2019 real con PostgreSQL 17 antes de publicarlo. -### 1) Backend — Modelo y EF Core -- Se crearon enums de dominio para roles, tipos y estados de procesos. -- Se definieron entidades base del esquema (usuarios, cuentas, titulares, extractos, permisos, alertas, auditoría, integración, tipos de cambio, configuración, backups/exportaciones). -- Se configuró `AppDbContext` con: - - `DbSet<>` completos. - - `ToTable` en mayúsculas. - - índices críticos (incluyendo `UNIQUE(cuenta_id, fila_numero)` en extractos). - - relaciones FK con `DeleteBehavior.Restrict`/`Cascade` según caso. - - `jsonb`, `inet`, precisiones decimales y enums PostgreSQL. - - filtro global de soft delete (`deleted_at IS NULL`) para entidades con borrado lógico. +## 2026-04-25 - Documento incidencias instalacion Windows Server 2019 -### 2) Backend — Startup y Seed -- Se activó `UseSnakeCaseNamingConvention()`. -- Se activó seed en startup (`SeedData.Initialize(db)`). -- Seed inicial cargado con: - - Admin por defecto: `admin@atlasbalnace.local` (bcrypt, 12 rounds). - - Divisas base: EUR/USD/MXN/DOP. - - Tipos de cambio iniciales. - - Claves iniciales de `CONFIGURACION`. +**Version:** V-01.05 -### 3) Backend — Migraciones y Base de Datos -- Se instaló `dotnet-ef` global versión 8.0.11. -- Se generó migración inicial: `Initial`. -- Se aplicó `dotnet ef database update` correctamente. -- Se detectó conflicto de puertos porque había otro PostgreSQL local en `5432`. - - Acción tomada: Docker Postgres movido a `5433`. - - `appsettings.Development.json` actualizado a puerto `5433`. +**Trabajo realizado:** Generado un documento TXT de traspaso con errores, bugs, incidencias y soluciones detectadas durante la instalacion real en Windows Server 2019. -### 4) Frontend — Layout Fase 0 -- Se implementó shell de layout con: - - `Sidebar`. - - `TopBar` con toggle dark/light. - - `Outlet` para contenido. -- Se dejaron rutas placeholder dentro de layout para todas las vistas previstas. -- Se añadió `layout.css` con comportamiento responsive básico: - - desktop: sidebar lateral. - - tablet: sidebar colapsado. - - mobile: navegación inferior. -- Se corrigió tipado `import.meta.env` con `vite-env.d.ts`. +**Archivos tocados:** +- `Documentacion/INCIDENCIAS_INSTALACION_WINDOWS_SERVER_2019_V-01.05.txt` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` -### 5) Frontend — Build y publicación en backend -- `npm install` ejecutado. -- `npm run build` ejecutado con éxito. -- `dist` copiado a `backend/src/GestionCaja.API/wwwroot`. +**Cambios implementados:** +- Registradas incidencias de instalacion desde carpeta fuente, paquete V-01.03 vs V-01.05, PostgreSQL 17, `winget`, wrapper `install.cmd`, certificado PFX, health check PowerShell, certificado cliente, credenciales iniciales, reset admin, bloqueo login, SQL con tablas en mayusculas, modal de importacion anti-frame y parche temporal del bundle. +- Incluido checklist para cerrar `V-01.05` sin documentar passwords ni secretos reales. -### 6) Verificaciones realizadas -- `docker compose up -d` OK. -- `dotnet restore` y `dotnet build` OK. -- `dotnet ef migrations add Initial` OK. -- `dotnet ef database update` OK. -- API levantada en Development y health check validado: - - `https://localhost:443/api/health` → `{"status":"healthy", ...}` -- Root estático validado: - - `https://localhost:443/` → 200 OK. +**Comandos ejecutados:** +- `Get-Content` sobre version actual, `v-01.05.md` y bitacora. +- Creacion del TXT con `apply_patch`. -### 7) Incidencias detectadas y resueltas -- PowerShell bloqueaba `npm.ps1`: se usó `npm.cmd`. -- `dotnet-ef` no instalado: se instaló. -- Error de mapping `inet` sobre `string`: se cambió a `IPAddress`. -- Doble PostgreSQL escuchando en `5432`: se movió Docker a `5433`. +**Resultado de verificacion:** +- Documento creado en `Documentacion`. +- No se incluyeron passwords reales. -### 8) Pendientes inmediatos (siguiente bloque) -- Ajustar credenciales/SSL de `appsettings.Production.json` para despliegue real. -- Empezar Fase 1 (Auth endpoints + flujo real de login/refresh/logout/me/cambio-password). +**Pendientes:** +- Convertir las soluciones pendientes en cambios de codigo/scripts antes de publicar `V-01.05`. ---- +## 2026-04-25 - Fix modal importacion bloqueado por anti-frame -## 2026-04-13 — Cierre formal Fase 0 (desarrollo local) +**Version:** V-01.05 -### Ajustes de cierre -- Se dejó `appsettings.json` con valores funcionales por defecto para evitar arranque roto en `Production` local. -- Se alineó `appsettings.Production.json.template` al puerto de desarrollo Docker (`5433`). -- Se documentó en `AGENTS.md` la regla obligatoria de bitácora de cambios por sesión. +**Trabajo realizado:** Corregido el bloqueo del modal `Importar movimientos` en produccion. -### Verificación final ejecutada -- PostgreSQL Docker operativo en `localhost:5433`. -- Migración inicial aplicada sin errores. -- Tablas creadas: `22` (incluyendo `__EFMigrationsHistory`). -- Seed validado vía SQL dinámico: - - `USUARIOS=1` - - `DIVISAS_ACTIVAS=4` - - `CONFIGURACION=18` -- API en `Production` local: - - `GET http://localhost:5000/api/health` → `200` - - `GET http://localhost:5000/` (estáticos React) → `200` +**Archivos tocados:** +- `Atlas Balance/backend/src/GestionCaja.API/Program.cs` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` -### Estado -- **Fase 0 cerrada y funcional para entorno local de desarrollo.** -- Nota: HTTPS de producción depende del certificado real del servidor (paso de despliegue, no bloqueo de fase de scaffolding local). +**Cambios implementados:** +- `X-Frame-Options` pasa de `DENY` a `SAMEORIGIN`. +- `Content-Security-Policy frame-ancestors` pasa de `'none'` a `'self'`. +- La app sigue bloqueando embebidos externos, pero permite su propia ruta `/importacion` dentro del modal. -## 2026-04-13 — Fase 1 (inicio: autenticación y base de frontend auth) +**Comandos ejecutados:** +- `Select-String` sobre frontend, bundle generado y `Program.cs`. +- `Get-Content` sobre `CuentaDetailPage.tsx`, `ImportacionPage.tsx` y cabeceras de produccion. -### Implementado -- Backend: - - `AuthController` con endpoints: - - `POST /api/auth/login` - - `POST /api/auth/refresh-token` - - `POST /api/auth/logout` - - `GET /api/auth/me` - - `PUT /api/auth/cambiar-password` - - `AuthService` con: - - JWT por cookie `access_token` (1h) - - refresh token por cookie `refresh_token` (7 días) - - rotación de refresh token - - hash SHA-256 de refresh token en BD +**Resultado de verificacion:** +- Causa identificada: iframe same-origin bloqueado por cabeceras HTTP de la API. +- Correccion aplicada en fuente `V-01.05`. + +**Pendientes:** +- Publicar/regenerar paquete para llevar la correccion al servidor. En `V-01.03` instalado puede mitigarse navegando a `/importacion` en pagina completa. + +## 2026-04-25 - Fix reinstalacion certificado HTTPS + +**Version:** V-01.05 + +**Trabajo realizado:** Diagnosticado y corregido un fallo de reinstalacion en Windows Server donde la API no arrancaba al cargar el certificado HTTPS. + +**Archivos tocados:** +- `Atlas Balance/scripts/Instalar-AtlasBalance.ps1` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` + +**Cambios implementados:** +- `New-AtlasCertificate` ya no reutiliza `atlas-balance.pfx` existente durante instalacion; elimina PFX/CER previos y genera un par nuevo con la password que se escribe en `appsettings.Production.json`. +- Registrada la incidencia y la mitigacion operativa para instalaciones afectadas. + +**Comandos ejecutados:** +- `Get-Service AtlasBalance.API,AtlasBalance.Watchdog` en servidor afectado, reportado por usuario. +- `Get-EventLog -LogName Application -Newest 50`, reportado por usuario. +- `netstat -ano | findstr :443`, reportado por usuario. +- `Select-String` y `Get-Content` sobre `Instalar-AtlasBalance.ps1` para revisar generacion de certificado y configuracion. + +**Resultado de verificacion:** +- Causa identificada en el flujo de instalacion: PFX existente + password nueva. +- Correccion aplicada en script para `V-01.05`. + +**Pendientes:** +- Regenerar paquete `V-01.05` antes de publicar una release nueva. + +## 2026-04-25 - Apertura version V-01.05 + +**Version:** V-01.05 + +**Trabajo realizado:** Apertura de la nueva linea de trabajo posterior a la publicacion de `V-01.03`, con rama propia y fuentes de version alineadas. + +**Archivos tocados:** +- `CLAUDE.md` +- `Atlas Balance/AGENTS.md` +- `Atlas Balance/CLAUDE.md` +- `Atlas Balance/VERSION` +- `Atlas Balance/Directory.Build.props` +- `Atlas Balance/frontend/package.json` +- `Atlas Balance/frontend/package-lock.json` +- `Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs` +- `Atlas Balance/scripts/Build-Release.ps1` +- `Atlas Balance/scripts/Instalar-AtlasBalance.ps1` +- `Atlas Balance/README_RELEASE.md` +- `Documentacion/documentacion.md` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/Versiones/version_actual.md` +- `Documentacion/Versiones/v-01.03.md` +- `Documentacion/Versiones/v-01.05.md` + +**Cambios implementados:** +- Creada rama local `V-01.05` desde `V-01.03`. +- Marcada `V-01.05` como version actual del proyecto. +- Cerrada `V-01.03` como version publicada/base anterior. +- Actualizadas fuentes runtime backend/frontend a `1.5.0` y `V-01.05`. +- Actualizados scripts y documentacion viva para generar paquetes `AtlasBalance-V-01.05-win-x64`. + +**Comandos ejecutados:** +- `git status --short --branch` +- `Get-Content` sobre `CLAUDE.md`, `Documentacion/Versiones/version_actual.md`, archivos `v-*` y fuentes runtime. +- `git branch --list V-01.05` +- `git ls-remote --heads origin V-01.05` +- `git switch -c V-01.05` +- `git switch V-01.05` +- `Select-String` para localizar referencias vivas a `V-01.03` y `1.3.0`. +- `git diff --check` +- `dotnet build '.\Atlas Balance\backend\GestionCaja.sln' -c Release --no-restore` +- `npm.cmd run build` + +**Resultado de verificacion:** +- Rama activa confirmada: `V-01.05`. +- `git diff --check`: OK; solo avisos esperados de normalizacion LF/CRLF. +- Backend build Release: OK, 0 warnings, 0 errores. +- Frontend build: OK con `atlas-balance-frontend@1.5.0`. +- Busqueda de referencias activas: sin restos de `V-01.03` en codigo/configuracion viva. + +**Pendientes:** +- Ninguno. + +## 2026-04-25 - Publicacion asset GitHub Release V-01.03 + +**Version:** V-01.03 + +**Trabajo realizado:** Publicacion del ZIP instalable `AtlasBalance-V-01.03-win-x64.zip` como asset de GitHub Release, sin meter el paquete generado en Git. + +**Archivos tocados:** +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/Versiones/v-01.03.md` + +**Cambios implementados:** +- Creado el release publico `V-01.03-win-x64` en `AtlasLabs797/AtlasBalance`. +- Subido el asset `AtlasBalance-V-01.03-win-x64.zip`. +- Asociado el tag `V-01.03-win-x64` al commit `8df640d86912eb39b900a59ea0fd8ba769cacc96` (`origin/V-01.03`). +- Marcado `V-01.03-win-x64` como ultimo release publicado. + +**Comandos ejecutados:** +- `gh auth status` +- `gh release list --repo AtlasLabs797/AtlasBalance --limit 20` +- `Get-FileHash -Algorithm SHA256` sobre el ZIP de release. +- `gh release create V-01.03-win-x64 ... --draft` +- `gh release edit V-01.03-win-x64 --draft=false --latest` +- `gh release view V-01.03-win-x64 --json tagName,name,isDraft,isImmutable,isPrerelease,url,assets,publishedAt,targetCommitish` +- `git ls-remote --tags origin V-01.03-win-x64` + +**Resultado de verificacion:** +- Release publicado: `https://github.com/AtlasLabs797/AtlasBalance/releases/tag/V-01.03-win-x64`. +- Asset publicado: `AtlasBalance-V-01.03-win-x64.zip`. +- Tamano del asset: `102249107` bytes. +- SHA256 verificado por GitHub y local: `71E51F49CF740D358E056F256B70B3352EE23E61BD6FFFF0F048627AA07FDFA2`. +- Release no queda en draft y no es prerelease. + +**Pendientes:** +- Ninguno para la publicacion del asset de release. + +## 2026-04-25 - Publicacion GitHub V-01.03 + +**Version:** V-01.03 + +**Trabajo realizado:** Publicacion del contenido versionable de `V-01.03` en GitHub, excluyendo `Otros/`, `Skills/` y paquetes generados de `Atlas Balance/Atlas Balance Release`. + +**Archivos tocados:** +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/Versiones/v-01.03.md` + +**Cambios implementados:** +- Validada la rama local `V-01.03` contra la version actual. +- Confirmado remoto oficial `https://github.com/AtlasLabs797/AtlasBalance.git`. +- Staged del contenido versionable del proyecto sin incluir directorios excluidos. +- Commit principal creado: `1155bac` (`Publica V-01.03`). +- Push realizado a `origin/V-01.03`. + +**Comandos ejecutados:** +- `Get-Content` sobre `CLAUDE.md`, `Documentacion/Versiones/version_actual.md` y `Documentacion/Versiones/v-01.03.md`. +- `git status --short --branch` +- `git remote -v` +- `gh --version` +- `gh auth status` +- `git ls-remote --heads origin V-01.03` +- `git diff --check` +- `dotnet test ".\Atlas Balance\backend\GestionCaja.sln" -c Release --no-restore` +- `npm.cmd run lint` +- `npm.cmd run build` +- `npm.cmd audit --audit-level=low` +- `dotnet list ".\Atlas Balance\backend\GestionCaja.sln" package --vulnerable --include-transitive` +- `git add -A -- .` +- `git config user.name "Codex"` +- `git config user.email "codex@atlasbalance.local"` +- `git commit -m "Publica V-01.03"` +- `git push -u origin V-01.03` + +**Resultado de verificacion:** +- `git diff --check`: OK. +- Tests backend Release: 94/94 OK. +- Frontend lint: OK. +- Frontend build: OK. +- `npm audit`: 0 vulnerabilidades. +- NuGet vulnerable: sin paquetes vulnerables. +- `Otros/`, `Skills/` y paquetes de release quedaron fuera del commit. +- Rama remota creada correctamente: `origin/V-01.03`. +- `gh` no estaba autenticado durante esta publicacion de codigo; no se creo PR desde esa sesion. +- Asset de release publicado posteriormente en `V-01.03-win-x64`. + +**Pendientes:** +- Crear PR si se quiere revisar/mergear desde GitHub. + +## 2026-04-25 - Generacion release Windows x64 V-01.03 + +**Version:** V-01.03 + +**Trabajo realizado:** Generacion del paquete instalable Windows x64 de la version actual, equivalente al release previo pero con runtime, frontend, API, Watchdog, scripts y manifiesto alineados a `V-01.03`. + +**Archivos tocados:** +- `Atlas Balance/backend/src/GestionCaja.API/wwwroot` +- `Atlas Balance/Atlas Balance Release/AtlasBalance-V-01.03-win-x64` +- `Atlas Balance/Atlas Balance Release/AtlasBalance-V-01.03-win-x64.zip` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/Versiones/v-01.03.md` + +**Cambios implementados:** +- Ejecutado `scripts/Build-Release.ps1 -Version V-01.03`. +- Recompilado frontend React/Vite y sincronizado en `GestionCaja.API/wwwroot`. +- Publicada API ASP.NET Core y Watchdog como self-contained `win-x64`. +- Copiados scripts operativos `install/update/uninstall/start`, wrappers historicos, `VERSION`, `README.md`, `.gitignore`, `documentacion.md` y `version.json`. +- Generados carpeta y ZIP finales `AtlasBalance-V-01.03-win-x64`. + +**Comandos ejecutados:** +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.03` +- `Get-ChildItem` sobre `Atlas Balance/Atlas Balance Release`. +- `Get-Content` sobre `version.json` y `VERSION` empaquetados. +- Barrido de `api` empaquetada para detectar `*Development*`, `*.template` o `.env`. + +**Resultado de verificacion:** +- `npm.cmd run build`: OK dentro del build de release. +- `dotnet publish` API `win-x64`: OK. +- `dotnet publish` Watchdog `win-x64`: OK. +- `version.json` empaquetado apunta a `V-01.03`. +- `VERSION` empaquetado contiene `V-01.03`. +- No se detectaron `appsettings.Development`, plantillas ni `.env` dentro de `api`. + +**Pendientes:** +- Ninguno. Si se publica en GitHub, este ZIP debe ir como asset de GitHub Release, no como archivo versionado. + +## 2026-04-25 - Auditoria profunda de seguridad y hardening + +**Version:** V-01.03 + +**Trabajo realizado:** Analisis de seguridad sobre backend, frontend, configuracion, scripts, dependencias y Watchdog; remediacion directa de hallazgos de sesion, SSRF, path traversal, rate limiting y dependencias. + +**Archivos tocados:** +- `Atlas Balance/backend/src/GestionCaja.API/Constants/AuthClaimNames.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Constants/SecurityPolicy.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Constants/AuditActions.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Models/Entities.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Data/AppDbContext.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Migrations/20260425081244_UserSessionHardening.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Middleware/UserStateMiddleware.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Middleware/IntegrationAuthMiddleware.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Services/AuthService.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Services/UserSessionState.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Controllers/UsuariosController.cs` +- `Atlas Balance/backend/src/GestionCaja.API/ConfigurationDefaults.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Controllers/ConfiguracionController.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Services/ActualizacionService.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Services/BackupService.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Services/ExportacionService.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Controllers/ExportacionesController.cs` +- `Atlas Balance/backend/src/GestionCaja.Watchdog/Services/WatchdogOperationsService.cs` +- `Atlas Balance/frontend/package-lock.json` +- `Atlas Balance/frontend/src/components/usuarios/UsuarioModal.tsx` +- `Atlas Balance/frontend/src/pages/ChangePasswordPage.tsx` +- `Atlas Balance/scripts/Instalar-AtlasBalance.ps1` +- Tests backend asociados y documentacion V-01.03. + +**Cambios implementados:** +- `postcss` actualizado de `8.5.9` a `8.5.10` para cerrar vulnerabilidad moderada reportada por `npm audit`. +- `SecurityStamp`/`PasswordChangedAt` en usuarios; los access tokens se invalidan si el stamp ya no coincide. +- Reset/cambio/delete de usuario y reuse de refresh token revocan refresh tokens activos. +- Login limita intentos por cliente/email y evita revelar bloqueo de cuenta. +- Integracion OpenClaw limita bearer invalido antes de consultar tokens activos. +- `app_update_check_url` solo acepta HTTPS del repositorio oficial de Atlas Balance en GitHub. +- Rutas de backup/export/Watchdog se validan como absolutas antes de normalizar. +- Password minimo sube a 12 caracteres con bloqueo de passwords comunes; frontend actualizado. +- `INSTALL_CREDENTIALS_ONCE.txt` queda con borrado automatico a 24 horas. +- Informe `Documentacion/SEGURIDAD_AUDITORIA_V-01.03.md` actualizado. + +**Comandos ejecutados:** +- `npm.cmd update postcss` +- `npm.cmd audit --audit-level=moderate` +- `dotnet list '.\Atlas Balance\backend\GestionCaja.sln' package --vulnerable --include-transitive` +- `dotnet ef migrations add UserSessionHardening` +- `dotnet test "GestionCaja.sln" --filter "FullyQualifiedName~AuthServiceTests|FullyQualifiedName~UserStateMiddlewareTests|FullyQualifiedName~IntegrationAuthMiddlewareTests|FullyQualifiedName~UsuariosControllerTests|FullyQualifiedName~SeedDataTests|FullyQualifiedName~ConfiguracionControllerTests|FullyQualifiedName~ActualizacionServiceTests"` +- `dotnet test "GestionCaja.sln"` +- `dotnet build "GestionCaja.sln" -c Release --no-restore` +- `dotnet test "GestionCaja.sln" -c Release --no-build` +- `npm.cmd run lint` +- `npm.cmd run build` +- Parser PowerShell sobre `Instalar-AtlasBalance.ps1`. + +**Resultado de verificacion:** +- Backend Release build: OK, 0 warnings, 0 errores. +- Suite backend completa: 94/94 OK. +- Frontend lint/build: OK. +- `npm audit`: 0 vulnerabilidades. +- NuGet vulnerable: sin paquetes vulnerables. +- Parser PowerShell instalador: OK. + +**Pendientes:** +- Ninguno de los hallazgos corregidos queda abierto. El estado Git local sigue sucio por trabajo previo y no se ha limpiado porque no corresponde a esta tarea. + +## 2026-04-20 - Apertura version V-01.03 + +**Version:** V-01.03 + +**Trabajo realizado:** Apertura de la nueva linea de trabajo posterior a la publicacion de `V-01.02`, con rama propia y fuentes de version alineadas. + +**Archivos tocados:** +- `CLAUDE.md` +- `Atlas Balance/AGENTS.md` +- `Atlas Balance/CLAUDE.md` +- `Atlas Balance/VERSION` +- `Atlas Balance/Directory.Build.props` +- `Atlas Balance/frontend/package.json` +- `Atlas Balance/frontend/package-lock.json` +- `Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs` +- `Atlas Balance/scripts/Build-Release.ps1` +- `Atlas Balance/scripts/Instalar-AtlasBalance.ps1` +- `Atlas Balance/README_RELEASE.md` +- `Documentacion/documentacion.md` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/Versiones/version_actual.md` +- `Documentacion/Versiones/v-01.02.md` +- `Documentacion/Versiones/v-01.03.md` + +**Cambios implementados:** +- Creada rama local `V-01.03` desde `V-01.02`. +- Marcada `V-01.03` como version actual del proyecto. +- Cerrada `V-01.02` como version publicada/base anterior. +- Actualizadas fuentes runtime backend/frontend a `1.3.0` y `V-01.03`. +- Actualizados scripts y documentacion viva para generar paquetes `AtlasBalance-V-01.03-win-x64`. + +**Comandos ejecutados:** +- `git status --short --branch` +- `Get-Content` sobre `CLAUDE.md`, `Documentacion/Versiones/version_actual.md`, `Documentacion/Versiones/v-01.02.md` y fuentes runtime. +- `git branch --list V-01.03` +- `git switch -c V-01.03` +- `Select-String` para localizar referencias vivas a `V-01.02` y `1.2.0`. +- `git diff --check` +- `dotnet build '.\Atlas Balance\backend\GestionCaja.sln' -c Release --no-restore` +- `npm.cmd run build` + +**Resultado de verificacion:** +- `git diff --check`: OK; solo avisos esperados de normalizacion LF/CRLF. +- Backend build Release: OK, 0 warnings, 0 errores. +- Frontend build: OK con `atlas-balance-frontend@1.3.0`. + +**Pendientes:** +- Ninguno. + +## 2026-04-20 - Publicacion GitHub V-01.02 + +**Version:** V-01.02 + +**Trabajo realizado:** Preparacion automatizada de la version actual para publicacion en GitHub siguiendo el flujo del proyecto: rama `V-01.02`, tag de distribucion `V-01.02-win-x64`, paquete Windows x64 como asset de GitHub Release y codigo/documentacion como contenido Git versionable. + +**Archivos tocados:** +- Contenido versionable del proyecto preparado para el commit de publicacion (`Atlas Balance/`, `.github/`, raiz y `Documentacion/`). +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/Versiones/v-01.02.md` + +**Cambios implementados:** +- Confirmado que `Documentacion/Versiones/version_actual.md` declara `V-01.02`. +- Confirmado que la version runtime coincide: `Atlas Balance/VERSION`, `Atlas Balance/Directory.Build.props` y `Atlas Balance/frontend/package.json`. +- Rama local `V-01.02` sincronizada por fast-forward sobre `origin/main` antes de crear el commit de version. +- Regenerado el paquete oficial con `Atlas Balance/scripts/Build-Release.ps1`. +- Verificado que `AtlasBalance-V-01.02-win-x64.zip` contiene `VERSION=V-01.02` y no contiene archivos prohibidos como `.env`, `appsettings.Development.json`, plantillas de configuracion, `node_modules`, `frontend/dist` suelto ni sourcemaps. +- Calculado SHA256 del ZIP para trazabilidad: `F2BDC7BAF0168631C6E11E2E802B4019A5A88BA944CA8426CFD3B5353D865386`. +- Limpieza mecanica de espacios finales y lineas extra al final de archivo para que el indice pase `git diff --cached --check`. + +**Comandos ejecutados:** +- `git fetch origin --prune --tags` +- `git merge --ff-only origin/main` +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.02` +- `npm.cmd run lint` +- `dotnet test ".\backend\GestionCaja.sln" -c Release --no-restore` +- `dotnet test ".\backend\GestionCaja.sln" -c Release --no-restore --filter "FullyQualifiedName!~ExtractosConcurrencyTests"` +- Inspeccion automatizada del ZIP con `System.IO.Compression.ZipFile`. +- `Get-FileHash -Algorithm SHA256` +- `git add -A` +- `git diff --cached --check` +- Validacion de que el indice no incluye `Otros/`, `Skills/`, `.env`, `appsettings.Development.json`, `bin/`, `obj/`, `node_modules/`, `frontend/dist/`, `wwwroot/` ni paquetes de `Atlas Balance Release`. + +**Resultado de verificacion:** +- Build de release: OK. +- Frontend lint: OK. +- Backend suite completa: 82/83 OK; falla solo `ExtractosConcurrencyTests` porque Docker/Testcontainers no esta disponible en este entorno, incidencia ya documentada. +- Backend suite filtrada sin Testcontainers: 82/82 OK. +- `git diff --cached --check`: OK. +- Archivos prohibidos en indice Git: 0. +- ZIP oficial generado: `Atlas Balance/Atlas Balance Release/AtlasBalance-V-01.02-win-x64.zip`. +- Flujo de publicacion objetivo: rama `V-01.02`, tag `V-01.02-win-x64`, GitHub Release `Atlas Balance V-01.02 Windows x64`. + +**Pendientes:** +- Ninguno dentro del flujo automatizado de publicacion. + +## 2026-04-20 - Release funcional autonoma V-01.02 + +**Version:** V-01.02 + +**Trabajo realizado:** Analisis de estructura real del proyecto y generacion de release Windows x64 funcional en `Atlas Balance/Atlas Balance Release`, con scripts obligatorios `install`, `update`, `uninstall` y `start`. + +**Archivos tocados:** +- `Atlas Balance/scripts/Instalar-AtlasBalance.ps1` +- `Atlas Balance/scripts/Actualizar-AtlasBalance.ps1` +- `Atlas Balance/scripts/Launch-AtlasBalance.ps1` +- `Atlas Balance/scripts/Build-Release.ps1` +- `Atlas Balance/scripts/setup-https.ps1` +- `Atlas Balance/scripts/install.ps1` +- `Atlas Balance/scripts/update.ps1` +- `Atlas Balance/scripts/start.ps1` +- `Atlas Balance/scripts/uninstall.ps1` +- `Atlas Balance/install.cmd` +- `Atlas Balance/update.cmd` +- `Atlas Balance/start.cmd` +- `Atlas Balance/uninstall.cmd` +- `Atlas Balance/README_RELEASE.md` +- `Atlas Balance/RELEASE.gitignore` +- `Atlas Balance/Atlas Balance Release/AtlasBalance-V-01.02-win-x64/**` +- `Atlas Balance/Atlas Balance Release/AtlasBalance-V-01.02-win-x64.zip` +- `Documentacion/documentacion.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/Versiones/v-01.02.md` + +**Cambios implementados:** +- Detectado flujo real: frontend React/Vite se compila a `dist`, se copia a `GestionCaja.API/wwwroot`, la API ASP.NET Core 8 sirve API + SPA y aplica migraciones EF Core al arrancar. +- Confirmado que produccion no necesita Node ni .NET Runtime en servidor por paquete self-contained; si necesita PostgreSQL. +- Creados scripts one-click `install.cmd`, `update.cmd`, `uninstall.cmd` y `start.cmd`. +- Creados wrappers PowerShell `install.ps1`, `update.ps1`, `start.ps1` y `uninstall.ps1`. +- El instalador puede preparar PostgreSQL 16 gestionado con `winget`, servicio `AtlasBalance.PostgreSQL`, password generada y puerto local libre si `5432` esta ocupado. +- El runtime instalado registra si PostgreSQL es gestionado; `start` y `update` arrancan la base antes de Watchdog/API. +- Desinstalador completo para servicios, firewall, atajos, carpeta instalada, Data Protection y PostgreSQL gestionado. +- `Build-Release.ps1` copia scripts obligatorios, README de release y `.gitignore` preventivo al paquete. +- Reescrito `setup-https.ps1` en ASCII porque no parseaba por codificacion rota. + +**Comandos ejecutados:** +- Lectura de `CLAUDE.md`, `AGENTS.md`, `Documentacion/Versiones/*`, `LOG_ERRORES_INCIDENCIAS.md`, `SKILLS_LOCALES.md`, scripts, csproj, package.json, appsettings, Program.cs y servicios. +- `rg --files` (fallo conocido por acceso denegado; se uso PowerShell). +- Parser PowerShell sobre scripts fuente y empaquetados. +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\Build-Release.ps1" -Version V-01.02` +- `npm.cmd run lint` +- `dotnet test .\backend\GestionCaja.sln -c Release --no-restore` +- `dotnet test .\backend\GestionCaja.sln -c Release --no-restore --filter "FullyQualifiedName!~ExtractosConcurrencyTests"` +- Scanner local de secretos `cyber-neo` sobre el paquete generado. +- Inspeccion de ZIP con `System.IO.Compression.ZipFile`. +- `winget search PostgreSQL.PostgreSQL --source winget` + +**Resultado de verificacion:** +- Release generado: `Atlas Balance/Atlas Balance Release/AtlasBalance-V-01.02-win-x64`. +- ZIP generado: `Atlas Balance/Atlas Balance Release/AtlasBalance-V-01.02-win-x64.zip`. +- Frontend build: OK. +- Frontend lint: OK. +- Parser PowerShell scripts fuente/paquete: OK. +- Backend tests filtrando Testcontainers: 82/82 OK. +- Backend suite completa: 82/83 OK; falla solo `ExtractosConcurrencyTests` porque Docker/Testcontainers no esta disponible en este entorno. +- Scanner de secretos sobre paquete: 0 hallazgos. +- Paquete verificado sin `appsettings.Development.json`, plantillas, source maps, `node_modules` ni `frontend/dist` suelto. +- `winget` local lista `PostgreSQL.PostgreSQL.16`, usado por el instalador automatico. + +**Pendientes:** +- Validar `install.cmd` en un Windows Server limpio con `winget` disponible antes de distribuir fuera de esta maquina. +- Ejecutar suite completa con Docker activo para cubrir `ExtractosConcurrencyTests`. + +## 2026-04-20 - Auditoria tecnica profunda y hardening V-01.02 + +**Version:** V-01.02 + +**Trabajo realizado:** Auditoria tecnica sobre backend, frontend, base de datos, configuracion, scripts, dependencias, artefactos publicos/runtime, logs, temporales y auxiliares ignorados. Se corrigieron los riesgos reales encontrados, no solo se listaron. + +**Archivos tocados:** +- `.gitignore` +- `Atlas Balance/.gitignore` +- `Atlas Balance/backend/src/GestionCaja.API/Program.cs` +- `Atlas Balance/backend/src/GestionCaja.API/appsettings.json` +- `Atlas Balance/backend/src/GestionCaja.API/appsettings.Production.json.template` +- `Atlas Balance/backend/src/GestionCaja.API/Services/SecretProtector.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Controllers/ConfiguracionController.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Services/EmailService.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Services/TiposCambioService.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Services/UserAccessService.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Controllers/ExportacionesController.cs` +- `Atlas Balance/backend/src/GestionCaja.Watchdog/Program.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/PlainTextSecretProtector.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/ConfiguracionControllerTests.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/DashboardServiceTests.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/TiposCambioServiceTests.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/UserAccessServiceTests.cs` +- `Atlas Balance/scripts/backup-manual.ps1` +- `Atlas Balance/scripts/Instalar-AtlasBalance.ps1` +- `Atlas Balance/scripts/restore-backup.ps1` +- `Atlas Balance/scripts/install-services.ps1` +- `Atlas Balance/scripts/uninstall-services.ps1` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/SEGURIDAD_AUDITORIA_V-01.02.md` +- `Documentacion/Versiones/v-01.02.md` + +**Artefactos eliminados:** +- Logs runtime temporales de API en `backend/src/GestionCaja.API`. +- JSON/cookies/cabeceras de smoke/login en `Otros/Auxiliares/artifacts`. +- Captura auxiliar de login rellenado en `Otros/Auxiliares/artifacts/phase4-visual`. + +**Cambios implementados:** +- Los secretos de configuracion en BD (`smtp_password`, `exchange_rate_api_key`) ahora se guardan protegidos con ASP.NET Core Data Protection y prefijo `enc:v1:`. +- Los secretos legacy que ya existan en claro se migran automaticamente en el siguiente arranque. +- En produccion, las claves de Data Protection se persisten fuera de rutas publicas, por defecto en `%ProgramData%/AtlasBalance/keys`, y en Windows se protegen con DPAPI de maquina. +- El endpoint de configuracion sigue sin devolver passwords/API keys al frontend; auditoria de cambios redacta valores sensibles. +- El servicio SMTP y la sincronizacion de tipos de cambio descifran secretos solo al usarlos. +- Corregido bug de autorizacion: `PuedeVerDashboard` global ya no concede acceso global a cuentas, titulares, exportaciones o extractos. +- Endurecida descarga de exportaciones: solo permite `.xlsx` dentro de la ruta `export_path` configurada. +- Watchdog queda forzado a `localhost:5001` mediante Kestrel, reduciendo exposicion accidental. +- Cualquier wildcard en `AllowedHosts` queda rechazado fuera de Development; la plantilla obliga a definir host real y el instalador usa `$ServerName;localhost`. +- La configuracion base versionable baja `AllowedHosts` a `localhost`. +- Scripts de backup/restore manual usan usuario `atlas_balance_app`, restauran `PGPASSWORD` anterior, limpian `SecureString` con `ZeroFreeBSTR` y validan backups `.dump`. +- Scripts de servicios usan nombres `AtlasBalance.API` y `AtlasBalance.Watchdog`. +- `.gitignore` ignora keyrings locales de Data Protection. +- La plantilla/instalador de produccion declaran `DataProtection:KeysPath` en `%ProgramData%/AtlasBalance/keys`. + +**Comandos ejecutados:** +- `Get-Content` y `Select-String` sobre instrucciones, version, errores, skills, configuracion, scripts, backend, frontend, docs y auxiliares. +- `Get-ChildItem` para localizar logs, backups, temporales, artefactos, certificados, dumps y archivos sensibles por nombre. +- Barrido de patrones sensibles (`password`, `secret`, `token`, `api_key`, `connectionstring`, `PGPASSWORD`, `csrf`) con salida redactada. +- `git check-ignore` sobre `.env`, `appsettings.Development.json`, logs y artefactos de `Otros`. +- `dotnet list "Atlas Balance/backend/GestionCaja.sln" package --vulnerable --include-transitive` +- `npm.cmd audit --audit-level=moderate` +- `npm.cmd run lint` +- `npm.cmd run build` +- `dotnet build "Atlas Balance/backend/GestionCaja.sln" -c Release --no-restore` +- `dotnet test "Atlas Balance/backend/GestionCaja.sln" -c Release --no-build` + +**Resultado de verificacion:** +- Backend build Release: OK, 0 warnings, 0 errores. +- Backend tests Release completos: 83/83 OK. +- Frontend lint: OK. +- Frontend build: OK. +- NuGet audit: sin paquetes vulnerables conocidos. +- npm audit: 0 vulnerabilidades. +- Barrido final de artefactos de login/cookies/cabeceras en `Otros/Auxiliares/artifacts`: sin restos. + +**Pendientes:** +- `.env` y `appsettings.Development.json` siguen existiendo localmente e ignorados; si esos secretos salieron alguna vez de esta maquina, hay que rotarlos. +- El estado Git local no permite diff fino porque la copia aparece practicamente entera como `untracked`; no se ha reparado porque no era parte segura de este cambio. + +## 2026-04-20 - Verificacion y cierre de bugs reportados V-01.02 + +**Version:** V-01.02 + +**Trabajo realizado:** Contraste punto por punto de la revision V-01.02 y correccion de restos reales que seguian activos en configuracion, scripts, frontend y documentacion. + +**Archivos tocados:** +- `Atlas Balance/AGENTS.md` +- `Atlas Balance/backend/src/GestionCaja.API/appsettings.json` +- `Atlas Balance/backend/src/GestionCaja.API/appsettings.Development.json.template` +- `Atlas Balance/backend/src/GestionCaja.API/appsettings.Production.json.template` +- `Atlas Balance/backend/src/GestionCaja.API/wwwroot/*` (bundle generado, ignorado por Git) +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/ActualizacionServiceTests.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/ExportacionServiceTests.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/UsuariosControllerTests.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/WatchdogOperationsServiceTests.cs` +- `Atlas Balance/frontend/e2e/README.md` +- `Atlas Balance/frontend/e2e/admin-smoke.spec.ts` +- `Atlas Balance/frontend/src/components/usuarios/UsuarioModal.tsx` +- `Atlas Balance/frontend/src/pages/ConfiguracionPage.tsx` +- `Atlas Balance/frontend/src/pages/CuentaDetailPage.tsx` +- `Atlas Balance/frontend/src/pages/ImportacionPage.tsx` +- `Atlas Balance/frontend/src/utils/appEvents.ts` +- `Atlas Balance/scripts/Instalar-AtlasBalance.ps1` +- `Atlas Balance/scripts/backup-manual.ps1` +- `Atlas Balance/scripts/install-cert-client.ps1` +- `Atlas Balance/scripts/install-services.ps1` +- `Atlas Balance/scripts/setup-https.ps1` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/SPEC.md` +- `Documentacion/Versiones/v-01.02.md` +- `Documentacion/documentacion.md` + +**Cambios implementados:** +- Confirmado que `App.tsx` ya evita CSRF vacio (`""`) y devuelve `null` si la cookie esta ausente o vacia. +- Confirmado que `useSessionTimeout.ts` ya limita `remainingSeconds` a cero con `Math.max`. +- Confirmado que `api.ts` ya marca `_retry` tambien en requests encoladas durante refresh y evita el logout prematuro por 401 concurrentes dentro de la misma pestana. +- Corregidos restos `atlasbalnace` en `SeedAdmin:Email`, plantillas, placeholders UI, tests E2E y scripts. +- Corregidos restos `atlas-blance` en rutas por defecto, placeholders, tests y evento interno de importacion. +- Creada constante compartida `IMPORTACION_COMPLETADA_EVENT` para que importacion y cuenta no repitan el string del evento. +- Corregido `Instalar-AtlasBalance.ps1`, que seguia escribiendo `V-01.01` en runtime. +- Actualizada documentacion de instalacion y SPEC a `V-01.02` y rutas `C:/AtlasBalance`. +- Recompilado el frontend y sincronizado `frontend/dist` con `backend/src/GestionCaja.API/wwwroot`. + +**Decisiones visuales:** +- No hubo cambios de diseno visual. Solo se corrigieron placeholders de ejemplo y nombres internos. + +**Comandos ejecutados:** +- `Get-Content` sobre instrucciones, version, log de errores y archivos afectados. +- `Get-ChildItem ... | Select-String -Pattern 'atlasbalnace|atlas-blance|V-01\.01'` +- `dotnet test "Atlas Balance\backend\GestionCaja.sln" -c Release --no-restore --filter "FullyQualifiedName!~ExtractosConcurrencyTests"` +- `npm.cmd run lint` +- `docker compose ps` +- `docker ps --filter "name=atlas_balance_db" --format "{{.Names}}\t{{.Status}}\t{{.Ports}}"` +- `docker compose ps -a` +- `npm.cmd run build` +- Limpieza segura de `backend/src/GestionCaja.API/wwwroot` y copia de `frontend/dist`. +- `dotnet test "Atlas Balance\backend\GestionCaja.sln" -c Release --no-restore` + +**Resultado de verificacion:** +- Backend tests Release sin Docker/Testcontainers: 81/81 OK. +- Backend tests Release completos con Docker disponible: 82/82 OK. +- Frontend lint: OK. +- Frontend build: OK. +- `atlas_balance_db`: contenedor Docker activo, puerto `5433->5432`. +- `docker compose ps` en esta carpeta no lista servicios porque el contenedor activo no pertenece al proyecto Compose actual. +- Barrido final en codigo activo y `wwwroot`: 0 coincidencias de `atlasbalnace`, `atlas-blance` o `V-01.01`. + +**Pendientes:** +- Ninguno sobre los bugs revisados. Si se quiere que `docker compose ps` muestre `atlas_balance_db`, hay que levantarlo desde este compose concreto o alinear el nombre de proyecto Compose. + +## 2026-04-20 - Auditoria de seguridad y bugs V-01.02 + +**Version:** V-01.02 + +**Trabajo realizado:** Revision completa de seguridad y bugs usando la skill local `cyber-neo`, auditoria manual de auth/config/permisos/supply chain, limpieza de secretos versionables y verificacion de backend/frontend. + +**Archivos tocados:** +- `.github/workflows/ci.yml` +- `Atlas Balance/.env.example` +- `Atlas Balance/.gitignore` +- `Atlas Balance/docker-compose.yml` +- `Atlas Balance/backend/src/GestionCaja.API/Program.cs` +- `Atlas Balance/backend/src/GestionCaja.API/appsettings.json` +- `Atlas Balance/backend/src/GestionCaja.API/appsettings.Development.json.template` +- `Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Services/ActualizacionService.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Services/EmailService.cs` +- `Atlas Balance/backend/src/GestionCaja.API/Services/ImportacionService.cs` +- `Atlas Balance/backend/src/GestionCaja.Watchdog/appsettings.json` +- `Atlas Balance/backend/src/GestionCaja.Watchdog/appsettings.Development.json.template` +- `Atlas Balance/backend/src/GestionCaja.Watchdog/appsettings.Production.json.template` +- `Atlas Balance/backend/src/GestionCaja.Watchdog/Services/WatchdogOperationsService.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/ImportacionServiceTests.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/PostgresFixture.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/SeedDataTests.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/UsuariosControllerTests.cs` +- `Atlas Balance/frontend/e2e/README.md` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/SEGURIDAD_AUDITORIA_V-01.02.md` +- `Documentacion/Versiones/v-01.02.md` +- `Documentacion/documentacion.md` + +**Cambios implementados:** +- Eliminados secretos/defaults de desarrollo de configuracion versionable. +- `SeedAdmin:Password` ahora es obligatorio antes del primer arranque con BD vacia. +- JWT en Development genera clave efimera si no hay secreto configurado; fuera de Development sigue exigiendo secreto real. +- Watchdog ya no usa password de BD por defecto para restauraciones. +- `docker-compose.yml` exige `ATLAS_BALANCE_POSTGRES_PASSWORD` desde `.env` local o entorno. +- Añadidas plantillas API/Watchdog y `.env.example` sin secretos reales. +- Corregida version residual `V-01.01` en seed y User-Agent de actualizaciones. +- Corregidos textos mojibake en importacion y SMTP. +- CI endurecido con actions fijadas a SHAs. +- Añadido `.gitignore` dentro de `Atlas Balance` para proteger la app si se usa como raiz independiente. + +**Comandos ejecutados:** +- `Get-Content` / `Get-ChildItem` / `Select-String` para inspeccion estatica. +- `python Skills/Seguridad/cyber-neo-main/skills/cyber-neo/scripts/scan_secrets.py "Atlas Balance" --json` +- `python Skills/Seguridad/cyber-neo-main/skills/cyber-neo/scripts/check_lockfiles.py "Atlas Balance/frontend"` +- `dotnet list "Atlas Balance/backend/GestionCaja.sln" package --vulnerable --include-transitive` +- `npm.cmd audit --json` +- `npm.cmd ci` +- `dotnet test "Atlas Balance/backend/GestionCaja.sln" -c Release --no-restore --filter "FullyQualifiedName!~ExtractosConcurrencyTests"` +- `npm.cmd run lint` +- `npm.cmd run build` + +**Resultado de verificacion:** +- Scanner de secretos local: 0 hallazgos. +- NuGet audit: sin paquetes vulnerables. +- npm audit: 0 vulnerabilidades. +- Backend tests Release sin Docker/Testcontainers: 81/81 OK. +- Frontend lint: OK. +- Frontend build: OK. + +**Pendientes:** +- Ejecutar `ExtractosConcurrencyTests` con Docker activo. +- Reparar la metadata Git local; `git status` falla porque `.git` apunta a un worktree inexistente. +- Revisar valores productivos reales de `AllowedHosts`, secretos y rutas antes de release. + +## 2026-04-20 - Auditoria y limpieza estructural del proyecto + +**Version:** V-01.02 + +**Trabajo realizado:** Auditoria completa del proyecto. Correccion de todos los problemas encontrados: git, configuracion, estructura de carpetas y documentacion. + +**Archivos tocados:** +- `.gitignore` — añadidos: `wwwroot/assets/`, `wwwroot/index.html`, `wwwroot/fonts/`, `wwwroot/logos/`, `appsettings.Development.json` +- `Atlas Balance/docker-compose.yml` — postgres actualizado de 14 a 16 +- `Atlas Balance/backend/src/GestionCaja.API/appsettings.Development.json` — reducido a solo los overrides reales (Kestrel, Serilog, paths watchdog dev) +- `Atlas Balance/backend/src/GestionCaja.API/appsettings.Development.json.template` — creado para nuevos devs +- `Atlas Balance/backend/src/GestionCaja.API/Constants/AuditActions.cs` — creado (movido desde Services/) +- `Atlas Balance/backend/src/GestionCaja.API/Services/AuditActions.cs` — eliminado +- `Atlas Balance/backend/src/GestionCaja.API/Services/{ExportacionService,BackupService,AuthService,AlertaService}.cs` — añadido `using GestionCaja.API.Constants` +- `Atlas Balance/backend/src/GestionCaja.API/Controllers/{AlertasController,UsuariosController,AuthController,IntegracionesController,ConfiguracionController}.cs` — añadido `using GestionCaja.API.Constants` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/{AlertaServiceTests,UsuariosControllerTests,ConfiguracionControllerTests}.cs` — añadido `using GestionCaja.API.Constants` +- `Atlas Balance/frontend/src/utils/navigation.ts` — creado (movido desde components/layout/) +- `Atlas Balance/frontend/src/components/layout/navigation.ts` — eliminado +- `Atlas Balance/frontend/src/components/layout/{TopBar,Sidebar,BottomNav}.tsx` — actualizado import de navigation +- `Atlas Balance/frontend/src/pages/PlaceholderPage.tsx` — eliminado (sin uso) +- `CLAUDE.md` y `Atlas Balance/CLAUDE.md` — corregidos: Vite 5?8, PostgreSQL 14?16, V-01.01?V-01.02, estructura de directorios actualizada + +**Comandos ejecutados:** +- `git rm --cached` sobre 18 archivos de wwwroot y appsettings.Development.json +- `dotnet restore GestionCaja.sln` + `dotnet build GestionCaja.sln -c Release --no-restore` + +**Resultado de verificacion:** +- Backend: `Compilación correcta. 0 Advertencias, 0 Errores` +- Frontend: node_modules no instalados en esta maquina; cambios son solo actualizaciones de ruta de import, sin cambios de logica + +**Pendientes:** +- Verificar `npm run build` del frontend en entorno con node_modules instalados +- La duplicacion de CLAUDE.md entre raiz y Atlas Balance/ sigue siendo un punto de fallo; considerar usar un symlink o script de sincronizacion +- `design-tokens.css` en Documentacion/ y `variables.css` en frontend/styles pueden desincronizarse; sin mecanismo de sync automatico + +--- +## 2026-04-26 - Actualizacion post-instalacion endurecida + +**Version:** V-01.05 + +**Trabajo realizado:** Corregir los dos fallos detectados al actualizar una instalacion real desde `V-01.03` con paquete `V-01.04`: reenvio roto de `-InstallPath` y arranque bloqueado por formatos de importacion duplicados. + +**Archivos tocados:** +- `Atlas Balance/scripts/update.ps1` +- `Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/SeedDataTests.cs` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/Versiones/v-01.05.md` + +**Cambios implementados:** +- `update.ps1` declara explicitamente `InstallPath` y `SkipBackup`, y los reenvia a `Actualizar-AtlasBalance.ps1` sin depender de argumentos residuales. +- `SeedData` comprueba IDs fijos existentes antes de insertar formatos de importacion por defecto. +- Agregado test de regresion para una fila legacy de `FORMATOS_IMPORTACION` con ID fijo ya existente pero datos de banco/divisa incompletos. + +**Comandos ejecutados:** +- Parser PowerShell sobre `Atlas Balance/scripts/update.ps1` y `Atlas Balance/scripts/Actualizar-AtlasBalance.ps1`. +- `dotnet test "Atlas Balance/backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj" --filter SeedDataTests` + +**Resultado de verificacion:** +- Parser PowerShell OK para `update.ps1` y `Actualizar-AtlasBalance.ps1`. +- `SeedDataTests`: 5/5 OK. + +**Pendientes:** +- Regenerar paquete `V-01.05` antes de publicarlo o usarlo para actualizar servidores. +## 2026-04-20 - Apertura version V-01.02 + +**Fase:** Control de versiones + +**Archivos tocados:** +- `Atlas Balance/VERSION` +- `Atlas Balance/Directory.Build.props` +- `Atlas Balance/frontend/package.json` +- `Atlas Balance/frontend/package-lock.json` +- `Atlas Balance/scripts/Build-Release.ps1` +- `Documentacion/Versiones/version_actual.md` +- `Documentacion/Versiones/v-01.01.md` +- `Documentacion/Versiones/v-01.02.md` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` + +**Cambios implementados:** +- Creada rama `V-01.02` desde `V-01.01`. +- Creado worktree separado en `C:\Proyectos\Atlas Balance Dev V-01.02` para no mezclar cambios pendientes de la carpeta principal. +- Actualizada la version runtime backend a `1.2.0` con `InformationalVersion` `V-01.02`. +- Actualizada la version frontend a `1.2.0` y `appVersion` `V-01.02`. +- Actualizado el script de release para generar `V-01.02` por defecto. +- Marcada `V-01.02` como version actual de trabajo y `V-01.01` como base anterior. + +**Comandos ejecutados:** +- `git branch V-01.02 V-01.01` +- `git worktree add 'C:\Proyectos\Atlas Balance Dev V-01.02' V-01.02` +- `git status --short --branch` + +**Resultado de verificacion:** +- La rama `V-01.02` queda abierta desde `V-01.01`. +- La carpeta original `C:\Proyectos\Atlas Balance Dev` queda intacta con sus cambios pendientes. + +**Pendientes:** +- Definir tickets concretos para bugs y funciones de `V-01.02`. +- Ejecutar build/tests cuando empiecen los cambios de codigo. + +## 2026-04-20 - Version V-01.01 - PR y release GitHub + +**Version:** V-01.01 + +**Trabajo realizado:** +- Ajustada la politica para publicar paquetes pesados como assets de GitHub Releases. +- `Atlas Balance/Atlas Balance Release` queda versionada solo con `.gitkeep`. +- Se preparo la rama `V-01.01` para abrir PR sin binarios generados en el diff final. +- Se publico el paquete local `AtlasBalance-V-01.01-win-x64.zip` como asset del release `V-01.01-win-x64`. +- Se fusiono `origin/main` para que el PR tenga historia comun con `main`. +- Se creo el PR draft `https://github.com/AtlasLabs797/AtlasBalance/pull/1`. +- Se elimino el draft untagged que quedo del primer intento de release. +- Se elimino el tag remoto accidental `V-01.01` para evitar ambiguedad con la rama `V-01.01`. + +**Archivos tocados:** +- `LICENSE` +- `.gitignore` +- `CLAUDE.md` +- `AGENTS.md` +- `Atlas Balance/CLAUDE.md` +- `Atlas Balance/AGENTS.md` +- `Atlas Balance/Atlas Balance Release/.gitkeep` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` + +**Comandos ejecutados:** +- `gh --version` +- `gh auth status` +- `git status --short --branch --untracked-files=all` +- `git rm -r --cached -- Atlas Balance/Atlas Balance Release` +- `git push -u origin HEAD:refs/heads/V-01.01` +- `git tag V-01.01-win-x64` +- `git push origin V-01.01-win-x64` +- GitHub REST API para crear release, subir asset y crear PR. +- `git merge --allow-unrelated-histories --no-edit origin/main` +- `git commit -m "docs: record release and PR setup"` +- `git push` +- GitHub REST API para crear PR draft. +- GitHub REST API para eliminar el draft untagged del primer intento. +- `git push origin :refs/tags/V-01.01` + +**Resultado de verificacion:** +- Release `V-01.01-win-x64` publicado con asset Windows x64. +- El intento inicial de PR fallo por falta de historia comun y se corrigio fusionando `origin/main`. +- PR draft creado: `https://github.com/AtlasLabs797/AtlasBalance/pull/1`. +- Releases restantes: `V-01.01-win-x64` publicado, 1 asset. +- Tags remotos restantes de release: `V-01.01-win-x64`. + +**Pendientes:** +- Revisar y marcar el PR como listo cuando se quiera mergear a `main`. + +--- +## 2026-04-26 - Actualizacion post-instalacion endurecida + +**Version:** V-01.05 + +**Trabajo realizado:** Corregir los dos fallos detectados al actualizar una instalacion real desde `V-01.03` con paquete `V-01.04`: reenvio roto de `-InstallPath` y arranque bloqueado por formatos de importacion duplicados. + +**Archivos tocados:** +- `Atlas Balance/scripts/update.ps1` +- `Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/SeedDataTests.cs` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/Versiones/v-01.05.md` + +**Cambios implementados:** +- `update.ps1` declara explicitamente `InstallPath` y `SkipBackup`, y los reenvia a `Actualizar-AtlasBalance.ps1` sin depender de argumentos residuales. +- `SeedData` comprueba IDs fijos existentes antes de insertar formatos de importacion por defecto. +- Agregado test de regresion para una fila legacy de `FORMATOS_IMPORTACION` con ID fijo ya existente pero datos de banco/divisa incompletos. + +**Comandos ejecutados:** +- Parser PowerShell sobre `Atlas Balance/scripts/update.ps1` y `Atlas Balance/scripts/Actualizar-AtlasBalance.ps1`. +- `dotnet test "Atlas Balance/backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj" --filter SeedDataTests` + +**Resultado de verificacion:** +- Parser PowerShell OK para `update.ps1` y `Actualizar-AtlasBalance.ps1`. +- `SeedDataTests`: 5/5 OK. + +**Pendientes:** +- Regenerar paquete `V-01.05` antes de publicarlo o usarlo para actualizar servidores. +## 2026-04-20 - Version V-01.01 - Politica GitHub sin Otros ni Skills + +**Version:** V-01.01 + +**Trabajo realizado:** +- Anadida regla para subir a GitHub todo lo versionable excepto `Otros/` y `Skills/`. +- Anadida exclusion `Skills/` en `.gitignore`. +- Mantenida exclusion de basura local, dependencias generadas y secretos. +- Permitido que `Atlas Balance/Atlas Balance Release` pueda entrar en Git si se sube todo el proyecto versionable. +- Creado commit `0d08ffe` y publicado en `origin/V-01.01`. +- Documentada la advertencia de GitHub por el ZIP de release grande. + +**Archivos tocados:** +- `.gitignore` +- `CLAUDE.md` +- `AGENTS.md` +- `Atlas Balance/CLAUDE.md` +- `Atlas Balance/AGENTS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` + +**Comandos ejecutados:** +- `Get-Content` sobre version e incidencias. +- `git status --short --untracked-files=all` +- `Get-ChildItem` para revisar tamanos de release. +- `git check-ignore` para verificar que `Otros/` y `Skills/` quedan fuera. +- `git diff --cached --check` para validar whitespace antes del commit. +- `git commit -m "chore: publish V-01.01 project layout"` +- `git push -u origin V-01.01` + +**Resultado de verificacion:** +- `Skills/` queda ignorada. +- `Otros/` queda ignorada. +- `Atlas Balance/Atlas Balance Release` deja de estar ignorada. +- `git diff --cached --check` quedo limpio tras corregir espacios finales detectados. +- Push correcto a `https://github.com/AtlasLabs797/AtlasBalance`, rama `V-01.01`. +- GitHub acepto el ZIP de release, pero aviso que 97.49 MiB supera el maximo recomendado de 50 MiB. + +**Pendientes:** +- Considerar GitHub Releases o Git LFS para paquetes de release futuros si superan 50 MiB. + +--- +## 2026-04-26 - Actualizacion post-instalacion endurecida + +**Version:** V-01.05 + +**Trabajo realizado:** Corregir los dos fallos detectados al actualizar una instalacion real desde `V-01.03` con paquete `V-01.04`: reenvio roto de `-InstallPath` y arranque bloqueado por formatos de importacion duplicados. + +**Archivos tocados:** +- `Atlas Balance/scripts/update.ps1` +- `Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/SeedDataTests.cs` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/Versiones/v-01.05.md` + +**Cambios implementados:** +- `update.ps1` declara explicitamente `InstallPath` y `SkipBackup`, y los reenvia a `Actualizar-AtlasBalance.ps1` sin depender de argumentos residuales. +- `SeedData` comprueba IDs fijos existentes antes de insertar formatos de importacion por defecto. +- Agregado test de regresion para una fila legacy de `FORMATOS_IMPORTACION` con ID fijo ya existente pero datos de banco/divisa incompletos. + +**Comandos ejecutados:** +- Parser PowerShell sobre `Atlas Balance/scripts/update.ps1` y `Atlas Balance/scripts/Actualizar-AtlasBalance.ps1`. +- `dotnet test "Atlas Balance/backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj" --filter SeedDataTests` + +**Resultado de verificacion:** +- Parser PowerShell OK para `update.ps1` y `Actualizar-AtlasBalance.ps1`. +- `SeedDataTests`: 5/5 OK. + +**Pendientes:** +- Regenerar paquete `V-01.05` antes de publicarlo o usarlo para actualizar servidores. +## 2026-04-20 - Version V-01.01 - Catalogo de skills locales + +**Version:** V-01.01 + +**Trabajo realizado:** +- Analizada la carpeta `Skills`. +- Identificados paquetes de construccion, diseno, escritura y seguridad. +- Separadas skills reales de duplicados por agente (`.agents`, `.codex`, `.claude`, `.cursor`, etc.). +- Creado `Documentacion/SKILLS_LOCALES.md` con rutas canonicas, casos de uso y forma de aplicar cada skill. +- Actualizadas instrucciones para agentes para consultar el catalogo antes de usar skills locales. +- Documentada la regla de adaptar cualquier skill al stack real de Atlas Balance y no introducir dependencias ajenas sin motivo. + +**Archivos tocados:** +- `CLAUDE.md` +- `AGENTS.md` +- `Atlas Balance/CLAUDE.md` +- `Atlas Balance/AGENTS.md` +- `Documentacion/SKILLS_LOCALES.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` + +**Comandos ejecutados:** +- `Get-Content` sobre archivos de version y log de incidencias. +- `Get-ChildItem -Recurse -Filter SKILL.md` para inventario. +- Lectura puntual de `README.md`, `CLAUDE.md` y `SKILL.md` canonicos. +- `Select-String` para verificacion de referencias. + +**Resultado de verificacion:** +- Catalogo creado con rutas canonicas. +- Instrucciones principales enlazan a `Documentacion/SKILLS_LOCALES.md`. +- Duplicados documentados como duplicados, no como skills independientes. + +**Pendientes:** +- No se ejecuto ningun script o CLI de las skills; solo se analizaron archivos locales. + +--- +## 2026-04-26 - Actualizacion post-instalacion endurecida + +**Version:** V-01.05 + +**Trabajo realizado:** Corregir los dos fallos detectados al actualizar una instalacion real desde `V-01.03` con paquete `V-01.04`: reenvio roto de `-InstallPath` y arranque bloqueado por formatos de importacion duplicados. + +**Archivos tocados:** +- `Atlas Balance/scripts/update.ps1` +- `Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/SeedDataTests.cs` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/Versiones/v-01.05.md` + +**Cambios implementados:** +- `update.ps1` declara explicitamente `InstallPath` y `SkipBackup`, y los reenvia a `Actualizar-AtlasBalance.ps1` sin depender de argumentos residuales. +- `SeedData` comprueba IDs fijos existentes antes de insertar formatos de importacion por defecto. +- Agregado test de regresion para una fila legacy de `FORMATOS_IMPORTACION` con ID fijo ya existente pero datos de banco/divisa incompletos. + +**Comandos ejecutados:** +- Parser PowerShell sobre `Atlas Balance/scripts/update.ps1` y `Atlas Balance/scripts/Actualizar-AtlasBalance.ps1`. +- `dotnet test "Atlas Balance/backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj" --filter SeedDataTests` + +**Resultado de verificacion:** +- Parser PowerShell OK para `update.ps1` y `Actualizar-AtlasBalance.ps1`. +- `SeedDataTests`: 5/5 OK. + +**Pendientes:** +- Regenerar paquete `V-01.05` antes de publicarlo o usarlo para actualizar servidores. +## 2026-04-20 - Version V-01.01 - Reorganizacion de carpetas y reglas de documentacion + +**Version:** V-01.01 + +**Trabajo realizado:** +- Reorganizada la raiz en `Atlas Balance`, `Documentacion` y `Otros`. +- Movida la aplicacion a `Atlas Balance`. +- Movidos paquetes existentes a `Atlas Balance/Atlas Balance Release`. +- Movida y centralizada la documentacion en `Documentacion`. +- Movidos duplicados, repos auxiliares de diseno y artefactos temporales a `Otros`. +- Reescritos `CLAUDE.md` y `AGENTS.md` sin secciones de planificacion por fases. +- Anade reglas de GitHub, versiones y documentacion. +- Movido `.git` a la raiz para versionar juntos app y documentacion. +- Ajustado `.github/workflows/ci.yml` para las nuevas rutas bajo `Atlas Balance`. +- Ajustado `Atlas Balance/scripts/Build-Release.ps1` para publicar en `Atlas Balance/Atlas Balance Release` y copiar documentacion desde `Documentacion`. +- Creados documentos base de version, tecnica, usuario, bugs y errores. +- Redactadas credenciales historicas explicitas encontradas en documentacion. + +**Archivos tocados:** +- `CLAUDE.md` +- `AGENTS.md` +- `.gitignore` +- `.github/workflows/ci.yml` +- `Atlas Balance/CLAUDE.md` +- `Atlas Balance/AGENTS.md` +- `Atlas Balance/scripts/Build-Release.ps1` +- `Documentacion/documentacion.md` +- `Documentacion/CORRECCIONES.md` +- `Documentacion/SPEC.md` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/Versiones/version_actual.md` +- `Documentacion/Versiones/v-01.01.md` +- `Atlas Balance/**` (movimiento estructural) +- `Otros/**` (material no runtime) + +**Comandos ejecutados:** +- `Get-Content .\CLAUDE.md -Raw` +- `Get-ChildItem -Recurse -Directory` +- `git status --short --untracked-files=all` +- `Move-Item` para app, documentacion, releases y auxiliares. +- `dotnet build '.\Atlas Balance\backend\GestionCaja.sln' --no-restore` +- `npm.cmd run build` en `Atlas Balance/frontend` +- `PSParser` sobre `Atlas Balance/scripts/Build-Release.ps1` + +**Resultado de verificacion:** +- Backend build OK: 0 warnings, 0 errores. +- Frontend build OK: `tsc && vite build`. +- `Build-Release.ps1` parse OK. +- Busqueda de secretos historicos exactos redactados sin resultados en instrucciones/documentacion actualizada. +- `CLAUDE.md` y `AGENTS.md` ya no contienen secciones de planificacion por fases. +- Git funciona desde la raiz del proyecto. + +**Pendientes:** +- No se ejecuto el empaquetado completo `Build-Release.ps1`; solo se valido sintaxis del script y builds de backend/frontend. +- No se hizo push a GitHub porque no fue solicitado. + +---## 2026-04-20 - Version V-01.01 e instalador Atlas Balance + +**Fase:** Empaquetado, instalacion y actualizaciones. + +**Archivos tocados:** +- `.gitignore` +- `Directory.Build.props` +- `VERSION` +- `Instalar Atlas Balance.cmd` +- `Actualizar Atlas Balance.cmd` +- `Atlas Balance.cmd` +- `documentacion.md` +- `frontend/package.json` +- `frontend/package-lock.json` +- `backend/src/GestionCaja.API/Data/SeedData.cs` +- `backend/src/GestionCaja.API/Services/ActualizacionService.cs` +- `backend/src/GestionCaja.API/appsettings.json` +- `backend/src/GestionCaja.API/appsettings.Production.json.template` +- `backend/src/GestionCaja.Watchdog/appsettings.json` +- `scripts/Build-Release.ps1` +- `scripts/Instalar-AtlasBalance.ps1` +- `scripts/Actualizar-AtlasBalance.ps1` +- `scripts/Launch-AtlasBalance.ps1` +- `backend/src/GestionCaja.API/wwwroot/**` (sincronizado desde build frontend) + +**Cambios implementados:** +- Fijada la version de backend como `V-01.01` mediante `AssemblyInformationalVersion`. +- Fijada version frontend `1.1.0` y `appVersion=V-01.01`. +- Anadido `VERSION` para trazabilidad del paquete. +- Desactivado el sufijo automatico de hash Git en la version informacional; la version publicada queda exactamente `V-01.01`. +- Creado generador de release self-contained para Windows x64: `scripts/Build-Release.ps1`. +- Creado instalador de servidor: `Instalar Atlas Balance.cmd` -> `scripts/Instalar-AtlasBalance.ps1`. +- Creado actualizador seguro: `Actualizar Atlas Balance.cmd` -> `scripts/Actualizar-AtlasBalance.ps1`. +- Creado lanzador `Atlas Balance.cmd`, que arranca servicios y abre la app; en instalacion crea acceso directo con logo. +- El instalador genera secretos, certificado HTTPS local, `appsettings.Production.json`, servicios Windows, firewall rule, base PostgreSQL y credenciales iniciales. +- El actualizador crea backup PostgreSQL previo, copia rollback de binarios, preserva configuracion y no toca datos. +- Actualizadas rutas por defecto de produccion a `C:\AtlasBalance`. +- Documentado paso a paso el primer despliegue y futuras actualizaciones en `documentacion.md`. + +**Comandos ejecutados:** +- Parser PowerShell sobre `scripts/Instalar-AtlasBalance.ps1`, `scripts/Actualizar-AtlasBalance.ps1`, `scripts/Build-Release.ps1`, `scripts/Launch-AtlasBalance.ps1`. +- Validacion JSON de `appsettings.json`, `appsettings.Production.json.template` y Watchdog `appsettings.json`. +- `dotnet build backend\GestionCaja.sln -c Release --no-restore` +- `npm.cmd run build` +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\Build-Release.ps1 -Version V-01.01` +- `npm.cmd install` para reparar `node_modules` tras un intento bloqueado de `npm ci` por un binario de Rolldown en uso. + +**Resultado de verificacion:** +- PowerShell parse OK en los 4 scripts nuevos. +- JSON de configuracion OK. +- Backend Release compila: 0 warnings, 0 errores. +- Frontend build OK. +- Release generado correctamente: + - `release\AtlasBalance-V-01.01-win-x64` + - `release\AtlasBalance-V-01.01-win-x64.zip` +- `GestionCaja.API.exe` publicado muestra: + - `ProductName = Atlas Balance` + - `ProductVersion = V-01.01` + - `FileVersion = 1.1.0.0` +- ZIP contiene instalador, actualizador, lanzador, scripts, `VERSION`, `version.json`, API y Watchdog publicados. + +**Pendientes:** +- No se ejecuto el instalador real en esta maquina porque instalaria servicios Windows y tocaria PostgreSQL local. La validacion hecha fue de build, paquete, sintaxis y estructura. +- En servidor real, PostgreSQL 14+ debe existir o el instalador debe poder usar `winget`. Sin PostgreSQL sano no hay instalacion seria, punto. + +## 2026-04-19 - Fix CI Testcontainers PostgreSQL + +**Fase:** Correccion CI + +**Archivos tocados:** +- `.github/workflows/ci.yml` +- `backend/tests/GestionCaja.API.Tests/PostgresFixture.cs` +- `DOCUMENTACION_CAMBIOS.md` + +**Problema detectado:** +- GitHub Actions fallaba en `ExtractosConcurrencyTests.Crear_Concurrente_Debe_Generar_FilaNumeros_Unicos`. +- La causa no era la prueba de concurrencia; el runner Windows intentaba crear `postgres:16-alpine` sin imagen disponible para Testcontainers. + +**Cambios implementados:** +- CI cambiado de `windows-latest` a `ubuntu-latest`, donde Docker Linux y Testcontainers funcionan de forma natural. +- Rutas del workflow ajustadas a `./backend/GestionCaja.sln`. +- Anadido `docker pull postgres:16-alpine` antes de `dotnet test` para que el fallo sea temprano y claro si Docker Hub o la imagen fallan. +- `PostgresFixture` ahora declara explicitamente `WithImagePullPolicy(PullPolicy.Missing)`. + +**Comandos ejecutados:** +- `dotnet test .\GestionCaja.sln -c Release --no-restore` +- `npm.cmd run lint` +- `npm.cmd run build` +- `npm.cmd audit --audit-level=moderate` +- `dotnet list .\GestionCaja.sln package --vulnerable --include-transitive` + +**Resultado de verificacion:** +- Backend tests pasan localmente: 75/75. +- Frontend lint pasa. +- Frontend build pasa. +- `npm audit`: 0 vulnerabilidades. +- Auditoria NuGet: 0 vulnerabilidades. + +**Pendientes:** +- Esperar nueva ejecucion de GitHub Actions en el PR #1 para confirmar que el runner Linux resuelve el fallo de Testcontainers. + +## 2026-04-19 - Push a GitHub y PR inicial + +**Fase:** Publicacion GitHub + +**Archivos tocados:** +- `DOCUMENTACION_CAMBIOS.md` +- `scripts/protect-main-branch.ps1` + +**Cambios implementados:** +- Configurado remoto `origin` apuntando a `https://github.com/AtlasLabs797/AtlasBalance.git`. +- Detectado que `main` remoto ya existia con un commit de licencia. +- Cambiada la rama local a `codex/initial-project-baseline`. +- Fusionada la base remota para conservar `LICENSE` y evitar historias sin ancestro comun en el PR. +- Push realizado a `origin/codex/initial-project-baseline`. +- Abierto PR draft: `https://github.com/AtlasLabs797/AtlasBalance/pull/1`. +- Instalado GitHub CLI `gh` 2.90.0 con `winget`. +- Anadido script `scripts/protect-main-branch.ps1` para aplicar branch protection tras autenticar `gh`. + +**Comandos ejecutados:** +- `git ls-remote https://github.com/AtlasLabs797/AtlasBalance.git` +- `git remote add origin https://github.com/AtlasLabs797/AtlasBalance.git` +- `git fetch origin main` +- `git branch -M codex/initial-project-baseline` +- `git merge origin/main --allow-unrelated-histories -m "merge remote baseline"` +- `git push -u origin codex/initial-project-baseline` +- `winget install --id GitHub.cli -e --accept-source-agreements --accept-package-agreements --silent` +- `gh --version` +- `gh auth status` + +**Resultado de verificacion:** +- Rama remota creada correctamente. +- PR draft #1 creado correctamente. +- `gh` instalado correctamente. +- `gh auth status` indica que no hay sesion autenticada. +- Script de branch protection versionado y pendiente de ejecucion autenticada. + +**Pendientes:** +- Ejecutar `gh auth login` con una cuenta que tenga permisos de administracion sobre el repo. +- Despues de autenticar, ejecutar `.\scripts\protect-main-branch.ps1`. + +## 2026-04-19 - Primer commit Git limpio + +**Fase:** Control de versiones + +**Archivos tocados:** +- `.gitattributes` +- `.gitignore` +- `DOCUMENTACION_CAMBIOS.md` + +**Cambios implementados:** +- Ajustado `.gitignore` para excluir `.claude/`, `artifacts/`, `frontend/.env.*` y `backend/src/GestionCaja.Watchdog/watchdog-state.json`. +- Anadido `.gitattributes` para normalizar finales de linea y marcar binarios. +- Creado commit local inicial en rama `main`: `6876494 initial project baseline`. +- Antes del commit se verifico que no entraran cookies, headers/login JSON, `.env`, `node_modules`, `dist`, `bin/obj`, artefactos ni estado runtime del Watchdog. + +**Comandos ejecutados:** +- `git branch -M main` +- `git add -A` +- `git rm --cached -- backend/src/GestionCaja.Watchdog/watchdog-state.json` +- `git commit -m "initial project baseline"` +- `git remote -v` +- `gh --version` + +**Resultado de verificacion:** +- Commit local creado correctamente. +- No hay remoto configurado. +- `gh` no esta instalado en esta maquina, por lo que no se pudo crear remoto/push/PR con el flujo seguro de GitHub. + +**Pendientes:** +- Instalar/autenticar GitHub CLI (`gh`) o indicar un remoto GitHub existente para ejecutar `git remote add origin ...` y `git push -u origin main`. + +## 2026-04-19 - Git, CI y seed admin seguro + +**Fase:** Hardening operacional + +**Archivos tocados:** +- `.github/workflows/ci.yml` +- `backend/src/GestionCaja.API/Program.cs` +- `backend/src/GestionCaja.API/Data/SeedData.cs` +- `backend/src/GestionCaja.API/appsettings.json` +- `backend/src/GestionCaja.API/appsettings.Development.json` +- `backend/src/GestionCaja.API/appsettings.Production.json.template` +- `backend/tests/GestionCaja.API.Tests/SeedDataTests.cs` +- `DOCUMENTACION_CAMBIOS.md` + +**Cambios implementados:** +- Inicializado repositorio Git local en `atlas-blance`. +- Anadido workflow de GitHub Actions con backend tests, auditoria NuGet, `npm audit`, lint y build frontend. +- El seed inicial de admin ya no usa una password fija en produccion. +- `SeedAdmin:Password` es obligatorio antes del primer arranque en produccion y se rechaza si usa passwords por defecto o placeholders tipo `CAMBIAR/AQUI`. +- Desarrollo usa una credencial local de conveniencia, no documentada aqui por higiene de seguridad. +- Anadidos tests para rechazar password seed insegura en produccion y verificar password configurada. + +**Comandos ejecutados:** +- `git init` +- `dotnet test .\GestionCaja.sln -c Release` +- `npm.cmd run lint` +- `npm.cmd run build` +- `npm.cmd audit --audit-level=moderate` +- `dotnet list .\GestionCaja.sln package --vulnerable --include-transitive` +- `git status --short` + +**Resultado de verificacion:** +- Backend Release compila y tests pasan: 75/75. +- Frontend lint pasa sin warnings. +- Frontend build pasa. +- `npm audit --audit-level=moderate`: 0 vulnerabilidades. +- Auditoria NuGet: 0 vulnerabilidades. +- Git queda inicializado; no se hizo commit automatico. + +**Pendientes:** +- Hacer primer commit intencional despues de revisar que archivos historicos como `artifacts/`, `.claude/` o documentos auxiliares realmente deban versionarse. + +## 2026-04-19 - Auditoria profunda de bugs y seguridad + +**Fase:** Hardening transversal post-Fase 13 + +**Archivos tocados:** +- `.gitignore` +- `backend/src/GestionCaja.API/GestionCaja.API.csproj` +- `backend/src/GestionCaja.API/Program.cs` +- `backend/src/GestionCaja.API/Controllers/AuthController.cs` +- `backend/src/GestionCaja.API/Controllers/BackupsController.cs` +- `backend/src/GestionCaja.API/Controllers/ExportacionesController.cs` +- `backend/src/GestionCaja.API/Controllers/ExtractosController.cs` +- `backend/src/GestionCaja.API/Services/AuthService.cs` +- `backend/src/GestionCaja.API/Services/BackupService.cs` +- `backend/src/GestionCaja.API/Services/EmailService.cs` +- `backend/src/GestionCaja.API/Services/ImportacionService.cs` +- `backend/src/GestionCaja.Watchdog/Program.cs` +- `backend/src/GestionCaja.Watchdog/Services/WatchdogOperationsService.cs` +- `backend/tests/GestionCaja.API.Tests/ExtractosControllerTests.cs` +- `backend/tests/GestionCaja.API.Tests/ImportacionServiceTests.cs` +- `DOCUMENTACION_CAMBIOS.md` + +**Archivos locales eliminados:** +- `backend/src/GestionCaja.API/phase12-login.json` +- `backend/src/GestionCaja.API/phase12.cookies.txt` +- `backend/src/GestionCaja.API/phase12-create-token.json` +- `backend/src/GestionCaja.API/phase12-create-token-rate.json` +- `backend/src/GestionCaja.API/phase12-create-token-write.json` + +**Cambios implementados:** +- Eliminadas cookies/JWTs/credenciales de humo local y anadidas reglas `.gitignore` para que no vuelvan a colarse. +- Validacion de produccion endurecida: JWT, secreto Watchdog y connection string ya rechazan placeholders, valores `dev-*`, `CAMBIAR`, `GENERAR`, `AQUI` y defaults conocidos. +- Fallback Docker de `pg_dump` y `pg_restore` migrado a `ProcessStartInfo.ArgumentList`; se elimino el overload con string de argumentos para no reabrir inyeccion por interpolacion. +- Watchdog compara `X-Watchdog-Secret` con `CryptographicOperations.FixedTimeEquals` y rechaza secretos placeholder fuera de Development. +- Restauracion Watchdog limitada a backups `.dump` dentro de `WatchdogSettings:BackupPath`. +- Usuarios no admin ya no pueden forzar `incluirEliminados=true` en extractos. +- `GetCuentasTitular` ya no filtra el nombre de titulares no autorizados. +- Backups/exportaciones devuelven solo nombre de archivo, no rutas absolutas del servidor. +- Email de alerta escapa tambien el `href` generado desde `app_base_url`. +- Importacion rechaza payloads mayores de 5 MB o 50.000 filas para evitar DoS por pegado masivo. +- Auth maneja cuerpo nulo y strings vacios sin caer en 500. +- Vulnerabilidad NuGet alta corregida: Hangfire arrastraba `Newtonsoft.Json 11.0.1`; se fijo `Newtonsoft.Json 13.0.4` sin usar Newtonsoft en codigo de aplicacion. +- Anadidos tests de regresion para soft-deletes no admin, acceso a titulares no autorizados y limites de importacion. + +**Comandos ejecutados:** +- `dotnet test .\GestionCaja.sln -c Release` +- `npm.cmd run build` +- `npm.cmd run lint` +- `npm.cmd audit --audit-level=moderate` +- `dotnet list .\GestionCaja.sln package --vulnerable --include-transitive` +- `dotnet nuget why .\src\GestionCaja.API\GestionCaja.API.csproj Newtonsoft.Json` +- `dotnet package search Newtonsoft.Json --exact-match --format json` + +**Resultado de verificacion:** +- Backend Release compila y tests pasan: 73/73. +- Frontend `tsc && vite build` compila. +- ESLint pasa sin warnings. +- `npm audit --audit-level=moderate`: 0 vulnerabilidades. +- `dotnet list package --vulnerable --include-transitive`: 0 vulnerabilidades en API, Watchdog y tests. +- Escaneo frontend: no se encontraron `dangerouslySetInnerHTML`, `innerHTML`, `eval` ni almacenamiento de tokens; solo preferencias benignas en `localStorage/sessionStorage` y `postMessage` same-origin. + +**Pendientes:** +- No hay repositorio Git inicializado en esta carpeta; no se pudo producir diff con `git status`. +- Cambiar en despliegue real cualquier password seed inmediatamente en primer login; dejarla viva seria una tonteria. + +## 2026-04-19 - Dashboard cuenta: flags, comentarios y notas + +**Fase:** Ajuste funcional post-Fase 13 + +**Archivos tocados:** +- `backend/src/GestionCaja.API/Models/Entities.cs` +- `backend/src/GestionCaja.API/DTOs/ExtractosDtos.cs` +- `backend/src/GestionCaja.API/DTOs/CuentasDtos.cs` +- `backend/src/GestionCaja.API/Controllers/ExtractosController.cs` +- `backend/src/GestionCaja.API/Controllers/CuentasController.cs` +- `backend/src/GestionCaja.API/Controllers/IntegrationOpenClawController.cs` +- `backend/src/GestionCaja.API/Migrations/20260419161617_AddCuentaNotasExtractoComentarios.cs` +- `backend/src/GestionCaja.API/Migrations/20260419161617_AddCuentaNotasExtractoComentarios.Designer.cs` +- `backend/src/GestionCaja.API/Migrations/AppDbContextModelSnapshot.cs` +- `frontend/src/types/index.ts` +- `frontend/src/components/extractos/AddRowForm.tsx` +- `frontend/src/components/extractos/ExtractoTable.tsx` +- `frontend/src/pages/ExtractosPage.tsx` +- `frontend/src/pages/CuentaDetailPage.tsx` +- `frontend/src/pages/CuentasPage.tsx` +- `frontend/src/styles/layout.css` +- `frontend/dist/**` +- `backend/src/GestionCaja.API/wwwroot/**` + +**Cambios implementados:** +- `EXTRACTOS` ahora tiene columna `comentarios` para anotaciones libres por linea. +- `CUENTAS` ahora tiene columna `notas` para notas generales por cuenta. +- El dashboard de cuenta muestra una caja de `Notas generales`, editable si el usuario puede editar esa cuenta. +- El desglose del dashboard de cuenta muestra columna `Comentarios` editable por linea. +- Al activar `Flag` en el dashboard de cuenta, la fila queda resaltada con el color de fila marcada. +- La tabla general de extractos incluye `comentarios` como columna base visible por defecto. +- Alta manual de extractos permite cargar comentarios desde el formulario. +- CRUD de cuentas permite editar notas generales desde el modal de cuenta. +- API OpenClaw incluye `comentarios` en la respuesta de extractos. + +**Decisiones visuales tomadas:** +- El resaltado de flag reutiliza los tokens existentes `--color-row-flagged` y `--color-row-flagged-border` para mantener coherencia light/dark. +- Las notas generales van en una seccion propia sobre el desglose para que no compitan con KPIs ni movimientos. +- Los comentarios por linea se muestran como columna estable, no como tooltip oculto, porque una nota que no se ve no sirve. + +**Comandos ejecutados:** +- `dotnet ef migrations add AddCuentaNotasExtractoComentarios --configuration Release` +- `dotnet build --configuration Release` +- `dotnet ef database update --configuration Release` +- `npm.cmd run build` +- `Copy-Item -Path 'dist\*' -Destination '..\backend\src\GestionCaja.API\wwwroot' -Recurse -Force` +- Restart del backend local en `https://localhost:5000` +- Smoke con Playwright contra `https://localhost:5000` + +**Resultado de verificacion:** +- Backend Release compila sin errores. +- Frontend `tsc && vite build` compila sin errores. +- Migracion aplicada correctamente a PostgreSQL local. +- `GET https://localhost:5000/api/health` devuelve 200. +- Smoke visual abre la app servida por backend y renderiza la pantalla de login sin overlay de Vite. +- Smoke autenticado no ejecutado: la credencial local redactada devuelve 401 en esta BD. + +**Pendientes:** +- Probar flujo autenticado real con credenciales validas: editar notas generales, editar comentarios por linea y confirmar persistencia tras recarga. + +### Ajuste posterior: resaltado amarillo de flag + +**Archivos tocados:** +- `frontend/src/styles/variables.css` +- `frontend/src/styles/layout.css` +- `frontend/src/pages/CuentaDetailPage.tsx` +- `frontend/dist/**` +- `backend/src/GestionCaja.API/wwwroot/**` + +**Cambios implementados:** +- Subido el contraste del color flagged a un amarillo visible en light/dark. +- Añadido `data-flagged="true"` y fondo inline en filas flagged del dashboard de cuenta. +- Reforzado el selector CSS para pintar todas las celdas de la fila flagged con `background-color`. +- Añadido borde lateral amarillo en la primera celda para que la marca se lea aunque haya muchas columnas. + +**Comandos ejecutados:** +- `npm.cmd run build` +- `dotnet build` +- `Copy-Item -Path 'dist\*' -Destination '..\backend\src\GestionCaja.API\wwwroot' -Recurse -Force` +- `curl.exe -k -s -o NUL -w "%{http_code}" https://localhost:5000/api/health` + +**Resultado de verificacion:** +- Frontend compila. +- Backend compila. +- `wwwroot/index.html` apunta a los assets nuevos `index-0g1FU-yq.js` e `index-CMPUqTQ-.css`. +- CSS servido contiene `--row-flagged-bg: #fff2bd` y reglas para `tr[data-flagged=true]`. +- Healthcheck devuelve 200. + +## 2026-04-13 — Fase 0 (Scaffolding e Infraestructura) + +### 1) Backend — Modelo y EF Core +- Se crearon enums de dominio para roles, tipos y estados de procesos. +- Se definieron entidades base del esquema (usuarios, cuentas, titulares, extractos, permisos, alertas, auditoría, integración, tipos de cambio, configuración, backups/exportaciones). +- Se configuró `AppDbContext` con: + - `DbSet<>` completos. + - `ToTable` en mayúsculas. + - índices críticos (incluyendo `UNIQUE(cuenta_id, fila_numero)` en extractos). + - relaciones FK con `DeleteBehavior.Restrict`/`Cascade` según caso. + - `jsonb`, `inet`, precisiones decimales y enums PostgreSQL. + - filtro global de soft delete (`deleted_at IS NULL`) para entidades con borrado lógico. + +### 2) Backend — Startup y Seed +- Se activó `UseSnakeCaseNamingConvention()`. +- Se activó seed en startup (`SeedData.Initialize(db)`). +- Seed inicial cargado con: + - Admin por defecto: `admin@atlasbalnace.local` (bcrypt, 12 rounds). + - Divisas base: EUR/USD/MXN/DOP. + - Tipos de cambio iniciales. + - Claves iniciales de `CONFIGURACION`. + +### 3) Backend — Migraciones y Base de Datos +- Se instaló `dotnet-ef` global versión 8.0.11. +- Se generó migración inicial: `Initial`. +- Se aplicó `dotnet ef database update` correctamente. +- Se detectó conflicto de puertos porque había otro PostgreSQL local en `5432`. + - Acción tomada: Docker Postgres movido a `5433`. + - `appsettings.Development.json` actualizado a puerto `5433`. + +### 4) Frontend — Layout Fase 0 +- Se implementó shell de layout con: + - `Sidebar`. + - `TopBar` con toggle dark/light. + - `Outlet` para contenido. +- Se dejaron rutas placeholder dentro de layout para todas las vistas previstas. +- Se añadió `layout.css` con comportamiento responsive básico: + - desktop: sidebar lateral. + - tablet: sidebar colapsado. + - mobile: navegación inferior. +- Se corrigió tipado `import.meta.env` con `vite-env.d.ts`. + +### 5) Frontend — Build y publicación en backend +- `npm install` ejecutado. +- `npm run build` ejecutado con éxito. +- `dist` copiado a `backend/src/GestionCaja.API/wwwroot`. + +### 6) Verificaciones realizadas +- `docker compose up -d` OK. +- `dotnet restore` y `dotnet build` OK. +- `dotnet ef migrations add Initial` OK. +- `dotnet ef database update` OK. +- API levantada en Development y health check validado: + - `https://localhost:443/api/health` ? `{"status":"healthy", ...}` +- Root estático validado: + - `https://localhost:443/` ? 200 OK. + +### 7) Incidencias detectadas y resueltas +- PowerShell bloqueaba `npm.ps1`: se usó `npm.cmd`. +- `dotnet-ef` no instalado: se instaló. +- Error de mapping `inet` sobre `string`: se cambió a `IPAddress`. +- Doble PostgreSQL escuchando en `5432`: se movió Docker a `5433`. + +### 8) Pendientes inmediatos (siguiente bloque) +- Ajustar credenciales/SSL de `appsettings.Production.json` para despliegue real. +- Empezar Fase 1 (Auth endpoints + flujo real de login/refresh/logout/me/cambio-password). + +--- +## 2026-04-26 - Actualizacion post-instalacion endurecida + +**Version:** V-01.05 + +**Trabajo realizado:** Corregir los dos fallos detectados al actualizar una instalacion real desde `V-01.03` con paquete `V-01.04`: reenvio roto de `-InstallPath` y arranque bloqueado por formatos de importacion duplicados. + +**Archivos tocados:** +- `Atlas Balance/scripts/update.ps1` +- `Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/SeedDataTests.cs` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/Versiones/v-01.05.md` + +**Cambios implementados:** +- `update.ps1` declara explicitamente `InstallPath` y `SkipBackup`, y los reenvia a `Actualizar-AtlasBalance.ps1` sin depender de argumentos residuales. +- `SeedData` comprueba IDs fijos existentes antes de insertar formatos de importacion por defecto. +- Agregado test de regresion para una fila legacy de `FORMATOS_IMPORTACION` con ID fijo ya existente pero datos de banco/divisa incompletos. + +**Comandos ejecutados:** +- Parser PowerShell sobre `Atlas Balance/scripts/update.ps1` y `Atlas Balance/scripts/Actualizar-AtlasBalance.ps1`. +- `dotnet test "Atlas Balance/backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj" --filter SeedDataTests` + +**Resultado de verificacion:** +- Parser PowerShell OK para `update.ps1` y `Actualizar-AtlasBalance.ps1`. +- `SeedDataTests`: 5/5 OK. + +**Pendientes:** +- Regenerar paquete `V-01.05` antes de publicarlo o usarlo para actualizar servidores. + +## 2026-04-13 — Cierre formal Fase 0 (desarrollo local) + +### Ajustes de cierre +- Se dejó `appsettings.json` con valores funcionales por defecto para evitar arranque roto en `Production` local. +- Se alineó `appsettings.Production.json.template` al puerto de desarrollo Docker (`5433`). +- Se documentó en `AGENTS.md` la regla obligatoria de bitácora de cambios por sesión. + +### Verificación final ejecutada +- PostgreSQL Docker operativo en `localhost:5433`. +- Migración inicial aplicada sin errores. +- Tablas creadas: `22` (incluyendo `__EFMigrationsHistory`). +- Seed validado vía SQL dinámico: + - `USUARIOS=1` + - `DIVISAS_ACTIVAS=4` + - `CONFIGURACION=18` +- API en `Production` local: + - `GET http://localhost:5000/api/health` ? `200` + - `GET http://localhost:5000/` (estáticos React) ? `200` + +### Estado +- **Fase 0 cerrada y funcional para entorno local de desarrollo.** +- Nota: HTTPS de producción depende del certificado real del servidor (paso de despliegue, no bloqueo de fase de scaffolding local). + +## 2026-04-13 — Fase 1 (inicio: autenticación y base de frontend auth) + +### Implementado +- Backend: + - `AuthController` con endpoints: + - `POST /api/auth/login` + - `POST /api/auth/refresh-token` + - `POST /api/auth/logout` + - `GET /api/auth/me` + - `PUT /api/auth/cambiar-password` + - `AuthService` con: + - JWT por cookie `access_token` (1h) + - refresh token por cookie `refresh_token` (7 días) + - rotación de refresh token + - hash SHA-256 de refresh token en BD - bloqueo por intentos fallidos (`5` intentos -> `30` min) - primer login (`primer_login`) respetado en respuesta - CSRF implementado: - `CsrfService` + `CsrfMiddleware` - token en cookie `csrf_token` y validación por header `X-CSRF-Token` para requests mutantes (excepto login/refresh) - Frontend: - - `LoginPage` real con React Hook Form y consumo de `/api/auth/login` - - `ChangePasswordPage` para flujo de primer login - - `ProtectedRoute` para proteger rutas y forzar cambio de contraseña si `primer_login=true` - - `RoleGuard` para restringir `Usuarios` a rol `ADMIN` - - bootstrap de sesión en `App.tsx` usando `/api/auth/me` - - logout funcional desde `TopBar` + - `LoginPage` real con React Hook Form y consumo de `/api/auth/login` + - `ChangePasswordPage` para flujo de primer login + - `ProtectedRoute` para proteger rutas y forzar cambio de contraseña si `primer_login=true` + - `RoleGuard` para restringir `Usuarios` a rol `ADMIN` + - bootstrap de sesión en `App.tsx` usando `/api/auth/me` + - logout funcional desde `TopBar` + +### Archivos tocados +- backend/src/GestionCaja.API/Program.cs +- backend/src/GestionCaja.API/Controllers/AuthController.cs +- backend/src/GestionCaja.API/DTOs/AuthDtos.cs +- backend/src/GestionCaja.API/Middleware/CsrfMiddleware.cs +- backend/src/GestionCaja.API/Models/Entities.cs +- backend/src/GestionCaja.API/Services/AuthService.cs +- backend/src/GestionCaja.API/Services/CsrfService.cs +- frontend/src/App.tsx +- frontend/src/main.tsx +- frontend/src/components/auth/ProtectedRoute.tsx +- frontend/src/components/auth/RoleGuard.tsx +- frontend/src/components/layout/Sidebar.tsx +- frontend/src/pages/TitularesPage.tsx +- frontend/src/components/layout/TopBar.tsx +- frontend/src/pages/LoginPage.tsx +- frontend/src/pages/ChangePasswordPage.tsx +- frontend/src/pages/PlaceholderPage.tsx +- frontend/src/stores/authStore.ts +- frontend/src/styles/layout.css + +### Comandos ejecutados +- `dotnet build` (backend) +- `npm.cmd run build` (frontend) +- copia de `frontend/dist` -> `backend/src/GestionCaja.API/wwwroot` + +### Resultado de verificación +- Backend compila OK. +- Frontend compila y genera build OK. +- Advertencia detectada: `MimeKit 4.9.0` con advisory `GHSA-g7hc-96xr-gvvx`. + +### Pendientes Fase 1 +- CRUD completo de usuarios (crear/editar/eliminar/restaurar) con soft delete. +- Asignación granular de permisos por cuenta/titular + columnas. +- Gestión de `USUARIO_EMAILS` desde API + UI. +- Auditoría explícita de acciones de auth y de cambios de usuarios/permisos. +- Validación manual end-to-end de login/refresh/logout/me/cambio-password con servidor en ejecución. + +## 2026-04-13 — Fase 1 (continuación: CRUD usuarios + permisos + emails) + +### Implementado +- Backend: + - Nuevo `UsuariosController` (solo `ADMIN`) con: + - `GET /api/usuarios` (paginación + filtros + orden) + - `GET /api/usuarios/{id}` + - `POST /api/usuarios` + - `PUT /api/usuarios/{id}` + - `DELETE /api/usuarios/{id}` (soft delete) + - `POST /api/usuarios/{id}/restaurar` + - Gestión de `USUARIO_EMAILS` incluida en create/update (reemplazo completo controlado). + - Gestión de permisos granulares (`PERMISOS_USUARIO`) incluida en create/update. + - Nuevo `AuditService` + registro de auditoría para altas/ediciones/bajas/restauraciones de usuarios. + - DTOs nuevos para usuarios/paginación/permisos (`UsuariosDtos.cs`). + - Registro de `IAuditService` en `Program.cs`. +- Frontend: + - Nueva `UsuariosPage` funcional para admin: + - listado paginado + búsqueda + incluir eliminados + - crear/editar usuario + - eliminar/restaurar + - edición básica de permisos globales (sin cuenta/titular) + - edición de emails de notificación (multilínea) + - Ruta `/usuarios` conectada a `UsuariosPage` bajo `RoleGuard` admin. + - Estilos añadidos para la pantalla de usuarios. + +### Archivos tocados +- backend/src/GestionCaja.API/Controllers/UsuariosController.cs +- backend/src/GestionCaja.API/DTOs/UsuariosDtos.cs +- backend/src/GestionCaja.API/Services/AuditService.cs +- backend/src/GestionCaja.API/Program.cs +- frontend/src/pages/UsuariosPage.tsx +- frontend/src/App.tsx +- frontend/src/styles/layout.css + +### Comandos ejecutados +- `dotnet build` (backend) +- `npm.cmd run build` (frontend) +- copia de `frontend/dist` -> `backend/src/GestionCaja.API/wwwroot` + +### Resultado de verificación +- Backend compila OK. +- Frontend compila/build OK. +- Advertencia persistente: `MimeKit 4.9.0` con advisory `GHSA-g7hc-96xr-gvvx`. + +### Pendientes Fase 1 +- Endurecer validación de permisos por recurso en endpoints de negocio (ahora se protegió auth/usuarios, falta expandir al resto de controladores futuros). +- Añadir auditoría más detallada por campo cambiado (actualmente es resumen por evento en usuarios). +- Pruebas manuales E2E de auth + CRUD usuarios con sesión real en navegador. +- Tests automatizados backend (xUnit/FluentAssertions) para auth y usuarios. + +## 2026-04-13 — Fase 4 (Importación completa backend + wizard frontend) + +### Implementado +- Backend: + - Nuevo `ImportacionController` con endpoints: + - `GET /api/importacion/contexto` + - `POST /api/importacion/validar` + - `POST /api/importacion/confirmar` + - Nuevo `ImportacionService` con: + - detección de separador (`tab`, `comma`, `semicolon`) + - parseo de líneas delimitadas con soporte básico de comillas + - validación por fila con errores específicos + - parseo de fecha: `DD/MM/YYYY`, `YYYY-MM-DD`, `DD-MM-YYYY` y serial Excel + - parseo robusto de decimales (`1.234,56`, `1,234.56`, etc.) + - verificación de permisos de importación en backend por cuenta/titular (`puede_importar`) + - inserción masiva de extractos + columnas extra + - auditoría de importación confirmada + - Nuevo contrato DTO de importación (`ImportacionDtos.cs`) para request/response tipados. + - Registro de DI en `Program.cs`: `IImportacionService`. + +- Frontend: + - Nueva `ImportacionPage` implementada como wizard de 4 pasos: + - Paso 1: selección de cuenta + textarea + preview primeras 3 filas + - Paso 2: mapeo de columnas base + columnas extra dinámicas + precarga de formato guardado + - Paso 3: preview validado con `?/?`, errores en rojo y selección de filas válidas + - Paso 4: resumen + confirmación + feedback final + - Ruta real `/importacion` conectada en `App.tsx` (reemplaza placeholder). + - Tipos TypeScript ampliados para contexto/validación/confirmación de importación. + - Estilos de wizard añadidos en `layout.css`. + - Fix adicional de tipos de dashboard faltantes para recuperar build frontend global. + +### Archivos tocados +- backend/src/GestionCaja.API/DTOs/ImportacionDtos.cs +- backend/src/GestionCaja.API/Services/ImportacionService.cs +- backend/src/GestionCaja.API/Controllers/ImportacionController.cs +- backend/src/GestionCaja.API/Program.cs +- frontend/src/pages/ImportacionPage.tsx +- frontend/src/App.tsx +- frontend/src/types/index.ts +- frontend/src/styles/layout.css + +### Comandos ejecutados +- `dotnet build` (backend) +- `npm.cmd run build` (frontend) +- `docker compose up -d` +- copia `frontend/dist/*` -> `backend/src/GestionCaja.API/wwwroot/` +- prueba E2E con script Python contra API real: + - login admin + - `GET /api/importacion/contexto` + - `POST /api/importacion/validar` + - `POST /api/importacion/confirmar` +- prueba adicional de validación de fechas (incluyendo serial Excel) y separador `;` + +### Resultado de verificación +- Backend compila OK. +- Frontend compila/build OK. +- Flujo E2E validado en API real: + - `validar` devolvió conteo correcto de filas OK/error y errores por fila. + - `confirmar` importó solo filas válidas (importación parcial). + - parseo de fechas confirmado para formatos requeridos + serial Excel. + - detección de separador confirmada (`tab` y `semicolon`). +- Advertencia persistente no bloqueante: + - `MimeKit 4.9.0` con advisory `GHSA-g7hc-96xr-gvvx`. + +### Pendientes +- Prueba visual/manual completa del wizard en navegador (interacción UI final). +- Cobertura de tests automatizados para parser/validator de importación. +- Fases 2/3 completas siguen pendientes en esta rama (la Fase 4 quedó operativa sobre una cuenta de prueba insertada en BD para verificar E2E). + +## 2026-04-13 — Fase 1 (validación E2E + tests automatizados) + +### Pruebas manuales E2E ejecutadas +- Se validó el flujo completo de autenticación y usuarios en local: + - `POST /api/auth/login` + - `GET /api/auth/me` + - `POST /api/usuarios` + - `GET /api/usuarios` con búsqueda + - `GET /api/usuarios/{id}` + - `PUT /api/usuarios/{id}` + - `PUT /api/auth/cambiar-password` (usuario nuevo) + - `POST /api/auth/refresh-token` + - `POST /api/auth/logout` + - `DELETE /api/usuarios/{id}` + - `POST /api/usuarios/{id}/restaurar` +- Resultado: flujo funcional extremo a extremo. + +### Hallazgo técnico corregido durante E2E +- En ejecución local por HTTP, cookies con `Secure=true` no mantienen sesión (401 tras login). +- Se ajustó `AuthController` para usar cookie segura solo cuando corresponde: + - siempre en no-Development + - en Development solo si request es HTTPS +- Se corrigió warning de EF de relación `RefreshToken.UsuarioId1` configurando explícitamente navegación en `AppDbContext`. + +### Tests automatizados añadidos +- Nuevo proyecto: `backend/tests/GestionCaja.API.Tests` +- Añadidos tests: + - `AuthServiceTests` + - bloqueo tras 5 intentos fallidos + - login válido resetea contador y genera tokens + - cambio de password actualiza hash y desactiva `primer_login` + - `UsuariosControllerTests` + - creación de usuario con emails + permisos + auditoría +- Solución actualizada para incluir proyecto de tests. + +### Comandos ejecutados +- `dotnet build` (backend) +- ejecución local API + pruebas E2E con sesión/cookies +- `dotnet sln add backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj` +- `dotnet test backend/GestionCaja.sln` + +### Resultado de verificación +- `dotnet test` -> **4/4 tests OK**. +- E2E manual de auth + usuarios -> OK. +- Warning persistente no bloqueante: `MimeKit 4.9.0` (`GHSA-g7hc-96xr-gvvx`). + +### Pendientes inmediatos +- Subir `MimeKit` a versión sin advisory. +- Extender tests a rate limiting real de login y a flujos de permisos por cuenta/titular específicos. + +## 2026-04-13 — Fase 2 (Titulares y Cuentas) — completada + +### Implementado +- Backend: + - Nuevo `UserAccessService` para resolver alcance de datos por usuario (admin/global/por titular/por cuenta). + - Nuevo `TitularesController` con: + - `GET /api/titulares` (paginación, ordenación, búsqueda, soft delete opcional para admin) + - `GET /api/titulares/{id}` + - `POST /api/titulares` (ADMIN) + - `PUT /api/titulares/{id}` (ADMIN) + - `DELETE /api/titulares/{id}` soft delete (ADMIN) + - `POST /api/titulares/{id}/restaurar` (ADMIN) + - Nuevo `CuentasController` con: + - `GET /api/cuentas` (paginación, ordenación, búsqueda, filtro por titular) + - `GET /api/cuentas/{id}` + - `GET /api/cuentas/{id}/resumen` (saldo actual + ingresos/egresos del mes) + - `GET /api/cuentas/divisas-activas` + - `POST /api/cuentas` (ADMIN) + - `PUT /api/cuentas/{id}` (ADMIN) + - `DELETE /api/cuentas/{id}` soft delete (ADMIN) + - `POST /api/cuentas/{id}/restaurar` (ADMIN) + - Nuevo `FormatosImportacionController` con CRUD completo: + - `GET /api/formatos-importacion` y `GET /api/formatos-importacion/{id}` + - `POST/PUT/DELETE/POST restaurar` para ADMIN + - `mapeo_json` persistido en JSONB + - Nuevos DTOs de Fase 2 para titulares/cuentas/formatos. + - Auditoría añadida en create/update/delete/restore de titulares, cuentas y formatos. + +- Frontend: + - `TitularesPage` implementada con cards, búsqueda, paginación y form CRUD (solo admin para mutaciones). + - `CuentasPage` implementada con lista filtrable por titular, selector de divisa, checkbox `es_efectivo`, asociación de formato y form CRUD (admin). + - `ImportacionPage` implementada como gestor de formatos de importación con constructor de columnas base + extras. + - Rutas actualizadas en `App.tsx` para usar páginas reales (`/titulares`, `/cuentas`, `/importacion`). + - Corrección del interceptor CSRF en `services/api.ts` para validar por método HTTP en minúsculas y contemplar `HEAD/OPTIONS`. + - Estilos añadidos en `layout.css` para vistas y formularios de Fase 2. + +### Archivos tocados +- backend/src/GestionCaja.API/Program.cs +- backend/src/GestionCaja.API/Services/UserAccessService.cs +- backend/src/GestionCaja.API/Controllers/TitularesController.cs +- backend/src/GestionCaja.API/Controllers/CuentasController.cs +- backend/src/GestionCaja.API/Controllers/FormatosImportacionController.cs +- backend/src/GestionCaja.API/DTOs/TitularesDtos.cs +- backend/src/GestionCaja.API/DTOs/CuentasDtos.cs +- backend/src/GestionCaja.API/DTOs/FormatosImportacionDtos.cs +- frontend/src/App.tsx +- frontend/src/services/api.ts +- frontend/src/pages/CuentasPage.tsx +- frontend/src/pages/ImportacionPage.tsx +- frontend/src/styles/layout.css + +### Comandos ejecutados +- `docker compose up -d` +- `dotnet build` (backend) +- `npm.cmd run build` (frontend) +- Smoke test HTTP E2E vía PowerShell (`Invoke-RestMethod`): + - login admin + - create titular + - create formato + - create cuenta + - get resumen + - create usuario con permisos acotados + - login usuario no-admin y verificación de filtrado (`titulares=1`, `cuentas=1`) +- copia de `frontend/dist` -> `backend/src/GestionCaja.API/wwwroot` + +### Resultado de verificación +- Backend compila OK (sin errores). +- Frontend compila/build OK. +- Endpoints Fase 2 responden correctamente en pruebas E2E. +- Resumen de cuenta responde con estructura esperada y valores iniciales (`saldo_actual=0`, `ingresos_mes=0`, `egresos_mes=0`) para cuenta recién creada. +- Filtro de permisos confirmado para usuario no admin (solo ve titular/cuenta autorizados). + +### Incidencias detectadas y resueltas +- `dotnet build` inicialmente falló por binario bloqueado (`GestionCaja.API.exe` en uso). Se liberó el proceso y compiló correctamente. +- En pruebas PowerShell hubo error de certificado TLS local; se resolvió habilitando callback de validación para la sesión de smoke test. + +### Pendientes +- Endurecer validaciones de negocio por rol/permiso fino en mutaciones futuras de fases siguientes (extractos/importación masiva). +- Añadir tests automatizados xUnit para controllers/servicios de Fase 2. +- Revisar actualización de `MimeKit` por advisory `GHSA-g7hc-96xr-gvvx`. + +## 2026-04-13 — Corrección post-Fase 2 (dependencias vulnerables) + +### Objetivo +- Corregir deuda de seguridad reportada tras Fase 2. + +### Cambios aplicados +- `GestionCaja.API.csproj`: + - Se forzó `Newtonsoft.Json` a `13.0.3` para neutralizar dependencia vulnerable transitiva. + - Se actualizaron paquetes Hangfire: + - `Hangfire.AspNetCore` `1.8.17` -> `1.8.23` + - `Hangfire.PostgreSql` `1.20.10` -> `1.21.1` + +### Archivos tocados +- backend/src/GestionCaja.API/GestionCaja.API.csproj + +### Comandos ejecutados +- `dotnet clean` +- `dotnet restore` +- `dotnet build` +- `dotnet list package --vulnerable --include-transitive` +- `dotnet list package --outdated` + +### Resultado de verificación +- Compilación backend: OK (0 errores, 0 warnings). +- Vulnerabilidades NuGet: `sin paquetes vulnerables` en `GestionCaja.API`. + +### Incidencias +- Durante build hubo lock temporal de proceso sobre binarios `GestionCaja.API`; recompilación posterior completó correctamente. + +## 2026-04-13 — Fase 1 (cierre y verificación final) + +### Objetivo +- Confirmar si Fase 1 queda realmente cerrada tras los últimos cambios en auth/usuarios/permisos. + +### Archivos tocados +- backend/src/GestionCaja.API/Controllers/AuthController.cs +- backend/src/GestionCaja.API/Data/AppDbContext.cs +- backend/src/GestionCaja.API/Controllers/UsuariosController.cs +- backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj +- backend/tests/GestionCaja.API.Tests/AuthServiceTests.cs +- backend/tests/GestionCaja.API.Tests/UsuariosControllerTests.cs +- backend/GestionCaja.sln +- frontend/src/pages/UsuariosPage.tsx +- DOCUMENTACION_CAMBIOS.md + +### Comandos ejecutados +- `dotnet build` (backend/src/GestionCaja.API) +- `dotnet test GestionCaja.sln` (backend) +- `npm.cmd run build` (frontend) + +### Resultado de verificación +- Backend compila OK (0 errores, 0 warnings). +- Tests backend OK: 4/4. +- Frontend build OK (Vite/TypeScript sin errores). +- Flujo Fase 1 cubierto: login/refresh/logout/me/cambio de password + primer login + CRUD usuarios + permisos granulares en UI + auditoría de cambios principales. + +### Incidencias +- El primer intento de `dotnet test` falló por proceso `dotnet` dejando DLLs bloqueadas; se detuvo proceso y se repitió con éxito. + +### Pendientes +- Aumentar cobertura de tests (hoy hay base crítica, pero no cobertura completa de todos los endpoints de usuarios/permisos). + +## 2026-04-13 — Fase 0 (auditoría real y correcciones de cierre) + +### Hallazgos corregidos +- `dotnet run` no garantizaba Development ni HTTPS en `https://localhost:5000`. + - Se añadió `Properties/launchSettings.json` para forzar `ASPNETCORE_ENVIRONMENT=Development`. + - Se añadió endpoint Kestrel HTTPS en `appsettings.Development.json`. +- El watchdog tenía un bug de middleware: + - `/watchdog/health` exigía `X-Watchdog-Secret` aunque el comentario decía lo contrario. + - Se dejó bypass explícito para health. +- `dotnet build` del backend no estaba realmente limpio: + - `UsuariosController` usaba `Cuenta.Titular` sin navegación declarada. + - Se añadió la navegación `Cuenta.Titular` y se ajustó Fluent API. +- EF Core emitía warning de filtro global por relación requerida `RefreshToken -> Usuario`. + - Se añadió query filter en `RefreshToken` para excluir tokens de usuarios soft-deleted. +- El backend compilaba con advisory conocida en `MailKit/MimeKit 4.9.0`. + - Se actualizaron ambos paquetes a `4.15.1`. +- El frontend tenía vulnerabilidades moderadas en `vite/esbuild`. + - Se actualizó `vite` a `8.0.8` y `@vitejs/plugin-react` a `6.0.1`. + - Se adaptó `manualChunks` a función porque Vite 8 ya no acepta el formato objeto anterior. +- El script `scripts/setup-https.ps1` dejaba una instrucción desfasada. + - Se aclaró desarrollo local vs despliegue real. + +### Archivos tocados +- `backend/src/GestionCaja.API/appsettings.Development.json` +- `backend/src/GestionCaja.API/Properties/launchSettings.json` +- `backend/src/GestionCaja.API/Data/AppDbContext.cs` +- `backend/src/GestionCaja.API/Models/Entities.cs` +- `backend/src/GestionCaja.API/GestionCaja.API.csproj` +- `backend/src/GestionCaja.Watchdog/Program.cs` +- `frontend/package.json` +- `frontend/package-lock.json` +- `frontend/vite.config.ts` +- `frontend/dist/*` +- `backend/src/GestionCaja.API/wwwroot/*` +- `scripts/setup-https.ps1` + +### Comandos ejecutados +- `docker compose up -d` +- `dotnet build backend/GestionCaja.sln` +- `dotnet test backend/GestionCaja.sln --no-build` +- `dotnet list backend/src/GestionCaja.API/GestionCaja.API.csproj package --vulnerable` +- `npm.cmd install` +- `npm.cmd run build` +- `npm.cmd audit --json` +- `curl.exe -k https://localhost:5000/api/health` +- `curl.exe -k -I https://localhost:5000/` +- `msedge.exe --headless --ignore-certificate-errors --dump-dom https://localhost:5000/login` +- `curl.exe http://127.0.0.1:5173/api/health` +- `curl.exe http://localhost:5001/watchdog/health` +- consultas `psql` en contenedor Docker para validar tablas y seed + +### Resultado de verificación +- `docker compose up -d` OK. +- Backend: + - `dotnet build` OK. + - `dotnet test` OK (`4/4`). + - `dotnet list package --vulnerable` OK (`0` vulnerables). + - `dotnet run` arranca en `Development` escuchando en `https://localhost:5000`. + - `GET https://localhost:5000/api/health` -> `200` (validado con `curl -k`). + - `GET https://localhost:5000/` -> `200` (estáticos desde `wwwroot`). +- Frontend: + - `npm install` OK. + - `npm run build` OK. + - `npm audit` OK (`0` vulnerabilidades). + - Vite dev proxy OK: `GET http://127.0.0.1:5173/api/health` -> `200`. +- Browser headless: + - `/login` renderiza correctamente el formulario React. + - Nota: el root ya no muestra el shell directamente porque Fase 1 añadió auth; el usuario no autenticado cae en flujo de login. +- Base de datos: + - tablas públicas: `22` + - seed admin presente: `admin@atlasbalnace.local` + - divisas activas: `4` + - configuración inicial presente: `18` +- Watchdog: + - `GET http://localhost:5001/watchdog/health` -> `200` sin secreto + +### Pendientes / residual real +- El certificado HTTPS de desarrollo sigue sin quedar confiado automáticamente porque Windows canceló la importación al store raíz al requerir confirmación gráfica. +- Consecuencia: + - `curl https://localhost:5000/api/health` sin `-k` falla. + - En navegador habrá advertencia hasta aceptar manualmente el trust. +- Acción manual pendiente si se quiere cero fricción en navegador: + - ejecutar `dotnet dev-certs https --trust` y aceptar el prompt de Windows. + +## 2026-04-13 — Fase 5 (Dashboards) completada + +### Implementado +- Backend: + - Nuevo `DashboardController` con endpoints: + - `GET /api/dashboard/principal` + - `GET /api/dashboard/evolucion` + - `GET /api/dashboard/titular/{titularId}` + - `GET /api/dashboard/saldos-divisa` + - Nuevo `DashboardService` con: + - agregación de saldos por divisa/titular/cuenta + - KPIs de ingresos y egresos del mes + - serie temporal de evolución por período (`1m`, `6m`, `9m`, `12m`, `18m`, `24m`) con granularidad diaria/semanal + - control de acceso dashboard para `ADMIN` y `GERENTE` con permisos `puede_ver_dashboard` + - filtrado de alcance por permisos granulares (titular/cuenta) para gerente + - Nuevo `TiposCambioService` (usado por dashboard): + - conversión multi-divisa con tasa directa, inversa y vía EUR + - fallback defensivo cuando no hay tasa disponible + - cache en memoria de tasas + - Nuevos DTOs de dashboard en `DTOs/DashboardDtos.cs`. + - Registro de servicios en `Program.cs` (`AddMemoryCache`, `ITiposCambioService`, `IDashboardService`). + - Corrección de compilación en `UsuariosController` (`catalogos-permisos`): se reemplazó navegación inexistente por `join` explícito con `TITULARES`. + +- Frontend: + - Nueva `DashboardPage` con: + - KPI cards (`Saldo total`, `Ingresos mes`, `Egresos mes`) + - selector de período + - selector de divisa principal + - card de saldos por divisa + - tabla de saldos por titular con enlace al dashboard detallado + - gráfica de evolución (`Recharts`) con 3 líneas (ingresos/egresos/saldo) + - Nueva `DashboardTitularPage` con: + - KPIs filtrados por titular + - desglose de saldos por cuenta + - gráfica de evolución por titular + - Nuevos componentes de dashboard: + - `KpiCard` + - `DivisaSelector` + - `SaldoPorDivisaCard` + - `EvolucionChart` + - Rutas actualizadas en `App.tsx`: + - `/dashboard` + - `/dashboard/titular/:id` + - Tipos TypeScript de dashboard actualizados en `types/index.ts`. + - Estilos dashboard añadidos en `styles/layout.css`. + - Build frontend copiado a `backend/src/GestionCaja.API/wwwroot`. + +### Archivos tocados +- backend/src/GestionCaja.API/Controllers/DashboardController.cs +- backend/src/GestionCaja.API/Controllers/UsuariosController.cs +- backend/src/GestionCaja.API/DTOs/DashboardDtos.cs +- backend/src/GestionCaja.API/Services/DashboardService.cs +- backend/src/GestionCaja.API/Services/TiposCambioService.cs +- backend/src/GestionCaja.API/Program.cs +- frontend/src/App.tsx +- frontend/src/types/index.ts +- frontend/src/pages/DashboardPage.tsx +- frontend/src/pages/DashboardTitularPage.tsx +- frontend/src/components/dashboard/KpiCard.tsx +- frontend/src/components/dashboard/DivisaSelector.tsx +- frontend/src/components/dashboard/SaldoPorDivisaCard.tsx +- frontend/src/components/dashboard/EvolucionChart.tsx +- frontend/src/styles/layout.css + +### Comandos ejecutados +- `dotnet build -c Release` (backend) +- `dotnet test -c Release --no-build` (backend tests) +- `npm.cmd run build` (frontend) +- Copia de `frontend/dist` -> `backend/src/GestionCaja.API/wwwroot` +- Smoke test HTTPS local levantando API: + - `dotnet .\\bin\\Release\\net8.0\\GestionCaja.API.dll --urls https://127.0.0.1:5081` + - `curl -k https://127.0.0.1:5081/api/health` + - login y consumo de endpoints dashboard con cookies (`curl -k -c/-b ...`) + +### Resultado de verificación +- Backend compila en Release sin errores. +- Frontend build generado sin errores. +- Tests backend: `4/4` OK. +- Endpoints validados en ejecución real con sesión autenticada: + - `/api/dashboard/principal` + - `/api/dashboard/evolucion` (`1m` y `6m`) + - `/api/dashboard/saldos-divisa` + - `/api/dashboard/titular/{id}` +- Conversión multi-divisa validada solicitando `divisaPrincipal=USD` (resultado convertido correcto usando tasas base). + +### Pendientes +- Recomendado: tests específicos del `DashboardService` para buckets semanales y escenarios de permisos (ADMIN/GERENTE global/GERENTE restringido). + +## 2026-04-13 — Fase 3 (Extractos / Tabla Excel-like) completada + +### Implementado +- Backend: + - Nuevo `ExtractosController` con CRUD completo de extractos y soporte de soft delete/restauracion. + - `fila_numero` inmutable por cuenta (`MAX+1` usando `IgnoreQueryFilters`, sin reutilizacion). + - Listado paginado con filtros y ordenacion (`page`, `pageSize`, `sortBy`, `sortDir`, cuenta, titular, rango fechas, checked, flagged, search, incluirEliminados). + - Toggle de `check` y `flag` con auditoria dedicada. + - Auditoria por celda (`GET /api/extractos/{id}/audit-celda`) incluyendo `valor_anterior`, `valor_nuevo` y `celda_referencia`. + - Soporte de columnas extra via `EXTRACTOS_COLUMNAS_EXTRA` en alta y edicion. + - Endpoints de vistas de fase 3: + - `GET /api/extractos/cuentas/{id}/resumen` + - `GET /api/extractos/titulares/{id}/cuentas` + - `GET /api/extractos/titulares-resumen` + - Persistencia de visibilidad de columnas por usuario/cuenta: + - `GET /api/extractos/columnas-visibles` + - `PUT /api/extractos/columnas-visibles` + +- Frontend: + - Nueva tabla virtualizada `ExtractoTable` con `@tanstack/react-virtual`. + - Columnas fijas: `N Fila`, `Check`, `Flag`, `Fecha`, `Concepto`, `Monto`, `Saldo`. + - Columnas extra dinamicas desde backend. + - Ordenacion por click en header + filtros inline. + - Edicion inline por celda con `EditableCell`. + - Modal de auditoria por celda (`AuditCellModal`) con click derecho. + - Formulario de alta manual (`AddRowForm`). + - Nuevas paginas: + - `ExtractosPage` (vista unificada) + - `TitularDetailPage` (tabs/listado por cuentas de titular) + - `CuentaDetailPage` (KPIs + tabla) + - Rutas actualizadas en `App.tsx` para las 3 vistas de fase 3. ### Archivos tocados -- backend/src/GestionCaja.API/Program.cs -- backend/src/GestionCaja.API/Controllers/AuthController.cs -- backend/src/GestionCaja.API/DTOs/AuthDtos.cs -- backend/src/GestionCaja.API/Middleware/CsrfMiddleware.cs -- backend/src/GestionCaja.API/Models/Entities.cs -- backend/src/GestionCaja.API/Services/AuthService.cs -- backend/src/GestionCaja.API/Services/CsrfService.cs +- backend/src/GestionCaja.API/Controllers/ExtractosController.cs +- backend/src/GestionCaja.API/DTOs/ExtractosDtos.cs +- frontend/src/components/extractos/EditableCell.tsx +- frontend/src/components/extractos/AuditCellModal.tsx +- frontend/src/components/extractos/AddRowForm.tsx +- frontend/src/components/extractos/ExtractoTable.tsx +- frontend/src/pages/ExtractosPage.tsx +- frontend/src/pages/TitularDetailPage.tsx +- frontend/src/pages/CuentaDetailPage.tsx - frontend/src/App.tsx -- frontend/src/main.tsx -- frontend/src/components/auth/ProtectedRoute.tsx -- frontend/src/components/auth/RoleGuard.tsx -- frontend/src/components/layout/Sidebar.tsx -- frontend/src/pages/TitularesPage.tsx -- frontend/src/components/layout/TopBar.tsx -- frontend/src/pages/LoginPage.tsx -- frontend/src/pages/ChangePasswordPage.tsx -- frontend/src/pages/PlaceholderPage.tsx -- frontend/src/stores/authStore.ts +- frontend/src/types/index.ts - frontend/src/styles/layout.css +- backend/src/GestionCaja.API/wwwroot/* (build frontend copiado) ### Comandos ejecutados -- `dotnet build` (backend) +- `dotnet build backend/GestionCaja.sln /p:UseAppHost=false` - `npm.cmd run build` (frontend) -- copia de `frontend/dist` -> `backend/src/GestionCaja.API/wwwroot` +- Smoke API en entorno local: + - login admin + - `GET /api/extractos` + - `POST /api/extractos` + - `PUT /api/extractos/{id}` + - `PATCH /api/extractos/{id}/check` + - `PATCH /api/extractos/{id}/flag` + - `GET /api/extractos/{id}/audit-celda?columna=concepto` + - `DELETE /api/extractos/{id}` + - `POST /api/extractos/{id}/restaurar` -### Resultado de verificación -- Backend compila OK. -- Frontend compila y genera build OK. -- Advertencia detectada: `MimeKit 4.9.0` con advisory `GHSA-g7hc-96xr-gvvx`. +### Resultado de verificacion +- Backend compila sin errores. +- Frontend build generado sin errores. +- Flujo de mutaciones de fase 3 validado en ejecucion real (create/update/check/flag/auditoria/delete/restore) con respuestas OK. +- Auditoria por celda devuelve historial con referencias de celda y cambios antes/despues. +- `fila_numero` se asigna por `MAX+1` y no se reutiliza tras soft delete. -### Pendientes Fase 1 -- CRUD completo de usuarios (crear/editar/eliminar/restaurar) con soft delete. -- Asignación granular de permisos por cuenta/titular + columnas. -- Gestión de `USUARIO_EMAILS` desde API + UI. -- Auditoría explícita de acciones de auth y de cambios de usuarios/permisos. -- Validación manual end-to-end de login/refresh/logout/me/cambio-password con servidor en ejecución. +### Pendientes +- Pendiente benchmark visual manual para confirmar UX sin lag con 10k+ filas reales en navegador (la virtualizacion ya esta implementada). +- Recomendado: tests automatizados de integracion para permisos de columnas editables y casos borde de auditoria por columna extra. -## 2026-04-13 — Fase 1 (continuación: CRUD usuarios + permisos + emails) +## 2026-04-13 — Ajuste de gobernanza de diseño (Figma obligatorio) ### Implementado -- Backend: - - Nuevo `UsuariosController` (solo `ADMIN`) con: - - `GET /api/usuarios` (paginación + filtros + orden) - - `GET /api/usuarios/{id}` - - `POST /api/usuarios` - - `PUT /api/usuarios/{id}` - - `DELETE /api/usuarios/{id}` (soft delete) - - `POST /api/usuarios/{id}/restaurar` - - Gestión de `USUARIO_EMAILS` incluida en create/update (reemplazo completo controlado). - - Gestión de permisos granulares (`PERMISOS_USUARIO`) incluida en create/update. - - Nuevo `AuditService` + registro de auditoría para altas/ediciones/bajas/restauraciones de usuarios. - - DTOs nuevos para usuarios/paginación/permisos (`UsuariosDtos.cs`). - - Registro de `IAuditService` en `Program.cs`. -- Frontend: - - Nueva `UsuariosPage` funcional para admin: - - listado paginado + búsqueda + incluir eliminados - - crear/editar usuario - - eliminar/restaurar - - edición básica de permisos globales (sin cuenta/titular) - - edición de emails de notificación (multilínea) - - Ruta `/usuarios` conectada a `UsuariosPage` bajo `RoleGuard` admin. - - Estilos añadidos para la pantalla de usuarios. +- Se añadió regla explícita en instrucciones del proyecto para exigir sincronización de UI en Figma por fase. +- Se registró URL oficial de diseño: + - https://www.figma.com/design/cFYBwjPLqAArvgg04DJLmp/Gestion-de-Caja?node-id=0-1&t=48b5SDF4kRLPXa4g-1 ### Archivos tocados -- backend/src/GestionCaja.API/Controllers/UsuariosController.cs -- backend/src/GestionCaja.API/DTOs/UsuariosDtos.cs -- backend/src/GestionCaja.API/Services/AuditService.cs -- backend/src/GestionCaja.API/Program.cs -- frontend/src/pages/UsuariosPage.tsx -- frontend/src/App.tsx -- frontend/src/styles/layout.css +- C:/Proyectos/Atlas Balance/AGENTS.md ### Comandos ejecutados -- `dotnet build` (backend) -- `npm.cmd run build` (frontend) -- copia de `frontend/dist` -> `backend/src/GestionCaja.API/wwwroot` +- Edición directa de `AGENTS.md` (patch) +- Intento de conexión al MCP de Figma para escritura en archivo de diseño ### Resultado de verificación -- Backend compila OK. -- Frontend compila/build OK. -- Advertencia persistente: `MimeKit 4.9.0` con advisory `GHSA-g7hc-96xr-gvvx`. +- Regla incorporada en instrucciones: vigente para siguientes fases y entregas. +- Conexión Figma en esta sesión: bloqueada por autenticación del conector (Auth required en handshake MCP). -### Pendientes Fase 1 -- Endurecer validación de permisos por recurso en endpoints de negocio (ahora se protegió auth/usuarios, falta expandir al resto de controladores futuros). -- Añadir auditoría más detallada por campo cambiado (actualmente es resumen por evento en usuarios). -- Pruebas manuales E2E de auth + CRUD usuarios con sesión real en navegador. -- Tests automatizados backend (xUnit/FluentAssertions) para auth y usuarios. +### Pendientes +- Reconectar/autenticar conector de Figma para poder escribir nodos y sincronizar la Fase 3 en el archivo de diseño. -## 2026-04-13 — Fase 4 (Importación completa backend + wizard frontend) +## 2026-04-13 — Fase 0 (verificación E2E navegador) -### Implementado -- Backend: - - Nuevo `ImportacionController` con endpoints: - - `GET /api/importacion/contexto` - - `POST /api/importacion/validar` - - `POST /api/importacion/confirmar` - - Nuevo `ImportacionService` con: - - detección de separador (`tab`, `comma`, `semicolon`) - - parseo de líneas delimitadas con soporte básico de comillas - - validación por fila con errores específicos - - parseo de fecha: `DD/MM/YYYY`, `YYYY-MM-DD`, `DD-MM-YYYY` y serial Excel - - parseo robusto de decimales (`1.234,56`, `1,234.56`, etc.) - - verificación de permisos de importación en backend por cuenta/titular (`puede_importar`) - - inserción masiva de extractos + columnas extra - - auditoría de importación confirmada - - Nuevo contrato DTO de importación (`ImportacionDtos.cs`) para request/response tipados. - - Registro de DI en `Program.cs`: `IImportacionService`. +### Hallazgos corregidos +- El frontend servido por Kestrel estaba compilado con `VITE_API_URL=https://localhost` en producción. + - Efecto real: las llamadas iban a `https://localhost/api/...` y perdían el puerto `5000`, rompiendo login y bootstrap visual. + - Se dejó `frontend/.env.production` con `VITE_API_URL=` para usar mismo origen. +- El bootstrap de sesión en `App.tsx` generaba 401 espurios en navegador: + - al entrar en `/login` pedía `/auth/me` sin sesión. + - tras login volvía a pedir `/auth/me` aunque el store ya estaba autenticado. + - Se ajustó para no disparar bootstrap en `/login` ni cuando la sesión ya está cargada en store. + +### Verificación E2E ejecutada +- Se levantó `GestionCaja.API` en `https://localhost:5000`. +- Se ejecutó prueba headless con Playwright + Edge sobre `/login`. +- Para permitir llegar al shell sin forzar cambio de contraseña, se puso temporalmente `primer_login = false` al admin seed en BD. +- Tras la prueba, se restauró `primer_login = true`. + +### Resultado +- Login visual OK con credencial local de desarrollo redactada. +- Redirección a `/dashboard` OK. +- Shell OK: + - sidebar visible + - topbar visible + - usuario mostrado: `Administrador` + - navegación visible completa para admin +- Sin errores de consola. +- Sin `pageErrors`. +- Sin requests fallidas. +- Sin respuestas HTTP >= 400 durante el flujo validado. + +### Estado +- Fase 0 verificada también con navegador headless sobre flujo real. +- Residual que sigue siendo manual: confiar certificado de desarrollo en Windows para evitar advertencia HTTPS en navegador. + +## 2026-04-13 — Fase 0 (verificación E2E completa con primer login) + +### Objetivo +- Validar en navegador headless el flujo real desde login hasta shell, incluso con `primer_login = true`. + +### Comandos ejecutados +- `node C:\Users\PcVIP\AppData\Local\Temp\gce2e-run\gce2e-phase0-full.js` +- `docker exec -i gestion_caja_db psql -U app_user -d gestion_caja` + +### Resultado de verificación +- Login del admin correcto. +- Redirección obligatoria a `/cambiar-password` correcta cuando `primer_login = true`. +- Cambio de contraseña en UI correcto. +- Redirección posterior a `/dashboard` correcta. +- Shell cargado sin errores de consola, sin excepciones de página y sin requests fallidas. +- Restauración del password original correcta (`200`) y `primer_login` restaurado a `true` por SQL para conservar el seed. + +### Estado +- Fase 0 sigue cerrada. +- La verificación visual E2E no detectó bugs nuevos de scaffolding/infrastructura; el desvío a cambio de contraseña pertenece a Fase 1 y está funcionando como se diseñó. + +## 2026-04-13 - Fase 1 (hardening y verificacion real) + +### Objetivo +- Revisar Fase 1 contra la especificacion y corregir bugs funcionales/backend-frontend detectados en autenticacion, sesiones y usuarios. + +### Hallazgos corregidos +- `primer_login` solo se imponia en frontend; cualquier usuario autenticado podia seguir llamando a la API directamente. +- Un usuario desactivado/eliminado o con rol cambiado podia seguir operando con un JWT ya emitido hasta expirar. +- `logout` dependia de un `access_token` valido y podia dejar el `refresh_token` activo en BD. +- El frontend no intentaba `refresh-token` cuando fallaban `/auth/me` o `/auth/cambiar-password`, provocando falsas expulsiones de sesion. +- Faltaban endpoints de Fase 1 para permisos y emails de usuario (`GET/PUT permisos`, `GET/POST/DELETE emails`). +- La logica de permisos trataba permisos por titular como si fueran globales sobre todas las cuentas. +- Auditoria de auth/usuarios no estaba alineada con acciones de la spec (`LOGIN`, `LOGOUT`, `LOGIN_FAILED`, `ACCOUNT_LOCKED`, `CREATE_USUARIO`, etc.). + +### Archivos tocados +- backend/src/GestionCaja.API/Controllers/AuthController.cs +- backend/src/GestionCaja.API/Controllers/UsuariosController.cs +- backend/src/GestionCaja.API/DTOs/UsuariosDtos.cs +- backend/src/GestionCaja.API/Middleware/UserStateMiddleware.cs +- backend/src/GestionCaja.API/Middleware/PrimerLoginMiddleware.cs +- backend/src/GestionCaja.API/Program.cs +- backend/src/GestionCaja.API/Services/AuditActions.cs +- backend/src/GestionCaja.API/Services/AuditService.cs +- backend/src/GestionCaja.API/Services/AuthService.cs +- backend/src/GestionCaja.API/Services/UserAccessService.cs +- frontend/src/services/api.ts +- frontend/src/stores/permisosStore.ts +- frontend/src/pages/ExtractosPage.tsx +- frontend/src/pages/UsuariosPage.tsx +- backend/tests/GestionCaja.API.Tests/AuthServiceTests.cs +- backend/tests/GestionCaja.API.Tests/UserAccessServiceTests.cs +- backend/tests/GestionCaja.API.Tests/UsuariosControllerTests.cs + +### Comandos ejecutados +- `dotnet build GestionCaja.sln` +- `dotnet test GestionCaja.sln` +- `npm.cmd run build` +- Smoke tests HTTP via PowerShell contra `https://localhost:5000`: +- login/logout/refresh +- enforcement de `primer_login` +- CRUD usuarios +- endpoints de permisos/emails +- delete/restore + +### Resultado de verificacion +- Backend compila OK (0 errores, 0 warnings). +- Frontend build OK. +- Tests backend OK: 6/6. +- `primer_login` bloquea la API hasta cambiar password y deja pasar despues del cambio. +- `logout` revoca el `refresh_token` aunque el `access_token` ya no exista. +- CRUD de usuarios, soft delete/restauracion y endpoints de permisos/emails responden correctamente. +- La resolucion de permisos ya no eleva permisos por error en scopes por titular. -- Frontend: - - Nueva `ImportacionPage` implementada como wizard de 4 pasos: - - Paso 1: selección de cuenta + textarea + preview primeras 3 filas - - Paso 2: mapeo de columnas base + columnas extra dinámicas + precarga de formato guardado - - Paso 3: preview validado con `✓/✗`, errores en rojo y selección de filas válidas - - Paso 4: resumen + confirmación + feedback final - - Ruta real `/importacion` conectada en `App.tsx` (reemplaza placeholder). - - Tipos TypeScript ampliados para contexto/validación/confirmación de importación. - - Estilos de wizard añadidos en `layout.css`. - - Fix adicional de tipos de dashboard faltantes para recuperar build frontend global. +### Pendientes +- La UI de usuarios sigue siendo formulario embebido; funcionalmente cubre Fase 1, pero si se quiere clavado a la spec quedaria mover permisos/emails a modal dedicado. + +## 2026-04-13 - Fase 1 (UsuariosPage modal) + +### Objetivo +- Alinear la UI de usuarios con la spec de Fase 1 usando modal dedicado para crear/editar, permisos y emails. + +### Cambios aplicados +- `UsuariosPage` pasa de layout partido con formulario fijo a tabla + modal dedicado. +- Se agrega `UsuarioModal` con secciones para identidad, emails de notificacion y permisos granulares. +- Se sustituye el `confirm()` nativo por confirmacion visual propia para eliminar usuarios. +- Se ajustan estilos responsive para modal, resumen y bloques de permisos. ### Archivos tocados -- backend/src/GestionCaja.API/DTOs/ImportacionDtos.cs -- backend/src/GestionCaja.API/Services/ImportacionService.cs -- backend/src/GestionCaja.API/Controllers/ImportacionController.cs -- backend/src/GestionCaja.API/Program.cs -- frontend/src/pages/ImportacionPage.tsx -- frontend/src/App.tsx -- frontend/src/types/index.ts +- frontend/src/components/usuarios/UsuarioModal.tsx +- frontend/src/pages/UsuariosPage.tsx - frontend/src/styles/layout.css +- DOCUMENTACION_CAMBIOS.md ### Comandos ejecutados -- `dotnet build` (backend) -- `npm.cmd run build` (frontend) -- `docker compose up -d` -- copia `frontend/dist/*` -> `backend/src/GestionCaja.API/wwwroot/` -- prueba E2E con script Python contra API real: - - login admin - - `GET /api/importacion/contexto` - - `POST /api/importacion/validar` - - `POST /api/importacion/confirmar` -- prueba adicional de validación de fechas (incluyendo serial Excel) y separador `;` +- `npm.cmd run build` -### Resultado de verificación -- Backend compila OK. -- Frontend compila/build OK. -- Flujo E2E validado en API real: - - `validar` devolvió conteo correcto de filas OK/error y errores por fila. - - `confirmar` importó solo filas válidas (importación parcial). - - parseo de fechas confirmado para formatos requeridos + serial Excel. - - detección de separador confirmada (`tab` y `semicolon`). -- Advertencia persistente no bloqueante: - - `MimeKit 4.9.0` con advisory `GHSA-g7hc-96xr-gvvx`. +### Resultado de verificacion +- Frontend build OK. +- Flujo de usuarios preparado para modal dedicado y confirmacion visual sin dialogs nativos. ### Pendientes -- Prueba visual/manual completa del wizard en navegador (interacción UI final). -- Cobertura de tests automatizados para parser/validator de importación. -- Fases 2/3 completas siguen pendientes en esta rama (la Fase 4 quedó operativa sobre una cuenta de prueba insertada en BD para verificar E2E). +- Verificacion visual manual en navegador para afinar densidad/espaciado si se quiere pulido final de UX. -## 2026-04-13 — Fase 1 (validación E2E + tests automatizados) +## 2026-04-13 - Fase 1 (Verificacion visual UsuariosPage) -### Pruebas manuales E2E ejecutadas -- Se validó el flujo completo de autenticación y usuarios en local: - - `POST /api/auth/login` - - `GET /api/auth/me` - - `POST /api/usuarios` - - `GET /api/usuarios` con búsqueda - - `GET /api/usuarios/{id}` - - `PUT /api/usuarios/{id}` - - `PUT /api/auth/cambiar-password` (usuario nuevo) - - `POST /api/auth/refresh-token` - - `POST /api/auth/logout` - - `DELETE /api/usuarios/{id}` - - `POST /api/usuarios/{id}/restaurar` -- Resultado: flujo funcional extremo a extremo. +### Objetivo +- Verificar visualmente la UI real de usuarios y comprobar que el modal de alta/edicion y la confirmacion de borrado funcionan sin regresiones. -### Hallazgo técnico corregido durante E2E -- En ejecución local por HTTP, cookies con `Secure=true` no mantienen sesión (401 tras login). -- Se ajustó `AuthController` para usar cookie segura solo cuando corresponde: - - siempre en no-Development - - en Development solo si request es HTTPS -- Se corrigió warning de EF de relación `RefreshToken.UsuarioId1` configurando explícitamente navegación en `AppDbContext`. +### Cambios aplicados +- Se recompila frontend y se copia frontend/dist/ a backend/src/GestionCaja.API/wwwroot/ para validar el escenario real servido por Kestrel. +- Se ejecuta verificacion automatizada con Chrome headless sobre https://localhost:5000 y tambien sobre http://localhost:5173. +- Se valida login, acceso a /usuarios, apertura de modal nuevo, apertura de modal edicion y flujo UI de crear -> eliminar -> restaurar. +- Se limpian a papelera los usuarios temporales ui.modal.* creados durante QA para no dejar ruido en la vista por defecto. -### Tests automatizados añadidos -- Nuevo proyecto: `backend/tests/GestionCaja.API.Tests` -- Añadidos tests: - - `AuthServiceTests` - - bloqueo tras 5 intentos fallidos - - login válido resetea contador y genera tokens - - cambio de password actualiza hash y desactiva `primer_login` - - `UsuariosControllerTests` - - creación de usuario con emails + permisos + auditoría -- Solución actualizada para incluir proyecto de tests. +### Archivos tocados +- DOCUMENTACION_CAMBIOS.md ### Comandos ejecutados -- `dotnet build` (backend) -- ejecución local API + pruebas E2E con sesión/cookies -- `dotnet sln add backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj` -- `dotnet test backend/GestionCaja.sln` - -### Resultado de verificación -- `dotnet test` -> **4/4 tests OK**. -- E2E manual de auth + usuarios -> OK. -- Warning persistente no bloqueante: `MimeKit 4.9.0` (`GHSA-g7hc-96xr-gvvx`). +- npm.cmd run build +- Copia de frontend/dist/* a backend/src/GestionCaja.API/wwwroot/ +- Script Node + Chrome headless para smoke visual en https://localhost:5000 +- Script Node + Chrome headless para smoke visual en http://localhost:5173 +- Script Node para soft delete de usuarios ui.modal.* creados en QA -### Pendientes inmediatos -- Subir `MimeKit` a versión sin advisory. -- Extender tests a rate limiting real de login y a flujos de permisos por cuenta/titular específicos. +### Resultado de verificacion +- La pantalla Usuarios renderiza correctamente en backend servido por Kestrel. +- Nuevo Usuario abre modal con bloques de identidad, emails y permisos. +- Editar abre modal con datos cargados. +- La confirmacion visual de borrado reemplaza correctamente al confirm() nativo. +- Flujo UI crear -> eliminar -> restaurar validado sin errores de consola. +- Verificacion via Vite (localhost:5173) tambien OK; el fallo inicial de prueba era del script de automatizacion, no de la app. -## 2026-04-13 — Fase 2 (Titulares y Cuentas) — completada +### Pendientes +- Ninguno para Fase 1 en esta pantalla; solo quedaria polish visual si mas adelante se quiere refinar densidad o jerarquia. +## 2026-04-13 - Fase 2 (Auditoria y correcciones) -### Implementado -- Backend: - - Nuevo `UserAccessService` para resolver alcance de datos por usuario (admin/global/por titular/por cuenta). - - Nuevo `TitularesController` con: - - `GET /api/titulares` (paginación, ordenación, búsqueda, soft delete opcional para admin) - - `GET /api/titulares/{id}` - - `POST /api/titulares` (ADMIN) - - `PUT /api/titulares/{id}` (ADMIN) - - `DELETE /api/titulares/{id}` soft delete (ADMIN) - - `POST /api/titulares/{id}/restaurar` (ADMIN) - - Nuevo `CuentasController` con: - - `GET /api/cuentas` (paginación, ordenación, búsqueda, filtro por titular) - - `GET /api/cuentas/{id}` - - `GET /api/cuentas/{id}/resumen` (saldo actual + ingresos/egresos del mes) - - `GET /api/cuentas/divisas-activas` - - `POST /api/cuentas` (ADMIN) - - `PUT /api/cuentas/{id}` (ADMIN) - - `DELETE /api/cuentas/{id}` soft delete (ADMIN) - - `POST /api/cuentas/{id}/restaurar` (ADMIN) - - Nuevo `FormatosImportacionController` con CRUD completo: - - `GET /api/formatos-importacion` y `GET /api/formatos-importacion/{id}` - - `POST/PUT/DELETE/POST restaurar` para ADMIN - - `mapeo_json` persistido en JSONB - - Nuevos DTOs de Fase 2 para titulares/cuentas/formatos. - - Auditoría añadida en create/update/delete/restore de titulares, cuentas y formatos. +### Objetivo +- Verificar Fase 2 contra la spec real y corregir bugs funcionales y de UX detectados en titulares, cuentas y formatos de importacion. -- Frontend: - - `TitularesPage` implementada con cards, búsqueda, paginación y form CRUD (solo admin para mutaciones). - - `CuentasPage` implementada con lista filtrable por titular, selector de divisa, checkbox `es_efectivo`, asociación de formato y form CRUD (admin). - - `ImportacionPage` implementada como gestor de formatos de importación con constructor de columnas base + extras. - - Rutas actualizadas en `App.tsx` para usar páginas reales (`/titulares`, `/cuentas`, `/importacion`). - - Corrección del interceptor CSRF en `services/api.ts` para validar por método HTTP en minúsculas y contemplar `HEAD/OPTIONS`. - - Estilos añadidos en `layout.css` para vistas y formularios de Fase 2. +### Cambios aplicados +- Se endurecen las validaciones de cuentas para ignorar `formato_id` cuando `es_efectivo = true` y evitar que una caja arrastre un formato bancario. +- Se corrigen mensajes con mojibake/encoding roto en backend (`FormatosImportacionController`, `PrimerLoginMiddleware`) y frontend (`TitularesPage`, `CuentasPage`, `ImportacionPage`, `index.html`). +- `CuentasPage` ahora filtra formatos por divisa, limpia el formato al pasar a efectivo y oculta el selector de formato para cuentas de efectivo. +- Se mantiene protegido `/api/formatos-importacion` para admin y se confirma por smoke test que los usuarios no admin solo ven titulares/cuentas autorizados. +- Se recompila frontend y se sincroniza `frontend/dist` con `backend/src/GestionCaja.API/wwwroot` para que Kestrel sirva la version corregida. ### Archivos tocados -- backend/src/GestionCaja.API/Program.cs -- backend/src/GestionCaja.API/Services/UserAccessService.cs -- backend/src/GestionCaja.API/Controllers/TitularesController.cs - backend/src/GestionCaja.API/Controllers/CuentasController.cs - backend/src/GestionCaja.API/Controllers/FormatosImportacionController.cs -- backend/src/GestionCaja.API/DTOs/TitularesDtos.cs -- backend/src/GestionCaja.API/DTOs/CuentasDtos.cs +- backend/src/GestionCaja.API/Middleware/PrimerLoginMiddleware.cs +- frontend/src/pages/CuentasPage.tsx - backend/src/GestionCaja.API/DTOs/FormatosImportacionDtos.cs - frontend/src/App.tsx -- frontend/src/services/api.ts +- frontend/src/components/layout/Sidebar.tsx +- frontend/index.html +- frontend/src/pages/ImportacionPage.tsx +- DOCUMENTACION_CAMBIOS.md + +### Comandos ejecutados +- `dotnet build` +- `npm.cmd run build` +- Copia de `frontend/dist/*` a `backend/src/GestionCaja.API/wwwroot/` +- Smoke tests HTTP manuales con `curl.exe` contra `https://localhost:5000`: +- login admin +- validacion negativa de formatos (`mapeo_json` con indices duplicados) +- CRUD parcial de titulares/cuentas/formatos +- verificacion de `GET /api/cuentas/{id}/resumen` +- verificacion de cuenta efectivo limpiando datos bancarios y `formato_id` +- login usuario no admin + filtro de permisos + 403 en formatos/importacion +- soft delete/restauracion de formato y cuenta +- limpieza de datos de prueba por API (soft delete) + +### Resultado de verificacion +- Backend compila OK (0 errores, 0 warnings). +- Frontend build OK. +- `https://localhost:5000/api/health` responde 200. +- Los errores de validacion ya no salen con texto roto por encoding. +- Las cuentas de efectivo ya no conservan datos bancarios ni `formato_id`. +- Los formatos visibles en UI quedan acotados por divisa y se ocultan en cuentas de efectivo. +- Usuario no admin ve exactamente 1 titular y 1 cuenta autorizados en el smoke test y recibe 403 en `/api/formatos-importacion` y al pedir una cuenta fuera de scope. +- `wwwroot` queda actualizado con la build nueva del frontend. + +### Pendientes +- Sigue habiendo `window.confirm()` en pantallas de Fase 2; funcionalmente no rompe nada, pero si quieres cumplir la spec al pie de la letra tocaria sustituirlos por confirmaciones visuales propias. + +## 2026-04-13 - Fase 2 (Confirmaciones visuales) + +### Objetivo +- Rematar Fase 2 quitando los dialogs nativos de borrado y dejar titulares, cuentas y formatos alineados con la regla de usar feedback visual propio. + +### Cambios aplicados +- Se crea `ConfirmDialog`, un modal reutilizable con cierre por backdrop/Escape y estado de carga. +- `TitularesPage` deja de usar `window.confirm()` y pasa a confirmacion visual antes de enviar a papelera. +- `CuentasPage` deja de usar `window.confirm()` y pasa a confirmacion visual antes de soft delete. +- `ImportacionPage` deja de usar `window.confirm()` y pasa a confirmacion visual antes de soft delete. +- Se recompila frontend y se vuelve a sincronizar `frontend/dist` con `backend/src/GestionCaja.API/wwwroot`. + +### Archivos tocados +- frontend/src/components/common/ConfirmDialog.tsx +- frontend/src/pages/TitularesPage.tsx - frontend/src/pages/CuentasPage.tsx - frontend/src/pages/ImportacionPage.tsx -- frontend/src/styles/layout.css +- DOCUMENTACION_CAMBIOS.md + +### Comandos ejecutados +- `Get-ChildItem frontend/src -Recurse -Include *.ts,*.tsx | Select-String -Pattern 'window\.confirm'` +- `npm.cmd run build` +- Copia de `frontend/dist/*` a `backend/src/GestionCaja.API/wwwroot/` + +### Resultado de verificacion +- No quedan usos de `window.confirm` en `frontend/src`. +- Frontend build OK. +- `wwwroot` queda actualizado con la build nueva del frontend. + +### Pendientes +- Ninguno para este ajuste; Fase 2 ya no depende de dialogs nativos para las acciones de borrado. + +## 2026-04-13 — Fase 3 QA hardening (bugs corregidos) + +### Implementado +- Corrección de permisos en frontend (`permisosStore`): + - Se reemplazó resolución por "primer match" por combinación de permisos coincidente (cuenta/titular/global), alineado con lógica backend. + - `canEditCuenta`, `canDeleteInCuenta`, `canImportInCuenta` ahora evalúan por agregación (`Any`) de filas aplicables. + - `getColumnasEditables` y `getColumnasVisibles` ahora combinan reglas correctamente (null = sin restricción). +- Corrección en `ExtractosPage`: + - Arreglo de toggle de columnas visibles cuando no había preferencia previa (antes colapsaba a una sola columna). + - Limpieza de textos corruptos en UI. +- Corrección en `ExtractoTable`: + - Check/flag ahora respetan `canEditCell` (inputs deshabilitados si no hay permiso). + - Nota de flag solo envía persistencia al perder foco cuando la fila está marcada y editable. + - Limpieza de caracteres corruptos en encabezado de sort. +- Corrección de seguridad/autorización en backend (`ExtractosController`): + - `PATCH /api/extractos/{id}/check` y `PATCH /api/extractos/{id}/flag` ahora requieren permisos de edición (no solo visibilidad). + - Validación adicional de columnas editables para `checked`, `flagged` y `flagged_nota`. + +### Archivos tocados +- frontend/src/stores/permisosStore.ts +- frontend/src/pages/ExtractosPage.tsx +- frontend/src/components/extractos/ExtractoTable.tsx +- frontend/src/components/extractos/EditableCell.tsx +- frontend/src/components/extractos/AuditCellModal.tsx +- frontend/src/components/extractos/AddRowForm.tsx +- backend/src/GestionCaja.API/Controllers/ExtractosController.cs +- backend/src/GestionCaja.API/wwwroot/* (build actualizado) ### Comandos ejecutados -- `docker compose up -d` -- `dotnet build` (backend) +- `dotnet build backend/GestionCaja.sln /p:UseAppHost=false` +- `dotnet test backend/GestionCaja.sln --no-build` - `npm.cmd run build` (frontend) -- Smoke test HTTP E2E vía PowerShell (`Invoke-RestMethod`): - - login admin - - create titular - - create formato - - create cuenta - - get resumen - - create usuario con permisos acotados - - login usuario no-admin y verificación de filtrado (`titulares=1`, `cuentas=1`) -- copia de `frontend/dist` -> `backend/src/GestionCaja.API/wwwroot` +- Copia de `frontend/dist` -> `backend/src/GestionCaja.API/wwwroot` +- Smoke test API fase 3 (create/update/check/flag/audit/delete/restore) con sesión autenticada ### Resultado de verificación -- Backend compila OK (sin errores). +- Backend compila OK (0 errores). - Frontend compila/build OK. -- Endpoints Fase 2 responden correctamente en pruebas E2E. -- Resumen de cuenta responde con estructura esperada y valores iniciales (`saldo_actual=0`, `ingresos_mes=0`, `egresos_mes=0`) para cuenta recién creada. -- Filtro de permisos confirmado para usuario no admin (solo ve titular/cuenta autorizados). - -### Incidencias detectadas y resueltas -- `dotnet build` inicialmente falló por binario bloqueado (`GestionCaja.API.exe` en uso). Se liberó el proceso y compiló correctamente. -- En pruebas PowerShell hubo error de certificado TLS local; se resolvió habilitando callback de validación para la sesión de smoke test. +- Tests backend OK (`6/6`). +- Smoke API fase 3 OK: + - `POST /api/extractos` -> OK + - `PUT /api/extractos/{id}` -> OK + - `PATCH /api/extractos/{id}/check` -> `200` (`Check actualizado`) + - `PATCH /api/extractos/{id}/flag` -> `200` (`Flag actualizado`) + - `GET /api/extractos/{id}/audit-celda?columna=concepto` -> historial presente + - `DELETE /api/extractos/{id}` + `POST /restaurar` -> OK ### Pendientes -- Endurecer validaciones de negocio por rol/permiso fino en mutaciones futuras de fases siguientes (extractos/importación masiva). -- Añadir tests automatizados xUnit para controllers/servicios de Fase 2. -- Revisar actualización de `MimeKit` por advisory `GHSA-g7hc-96xr-gvvx`. +- Para afirmar "0 bugs" con evidencia fuerte, falta suite dedicada de integración para matriz de permisos por columna (incluyendo combinaciones cuenta/titular/global y usuario no admin). +- Falta benchmark automatizado de scroll/edición con dataset 10k+ filas en navegador real (virtualización ya implementada y validada funcionalmente). -## 2026-04-13 — Corrección post-Fase 2 (dependencias vulnerables) +## 2026-04-13 - Fase 1 (Responsive y UX Usuarios modal) ### Objetivo -- Corregir deuda de seguridad reportada tras Fase 2. +- Afinar el modal de usuarios para tablet y movil, mejorando legibilidad, targets tactiles y uso del espacio sin cambiar el flujo funcional. ### Cambios aplicados -- `GestionCaja.API.csproj`: - - Se forzó `Newtonsoft.Json` a `13.0.3` para neutralizar dependencia vulnerable transitiva. - - Se actualizaron paquetes Hangfire: - - `Hangfire.AspNetCore` `1.8.17` -> `1.8.23` - - `Hangfire.PostgreSql` `1.20.10` -> `1.21.1` +- Se agrega scroll horizontal controlado a la tabla de usuarios para evitar overflow en pantallas estrechas. +- El modal gana header estable, footer de acciones sticky y version full-height en movil para no perder las acciones principales. +- Se mejoran filtros, grids y checkboxes para tablet/movil con targets mas grandes y stacking mas limpio. +- Cada bloque de permiso ahora muestra un resumen de scope y placeholders mas claros en columnas visibles/editables. ### Archivos tocados -- backend/src/GestionCaja.API/GestionCaja.API.csproj +- frontend/src/pages/UsuariosPage.tsx +- frontend/src/components/usuarios/UsuarioModal.tsx +- frontend/src/styles/layout.css +- DOCUMENTACION_CAMBIOS.md ### Comandos ejecutados -- `dotnet clean` -- `dotnet restore` -- `dotnet build` -- `dotnet list package --vulnerable --include-transitive` -- `dotnet list package --outdated` - -### Resultado de verificación -- Compilación backend: OK (0 errores, 0 warnings). -- Vulnerabilidades NuGet: `sin paquetes vulnerables` en `GestionCaja.API`. +- npm.cmd run build +- Copia de frontend/dist/* a backend/src/GestionCaja.API/wwwroot/ +- Verificacion automatizada con Chrome headless en https://localhost:5000 para desktop, tablet y movil -### Incidencias -- Durante build hubo lock temporal de proceso sobre binarios `GestionCaja.API`; recompilación posterior completó correctamente. +### Resultado de verificacion +- Frontend build OK. +- Desktop: modal cargado sin overflow y footer de acciones presente. +- Tablet: modal refluye a dos columnas utiles, sin desbordes horizontales. +- Movil: modal full-height, botones principales siempre accesibles y pagina sin overflow lateral. -## 2026-04-13 — Fase 1 (cierre y verificación final) +### Pendientes +- Ninguno en esta pasada; solo quedaria micro-polish visual futuro si quieres una jerarquia aun mas editorial. +## 2026-04-13 - Fase 4 (ajuste final y verificacion completa) -### Objetivo -- Confirmar si Fase 1 queda realmente cerrada tras los últimos cambios en auth/usuarios/permisos. +### Implementado +- Frontend: + - Se separo la UI para evitar conflicto entre fases: + - /importacion ahora es el wizard de importacion de Fase 4 (4 pasos completos). + - /formatos-importacion mantiene el CRUD de formatos (Fase 2) solo para ADMIN. + - Nuevo ImportacionPage (wizard): + - Paso 1: cuenta + textarea + preview primeras 3 filas. + - Paso 2: mapeo manual/precarga formato + columnas extra. + - Paso 3: preview validado con check/cross, errores por fila y seleccion de filas validas. + - Paso 4: resumen + confirmar + feedback. + - Se anadio acceso a "Formatos" en sidebar solo para admin y se dejo "Importacion" accesible para usuarios autenticados (el backend filtra permisos reales). + - Se preservo la pagina previa de formatos como FormatosImportacionPage. ### Archivos tocados -- backend/src/GestionCaja.API/Controllers/AuthController.cs -- backend/src/GestionCaja.API/Data/AppDbContext.cs -- backend/src/GestionCaja.API/Controllers/UsuariosController.cs -- backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj -- backend/tests/GestionCaja.API.Tests/AuthServiceTests.cs -- backend/tests/GestionCaja.API.Tests/UsuariosControllerTests.cs -- backend/GestionCaja.sln -- frontend/src/pages/UsuariosPage.tsx -- DOCUMENTACION_CAMBIOS.md +- frontend/src/pages/ImportacionPage.tsx +- frontend/src/pages/FormatosImportacionPage.tsx +- frontend/src/App.tsx +- frontend/src/components/layout/Sidebar.tsx +- frontend/dist/* (build) +- backend/src/GestionCaja.API/wwwroot/* (copia de build) ### Comandos ejecutados -- `dotnet build` (backend/src/GestionCaja.API) -- `dotnet test GestionCaja.sln` (backend) -- `npm.cmd run build` (frontend) - -### Resultado de verificación -- Backend compila OK (0 errores, 0 warnings). -- Tests backend OK: 4/4. -- Frontend build OK (Vite/TypeScript sin errores). -- Flujo Fase 1 cubierto: login/refresh/logout/me/cambio de password + primer login + CRUD usuarios + permisos granulares en UI + auditoría de cambios principales. +- dotnet build (backend) +- npm.cmd run build (frontend) +- docker compose up -d +- ejecucion de API local (dotnet run) con logs +- prueba E2E real en API: + - POST /api/auth/login + - GET /api/importacion/contexto + - POST /api/importacion/validar (caso tab + errores por fila) + - POST /api/importacion/confirmar (importacion parcial) + - POST /api/importacion/validar (caso semicolon + fecha serial Excel) +- copia de frontend/dist/* -> backend/src/GestionCaja.API/wwwroot/ -### Incidencias -- El primer intento de `dotnet test` falló por proceso `dotnet` dejando DLLs bloqueadas; se detuvo proceso y se repitió con éxito. +### Resultado de verificacion +- Backend compila OK. +- Frontend compila/build OK. +- Verificacion E2E de Fase 4 OK: + - Pegar desde Excel/tab-separated: OK. + - Parseo de fechas DD/MM/YYYY, YYYY-MM-DD, DD-MM-YYYY, serial Excel: OK. + - Errores por fila con mensaje especifico: OK. + - Importacion parcial (solo filas validas): OK. ### Pendientes -- Aumentar cobertura de tests (hoy hay base crítica, pero no cobertura completa de todos los endpoints de usuarios/permisos). - -## 2026-04-13 — Fase 0 (auditoría real y correcciones de cierre) +- Prueba visual/manual final en navegador del wizard en entorno del usuario. +- Tests automatizados especificos de ImportacionService (parser/detector/validator) aun pendientes. -### Hallazgos corregidos -- `dotnet run` no garantizaba Development ni HTTPS en `https://localhost:5000`. - - Se añadió `Properties/launchSettings.json` para forzar `ASPNETCORE_ENVIRONMENT=Development`. - - Se añadió endpoint Kestrel HTTPS en `appsettings.Development.json`. -- El watchdog tenía un bug de middleware: - - `/watchdog/health` exigía `X-Watchdog-Secret` aunque el comentario decía lo contrario. - - Se dejó bypass explícito para health. -- `dotnet build` del backend no estaba realmente limpio: - - `UsuariosController` usaba `Cuenta.Titular` sin navegación declarada. - - Se añadió la navegación `Cuenta.Titular` y se ajustó Fluent API. -- EF Core emitía warning de filtro global por relación requerida `RefreshToken -> Usuario`. - - Se añadió query filter en `RefreshToken` para excluir tokens de usuarios soft-deleted. -- El backend compilaba con advisory conocida en `MailKit/MimeKit 4.9.0`. - - Se actualizaron ambos paquetes a `4.15.1`. -- El frontend tenía vulnerabilidades moderadas en `vite/esbuild`. - - Se actualizó `vite` a `8.0.8` y `@vitejs/plugin-react` a `6.0.1`. - - Se adaptó `manualChunks` a función porque Vite 8 ya no acepta el formato objeto anterior. -- El script `scripts/setup-https.ps1` dejaba una instrucción desfasada. - - Se aclaró desarrollo local vs despliegue real. +## 2026-04-13 - Fase 4 (verificacion visual E2E en navegador) ### Archivos tocados -- `backend/src/GestionCaja.API/appsettings.Development.json` -- `backend/src/GestionCaja.API/Properties/launchSettings.json` -- `backend/src/GestionCaja.API/Data/AppDbContext.cs` -- `backend/src/GestionCaja.API/Models/Entities.cs` -- `backend/src/GestionCaja.API/GestionCaja.API.csproj` -- `backend/src/GestionCaja.Watchdog/Program.cs` -- `frontend/package.json` -- `frontend/package-lock.json` -- `frontend/vite.config.ts` -- `frontend/dist/*` -- `backend/src/GestionCaja.API/wwwroot/*` -- `scripts/setup-https.ps1` +- backend/src/GestionCaja.API/wwwroot/* (sync de build frontend final) ### Comandos ejecutados -- `docker compose up -d` -- `dotnet build backend/GestionCaja.sln` -- `dotnet test backend/GestionCaja.sln --no-build` -- `dotnet list backend/src/GestionCaja.API/GestionCaja.API.csproj package --vulnerable` -- `npm.cmd install` -- `npm.cmd run build` -- `npm.cmd audit --json` -- `curl.exe -k https://localhost:5000/api/health` -- `curl.exe -k -I https://localhost:5000/` -- `msedge.exe --headless --ignore-certificate-errors --dump-dom https://localhost:5000/login` -- `curl.exe http://127.0.0.1:5173/api/health` -- `curl.exe http://localhost:5001/watchdog/health` -- consultas `psql` en contenedor Docker para validar tablas y seed - -### Resultado de verificación -- `docker compose up -d` OK. -- Backend: - - `dotnet build` OK. - - `dotnet test` OK (`4/4`). - - `dotnet list package --vulnerable` OK (`0` vulnerables). - - `dotnet run` arranca en `Development` escuchando en `https://localhost:5000`. - - `GET https://localhost:5000/api/health` -> `200` (validado con `curl -k`). - - `GET https://localhost:5000/` -> `200` (estáticos desde `wwwroot`). -- Frontend: - - `npm install` OK. - - `npm run build` OK. - - `npm audit` OK (`0` vulnerabilidades). - - Vite dev proxy OK: `GET http://127.0.0.1:5173/api/health` -> `200`. -- Browser headless: - - `/login` renderiza correctamente el formulario React. - - Nota: el root ya no muestra el shell directamente porque Fase 1 añadió auth; el usuario no autenticado cae en flujo de login. -- Base de datos: - - tablas públicas: `22` - - seed admin presente: `admin@atlasbalnace.local` - - divisas activas: `4` - - configuración inicial presente: `18` -- Watchdog: - - `GET http://localhost:5001/watchdog/health` -> `200` sin secreto +- curl.exe -k https://localhost:5000/api/health +- npx.cmd -y playwright install chromium +- node scripts de verificacion visual (Playwright via NODE_PATH cache npx) +- dotnet build (backend) +- npm run build (frontend) +- copia de dist -> backend/src/GestionCaja.API/wwwroot/ -### Pendientes / residual real -- El certificado HTTPS de desarrollo sigue sin quedar confiado automáticamente porque Windows canceló la importación al store raíz al requerir confirmación gráfica. -- Consecuencia: - - `curl https://localhost:5000/api/health` sin `-k` falla. - - En navegador habrá advertencia hasta aceptar manualmente el trust. -- Acción manual pendiente si se quiere cero fricción en navegador: - - ejecutar `dotnet dev-certs https --trust` y aceptar el prompt de Windows. +### Resultado de verificacion +- OK: flujo visual E2E login + wizard importacion completo (4 pasos) con capturas. +- OK: validacion muestra filas invalidas por fila. +- OK: confirmacion importa solo filas validas (resultado visual: 3 procesadas, 2 importadas, 1 con error). +- OK: backend y frontend compilan sin errores. -## 2026-04-13 — Fase 5 (Dashboards) completada +### Evidencia +- artifacts/phase4-visual/01-login-filled.png +- artifacts/phase4-visual/02-step1-paste-preview.png +- artifacts/phase4-visual/03-step2-mapping.png +- artifacts/phase4-visual/04-step3-validation.png +- artifacts/phase4-visual/05-step4-summary-before-confirm.png +- artifacts/phase4-visual/06-step4-summary-after-confirm.png -### Implementado -- Backend: - - Nuevo `DashboardController` con endpoints: - - `GET /api/dashboard/principal` - - `GET /api/dashboard/evolucion` - - `GET /api/dashboard/titular/{titularId}` - - `GET /api/dashboard/saldos-divisa` - - Nuevo `DashboardService` con: - - agregación de saldos por divisa/titular/cuenta - - KPIs de ingresos y egresos del mes - - serie temporal de evolución por período (`1m`, `6m`, `9m`, `12m`, `18m`, `24m`) con granularidad diaria/semanal - - control de acceso dashboard para `ADMIN` y `GERENTE` con permisos `puede_ver_dashboard` - - filtrado de alcance por permisos granulares (titular/cuenta) para gerente - - Nuevo `TiposCambioService` (usado por dashboard): - - conversión multi-divisa con tasa directa, inversa y vía EUR - - fallback defensivo cuando no hay tasa disponible - - cache en memoria de tasas - - Nuevos DTOs de dashboard en `DTOs/DashboardDtos.cs`. - - Registro de servicios en `Program.cs` (`AddMemoryCache`, `ITiposCambioService`, `IDashboardService`). - - Corrección de compilación en `UsuariosController` (`catalogos-permisos`): se reemplazó navegación inexistente por `join` explícito con `TITULARES`. +### Pendientes +- Ninguno de Fase 4. -- Frontend: - - Nueva `DashboardPage` con: - - KPI cards (`Saldo total`, `Ingresos mes`, `Egresos mes`) - - selector de período - - selector de divisa principal - - card de saldos por divisa - - tabla de saldos por titular con enlace al dashboard detallado - - gráfica de evolución (`Recharts`) con 3 líneas (ingresos/egresos/saldo) - - Nueva `DashboardTitularPage` con: - - KPIs filtrados por titular - - desglose de saldos por cuenta - - gráfica de evolución por titular - - Nuevos componentes de dashboard: - - `KpiCard` - - `DivisaSelector` - - `SaldoPorDivisaCard` - - `EvolucionChart` - - Rutas actualizadas en `App.tsx`: - - `/dashboard` - - `/dashboard/titular/:id` - - Tipos TypeScript de dashboard actualizados en `types/index.ts`. - - Estilos dashboard añadidos en `styles/layout.css`. - - Build frontend copiado a `backend/src/GestionCaja.API/wwwroot`. +## 2026-04-13 - Fase 1 (auditoria y correcciones finales) ### Archivos tocados -- backend/src/GestionCaja.API/Controllers/DashboardController.cs -- backend/src/GestionCaja.API/Controllers/UsuariosController.cs -- backend/src/GestionCaja.API/DTOs/DashboardDtos.cs -- backend/src/GestionCaja.API/Services/DashboardService.cs -- backend/src/GestionCaja.API/Services/TiposCambioService.cs -- backend/src/GestionCaja.API/Program.cs -- frontend/src/App.tsx -- frontend/src/types/index.ts -- frontend/src/pages/DashboardPage.tsx -- frontend/src/pages/DashboardTitularPage.tsx -- frontend/src/components/dashboard/KpiCard.tsx -- frontend/src/components/dashboard/DivisaSelector.tsx -- frontend/src/components/dashboard/SaldoPorDivisaCard.tsx -- frontend/src/components/dashboard/EvolucionChart.tsx -- frontend/src/styles/layout.css +- backend/src/GestionCaja.API/Services/AuthService.cs +- backend/tests/GestionCaja.API.Tests/AuthServiceTests.cs +- frontend/src/services/api.ts +- backend/src/GestionCaja.API/wwwroot/* (sync del build frontend tras fix) ### Comandos ejecutados -- `dotnet build -c Release` (backend) -- `dotnet test -c Release --no-build` (backend tests) -- `npm.cmd run build` (frontend) -- Copia de `frontend/dist` -> `backend/src/GestionCaja.API/wwwroot` -- Smoke test HTTPS local levantando API: - - `dotnet .\\bin\\Release\\net8.0\\GestionCaja.API.dll --urls https://127.0.0.1:5081` - - `curl -k https://127.0.0.1:5081/api/health` - - login y consumo de endpoints dashboard con cookies (`curl -k -c/-b ...`) +- python requests contra `https://localhost:5000/api/*` para auditar login, me, cambio-password, refresh, logout, CRUD usuarios, restore y lockout +- dotnet build +- dotnet test +- npm run build +- copia de `frontend/dist` a `backend/src/GestionCaja.API/wwwroot/` -### Resultado de verificación -- Backend compila en Release sin errores. -- Frontend build generado sin errores. -- Tests backend: `4/4` OK. -- Endpoints validados en ejecución real con sesión autenticada: - - `/api/dashboard/principal` - - `/api/dashboard/evolucion` (`1m` y `6m`) - - `/api/dashboard/saldos-divisa` - - `/api/dashboard/titular/{id}` -- Conversión multi-divisa validada solicitando `divisaPrincipal=USD` (resultado convertido correcto usando tasas base). +### Resultado de verificacion +- OK: `POST /api/auth/login` entrega cookies + CSRF y `GET /api/auth/me` devuelve usuario + permisos. +- OK: `PUT /api/auth/cambiar-password` limpia `primer_login` y desbloquea el acceso al resto de endpoints. +- OK: `POST /api/auth/refresh-token` rota refresh token y CSRF; refresh revocado o reutilizado devuelve 401. +- OK: `POST /api/auth/logout` revoca refresh token y deja el token inutilizable. +- FIX: el bloqueo por intentos fallidos ahora devuelve 423 en el quinto intento fallido, no en el sexto. +- FIX: el frontend ahora sincroniza permisos al refrescar sesion y limpia permisos al perder la sesion. +- OK: CRUD de usuarios, emails adicionales, permisos y restauracion verificados por API. +- OK: backend compila, frontend compila y tests backend pasan. ### Pendientes -- Recomendado: tests específicos del `DashboardService` para buckets semanales y escenarios de permisos (ADMIN/GERENTE global/GERENTE restringido). +- No hay pendientes funcionales detectados dentro del alcance de Fase 1. -## 2026-04-13 — Fase 3 (Extractos / Tabla Excel-like) completada +## 2026-04-14 - Fase 4 (auditoria critica y correccion de bugs) ### Implementado - Backend: - - Nuevo `ExtractosController` con CRUD completo de extractos y soporte de soft delete/restauracion. - - `fila_numero` inmutable por cuenta (`MAX+1` usando `IgnoreQueryFilters`, sin reutilizacion). - - Listado paginado con filtros y ordenacion (`page`, `pageSize`, `sortBy`, `sortDir`, cuenta, titular, rango fechas, checked, flagged, search, incluirEliminados). - - Toggle de `check` y `flag` con auditoria dedicada. - - Auditoria por celda (`GET /api/extractos/{id}/audit-celda`) incluyendo `valor_anterior`, `valor_nuevo` y `celda_referencia`. - - Soporte de columnas extra via `EXTRACTOS_COLUMNAS_EXTRA` en alta y edicion. - - Endpoints de vistas de fase 3: - - `GET /api/extractos/cuentas/{id}/resumen` - - `GET /api/extractos/titulares/{id}/cuentas` - - `GET /api/extractos/titulares-resumen` - - Persistencia de visibilidad de columnas por usuario/cuenta: - - `GET /api/extractos/columnas-visibles` - - `PUT /api/extractos/columnas-visibles` - + - Se endurecio `ImportacionService` para rechazar mapeos invalidos que antes pasaban: + - indices base duplicados + - nombres de columnas extra duplicados + - `mapeo` nulo + - Se corrigio el parser de filas para no destruir columnas vacias al inicio o al final de la linea (`TrimEntries` estaba comiendose tabs validos). + - Se mejoro el parseo numerico para aceptar importes con separadores de miles (`1.234`, `1,234`, etc.) sin interpretarlos como decimales falsos. + - Se limpiaron mensajes de error visibles al usuario y se hicieron mas especificos (`Fecha vacia`, `Monto no numerico`, `Saldo vacio`, etc.). - Frontend: - - Nueva tabla virtualizada `ExtractoTable` con `@tanstack/react-virtual`. - - Columnas fijas: `N Fila`, `Check`, `Flag`, `Fecha`, `Concepto`, `Monto`, `Saldo`. - - Columnas extra dinamicas desde backend. - - Ordenacion por click en header + filtros inline. - - Edicion inline por celda con `EditableCell`. - - Modal de auditoria por celda (`AuditCellModal`) con click derecho. - - Formulario de alta manual (`AddRowForm`). - - Nuevas paginas: - - `ExtractosPage` (vista unificada) - - `TitularDetailPage` (tabs/listado por cuentas de titular) - - `CuentaDetailPage` (KPIs + tabla) - - Rutas actualizadas en `App.tsx` para las 3 vistas de fase 3. + - `ImportacionPage` ya no permite reconfirmar una importacion completada en el paso 4, evitando duplicados por doble confirmacion. + - Se tiparon los errores Axios del wizard en lugar de usar `any`. +- Testing: + - Se ampliaron los tests de `ImportacionService` para cubrir: + - separadores de miles + - mapeos duplicados + - mensajes especificos para valores vacios/no numericos ### Archivos tocados -- backend/src/GestionCaja.API/Controllers/ExtractosController.cs -- backend/src/GestionCaja.API/DTOs/ExtractosDtos.cs -- frontend/src/components/extractos/EditableCell.tsx -- frontend/src/components/extractos/AuditCellModal.tsx -- frontend/src/components/extractos/AddRowForm.tsx -- frontend/src/components/extractos/ExtractoTable.tsx -- frontend/src/pages/ExtractosPage.tsx -- frontend/src/pages/TitularDetailPage.tsx -- frontend/src/pages/CuentaDetailPage.tsx -- frontend/src/App.tsx -- frontend/src/types/index.ts -- frontend/src/styles/layout.css -- backend/src/GestionCaja.API/wwwroot/* (build frontend copiado) +- backend/src/GestionCaja.API/Services/ImportacionService.cs +- backend/tests/GestionCaja.API.Tests/ImportacionServiceTests.cs +- frontend/src/pages/ImportacionPage.tsx +- atlas-blance/DOCUMENTACION_CAMBIOS.md ### Comandos ejecutados -- `dotnet build backend/GestionCaja.sln /p:UseAppHost=false` -- `npm.cmd run build` (frontend) -- Smoke API en entorno local: - - login admin - - `GET /api/extractos` - - `POST /api/extractos` - - `PUT /api/extractos/{id}` - - `PATCH /api/extractos/{id}/check` - - `PATCH /api/extractos/{id}/flag` - - `GET /api/extractos/{id}/audit-celda?columna=concepto` - - `DELETE /api/extractos/{id}` - - `POST /api/extractos/{id}/restaurar` +- dotnet test backend/GestionCaja.sln +- npm.cmd run build +- npx.cmd eslint src/pages/ImportacionPage.tsx ### Resultado de verificacion -- Backend compila sin errores. -- Frontend build generado sin errores. -- Flujo de mutaciones de fase 3 validado en ejecucion real (create/update/check/flag/auditoria/delete/restore) con respuestas OK. -- Auditoria por celda devuelve historial con referencias de celda y cambios antes/despues. -- `fila_numero` se asigna por `MAX+1` y no se reutiliza tras soft delete. +- OK: 12/12 tests backend en verde tras ampliar cobertura de importacion. +- OK: frontend build de produccion sin errores. +- OK: `ImportacionPage.tsx` pasa ESLint en aislamiento. +- FIX: se evita el bug de duplicar importaciones desde el propio wizard despues de confirmar. +- FIX: columnas vacias iniciales/finales ya no se rompen al validar. +- FIX: importes con miles se importan como miles, no como decimales falsos. ### Pendientes -- Pendiente benchmark visual manual para confirmar UX sin lag con 10k+ filas reales en navegador (la virtualizacion ya esta implementada). -- Recomendado: tests automatizados de integracion para permisos de columnas editables y casos borde de auditoria por columna extra. +- Sin pendientes funcionales detectados en Fase 4 tras esta pasada. +- Bloqueo de proceso: no se pudo sincronizar este ajuste menor de UX en Figma porque en esta sesion solo hay herramientas de lectura de Figma y no hay herramienta de escritura sobre el archivo fuente. -## 2026-04-13 — Ajuste de gobernanza de diseño (Figma obligatorio) +## 2026-04-14 - Revision Fase 5 (Dashboards) ### Implementado -- Se añadió regla explícita en instrucciones del proyecto para exigir sincronización de UI en Figma por fase. -- Se registró URL oficial de diseño: - - https://www.figma.com/design/cFYBwjPLqAArvgg04DJLmp/Gestion-de-Caja?node-id=0-1&t=48b5SDF4kRLPXa4g-1 - -### Archivos tocados -- C:/Proyectos/Atlas Balance/AGENTS.md +- Backend: + - `GET /api/dashboard/saldos-divisa` ahora acepta `titularId` opcional para devolver el desglose por divisa filtrado por titular, manteniendo la validacion de permisos del dashboard. +- Frontend: + - `DashboardTitularPage` se alineo con la spec de Fase 5 y ahora replica el layout principal con bloque de saldos por divisa + desglose por cuenta. + - `SaldoPorDivisaCard` dejo de desperdiciar `saldo_convertido`: ahora muestra el equivalente en la divisa principal seleccionada cuando aplica. + - Se copio el build actualizado a `backend/src/GestionCaja.API/wwwroot` para que el backend sirva el frontend corregido. +- Testing: + - Se agregaron tests de regresion para `DashboardService` cubriendo agregacion multi-divisa, KPIs mensuales, filtro por titular en `saldos-divisa` y denegacion de acceso para gerentes sin permiso sobre el titular. -### Comandos ejecutados -- Edición directa de `AGENTS.md` (patch) -- Intento de conexión al MCP de Figma para escritura en archivo de diseño +### Decisiones visuales +- Se mantuvo el lenguaje visual existente del dashboard. +- El dashboard por titular usa la misma rejilla que el dashboard principal para no abrir una UX paralela innecesaria. +- El equivalente convertido se muestra como texto secundario en la card de divisas para que el selector de divisa tenga impacto visible sin recargar la UI. -### Resultado de verificación -- Regla incorporada en instrucciones: vigente para siguientes fases y entregas. -- Conexión Figma en esta sesión: bloqueada por autenticación del conector (Auth required en handshake MCP). +### Figma +- Pantalla/nodo actualizado: pendiente. +- Motivo: en esta sesion solo hubo herramientas de lectura de Figma; no hubo herramienta de escritura para sincronizar el archivo fuente `Gestion-de-Caja`. -### Pendientes -- Reconectar/autenticar conector de Figma para poder escribir nodos y sincronizar la Fase 3 en el archivo de diseño. +### Archivos tocados +- backend/src/GestionCaja.API/Controllers/DashboardController.cs +- backend/src/GestionCaja.API/Services/DashboardService.cs +- backend/tests/GestionCaja.API.Tests/DashboardServiceTests.cs +- frontend/src/components/dashboard/SaldoPorDivisaCard.tsx +- frontend/src/pages/DashboardPage.tsx +- frontend/src/pages/DashboardTitularPage.tsx +- frontend/src/styles/layout.css +- backend/src/GestionCaja.API/wwwroot/* +- atlas-blance/DOCUMENTACION_CAMBIOS.md -## 2026-04-13 — Fase 0 (verificación E2E navegador) +### Comandos ejecutados +- `docker compose up -d` +- `dotnet build backend/GestionCaja.sln -c Release` +- `dotnet test backend/GestionCaja.sln -c Release` +- `npm.cmd run build` +- Copia de `frontend/dist` -> `backend/src/GestionCaja.API/wwwroot` +- Smoke real contra HTTPS local: + - login `POST /api/auth/login` + - `GET /api/dashboard/principal` + - `GET /api/dashboard/evolucion` + - `GET /api/dashboard/saldos-divisa?divisaPrincipal=USD&titularId=...` + - `GET /api/dashboard/titular/{id}` -### Hallazgos corregidos -- El frontend servido por Kestrel estaba compilado con `VITE_API_URL=https://localhost` en producción. - - Efecto real: las llamadas iban a `https://localhost/api/...` y perdían el puerto `5000`, rompiendo login y bootstrap visual. - - Se dejó `frontend/.env.production` con `VITE_API_URL=` para usar mismo origen. -- El bootstrap de sesión en `App.tsx` generaba 401 espurios en navegador: - - al entrar en `/login` pedía `/auth/me` sin sesión. - - tras login volvía a pedir `/auth/me` aunque el store ya estaba autenticado. - - Se ajustó para no disparar bootstrap en `/login` ni cuando la sesión ya está cargada en store. +### Resultado de verificacion +- OK: backend Release compila sin errores. +- OK: frontend build de produccion sin errores. +- OK: tests backend en verde (`14/14`). +- OK: el endpoint `saldos-divisa` filtrado por titular responde JSON correcto en ejecucion real. +- FIX: el dashboard por titular ya no incumple la spec al omitir el bloque de saldos por divisa. +- FIX: la divisa principal seleccionada ya tiene efecto visible dentro de la card de saldos por divisa. -### Verificación E2E ejecutada -- Se levantó `GestionCaja.API` en `https://localhost:5000`. -- Se ejecutó prueba headless con Playwright + Edge sobre `/login`. -- Para permitir llegar al shell sin forzar cambio de contraseña, se puso temporalmente `primer_login = false` al admin seed en BD. -- Tras la prueba, se restauró `primer_login = true`. +### Pendientes +- Pendiente externo: sincronizar en Figma los cambios de layout del dashboard por titular cuando haya herramienta de escritura disponible en sesion. -### Resultado -- Login visual OK con credencial local de desarrollo redactada. -- Redirección a `/dashboard` OK. -- Shell OK: - - sidebar visible - - topbar visible - - usuario mostrado: `Administrador` - - navegación visible completa para admin -- Sin errores de consola. -- Sin `pageErrors`. -- Sin requests fallidas. -- Sin respuestas HTTP >= 400 durante el flujo validado. +## 2026-04-14 - Fase 6 (Tipos de Cambio) completada -### Estado -- Fase 0 verificada también con navegador headless sobre flujo real. -- Residual que sigue siendo manual: confiar certificado de desarrollo en Windows para evitar advertencia HTTPS en navegador. +### Implementado +- Backend: + - `TiposCambioService` ampliado para: + - sincronizacion real contra ExchangeRate-API (endpoint oficial del proveedor) + - cache en memoria con invalidacion en cambios manuales/sync + - fallback operativo a tasas persistidas en BD (si la API falla, no se sobreescriben tasas) + - CRUD de soporte para tipos de cambio y divisas activas + - Nuevos endpoints admin: + - `GET /api/tipos-cambio` + - `PUT /api/tipos-cambio/{origen}/{destino}` + - `POST /api/tipos-cambio/sincronizar` + - `GET /api/divisas` + - `POST /api/divisas` + - `PUT /api/divisas/{codigo}` + - Nuevos jobs Hangfire: + - `SyncTiposCambioJob` (cada 12 horas) + - `LimpiezaRefreshTokensJob` (diario) + - Registro de `HttpClient` para ExchangeRate-API y programacion de recurring jobs en `Program.cs`. -## 2026-04-13 — Fase 0 (verificación E2E completa con primer login) +- Frontend: + - Nueva `ConfiguracionPage` (reemplaza placeholder) con: + - estado de ultima sincronizacion + - indicador visual de tasas desactualizadas (>24h) + - sync manual + - edicion manual de tasas + - gestion de divisas (editar + alta) + - Ruta `/configuracion` protegida para `ADMIN`. + - Sidebar ajustado para ocultar Configuracion a no-admin. + - Estilos CSS para la pagina de configuracion. -### Objetivo -- Validar en navegador headless el flujo real desde login hasta shell, incluso con `primer_login = true`. +### Archivos tocados +- backend/src/GestionCaja.API/Services/TiposCambioService.cs +- backend/src/GestionCaja.API/Controllers/TiposCambioController.cs +- backend/src/GestionCaja.API/Controllers/DivisasController.cs +- backend/src/GestionCaja.API/Jobs/SyncTiposCambioJob.cs +- backend/src/GestionCaja.API/Jobs/LimpiezaRefreshTokensJob.cs +- backend/src/GestionCaja.API/Program.cs +- frontend/src/pages/ConfiguracionPage.tsx +- frontend/src/App.tsx +- frontend/src/components/layout/Sidebar.tsx +- frontend/src/styles/layout.css ### Comandos ejecutados -- `node C:\Users\PcVIP\AppData\Local\Temp\gce2e-run\gce2e-phase0-full.js` -- `docker exec -i gestion_caja_db psql -U app_user -d gestion_caja` - -### Resultado de verificación -- Login del admin correcto. -- Redirección obligatoria a `/cambiar-password` correcta cuando `primer_login = true`. -- Cambio de contraseña en UI correcto. -- Redirección posterior a `/dashboard` correcta. -- Shell cargado sin errores de consola, sin excepciones de página y sin requests fallidas. -- Restauración del password original correcta (`200`) y `primer_login` restaurado a `true` por SQL para conservar el seed. +- `docker compose up -d` +- `dotnet build` (backend) +- `npm.cmd run build` (frontend) +- Arranque API temporal para pruebas E2E de Fase 6 (`dotnet run --no-build`) +- Verificacion jobs Hangfire en PostgreSQL: + - `SELECT value FROM hangfire.set WHERE key = 'recurring-jobs' ORDER BY value;` -### Estado -- Fase 0 sigue cerrada. -- La verificación visual E2E no detectó bugs nuevos de scaffolding/infrastructura; el desvío a cambio de contraseña pertenece a Fase 1 y está funcionando como se diseñó. +### Verificacion funcional +- Build backend: OK (0 errores). +- Build frontend: OK. +- Smoke tests API Fase 6 con sesion admin + CSRF: + - `POST /api/auth/login` -> 200 + - `GET /api/divisas` -> 200 + - `GET /api/tipos-cambio` -> 200 + - `PUT /api/tipos-cambio/EUR/USD` -> 200 + - `POST /api/tipos-cambio/sincronizar` -> 200 + - `POST /api/divisas` (GBP) -> 201 + - `PUT /api/divisas/USD` -> 200 +- Recurring jobs registrados en Hangfire: `sync-tipos-cambio`, `limpieza-refresh-tokens`. -## 2026-04-13 - Fase 1 (hardening y verificacion real) +### Pendientes +- Sin pendientes funcionales detectados dentro del alcance de Fase 6. +- Pendiente externo de proceso: sincronizacion en Figma no ejecutada en esta sesion (no se realizo escritura en archivo de diseño). -### Objetivo -- Revisar Fase 1 contra la especificacion y corregir bugs funcionales/backend-frontend detectados en autenticacion, sesiones y usuarios. +## 2026-04-14 - Fase 6 (auditoria y correcciones) ### Hallazgos corregidos -- `primer_login` solo se imponia en frontend; cualquier usuario autenticado podia seguir llamando a la API directamente. -- Un usuario desactivado/eliminado o con rol cambiado podia seguir operando con un JWT ya emitido hasta expirar. -- `logout` dependia de un `access_token` valido y podia dejar el `refresh_token` activo en BD. -- El frontend no intentaba `refresh-token` cuando fallaban `/auth/me` o `/auth/cambiar-password`, provocando falsas expulsiones de sesion. -- Faltaban endpoints de Fase 1 para permisos y emails de usuario (`GET/PUT permisos`, `GET/POST/DELETE emails`). -- La logica de permisos trataba permisos por titular como si fueran globales sobre todas las cuentas. -- Auditoria de auth/usuarios no estaba alineada con acciones de la spec (`LOGIN`, `LOGOUT`, `LOGIN_FAILED`, `ACCOUNT_LOCKED`, `CREATE_USUARIO`, etc.). +- `TiposCambioService` no resolvia conversiones cruzadas cuando la divisa base activa dejaba de ser `EUR`; se reemplazo la resolucion fija por un catalogo/grafo de tasas para soportar rutas arbitrarias entre divisas. +- Al cambiar la divisa base, `divisa_principal_default` podia quedar desalineada respecto a `DIVISAS_ACTIVAS`; ahora se sincroniza al guardar la base y `DashboardService` prioriza la base activa real. +- `ConfiguracionPage` podia conservar una combinacion origen/destino invalida despues de desactivar o cambiar la base de una divisa; ahora normaliza la seleccion y bloquea guardar si origen y destino coinciden. +- Los tests backend de dashboard estaban rotos por una firma obsoleta de `TiposCambioService`; se actualizaron y se anadieron regresiones de base no-EUR y fallback offline. + +### Figma +- Sin cambios visuales en esta sesion. +- No se actualizo Figma porque el ajuste en frontend fue de validacion/estado, no de diseno. ### Archivos tocados -- backend/src/GestionCaja.API/Controllers/AuthController.cs -- backend/src/GestionCaja.API/Controllers/UsuariosController.cs -- backend/src/GestionCaja.API/DTOs/UsuariosDtos.cs -- backend/src/GestionCaja.API/Middleware/UserStateMiddleware.cs -- backend/src/GestionCaja.API/Middleware/PrimerLoginMiddleware.cs -- backend/src/GestionCaja.API/Program.cs -- backend/src/GestionCaja.API/Services/AuditActions.cs -- backend/src/GestionCaja.API/Services/AuditService.cs -- backend/src/GestionCaja.API/Services/AuthService.cs -- backend/src/GestionCaja.API/Services/UserAccessService.cs -- frontend/src/services/api.ts -- frontend/src/stores/permisosStore.ts -- frontend/src/pages/ExtractosPage.tsx -- frontend/src/pages/UsuariosPage.tsx -- backend/tests/GestionCaja.API.Tests/AuthServiceTests.cs -- backend/tests/GestionCaja.API.Tests/UserAccessServiceTests.cs -- backend/tests/GestionCaja.API.Tests/UsuariosControllerTests.cs +- backend/src/GestionCaja.API/Services/TiposCambioService.cs +- backend/src/GestionCaja.API/Services/DashboardService.cs +- backend/tests/GestionCaja.API.Tests/DashboardServiceTests.cs +- backend/tests/GestionCaja.API.Tests/TiposCambioServiceTests.cs +- frontend/src/pages/ConfiguracionPage.tsx +- DOCUMENTACION_CAMBIOS.md ### Comandos ejecutados -- `dotnet build GestionCaja.sln` -- `dotnet test GestionCaja.sln` +- `docker compose up -d` +- `dotnet build -c Release` +- `dotnet test -c Release` - `npm.cmd run build` -- Smoke tests HTTP via PowerShell contra `https://localhost:5000`: -- login/logout/refresh -- enforcement de `primer_login` -- CRUD usuarios -- endpoints de permisos/emails -- delete/restore +- Arranque aislado de `GestionCaja.API` Release contra base PostgreSQL temporal para smoke real de Fase 6. +- Verificacion SQL directa en PostgreSQL temporal para `CONFIGURACION`, `TIPOS_CAMBIO` y hashes de recurring jobs de Hangfire. ### Resultado de verificacion -- Backend compila OK (0 errores, 0 warnings). -- Frontend build OK. -- Tests backend OK: 6/6. -- `primer_login` bloquea la API hasta cambiar password y deja pasar despues del cambio. -- `logout` revoca el `refresh_token` aunque el `access_token` ya no exista. -- CRUD de usuarios, soft delete/restauracion y endpoints de permisos/emails responden correctamente. -- La resolucion de permisos ya no eleva permisos por error en scopes por titular. +- Backend Release: OK (0 errores de compilacion). +- Frontend build: OK. +- Tests backend: OK (`18/18`). +- Smoke real Fase 6: OK. + - login admin + cambio obligatorio de password + - sync manual inicial -> `updated_count = 3` + - alta de `GBP` -> OK + - tasa manual `USD -> GBP` -> fuente `MANUAL` + - cambio de base a `USD` -> OK + - sync posterior -> `updated_count = 4` + - `CONFIGURACION.divisa_principal_default = USD` + - tasas persistidas `USD -> EUR/MXN/DOP/GBP` + - recurring jobs presentes: `sync-tipos-cambio`, `limpieza-refresh-tokens` ### Pendientes -- La UI de usuarios sigue siendo formulario embebido; funcionalmente cubre Fase 1, pero si se quiere clavado a la spec quedaria mover permisos/emails a modal dedicado. - -## 2026-04-13 - Fase 1 (UsuariosPage modal) +- No se detectaron pendientes funcionales nuevos dentro del alcance de Fase 6. +- Sigue pendiente externo de proceso: sincronizacion de Figma cuando haya cambios visuales reales o escritura disponible. -### Objetivo -- Alinear la UI de usuarios con la spec de Fase 1 usando modal dedicado para crear/editar, permisos y emails. +## 2026-04-14 - Fase 7 (Alertas de Saldo Bajo) completada end-to-end -### Cambios aplicados -- `UsuariosPage` pasa de layout partido con formulario fijo a tabla + modal dedicado. -- Se agrega `UsuarioModal` con secciones para identidad, emails de notificacion y permisos granulares. -- Se sustituye el `confirm()` nativo por confirmacion visual propia para eliminar usuarios. -- Se ajustan estilos responsive para modal, resumen y bloques de permisos. +### Implementado +- Backend: + - Nuevo `AlertasController` con endpoints: + - `GET /api/alertas` + - `GET /api/alertas/contexto` + - `POST /api/alertas` + - `PUT /api/alertas/{id}` + - `DELETE /api/alertas/{id}` + - `GET /api/alertas/activas` + - Nuevo `AlertaService`: + - `EvaluateSaldoPostAsync()` se ejecuta automáticamente tras `POST/PUT /api/extractos`. + - Resolución de alerta aplicable: por cuenta (si existe) y fallback a global (`cuenta_id = null`). + - Actualiza `fecha_ultima_alerta` y registra auditoría de disparo. + - Nuevo `EmailService` con MailKit: + - Lee SMTP y `app_base_url` desde `CONFIGURACION`. + - Genera email HTML con titular, cuenta, saldo actual, mínimo y link a cuenta. + - `ExtractosController` actualizado para disparar evaluación de alertas después de crear/editar extracto. +- Frontend: + - Nueva `AlertasPage` real (admin): CRUD de alerta global + alertas por cuenta + destinatarios. + - `alertasStore` completo: carga de alertas activas, contador para sidebar, dismiss por sesión. + - `AlertBanner` nuevo en layout (dismissible por sesión). + - Badge de alertas en sidebar. + - Ruta `/alertas` deja de ser placeholder y queda protegida para `ADMIN`. ### Archivos tocados -- frontend/src/components/usuarios/UsuarioModal.tsx -- frontend/src/pages/UsuariosPage.tsx +- backend/src/GestionCaja.API/Controllers/AlertasController.cs +- backend/src/GestionCaja.API/Controllers/ExtractosController.cs +- backend/src/GestionCaja.API/DTOs/AlertasDtos.cs +- backend/src/GestionCaja.API/Services/AlertaService.cs +- backend/src/GestionCaja.API/Services/EmailService.cs +- backend/src/GestionCaja.API/Services/AuditActions.cs +- backend/src/GestionCaja.API/Program.cs +- frontend/src/App.tsx +- frontend/src/components/layout/AlertBanner.tsx +- frontend/src/components/layout/Layout.tsx +- frontend/src/components/layout/Sidebar.tsx +- frontend/src/components/layout/TopBar.tsx +- frontend/src/pages/AlertasPage.tsx +- frontend/src/pages/LoginPage.tsx +- frontend/src/stores/alertasStore.ts - frontend/src/styles/layout.css -- DOCUMENTACION_CAMBIOS.md +- backend/src/GestionCaja.API/wwwroot/* +- atlas-blance/DOCUMENTACION_CAMBIOS.md ### Comandos ejecutados +- `docker compose up -d` +- `dotnet build backend/GestionCaja.sln` +- `dotnet test backend/GestionCaja.sln --no-build` - `npm.cmd run build` +- copia `frontend/dist/*` -> `backend/src/GestionCaja.API/wwwroot/` +- Smoke Fase 7 real contra `https://localhost:5000`: + - login admin + - limpieza de alertas previas + - creación alerta global + - creación alerta por cuenta + - creación de extracto con saldo bajo para disparo + - consulta `GET /api/alertas/activas` + - validación `fecha_ultima_alerta` +- Verificación fallback global: + - creación de extracto con saldo bajo en cuenta sin alerta específica + - validación de que se usa `alerta_id` global +- SMTP de prueba: + - contenedor `gestion_caja_mailhog` (puertos `1025/8025`) + - actualización de claves SMTP en `CONFIGURACION` + - verificación de mensajes en `http://localhost:8025/api/v2/messages` -### Resultado de verificacion +### Resultado de verificación +- Backend compila OK (`0 errores`). - Frontend build OK. -- Flujo de usuarios preparado para modal dedicado y confirmacion visual sin dialogs nativos. +- Tests backend OK (`18/18`). +- Fase 7 validada por smoke real: + - alerta por cuenta se dispara al crear extracto bajo mínimo. + - fallback global funciona en cuenta sin alerta propia. + - banner y contador consumen `GET /api/alertas/activas`. + - `fecha_ultima_alerta` se actualiza. + - email enviado y recibido en MailHog (`mailhog_messages = 1` en el flujo validado). ### Pendientes -- Verificacion visual manual en navegador para afinar densidad/espaciado si se quiere pulido final de UX. +- Pendiente de proceso: sincronización en Figma de la pantalla de alertas y del banner cuando esté disponible la escritura de Figma en sesión. -## 2026-04-13 - Fase 1 (Verificacion visual UsuariosPage) +### Estado Figma Fase 7 (bloqueo de permisos) +- Intento de sincronizacion Figma en esta sesion bloqueado por permisos del conector: `seatType: view` (sin capacidad de escritura). +- Validacion ejecutada con `mcp__codex_apps__figma._whoami` (usuario: andi.seo.social@gmail.com, plan starter, team::1625133788451949600). +- Llamadas de lectura (`_get_metadata`, `_get_screenshot`) al archivo `cFYBwjPLqAArvgg04DJLmp` terminaron por timeout. +- Accion necesaria para cerrar Fase 7 al 100 por ciento segun regla del proyecto: acceso Editor en Figma + reintento de sincronizacion desde MCP. -### Objetivo -- Verificar visualmente la UI real de usuarios y comprobar que el modal de alta/edicion y la confirmacion de borrado funcionan sin regresiones. +## 2026-04-14 - Fase 7 - revision correctiva -### Cambios aplicados -- Se recompila frontend y se copia frontend/dist/ a backend/src/GestionCaja.API/wwwroot/ para validar el escenario real servido por Kestrel. -- Se ejecuta verificacion automatizada con Chrome headless sobre https://localhost:5000 y tambien sobre http://localhost:5173. -- Se valida login, acceso a /usuarios, apertura de modal nuevo, apertura de modal edicion y flujo UI de crear -> eliminar -> restaurar. -- Se limpian a papelera los usuarios temporales ui.modal.* creados durante QA para no dejar ruido en la vista por defecto. +### Que se reviso +- Verificacion completa de la Fase 7 contra la especificacion: alertas por saldo, destinatarios, banner, badge, permisos y robustez del flujo. + +### Bugs y desviaciones corregidos +- La ruta `/alertas` ya no queda bloqueada solo para `ADMIN`: cualquier usuario autenticado puede ver sus alertas activas, y `ADMIN` mantiene la configuracion. +- El sidebar ya no oculta `/alertas` a usuarios no admin, asi que el badge y el acceso a alertas activas dejan de ser un callejon sin salida. +- `alertasStore.loadAlertasActivas()` ya no rompe el bootstrap de sesion si falla `/api/alertas/activas`; ahora limpia estado obsoleto y guarda el error. +- El backend ya no evalua alertas sobre cuentas inactivas ni permite crear alertas para cuentas inactivas. +- Se agregaron restricciones unicas en base de datos para impedir datos duplicados que la API no estaba blindando por si sola: + - una sola alerta global (`cuenta_id IS NULL`) + - una sola alerta por cuenta + - un solo destinatario por par `alerta_id` + `usuario_id` +- Se añadieron tests para cubrir override de alerta por cuenta sobre alerta global y la exclusion de cuentas inactivas. ### Archivos tocados -- DOCUMENTACION_CAMBIOS.md +- backend/src/GestionCaja.API/Data/AppDbContext.cs +- backend/src/GestionCaja.API/Services/AlertaService.cs +- backend/src/GestionCaja.API/Controllers/AlertasController.cs +- backend/src/GestionCaja.API/Migrations/20260414200917_AlertasSaldoConstraints.cs +- backend/src/GestionCaja.API/Migrations/20260414200917_AlertasSaldoConstraints.Designer.cs +- backend/src/GestionCaja.API/Migrations/AppDbContextModelSnapshot.cs +- backend/tests/GestionCaja.API.Tests/AlertaServiceTests.cs +- frontend/src/stores/alertasStore.ts +- frontend/src/components/layout/Sidebar.tsx +- frontend/src/App.tsx +- frontend/src/pages/AlertasPage.tsx +- atlas-blance/DOCUMENTACION_CAMBIOS.md ### Comandos ejecutados -- npm.cmd run build -- Copia de frontend/dist/* a backend/src/GestionCaja.API/wwwroot/ -- Script Node + Chrome headless para smoke visual en https://localhost:5000 -- Script Node + Chrome headless para smoke visual en http://localhost:5173 -- Script Node para soft delete de usuarios ui.modal.* creados en QA +- `dotnet build` en `backend/src/GestionCaja.API` +- `dotnet test` en `backend/tests/GestionCaja.API.Tests` +- `dotnet ef migrations add AlertasSaldoConstraints` +- `dotnet ef database update` +- `npm.cmd run build` en `frontend` +- `docker exec gestion_caja_db psql ...` para verificar indices unicos +- `curl.exe -k ...` para login y smoke de: + - `GET /api/alertas` + - `GET /api/alertas/activas` + - `GET /api/alertas/contexto` ### Resultado de verificacion -- La pantalla Usuarios renderiza correctamente en backend servido por Kestrel. -- Nuevo Usuario abre modal con bloques de identidad, emails y permisos. -- Editar abre modal con datos cargados. -- La confirmacion visual de borrado reemplaza correctamente al confirm() nativo. -- Flujo UI crear -> eliminar -> restaurar validado sin errores de consola. -- Verificacion via Vite (localhost:5173) tambien OK; el fallo inicial de prueba era del script de automatizacion, no de la app. +- Backend compila OK. +- Tests backend OK (`20/20`). +- Frontend build OK. +- Migracion aplicada OK. +- Restricciones unicas verificadas en PostgreSQL. +- Endpoints de Fase 7 responden `200` tras autenticacion. +- La implementacion anterior no estaba cerrada del todo; esta revision elimina fallos funcionales y endurece integridad de datos. ### Pendientes -- Ninguno para Fase 1 en esta pantalla; solo quedaria polish visual si mas adelante se quiere refinar densidad o jerarquia. -## 2026-04-13 - Fase 2 (Auditoria y correcciones) +- Falta validacion visual manual completa del flujo de alertas en navegador tras los cambios de acceso. +- Falta revalidar envio SMTP real en esta sesion; no fue necesario para corregir los bugs detectados. +- Sigue bloqueada la sincronizacion en Figma en esta sesion por falta de capacidad de escritura del conector. -### Objetivo -- Verificar Fase 2 contra la spec real y corregir bugs funcionales y de UX detectados en titulares, cuentas y formatos de importacion. +## 2026-04-14 - Fase 8 (Auditoría UI) completada end-to-end -### Cambios aplicados -- Se endurecen las validaciones de cuentas para ignorar `formato_id` cuando `es_efectivo = true` y evitar que una caja arrastre un formato bancario. -- Se corrigen mensajes con mojibake/encoding roto en backend (`FormatosImportacionController`, `PrimerLoginMiddleware`) y frontend (`TitularesPage`, `CuentasPage`, `ImportacionPage`, `index.html`). -- `CuentasPage` ahora filtra formatos por divisa, limpia el formato al pasar a efectivo y oculta el selector de formato para cuentas de efectivo. -- Se mantiene protegido `/api/formatos-importacion` para admin y se confirma por smoke test que los usuarios no admin solo ven titulares/cuentas autorizados. -- Se recompila frontend y se sincroniza `frontend/dist` con `backend/src/GestionCaja.API/wwwroot` para que Kestrel sirva la version corregida. +### Implementado +- Backend: + - Nuevo `AuditoriaController` (`/api/auditoria`) con: + - `GET /api/auditoria` paginado con filtros combinables por `usuarioId`, `cuentaId`, `tipoAccion`, `fechaDesde`, `fechaHasta`. + - `GET /api/auditoria/filtros` para poblar combos (usuarios, cuentas y tipos de acción). + - `GET /api/auditoria/exportar-csv` con los mismos filtros aplicados. + - Enriquecimiento de filas de auditoría con `usuario_nombre`, `cuenta_nombre`, `titular_nombre` para mostrar contexto legible en UI. + - Fix crítico: filtro por `tipoAccion` ahora es case-insensitive (antes fallaba al mezclar acciones en mayúscula/minúscula). +- Frontend: + - Nueva `AuditoriaPage` real (reemplaza placeholder): + - tabla paginada + - filtros por usuario/fecha/tipo/cuenta + - expansión por fila para ver `valor_anterior` / `valor_nuevo` / `detalles_json` + - referencia de celda legible (`A1 (Fecha)`, etc.) + - botón de exportación CSV con descarga real. + - Ruta `/auditoria` protegida para `ADMIN`. + - Sidebar ajustado para ocultar `Auditoría` a no-admin. + - Estilos CSS añadidos para la nueva pantalla. + +### Decisiones visuales +- Se reutilizó el lenguaje visual existente de tablas/cards (`users-*`) para evitar deuda de diseño. +- La expansión se resolvió inline por fila en vez de modal para acelerar revisión comparativa de cambios. +- La celda muestra referencia + nombre de columna para que la lectura sea inmediata sin contexto externo. + +### Figma +- Pendiente de sincronización. +- Motivo: en esta sesión no se ejecutó escritura sobre Figma (bloqueo de permisos ya reportado en fases previas). ### Archivos tocados -- backend/src/GestionCaja.API/Controllers/CuentasController.cs -- backend/src/GestionCaja.API/Controllers/FormatosImportacionController.cs -- backend/src/GestionCaja.API/Middleware/PrimerLoginMiddleware.cs -- frontend/src/pages/CuentasPage.tsx -- backend/src/GestionCaja.API/DTOs/FormatosImportacionDtos.cs +- backend/src/GestionCaja.API/Controllers/AuditoriaController.cs +- backend/src/GestionCaja.API/DTOs/AuditoriaDtos.cs +- frontend/src/pages/AuditoriaPage.tsx - frontend/src/App.tsx - frontend/src/components/layout/Sidebar.tsx -- frontend/index.html -- frontend/src/pages/ImportacionPage.tsx -- DOCUMENTACION_CAMBIOS.md +- frontend/src/styles/layout.css +- frontend/src/types/index.ts +- backend/src/GestionCaja.API/wwwroot/* +- atlas-blance/DOCUMENTACION_CAMBIOS.md ### Comandos ejecutados -- `dotnet build` -- `npm.cmd run build` -- Copia de `frontend/dist/*` a `backend/src/GestionCaja.API/wwwroot/` -- Smoke tests HTTP manuales con `curl.exe` contra `https://localhost:5000`: -- login admin -- validacion negativa de formatos (`mapeo_json` con indices duplicados) -- CRUD parcial de titulares/cuentas/formatos -- verificacion de `GET /api/cuentas/{id}/resumen` -- verificacion de cuenta efectivo limpiando datos bancarios y `formato_id` -- login usuario no admin + filtro de permisos + 403 en formatos/importacion -- soft delete/restauracion de formato y cuenta -- limpieza de datos de prueba por API (soft delete) +- `dotnet build` (backend API) +- `npm.cmd run build` (frontend) +- `dotnet test --no-build` (backend tests) +- `docker compose up -d` +- smoke real contra `https://localhost:5000`: + - `POST /api/auth/login` + - `GET /api/auditoria/filtros` + - `GET /api/auditoria?page=1&pageSize=25` + - `GET /api/auditoria` con filtros combinados + - `GET /api/auditoria/exportar-csv` (general y filtrado) +- copia `frontend/dist/*` -> `backend/src/GestionCaja.API/wwwroot/` -### Resultado de verificacion -- Backend compila OK (0 errores, 0 warnings). +### Resultado de verificación +- Backend compila OK (`0 errores`). - Frontend build OK. -- `https://localhost:5000/api/health` responde 200. -- Los errores de validacion ya no salen con texto roto por encoding. -- Las cuentas de efectivo ya no conservan datos bancarios ni `formato_id`. -- Los formatos visibles en UI quedan acotados por divisa y se ocultan en cuentas de efectivo. -- Usuario no admin ve exactamente 1 titular y 1 cuenta autorizados en el smoke test y recibe 403 en `/api/formatos-importacion` y al pedir una cuenta fuera de scope. -- `wwwroot` queda actualizado con la build nueva del frontend. +- Tests backend OK (`20/20`). +- Endpoints de Fase 8 responden correctamente con autenticación admin. +- Filtros combinados verificados en ejecución real (incluyendo `tipoAccion` + `cuentaId` + rango de fechas). +- Export CSV verificado con archivo real generado y contenido filtrado correcto. ### Pendientes -- Sigue habiendo `window.confirm()` en pantallas de Fase 2; funcionalmente no rompe nada, pero si quieres cumplir la spec al pie de la letra tocaria sustituirlos por confirmaciones visuales propias. +- Pendiente de proceso: sincronizar el nodo/pantalla de Auditoría en Figma cuando haya permisos de escritura del conector. -## 2026-04-13 - Fase 2 (Confirmaciones visuales) +## 2026-04-14 - Revision critica Fase 8 (auditoria) -### Objetivo -- Rematar Fase 2 quitando los dialogs nativos de borrado y dejar titulares, cuentas y formatos alineados con la regla de usar feedback visual propio. +### Implementado +- Backend: + - Corregido el filtro `cuentaId` de `/api/auditoria` y `/api/auditoria/exportar-csv`: ahora incluye tanto auditoria de extractos como auditoria de la propia cuenta. + - Enriquecidas las filas de auditoria de `CUENTAS` y `TITULARES` para devolver `cuenta_nombre` y `titular_nombre` cuando exista contexto relacionado. + - Eliminado el limite artificial de `10000` filas en la exportacion CSV para que la exportacion respete el historial filtrado completo. +- Testing: + - Nuevos tests para cubrir el bug del filtro por cuenta y la ausencia de truncado en CSV. +- Infraestructura de auditoria: + - Anadidas constantes faltantes en `AuditActions` para desbloquear compilacion real del backend durante la verificacion. -### Cambios aplicados -- Se crea `ConfirmDialog`, un modal reutilizable con cierre por backdrop/Escape y estado de carga. -- `TitularesPage` deja de usar `window.confirm()` y pasa a confirmacion visual antes de enviar a papelera. -- `CuentasPage` deja de usar `window.confirm()` y pasa a confirmacion visual antes de soft delete. -- `ImportacionPage` deja de usar `window.confirm()` y pasa a confirmacion visual antes de soft delete. -- Se recompila frontend y se vuelve a sincronizar `frontend/dist` con `backend/src/GestionCaja.API/wwwroot`. +### Figma +- Sin cambios en esta sesion. +- No hubo modificaciones de UI/UX; por tanto no correspondia nueva sincronizacion visual. +- Sigue pendiente el gap historico ya documentado de la implementacion original de Fase 8. ### Archivos tocados -- frontend/src/components/common/ConfirmDialog.tsx -- frontend/src/pages/TitularesPage.tsx -- frontend/src/pages/CuentasPage.tsx -- frontend/src/pages/ImportacionPage.tsx +- backend/src/GestionCaja.API/Controllers/AuditoriaController.cs +- backend/src/GestionCaja.API/Services/AuditActions.cs +- backend/tests/GestionCaja.API.Tests/AuditoriaControllerTests.cs - DOCUMENTACION_CAMBIOS.md ### Comandos ejecutados -- `Get-ChildItem frontend/src -Recurse -Include *.ts,*.tsx | Select-String -Pattern 'window\.confirm'` +- `dotnet build backend/src/GestionCaja.API/GestionCaja.API.csproj` +- `dotnet test backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj --no-restore` - `npm.cmd run build` -- Copia de `frontend/dist/*` a `backend/src/GestionCaja.API/wwwroot/` ### Resultado de verificacion -- No quedan usos de `window.confirm` en `frontend/src`. +- Backend compila OK tras la correccion (`0 errores`, advertencias existentes fuera de Fase 8 en `BackupsController`). - Frontend build OK. -- `wwwroot` queda actualizado con la build nueva del frontend. +- Tests backend OK (`22/22`). +- Verificado por test que el filtro por cuenta ya no oculta auditoria de la entidad `CUENTAS`. +- Verificado por test que la exportacion CSV ya no corta el resultado al pasar de `10000` filas. ### Pendientes -- Ninguno para este ajuste; Fase 2 ya no depende de dialogs nativos para las acciones de borrado. - -## 2026-04-13 — Fase 3 QA hardening (bugs corregidos) +- Pendiente de proceso: sincronizar en Figma la pantalla de Auditoria de la implementacion original de Fase 8 cuando el conector permita escritura. +- Pendiente tecnico fuera de Fase 8: warnings de nullability en `BackupsController`. +## 2026-04-14 - Fase 9 (Backups, Exportaciones y Watchdog) completada end-to-end ### Implementado -- Corrección de permisos en frontend (`permisosStore`): - - Se reemplazó resolución por "primer match" por combinación de permisos coincidente (cuenta/titular/global), alineado con lógica backend. - - `canEditCuenta`, `canDeleteInCuenta`, `canImportInCuenta` ahora evalúan por agregación (`Any`) de filas aplicables. - - `getColumnasEditables` y `getColumnasVisibles` ahora combinan reglas correctamente (null = sin restricción). -- Corrección en `ExtractosPage`: - - Arreglo de toggle de columnas visibles cuando no había preferencia previa (antes colapsaba a una sola columna). - - Limpieza de textos corruptos en UI. -- Corrección en `ExtractoTable`: - - Check/flag ahora respetan `canEditCell` (inputs deshabilitados si no hay permiso). - - Nota de flag solo envía persistencia al perder foco cuando la fila está marcada y editable. - - Limpieza de caracteres corruptos en encabezado de sort. -- Corrección de seguridad/autorización en backend (`ExtractosController`): - - `PATCH /api/extractos/{id}/check` y `PATCH /api/extractos/{id}/flag` ahora requieren permisos de edición (no solo visibilidad). - - Validación adicional de columnas editables para `checked`, `flagged` y `flagged_nota`. +- Backend API: + - Nuevos endpoints: + - `GET /api/backups` + - `POST /api/backups/manual` + - `POST /api/backups/{id}/restaurar` (confirmación doble con payload `confirmacion=RESTAURAR`) + - `GET /api/exportaciones` + - `POST /api/exportaciones/manual` + - `GET /api/exportaciones/{id}/descargar` + - `GET /api/sistema/estado` (polling de estado del Watchdog) + - Nuevos servicios: + - `BackupService` con ejecución de `pg_dump`, fallback automático a Docker (`gestion_caja_db`) en dev, auditoría y retención automática de backups. + - `ExportacionService` con generación XLSX (ClosedXML), registro en `EXPORTACIONES`, descarga y notificación admin. + - `WatchdogClientService` para comunicación segura API -> Watchdog con `X-Watchdog-Secret`. + - Nuevos jobs Hangfire: + - `BackupWeeklyJob` (domingo 02:00) + - `ExportMensualJob` (día 1 a las 01:00) + - Registro de jobs/servicios y cliente HTTP de Watchdog en `Program.cs`. + +- Watchdog Service: + - Endpoints operativos implementados: + - `POST /watchdog/restaurar-backup` + - `POST /watchdog/actualizar-app` + - `GET /watchdog/estado` + - Persistencia de estado en JSON compartido (`watchdog-state.json`) y autenticación por header `X-Watchdog-Secret`. + - Restauración con `pg_restore` y fallback automático a Docker en dev. + - Control de ciclo de servicio API (stop/start) con degradación segura en entornos no-Windows. + +- Frontend: + - `BackupsPage` real: + - listado paginado + - botón de backup manual + - restauración con confirmación doble + - overlay de carga + polling a `/api/sistema/estado` + - redirección a login tras restauración exitosa + - `ExportacionesPage` real: + - listado paginado + - selector de cuenta + - exportación manual + - descarga de XLSX + - Rutas actualizadas en `App.tsx` y control de visibilidad de navegación en `Sidebar.tsx`. + - Build actualizado y sincronizado a `backend/src/GestionCaja.API/wwwroot`. ### Archivos tocados -- frontend/src/stores/permisosStore.ts -- frontend/src/pages/ExtractosPage.tsx -- frontend/src/components/extractos/ExtractoTable.tsx -- frontend/src/components/extractos/EditableCell.tsx -- frontend/src/components/extractos/AuditCellModal.tsx -- frontend/src/components/extractos/AddRowForm.tsx -- backend/src/GestionCaja.API/Controllers/ExtractosController.cs -- backend/src/GestionCaja.API/wwwroot/* (build actualizado) +- backend/src/GestionCaja.API/Program.cs +- backend/src/GestionCaja.API/appsettings.json +- backend/src/GestionCaja.API/appsettings.Development.json +- backend/src/GestionCaja.API/appsettings.Production.json.template +- backend/src/GestionCaja.API/Controllers/BackupsController.cs +- backend/src/GestionCaja.API/Controllers/ExportacionesController.cs +- backend/src/GestionCaja.API/Controllers/SistemaController.cs +- backend/src/GestionCaja.API/DTOs/BackupsDtos.cs +- backend/src/GestionCaja.API/DTOs/ExportacionesDtos.cs +- backend/src/GestionCaja.API/Jobs/BackupWeeklyJob.cs +- backend/src/GestionCaja.API/Jobs/ExportMensualJob.cs +- backend/src/GestionCaja.API/Services/BackupService.cs +- backend/src/GestionCaja.API/Services/ExportacionService.cs +- backend/src/GestionCaja.API/Services/WatchdogClientService.cs +- backend/src/GestionCaja.API/Services/AuditActions.cs +- backend/src/GestionCaja.Watchdog/Program.cs +- backend/src/GestionCaja.Watchdog/appsettings.json +- backend/src/GestionCaja.Watchdog/Controllers/WatchdogController.cs +- backend/src/GestionCaja.Watchdog/Models/WatchdogContracts.cs +- backend/src/GestionCaja.Watchdog/Services/WatchdogStateStore.cs +- backend/src/GestionCaja.Watchdog/Services/WatchdogOperationsService.cs +- frontend/src/App.tsx +- frontend/src/components/layout/Sidebar.tsx +- frontend/src/pages/BackupsPage.tsx +- frontend/src/pages/ExportacionesPage.tsx +- frontend/src/styles/layout.css +- frontend/src/types/index.ts +- backend/src/GestionCaja.API/wwwroot/* +- atlas-blance/DOCUMENTACION_CAMBIOS.md ### Comandos ejecutados -- `dotnet build backend/GestionCaja.sln /p:UseAppHost=false` +- `dotnet build backend/GestionCaja.sln` - `dotnet test backend/GestionCaja.sln --no-build` -- `npm.cmd run build` (frontend) -- Copia de `frontend/dist` -> `backend/src/GestionCaja.API/wwwroot` -- Smoke test API fase 3 (create/update/check/flag/audit/delete/restore) con sesión autenticada +- `npm.cmd run build` +- `docker compose up -d` +- Copia de `frontend/dist/*` -> `backend/src/GestionCaja.API/wwwroot/` +- Arranque temporal en background: + - `dotnet run --no-build` (Watchdog) + - `dotnet run --no-build` (API) +- Smoke real Fase 9 por HTTPS: + - `POST /api/auth/login` + - `POST /api/backups/manual` + - `GET /api/backups` + - `POST /api/exportaciones/manual` + - `GET /api/exportaciones` + - `GET /api/exportaciones/{id}/descargar` + - `POST /api/backups/{id}/restaurar` + - polling `GET /api/sistema/estado` ### Resultado de verificación -- Backend compila OK (0 errores). -- Frontend compila/build OK. -- Tests backend OK (`6/6`). -- Smoke API fase 3 OK: - - `POST /api/extractos` -> OK - - `PUT /api/extractos/{id}` -> OK - - `PATCH /api/extractos/{id}/check` -> `200` (`Check actualizado`) - - `PATCH /api/extractos/{id}/flag` -> `200` (`Flag actualizado`) - - `GET /api/extractos/{id}/audit-celda?columna=concepto` -> historial presente - - `DELETE /api/extractos/{id}` + `POST /restaurar` -> OK +- Backend: compila OK (0 errores). +- Tests backend: OK (`22/22`). +- Frontend: build OK. +- Watchdog: endpoints activos y autenticados por secret. +- Backup manual: OK (archivo dump generado y registro `SUCCESS`). +- Exportación manual: OK (XLSX generado y descargable). +- Restauración via Watchdog: OK (request aceptada y estado `SUCCESS` reportado en `/api/sistema/estado`). +- Integración API->Watchdog validada con fallback Docker para desarrollo. ### Pendientes -- Para afirmar "0 bugs" con evidencia fuerte, falta suite dedicada de integración para matriz de permisos por columna (incluyendo combinaciones cuenta/titular/global y usuario no admin). -- Falta benchmark automatizado de scroll/edición con dataset 10k+ filas en navegador real (virtualización ya implementada y validada funcionalmente). +- Validación de retención (>6 semanas) cubierta por implementación y ejecución en flujo de backup, pero no se cerró con una prueba SQL sintética completamente automatizada en esta sesión por fricción de quoting contra PostgreSQL en shell Windows. +- Pendiente de proceso: sincronizar en Figma las nuevas pantallas `Backups` y `Exportaciones` cuando haya capacidad de escritura del conector en sesión. -## 2026-04-13 - Fase 1 (Responsive y UX Usuarios modal) +## 2026-04-15 - Fase 10 (Actualización de App) completada end-to-end -### Objetivo -- Afinar el modal de usuarios para tablet y movil, mejorando legibilidad, targets tactiles y uso del espacio sin cambiar el flujo funcional. +### Implementado +- Backend: + - Nuevo `ActualizacionService` con: + - `GetVersionActualAsync()` + - `CheckVersionDisponibleAsync()` (consulta `app_update_check_url`) + - `IniciarActualizacionAsync()` (disparo de update vía Watchdog) + - `SistemaController` ampliado con endpoints admin: + - `GET /api/sistema/version-actual` + - `GET /api/sistema/version-disponible` + - `POST /api/sistema/actualizar` + - `GET /api/sistema/estado` (se mantiene) + - `WatchdogClientService` ampliado con `SolicitarActualizacionAsync()` contra `/watchdog/actualizar-app`. + - Registro DI en `Program.cs` para `IActualizacionService`. -### Cambios aplicados -- Se agrega scroll horizontal controlado a la tabla de usuarios para evitar overflow en pantallas estrechas. -- El modal gana header estable, footer de acciones sticky y version full-height en movil para no perder las acciones principales. -- Se mejoran filtros, grids y checkboxes para tablet/movil con targets mas grandes y stacking mas limpio. -- Cada bloque de permiso ahora muestra un resumen de scope y placeholders mas claros en columnas visibles/editables. +- Frontend: + - Nuevo store `updateStore` para check de versión disponible con cache corta. + - Sidebar admin con badge de actualización en navegación (`Configuración`) cuando hay update disponible. + - `ConfiguracionPage` ampliada con sección de sistema: + - versión actual + - versión disponible + - estado de actualización + - botón `Verificar actualización` + - botón `Actualizar ahora` + - Flujo de actualización en frontend: + - llama `POST /api/sistema/actualizar` + - hace polling a `GET /api/sistema/estado` + - al `SUCCESS` redirige a login con mensaje de confirmación. + - `LoginPage` muestra mensaje post-update al volver desde el flujo de actualización. + +### Figma +- No se sincronizó Figma en esta sesión. +- Motivo: esta sesión cerró lógica de sistema (backend + wiring UI de configuración existente), sin iteración visual de layouts nuevos. +- Sigue pendiente operativo del proyecto: mantener sincronía de Figma cuando el conector permita escritura en sesión. ### Archivos tocados -- frontend/src/pages/UsuariosPage.tsx -- frontend/src/components/usuarios/UsuarioModal.tsx +- backend/src/GestionCaja.API/Controllers/SistemaController.cs +- backend/src/GestionCaja.API/DTOs/SistemaDtos.cs +- backend/src/GestionCaja.API/Program.cs +- backend/src/GestionCaja.API/Services/ActualizacionService.cs +- backend/src/GestionCaja.API/Services/WatchdogClientService.cs +- frontend/src/components/layout/Sidebar.tsx +- frontend/src/pages/ConfiguracionPage.tsx +- frontend/src/pages/LoginPage.tsx +- frontend/src/stores/updateStore.ts - frontend/src/styles/layout.css +- frontend/src/types/index.ts +- backend/src/GestionCaja.API/wwwroot/* - DOCUMENTACION_CAMBIOS.md ### Comandos ejecutados -- npm.cmd run build -- Copia de frontend/dist/* a backend/src/GestionCaja.API/wwwroot/ -- Verificacion automatizada con Chrome headless en https://localhost:5000 para desktop, tablet y movil +- `dotnet build backend/GestionCaja.sln` +- `dotnet test backend/GestionCaja.sln --no-build` +- `npm.cmd run build` +- `docker compose up -d` +- arranque local: + - `dotnet run --no-build` (Watchdog) + - `dotnet run --no-build` (API) +- smoke real fase 10: + - `POST /api/auth/login` + - `GET /api/sistema/version-actual` + - `GET /api/sistema/version-disponible` + - `POST /api/sistema/actualizar` + - polling `GET /api/sistema/estado` +- actualización de config de test para smoke: + - `CONFIGURACION.app_update_check_url = http://localhost:5088/update.json` +- copia de `frontend/dist/*` -> `backend/src/GestionCaja.API/wwwroot/` -### Resultado de verificacion +### Resultado de verificación +- Backend compila OK (`0 errores`). +- Tests backend OK (`22/22`). - Frontend build OK. -- Desktop: modal cargado sin overflow y footer de acciones presente. -- Tablet: modal refluye a dos columnas utiles, sin desbordes horizontales. -- Movil: modal full-height, botones principales siempre accesibles y pagina sin overflow lateral. +- Smoke real fase 10 OK: + - `version-disponible` reporta update cuando existe versión mayor. + - `POST /api/sistema/actualizar` responde `Accepted`. + - polling en `/api/sistema/estado` termina en `SUCCESS` con `operacion = UPDATE_APP`. + - flujo frontend preparado para volver a login con mensaje al completar. +- Migraciones automáticas al reiniciar: se mantienen activas vía `db.Database.Migrate()` en `Program.cs` (ya existente y verificado). ### Pendientes -- Ninguno en esta pasada; solo quedaria micro-polish visual futuro si quieres una jerarquia aun mas editorial. -## 2026-04-13 - Fase 4 (ajuste final y verificacion completa) +- Pendiente de proceso: sincronización en Figma de los cambios de UI de configuración/sidebar cuando haya capacidad de escritura del conector en sesión. + +## 2026-04-15 - Fase 9 - Auditoria y correccion de backups, exportaciones y watchdog ### Implementado +- Backend: + - Corregido `ExportacionService` para generar un XLSX distinto por ejecucion y no pisar historico de exportaciones manuales del mismo mes. + - Corregida la carga de columnas extra en exportacion para agrupar en memoria y evitar consultas LINQ fragiles. + - Añadido `NotificacionesAdminController` con: + - `GET /api/notificaciones-admin/resumen` + - `POST /api/notificaciones-admin/marcar-leidas` + - Corregido `WatchdogClientService` para parsear respuestas HTTP camelCase sin depender del state file. + - Endurecido `WatchdogController` en `POST /watchdog/actualizar-app`: + - valida `source_path` + - valida `target_path` + - rechaza source/target iguales + - Corregido `WatchdogOperationsService`: + - publica estado `RUNNING` antes de devolver `202 Accepted` + - reinicia la API tambien cuando restore/update fallan + - evita aceptar updates con rutas invalidas o iguales - Frontend: - - Se separo la UI para evitar conflicto entre fases: - - /importacion ahora es el wizard de importacion de Fase 4 (4 pasos completos). - - /formatos-importacion mantiene el CRUD de formatos (Fase 2) solo para ADMIN. - - Nuevo ImportacionPage (wizard): - - Paso 1: cuenta + textarea + preview primeras 3 filas. - - Paso 2: mapeo manual/precarga formato + columnas extra. - - Paso 3: preview validado con check/cross, errores por fila y seleccion de filas validas. - - Paso 4: resumen + confirmar + feedback. - - Se anadio acceso a "Formatos" en sidebar solo para admin y se dejo "Importacion" accesible para usuarios autenticados (el backend filtra permisos reales). - - Se preservo la pagina previa de formatos como FormatosImportacionPage. - -### Archivos tocados -- frontend/src/pages/ImportacionPage.tsx -- frontend/src/pages/FormatosImportacionPage.tsx -- frontend/src/App.tsx -- frontend/src/components/layout/Sidebar.tsx -- frontend/dist/* (build) -- backend/src/GestionCaja.API/wwwroot/* (copia de build) - -### Comandos ejecutados -- dotnet build (backend) -- npm.cmd run build (frontend) -- docker compose up -d -- ejecucion de API local (dotnet run) con logs -- prueba E2E real en API: - - POST /api/auth/login - - GET /api/importacion/contexto - - POST /api/importacion/validar (caso tab + errores por fila) - - POST /api/importacion/confirmar (importacion parcial) - - POST /api/importacion/validar (caso semicolon + fecha serial Excel) -- copia de frontend/dist/* -> backend/src/GestionCaja.API/wwwroot/ - -### Resultado de verificacion -- Backend compila OK. -- Frontend compila/build OK. -- Verificacion E2E de Fase 4 OK: - - Pegar desde Excel/tab-separated: OK. - - Parseo de fechas DD/MM/YYYY, YYYY-MM-DD, DD-MM-YYYY, serial Excel: OK. - - Errores por fila con mensaje especifico: OK. - - Importacion parcial (solo filas validas): OK. - -### Pendientes -- Prueba visual/manual final en navegador del wizard en entorno del usuario. -- Tests automatizados especificos de ImportacionService (parser/detector/validator) aun pendientes. + - Nuevo store `notificacionesAdminStore` para resumen y marcado de notificaciones admin. + - Sidebar admin ahora muestra badge en `Exportaciones` cuando hay exportaciones pendientes de revisar. + - `ExportacionesPage` marca como leidas las notificaciones de exportacion al entrar y tras generar exportacion manual. + - Rebuild de frontend y copia a `backend/src/GestionCaja.API/wwwroot/`. +- Tests: + - Nuevo test para exportaciones con rutas de archivo distintas por ejecucion. + - Nuevo test para resumen/marcado de `NOTIFICACIONES_ADMIN`. + - Nuevo test para fallback HTTP de `WatchdogClientService` con payload camelCase. -## 2026-04-13 - Fase 4 (verificacion visual E2E en navegador) +### Figma +- No se sincronizo Figma en esta sesion. +- Pendiente abierto: reflejar el badge de exportaciones en el archivo fuente cuando se haga una pasada de UI/Figma con el conector activo. ### Archivos tocados -- backend/src/GestionCaja.API/wwwroot/* (sync de build frontend final) +- backend/src/GestionCaja.API/Controllers/BackupsController.cs +- backend/src/GestionCaja.API/Controllers/ExportacionesController.cs +- backend/src/GestionCaja.API/Controllers/NotificacionesAdminController.cs +- backend/src/GestionCaja.API/DTOs/NotificacionesAdminDtos.cs +- backend/src/GestionCaja.API/Services/ExportacionService.cs +- backend/src/GestionCaja.API/Services/WatchdogClientService.cs +- backend/src/GestionCaja.Watchdog/Controllers/WatchdogController.cs +- backend/src/GestionCaja.Watchdog/Services/WatchdogOperationsService.cs +- backend/tests/GestionCaja.API.Tests/ExportacionServiceTests.cs +- backend/tests/GestionCaja.API.Tests/ManualProcessResponseTests.cs +- backend/tests/GestionCaja.API.Tests/NotificacionesAdminControllerTests.cs +- backend/tests/GestionCaja.API.Tests/WatchdogClientServiceTests.cs +- frontend/src/components/layout/Sidebar.tsx +- frontend/src/pages/ExportacionesPage.tsx +- frontend/src/stores/notificacionesAdminStore.ts +- backend/src/GestionCaja.API/wwwroot/* +- DOCUMENTACION_CAMBIOS.md ### Comandos ejecutados -- curl.exe -k https://localhost:5000/api/health -- npx.cmd -y playwright install chromium -- node scripts de verificacion visual (Playwright via NODE_PATH cache npx) -- dotnet build (backend) -- npm run build (frontend) -- copia de dist -> backend/src/GestionCaja.API/wwwroot/ +- `dotnet test backend/GestionCaja.sln` +- `npm.cmd run build` +- `dotnet build backend/src/GestionCaja.API/GestionCaja.API.csproj` +- `dotnet build backend/src/GestionCaja.Watchdog/GestionCaja.Watchdog.csproj` +- `robocopy frontend/dist backend/src/GestionCaja.API/wwwroot /MIR` +- smoke manual fase 9: + - `POST /api/auth/login` + - `POST /api/exportaciones/manual` + - `POST /api/backups/manual` + - `GET /api/notificaciones-admin/resumen` + - `POST /api/notificaciones-admin/marcar-leidas` + - `POST /watchdog/actualizar-app` + - `GET /watchdog/estado` ### Resultado de verificacion -- OK: flujo visual E2E login + wizard importacion completo (4 pasos) con capturas. -- OK: validacion muestra filas invalidas por fila. -- OK: confirmacion importa solo filas validas (resultado visual: 3 procesadas, 2 importadas, 1 con error). -- OK: backend y frontend compilan sin errores. - -### Evidencia -- artifacts/phase4-visual/01-login-filled.png -- artifacts/phase4-visual/02-step1-paste-preview.png -- artifacts/phase4-visual/03-step2-mapping.png -- artifacts/phase4-visual/04-step3-validation.png -- artifacts/phase4-visual/05-step4-summary-before-confirm.png -- artifacts/phase4-visual/06-step4-summary-after-confirm.png +- Backend OK: `27/27` tests pasando. +- Frontend OK: `npm.cmd run build` sin errores. +- Exportaciones manuales consecutivas ya no comparten el mismo `ruta_archivo`. +- Resumen de notificaciones admin funciona y `marcar-leidas` deja `exportaciones_pendientes = 0`. +- Watchdog rechaza updates invalidos con `400`. +- Watchdog publica `RUNNING` de inmediato al aceptar update y termina en `SUCCESS` con archivos copiados al target. +- Backup manual verificado en runtime con archivo generado en disco. +- Respuestas inmediatas de POST /api/backups/manual y POST /api/exportaciones/manual normalizadas: estado ahora sale como string (SUCCESS/FAILED), no como entero. ### Pendientes -- Ninguno de Fase 4. +- Sincronizar Figma del badge de exportaciones en una sesion con conector operativo. -## 2026-04-13 - Fase 1 (auditoria y correcciones finales) +## 2026-04-15 - Auditoria Fase 10 (correcciones post-verificacion) + +### Implementado +- Backend: + - Corregido `WatchdogOperationsService` para que la actualizacion haga reemplazo real del deploy: + - copia archivos nuevos/actualizados + - elimina archivos obsoletos del target + - conserva runtime local sensible (`appsettings*.json`, `logs`) + - Endurecido `WatchdogController` y `WatchdogOperationsService` para rechazar rutas solapadas/anidadas entre `source_path` y `target_path`. +- Frontend: + - Corregido `ConfiguracionPage` para hacer `POST /api/auth/logout` tras `SUCCESS` antes de redirigir a `/login`, evitando que la cookie `httpOnly` deje una sesion reutilizable despues de la actualizacion. +- Tests: + - Nuevo test de watchdog para verificar que el update elimina archivos obsoletos y preserva configuracion/logs. + - Nuevo test de watchdog para verificar rechazo de rutas anidadas. + - Corregido stub `RecordingEmailService` en tests para compilar con la interfaz actual de `IEmailService`. ### Archivos tocados -- backend/src/GestionCaja.API/Services/AuthService.cs -- backend/tests/GestionCaja.API.Tests/AuthServiceTests.cs -- frontend/src/services/api.ts -- backend/src/GestionCaja.API/wwwroot/* (sync del build frontend tras fix) +- backend/src/GestionCaja.Watchdog/Controllers/WatchdogController.cs +- backend/src/GestionCaja.Watchdog/Services/WatchdogOperationsService.cs +- backend/tests/GestionCaja.API.Tests/AlertaServiceTests.cs +- backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj +- backend/tests/GestionCaja.API.Tests/WatchdogOperationsServiceTests.cs +- frontend/src/pages/ConfiguracionPage.tsx +- DOCUMENTACION_CAMBIOS.md ### Comandos ejecutados -- python requests contra `https://localhost:5000/api/*` para auditar login, me, cambio-password, refresh, logout, CRUD usuarios, restore y lockout -- dotnet build -- dotnet test -- npm run build -- copia de `frontend/dist` a `backend/src/GestionCaja.API/wwwroot/` +- `dotnet test backend/GestionCaja.sln --no-restore` +- `dotnet build backend/GestionCaja.sln --no-restore` +- `npm.cmd run build` ### Resultado de verificacion -- OK: `POST /api/auth/login` entrega cookies + CSRF y `GET /api/auth/me` devuelve usuario + permisos. -- OK: `PUT /api/auth/cambiar-password` limpia `primer_login` y desbloquea el acceso al resto de endpoints. -- OK: `POST /api/auth/refresh-token` rota refresh token y CSRF; refresh revocado o reutilizado devuelve 401. -- OK: `POST /api/auth/logout` revoca refresh token y deja el token inutilizable. -- FIX: el bloqueo por intentos fallidos ahora devuelve 423 en el quinto intento fallido, no en el sexto. -- FIX: el frontend ahora sincroniza permisos al refrescar sesion y limpia permisos al perder la sesion. -- OK: CRUD de usuarios, emails adicionales, permisos y restauracion verificados por API. -- OK: backend compila, frontend compila y tests backend pasan. +- Backend compila OK. +- Frontend build OK. +- Tests backend OK (`29/29`). +- Verificacion funcional cubierta por tests nuevos del watchdog: + - el target ya no conserva binarios viejos tras actualizar + - se rechazan rutas `source/target` anidadas +- Flujo frontend endurecido: tras update exitoso se invalida sesion en backend antes de enviar al login. ### Pendientes -- No hay pendientes funcionales detectados dentro del alcance de Fase 1. +- Pendiente de proceso: reflejar en Figma los cambios de comportamiento/estado del flujo de actualizacion cuando haya conector de escritura disponible. -## 2026-04-14 - Fase 4 (auditoria critica y correccion de bugs) +## 2026-04-15 - Fase 11 completada (Papelera + Configuracion completa + Integraciones) ### Implementado - Backend: - - Se endurecio `ImportacionService` para rechazar mapeos invalidos que antes pasaban: - - indices base duplicados - - nombres de columnas extra duplicados - - `mapeo` nulo - - Se corrigio el parser de filas para no destruir columnas vacias al inicio o al final de la linea (`TrimEntries` estaba comiendose tabs validos). - - Se mejoro el parseo numerico para aceptar importes con separadores de miles (`1.234`, `1,234`, etc.) sin interpretarlos como decimales falsos. - - Se limpiaron mensajes de error visibles al usuario y se hicieron mas especificos (`Fecha vacia`, `Monto no numerico`, `Saldo vacio`, etc.). + - Nuevo `ConfiguracionController` (`/api/configuracion`) con: + - `GET /api/configuracion` + - `PUT /api/configuracion` + - `POST /api/configuracion/smtp/test` + - Nuevo `IntegracionesController` (`/api/integraciones/tokens`) con: + - `GET /api/integraciones/tokens` + - `GET /api/integraciones/tokens/{id}` + - `POST /api/integraciones/tokens` + - `PUT /api/integraciones/tokens/{id}` + - `POST /api/integraciones/tokens/{id}/revocar` + - `DELETE /api/integraciones/tokens/{id}` + - `GET /api/integraciones/tokens/{id}/auditoria` + - Extendido `EmailService` con `SendTestEmailAsync`. + - Nuevas acciones de auditoria para configuracion/smtp/integraciones. - Frontend: - - `ImportacionPage` ya no permite reconfirmar una importacion completada en el paso 4, evitando duplicados por doble confirmacion. - - Se tiparon los errores Axios del wizard en lugar de usar `any`. -- Testing: - - Se ampliaron los tests de `ImportacionService` para cubrir: - - separadores de miles - - mapeos duplicados - - mensajes especificos para valores vacios/no numericos + - Nuevo `PapeleraPage` real con tabs por entidad (titulares, cuentas, extractos, usuarios) y restauracion. + - `App.tsx` actualizado para usar `PapeleraPage` en `/papelera`. + - `ConfiguracionPage` rehacida por secciones: + - General + SMTP (incluye envio de correo de prueba) + - Divisas y tipos de cambio + - Sistema (version/check/update) + - Integraciones (creacion/listado/revocacion/eliminacion de tokens) + - Tipos TS ampliados para configuracion e integraciones. + - Estilos CSS ampliados para tabs/config/integraciones/papelera. + +### Figma +- No se sincronizo Figma en esta sesion. +- Pendiente abierto: reflejar `PapeleraPage` y la nueva estructura de `ConfiguracionPage` en el archivo fuente cuando el conector de escritura este operativo. ### Archivos tocados -- backend/src/GestionCaja.API/Services/ImportacionService.cs -- backend/tests/GestionCaja.API.Tests/ImportacionServiceTests.cs -- frontend/src/pages/ImportacionPage.tsx -- atlas-blance/DOCUMENTACION_CAMBIOS.md +- backend/src/GestionCaja.API/Controllers/ConfiguracionController.cs +- backend/src/GestionCaja.API/Controllers/IntegracionesController.cs +- backend/src/GestionCaja.API/DTOs/ConfiguracionDtos.cs +- backend/src/GestionCaja.API/DTOs/IntegracionesDtos.cs +- backend/src/GestionCaja.API/Services/AuditActions.cs +- backend/src/GestionCaja.API/Services/EmailService.cs +- frontend/src/App.tsx +- frontend/src/pages/ConfiguracionPage.tsx +- frontend/src/pages/PapeleraPage.tsx +- frontend/src/styles/layout.css +- frontend/src/types/index.ts +- backend/src/GestionCaja.API/wwwroot/* +- DOCUMENTACION_CAMBIOS.md ### Comandos ejecutados -- dotnet test backend/GestionCaja.sln -- npm.cmd run build -- npx.cmd eslint src/pages/ImportacionPage.tsx +- `docker compose -f docker-compose.yml up -d` +- `dotnet build backend/GestionCaja.sln` +- `dotnet test backend/GestionCaja.sln --no-build` +- `npm.cmd run build` +- `robocopy frontend/dist backend/src/GestionCaja.API/wwwroot /MIR` +- `docker run -d --name gestion_caja_mailhog -p 1025:1025 -p 8025:8025 mailhog/mailhog` +- smoke runtime Fase 11: + - `POST /api/auth/login` + - `GET/PUT /api/configuracion` + - `POST /api/configuracion/smtp/test` + - `POST/POST revocar/DELETE /api/integraciones/tokens` + - flujo papelera: + - `POST/DELETE/POST restaurar /api/titulares` + - `POST/DELETE/POST restaurar /api/cuentas` + - `POST/DELETE/POST restaurar /api/extractos` + - `POST/DELETE/POST restaurar /api/usuarios` + - verificacion de listado en papelera con `incluirEliminados=true` para cada entidad ### Resultado de verificacion -- OK: 12/12 tests backend en verde tras ampliar cobertura de importacion. -- OK: frontend build de produccion sin errores. -- OK: `ImportacionPage.tsx` pasa ESLint en aislamiento. -- FIX: se evita el bug de duplicar importaciones desde el propio wizard despues de confirmar. -- FIX: columnas vacias iniciales/finales ya no se rompen al validar. -- FIX: importes con miles se importan como miles, no como decimales falsos. +- Backend compila OK (`0 errores`). +- Tests backend OK (`29/29`). +- Frontend build OK. +- SMTP test endpoint validado contra MailHog local (`localhost:1025`) con respuesta `200`. +- CRUD de tokens de integracion validado (crear con token plano visible una vez, revocar, eliminar). +- Papelera validada end-to-end para titulares, cuentas, extractos y usuarios (aparece eliminado y restaura). ### Pendientes -- Sin pendientes funcionales detectados en Fase 4 tras esta pasada. -- Bloqueo de proceso: no se pudo sincronizar este ajuste menor de UX en Figma porque en esta sesion solo hay herramientas de lectura de Figma y no hay herramienta de escritura sobre el archivo fuente. +- Sincronizacion Figma pendiente por limitacion operativa del conector de escritura en esta sesion. -## 2026-04-14 - Revision Fase 5 (Dashboards) +## 2026-04-15 - Auditoria Fase 11 (correcciones post-verificacion) ### Implementado - Backend: - - `GET /api/dashboard/saldos-divisa` ahora acepta `titularId` opcional para devolver el desglose por divisa filtrado por titular, manteniendo la validacion de permisos del dashboard. + - Endurecida validacion de `IntegracionesController` para bloquear tokens OpenClaw inutiles o incoherentes: + - obliga a definir al menos lectura o escritura a nivel de token + - obliga a definir al menos un permiso de alcance + - rechaza `acceso_tipo` invalidos + - rechaza permisos de escritura si el token no tiene escritura global + - normaliza `acceso_tipo` a lowercase al persistir - Frontend: - - `DashboardTitularPage` se alineo con la spec de Fase 5 y ahora replica el layout principal con bloque de saldos por divisa + desglose por cuenta. - - `SaldoPorDivisaCard` dejo de desperdiciar `saldo_convertido`: ahora muestra el equivalente en la divisa principal seleccionada cuando aplica. - - Se copio el build actualizado a `backend/src/GestionCaja.API/wwwroot` para que el backend sirva el frontend corregido. -- Testing: - - Se agregaron tests de regresion para `DashboardService` cubriendo agregacion multi-divisa, KPIs mensuales, filtro por titular en `saldos-divisa` y denegacion de acceso para gerentes sin permiso sobre el titular. - -### Decisiones visuales -- Se mantuvo el lenguaje visual existente del dashboard. -- El dashboard por titular usa la misma rejilla que el dashboard principal para no abrir una UX paralela innecesaria. -- El equivalente convertido se muestra como texto secundario en la card de divisas para que el selector de divisa tenga impacto visible sin recargar la UI. + - `PapeleraPage` corregida para usar nombres singulares correctos al restaurar. + - Ruta `/papelera` protegida con `RoleGuard` de admin; antes estaba oculta en sidebar pero accesible por URL directa. + - `ConfiguracionPage` ampliada para cubrir mejor la especificacion: + - listado editable de divisas registradas (nombre, simbolo, activa, base) + - tabla visible de tipos de cambio vigentes + - sincronizacion de tipos con manejo de error/feedback + - validacion de tasa manual cuando no hay dos divisas activas + - logout real contra backend tras actualizacion satisfactoria + - `CreateTokenModal` y `TokenPermissionsEditor` endurecidos: + - no permite crear tokens sin permisos de alcance + - no permite crear tokens sin lectura/escritura global + - no permite scopes de escritura si el token no tiene escritura + - mensaje explicito cuando un token no tendria acceso a ningun dato +- Tests: + - Nuevos tests backend para validar rechazo de tokens sin scope, rechazo de accesos invalidos y normalizacion de `acceso_tipo`. ### Figma -- Pantalla/nodo actualizado: pendiente. -- Motivo: en esta sesion solo hubo herramientas de lectura de Figma; no hubo herramienta de escritura para sincronizar el archivo fuente `Gestion-de-Caja`. - -### Archivos tocados -- backend/src/GestionCaja.API/Controllers/DashboardController.cs -- backend/src/GestionCaja.API/Services/DashboardService.cs -- backend/tests/GestionCaja.API.Tests/DashboardServiceTests.cs -- frontend/src/components/dashboard/SaldoPorDivisaCard.tsx -- frontend/src/pages/DashboardPage.tsx -- frontend/src/pages/DashboardTitularPage.tsx -- frontend/src/styles/layout.css -- backend/src/GestionCaja.API/wwwroot/* -- atlas-blance/DOCUMENTACION_CAMBIOS.md +- No se sincronizo Figma en esta sesion. +- Pendiente abierto: reflejar en el archivo fuente la proteccion de `/papelera`, los nuevos bloques de divisas/tipos en `ConfiguracionPage` y los estados de validacion del modal de tokens. + +### Archivos tocados +- backend/src/GestionCaja.API/Controllers/IntegracionesController.cs +- backend/tests/GestionCaja.API.Tests/IntegracionesControllerTests.cs +- frontend/src/App.tsx +- frontend/src/pages/ConfiguracionPage.tsx +- frontend/src/pages/PapeleraPage.tsx +- frontend/src/components/integraciones/CreateTokenModal.tsx +- frontend/src/components/integraciones/TokenPermissionsEditor.tsx +- DOCUMENTACION_CAMBIOS.md ### Comandos ejecutados - `docker compose up -d` +- `docker run -d --name gestion_caja_mailhog -p 1025:1025 -p 8025:8025 mailhog/mailhog` - `dotnet build backend/GestionCaja.sln -c Release` -- `dotnet test backend/GestionCaja.sln -c Release` +- `dotnet test backend/GestionCaja.sln -c Release --no-build` - `npm.cmd run build` -- Copia de `frontend/dist` -> `backend/src/GestionCaja.API/wwwroot` -- Smoke real contra HTTPS local: - - login `POST /api/auth/login` - - `GET /api/dashboard/principal` - - `GET /api/dashboard/evolucion` - - `GET /api/dashboard/saldos-divisa?divisaPrincipal=USD&titularId=...` - - `GET /api/dashboard/titular/{id}` +- smoke backend: + - `POST /api/auth/login` + - `PUT /api/configuracion` + - `POST /api/configuracion/smtp/test` + - `POST /api/integraciones/tokens` (valido e invalidos) ### Resultado de verificacion -- OK: backend Release compila sin errores. -- OK: frontend build de produccion sin errores. -- OK: tests backend en verde (`14/14`). -- OK: el endpoint `saldos-divisa` filtrado por titular responde JSON correcto en ejecucion real. -- FIX: el dashboard por titular ya no incumple la spec al omitir el bloque de saldos por divisa. -- FIX: la divisa principal seleccionada ya tiene efecto visible dentro de la card de saldos por divisa. +- Backend compila OK en `Release`. +- Tests backend OK (`36/36`). +- Frontend build OK. +- Smoke manual: + - configuracion persiste correctamente + - correo de prueba SMTP responde `200` usando MailHog local + - token valido se crea + - token sin scope devuelve `400` + - token con scope de escritura sin permiso global devuelve `400` ### Pendientes -- Pendiente externo: sincronizar en Figma los cambios de layout del dashboard por titular cuando haya herramienta de escritura disponible en sesion. +- Sincronizacion Figma pendiente por limitacion operativa del conector de escritura en esta sesion. +- Hallazgo fuera de Fase 11: `appsettings.Development.json` fija Kestrel en `https://0.0.0.0:5000` y pisa `ASPNETCORE_URLS`, lo que complica arrancar una segunda instancia en otro puerto para smoke aislado. -## 2026-04-14 - Fase 6 (Tipos de Cambio) completada + +## 2026-04-15 - Fase 12 completada (Integracion OpenClaw end-to-end) ### Implementado - Backend: - - `TiposCambioService` ampliado para: - - sincronizacion real contra ExchangeRate-API (endpoint oficial del proveedor) - - cache en memoria con invalidacion en cambios manuales/sync - - fallback operativo a tasas persistidas en BD (si la API falla, no se sobreescriben tasas) - - CRUD de soporte para tipos de cambio y divisas activas - - Nuevos endpoints admin: - - `GET /api/tipos-cambio` - - `PUT /api/tipos-cambio/{origen}/{destino}` - - `POST /api/tipos-cambio/sincronizar` - - `GET /api/divisas` - - `POST /api/divisas` - - `PUT /api/divisas/{codigo}` - - Nuevos jobs Hangfire: - - `SyncTiposCambioJob` (cada 12 horas) - - `LimpiezaRefreshTokensJob` (diario) - - Registro de `HttpClient` para ExchangeRate-API y programacion de recurring jobs en `Program.cs`. + - Nuevo `IntegrationAuthMiddleware` para rutas `GET /api/integration/openclaw/*`: + - valida Bearer token contra `INTEGRATION_TOKENS` (SHA-256 hash) + - aplica rate limit por token (`integration_rate_limit_per_minute`, default 100 req/min) + - registra cada request en `AUDITORIA_INTEGRACIONES` con endpoint, metodo, codigo, IP y tiempo + - Nuevo `IntegrationTokenService`: + - generacion de token plano (`sk_gestion_caja_*`) + - hash SHA-256 + - validacion de token activo + - revocacion de token + - Nuevo `IntegrationAuthorizationService`: + - resuelve alcance por `INTEGRATION_PERMISSIONS` + - filtra titulares/cuentas/extractos segun permiso global, por titular o por cuenta + - Nuevo `IntegrationOpenClawController` con endpoints: + - `GET /api/integration/openclaw/titulares` + - `GET /api/integration/openclaw/saldos` + - `GET /api/integration/openclaw/extractos` + - `GET /api/integration/openclaw/grafica-evolucion` + - `GET /api/integration/openclaw/alertas` + - `GET /api/integration/openclaw/auditoria` + - `IntegracionesController` actualizado para usar `IntegrationTokenService` y añadir: + - `GET /api/integraciones/tokens/{id}/metricas` (total requests, % exitoso, tiempo promedio) + - `GET /api/integraciones/tokens/auditoria` (tabla paginada global) + - Registro DI y pipeline actualizado en `Program.cs`. - Frontend: - - Nueva `ConfiguracionPage` (reemplaza placeholder) con: - - estado de ultima sincronizacion - - indicador visual de tasas desactualizadas (>24h) - - sync manual - - edicion manual de tasas - - gestion de divisas (editar + alta) - - Ruta `/configuracion` protegida para `ADMIN`. - - Sidebar ajustado para ocultar Configuracion a no-admin. - - Estilos CSS para la pagina de configuracion. - -### Archivos tocados -- backend/src/GestionCaja.API/Services/TiposCambioService.cs -- backend/src/GestionCaja.API/Controllers/TiposCambioController.cs -- backend/src/GestionCaja.API/Controllers/DivisasController.cs -- backend/src/GestionCaja.API/Jobs/SyncTiposCambioJob.cs -- backend/src/GestionCaja.API/Jobs/LimpiezaRefreshTokensJob.cs -- backend/src/GestionCaja.API/Program.cs -- frontend/src/pages/ConfiguracionPage.tsx -- frontend/src/App.tsx -- frontend/src/components/layout/Sidebar.tsx -- frontend/src/styles/layout.css - -### Comandos ejecutados -- `docker compose up -d` -- `dotnet build` (backend) -- `npm.cmd run build` (frontend) -- Arranque API temporal para pruebas E2E de Fase 6 (`dotnet run --no-build`) -- Verificacion jobs Hangfire en PostgreSQL: - - `SELECT value FROM hangfire.set WHERE key = 'recurring-jobs' ORDER BY value;` - -### Verificacion funcional -- Build backend: OK (0 errores). -- Build frontend: OK. -- Smoke tests API Fase 6 con sesion admin + CSRF: - - `POST /api/auth/login` -> 200 - - `GET /api/divisas` -> 200 - - `GET /api/tipos-cambio` -> 200 - - `PUT /api/tipos-cambio/EUR/USD` -> 200 - - `POST /api/tipos-cambio/sincronizar` -> 200 - - `POST /api/divisas` (GBP) -> 201 - - `PUT /api/divisas/USD` -> 200 -- Recurring jobs registrados en Hangfire: `sync-tipos-cambio`, `limpieza-refresh-tokens`. - -### Pendientes -- Sin pendientes funcionales detectados dentro del alcance de Fase 6. -- Pendiente externo de proceso: sincronizacion en Figma no ejecutada en esta sesion (no se realizo escritura en archivo de diseño). - -## 2026-04-14 - Fase 6 (auditoria y correcciones) + - Integraciones en componentes separados: + - `TokenList` + - `CreateTokenModal` + - `TokenCreatedModal` + - `TokenPermissionsEditor` + - `ConfiguracionPage` refactorizada para usar esos componentes y mostrar metricas por token. + - Nueva tabla `IntegrationAuditTable` integrada en `AuditoriaPage` como pestaña "Auditoria Integraciones". + - Estilos de modal añadidos en `layout.css`. -### Hallazgos corregidos -- `TiposCambioService` no resolvia conversiones cruzadas cuando la divisa base activa dejaba de ser `EUR`; se reemplazo la resolucion fija por un catalogo/grafo de tasas para soportar rutas arbitrarias entre divisas. -- Al cambiar la divisa base, `divisa_principal_default` podia quedar desalineada respecto a `DIVISAS_ACTIVAS`; ahora se sincroniza al guardar la base y `DashboardService` prioriza la base activa real. -- `ConfiguracionPage` podia conservar una combinacion origen/destino invalida despues de desactivar o cambiar la base de una divisa; ahora normaliza la seleccion y bloquea guardar si origen y destino coinciden. -- Los tests backend de dashboard estaban rotos por una firma obsoleta de `TiposCambioService`; se actualizaron y se anadieron regresiones de base no-EUR y fallback offline. +- Testing backend: + - `IntegrationTokenServiceTests` + - `IntegrationAuthorizationServiceTests` ### Figma -- Sin cambios visuales en esta sesion. -- No se actualizo Figma porque el ajuste en frontend fue de validacion/estado, no de diseno. +- No se sincronizo Figma en esta sesion. +- Pendiente operativo abierto: reflejar en Figma la nueva pestaña de auditoria de integraciones y el flujo modal de tokens en configuracion cuando el conector de escritura este disponible. ### Archivos tocados -- backend/src/GestionCaja.API/Services/TiposCambioService.cs -- backend/src/GestionCaja.API/Services/DashboardService.cs -- backend/tests/GestionCaja.API.Tests/DashboardServiceTests.cs -- backend/tests/GestionCaja.API.Tests/TiposCambioServiceTests.cs +- backend/src/GestionCaja.API/Program.cs +- backend/src/GestionCaja.API/Controllers/IntegracionesController.cs +- backend/src/GestionCaja.API/Controllers/IntegrationOpenClawController.cs +- backend/src/GestionCaja.API/DTOs/IntegracionesDtos.cs +- backend/src/GestionCaja.API/Middleware/IntegrationAuthMiddleware.cs +- backend/src/GestionCaja.API/Services/IntegrationTokenService.cs +- backend/src/GestionCaja.API/Services/IntegrationAuthorizationService.cs +- backend/tests/GestionCaja.API.Tests/IntegrationTokenServiceTests.cs +- backend/tests/GestionCaja.API.Tests/IntegrationAuthorizationServiceTests.cs - frontend/src/pages/ConfiguracionPage.tsx +- frontend/src/pages/AuditoriaPage.tsx +- frontend/src/components/integraciones/CreateTokenModal.tsx +- frontend/src/components/integraciones/TokenCreatedModal.tsx +- frontend/src/components/integraciones/TokenList.tsx +- frontend/src/components/integraciones/TokenPermissionsEditor.tsx +- frontend/src/components/auditoria/IntegrationAuditTable.tsx +- frontend/src/styles/layout.css +- frontend/src/types/index.ts - DOCUMENTACION_CAMBIOS.md ### Comandos ejecutados -- `docker compose up -d` -- `dotnet build -c Release` -- `dotnet test -c Release` +- `docker compose -f docker-compose.yml up -d` +- `dotnet build backend/GestionCaja.sln` +- `dotnet test backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj` - `npm.cmd run build` -- Arranque aislado de `GestionCaja.API` Release contra base PostgreSQL temporal para smoke real de Fase 6. -- Verificacion SQL directa en PostgreSQL temporal para `CONFIGURACION`, `TIPOS_CAMBIO` y hashes de recurring jobs de Hangfire. +- smoke backend fase 12: + - `POST /api/auth/login` + - `POST /api/integraciones/tokens` + - `GET /api/integration/openclaw/saldos` + - `POST /api/integraciones/tokens/{id}/revocar` + - `GET /api/integration/openclaw/saldos` (token revocado -> 401) + - `GET /api/integraciones/tokens/auditoria` + - prueba determinista rate limit (ajuste temporal a 5): `GET /api/integration/openclaw/titulares` x6 -> 6ta = 429 + - restaurado `integration_rate_limit_per_minute` a 100 ### Resultado de verificacion -- Backend Release: OK (0 errores de compilacion). -- Frontend build: OK. -- Tests backend: OK (`18/18`). -- Smoke real Fase 6: OK. - - login admin + cambio obligatorio de password - - sync manual inicial -> `updated_count = 3` - - alta de `GBP` -> OK - - tasa manual `USD -> GBP` -> fuente `MANUAL` - - cambio de base a `USD` -> OK - - sync posterior -> `updated_count = 4` - - `CONFIGURACION.divisa_principal_default = USD` - - tasas persistidas `USD -> EUR/MXN/DOP/GBP` - - recurring jobs presentes: `sync-tipos-cambio`, `limpieza-refresh-tokens` +- Backend compila OK. +- Frontend build OK. +- Tests backend OK (`36/36`). +- Verificado funcionalmente: + - token nuevo accede a OpenClaw (`200`) + - token revocado devuelve `401` + - auditoria de integraciones admin disponible (`200`) + - rate limit por token operativo (`429` al exceder limite) ### Pendientes -- No se detectaron pendientes funcionales nuevos dentro del alcance de Fase 6. -- Sigue pendiente externo de proceso: sincronizacion de Figma cuando haya cambios visuales reales o escritura disponible. +- Sincronizacion Figma pendiente por disponibilidad del conector de escritura. -## 2026-04-14 - Fase 7 (Alertas de Saldo Bajo) completada end-to-end +## 2026-04-15 - Fase 13 completada (Polish, seguridad y responsive) ### Implementado -- Backend: - - Nuevo `AlertasController` con endpoints: - - `GET /api/alertas` - - `GET /api/alertas/contexto` - - `POST /api/alertas` - - `PUT /api/alertas/{id}` - - `DELETE /api/alertas/{id}` - - `GET /api/alertas/activas` - - Nuevo `AlertaService`: - - `EvaluateSaldoPostAsync()` se ejecuta automáticamente tras `POST/PUT /api/extractos`. - - Resolución de alerta aplicable: por cuenta (si existe) y fallback a global (`cuenta_id = null`). - - Actualiza `fecha_ultima_alerta` y registra auditoría de disparo. - - Nuevo `EmailService` con MailKit: - - Lee SMTP y `app_base_url` desde `CONFIGURACION`. - - Genera email HTML con titular, cuenta, saldo actual, mínimo y link a cuenta. - - `ExtractosController` actualizado para disparar evaluación de alertas después de crear/editar extracto. - Frontend: - - Nueva `AlertasPage` real (admin): CRUD de alerta global + alertas por cuenta + destinatarios. - - `alertasStore` completo: carga de alertas activas, contador para sidebar, dismiss por sesión. - - `AlertBanner` nuevo en layout (dismissible por sesión). - - Badge de alertas en sidebar. - - Ruta `/alertas` deja de ser placeholder y queda protegida para `ADMIN`. + - Error boundaries aplicados por seccion principal de rutas con `AppErrorBoundary`. + - Sistema de toast global conectado al store de UI (`ToastViewport`) y envio uniforme de errores API desde interceptor Axios. + - Nueva pagina 404 real (`NotFoundPage`) en lugar de placeholder generico. + - Skeleton reutilizable (`PageSkeleton`) y empty state reutilizable (`EmptyState`) en vistas clave: + - dashboard global y por titular + - cuentas, titulares, formatos de importacion + - auditoria, backups, exportaciones, papelera + - detalle de cuenta y detalle de titular + - estado de carga en configuracion + - Responsive reforzado: + - sidebar colapsable en tablet + - navegacion inferior utilizable en mobile + - boton de toggle de sidebar en topbar + - ajustes de acciones/notificaciones para pantallas pequenas +- Backend: + - Verificacion de CSRF para endpoints de mutacion bajo `/api` confirmada por pipeline de `CsrfMiddleware`. + - Correccion de indice en migracion inicial para `ALERTA_DESTINATARIOS`: + - ahora es compuesto y unico por (`alerta_id`, `usuario_id`) para alinear modelo y DDL. + - Revision de logs sensibles: + - sin hallazgos de logs con tokens/passwords en templates de logging de servicios/controladores. + +### Figma +- No se sincronizo Figma en esta sesion. +- Pendiente abierto: reflejar en Figma la navegacion responsive final (colapso de sidebar y comportamiento mobile), estados de error boundary y 404 final. ### Archivos tocados -- backend/src/GestionCaja.API/Controllers/AlertasController.cs -- backend/src/GestionCaja.API/Controllers/ExtractosController.cs -- backend/src/GestionCaja.API/DTOs/AlertasDtos.cs -- backend/src/GestionCaja.API/Services/AlertaService.cs -- backend/src/GestionCaja.API/Services/EmailService.cs -- backend/src/GestionCaja.API/Services/AuditActions.cs -- backend/src/GestionCaja.API/Program.cs +- backend/src/GestionCaja.API/Migrations/20260413120705_Initial.cs - frontend/src/App.tsx -- frontend/src/components/layout/AlertBanner.tsx +- frontend/src/components/auth/ProtectedRoute.tsx +- frontend/src/components/common/AppErrorBoundary.tsx +- frontend/src/components/common/EmptyState.tsx +- frontend/src/components/common/PageSkeleton.tsx +- frontend/src/components/common/ToastViewport.tsx - frontend/src/components/layout/Layout.tsx - frontend/src/components/layout/Sidebar.tsx - frontend/src/components/layout/TopBar.tsx -- frontend/src/pages/AlertasPage.tsx -- frontend/src/pages/LoginPage.tsx -- frontend/src/stores/alertasStore.ts +- frontend/src/pages/AuditoriaPage.tsx +- frontend/src/pages/BackupsPage.tsx +- frontend/src/pages/ConfiguracionPage.tsx +- frontend/src/pages/CuentaDetailPage.tsx +- frontend/src/pages/CuentasPage.tsx +- frontend/src/pages/DashboardPage.tsx +- frontend/src/pages/DashboardTitularPage.tsx +- frontend/src/pages/ExportacionesPage.tsx +- frontend/src/pages/FormatosImportacionPage.tsx +- frontend/src/pages/NotFoundPage.tsx +- frontend/src/pages/PapeleraPage.tsx +- frontend/src/pages/TitularDetailPage.tsx +- frontend/src/pages/TitularesPage.tsx +- frontend/src/services/api.ts - frontend/src/styles/layout.css - backend/src/GestionCaja.API/wwwroot/* -- atlas-blance/DOCUMENTACION_CAMBIOS.md +- DOCUMENTACION_CAMBIOS.md ### Comandos ejecutados -- `docker compose up -d` - `dotnet build backend/GestionCaja.sln` - `dotnet test backend/GestionCaja.sln --no-build` +- `dotnet build backend/GestionCaja.sln -c Release` +- `dotnet test backend/GestionCaja.sln -c Release --no-build` - `npm.cmd run build` -- copia `frontend/dist/*` -> `backend/src/GestionCaja.API/wwwroot/` -- Smoke Fase 7 real contra `https://localhost:5000`: - - login admin - - limpieza de alertas previas - - creación alerta global - - creación alerta por cuenta - - creación de extracto con saldo bajo para disparo - - consulta `GET /api/alertas/activas` - - validación `fecha_ultima_alerta` -- Verificación fallback global: - - creación de extracto con saldo bajo en cuenta sin alerta específica - - validación de que se usa `alerta_id` global -- SMTP de prueba: - - contenedor `gestion_caja_mailhog` (puertos `1025/8025`) - - actualización de claves SMTP en `CONFIGURACION` - - verificación de mensajes en `http://localhost:8025/api/v2/messages` +- `robocopy frontend/dist backend/src/GestionCaja.API/wwwroot /MIR` +- revision de seguridad/logs: + - busqueda de logging sensible (`token|password|secret`) en backend + - verificacion de atributos de endpoints mutables en controllers -### Resultado de verificación -- Backend compila OK (`0 errores`). +### Resultado de verificacion - Frontend build OK. -- Tests backend OK (`18/18`). -- Fase 7 validada por smoke real: - - alerta por cuenta se dispara al crear extracto bajo mínimo. - - fallback global funciona en cuenta sin alerta propia. - - banner y contador consumen `GET /api/alertas/activas`. - - `fecha_ultima_alerta` se actualiza. - - email enviado y recibido en MailHog (`mailhog_messages = 1` en el flujo validado). +- Backend tests OK (`36/36`) en Debug y Release. +- Backend build OK en Release. +- Backend build en Debug no concluye mientras la API esta corriendo por bloqueo del ejecutable (`GestionCaja.API.exe` en uso); no es error de codigo. +- Activos de frontend copiados a `wwwroot`. +- Checklist fase 13 cubierta en codigo: + - dark/light mode preservado y aplicado en componentes de layout + - responsive tablet/mobile reforzado + - CSRF aplicado a mutaciones `/api` + - indices revisados y corregido indice compuesto unico faltante + - error boundaries, toasts de error, skeletons, empty states y 404 implementados + - sin hallazgos de logging de secrets/tokens/passwords ### Pendientes -- Pendiente de proceso: sincronización en Figma de la pantalla de alertas y del banner cuando esté disponible la escritura de Figma en sesión. +- Sincronizacion Figma pendiente por disponibilidad del conector de escritura. +- Validacion visual manual final recomendada en navegador real para confirmar comportamiento responsive exacto en breakpoints de tablet/mobile. -### Estado Figma Fase 7 (bloqueo de permisos) -- Intento de sincronizacion Figma en esta sesion bloqueado por permisos del conector: `seatType: view` (sin capacidad de escritura). -- Validacion ejecutada con `mcp__codex_apps__figma._whoami` (usuario: andi.seo.social@gmail.com, plan starter, team::1625133788451949600). -- Llamadas de lectura (`_get_metadata`, `_get_screenshot`) al archivo `cFYBwjPLqAArvgg04DJLmp` terminaron por timeout. -- Accion necesaria para cerrar Fase 7 al 100 por ciento segun regla del proyecto: acceso Editor en Figma + reintento de sincronizacion desde MCP. +--- +## 2026-04-26 - Actualizacion post-instalacion endurecida -## 2026-04-14 - Fase 7 - revision correctiva +**Version:** V-01.05 -### Que se reviso -- Verificacion completa de la Fase 7 contra la especificacion: alertas por saldo, destinatarios, banner, badge, permisos y robustez del flujo. +**Trabajo realizado:** Corregir los dos fallos detectados al actualizar una instalacion real desde `V-01.03` con paquete `V-01.04`: reenvio roto de `-InstallPath` y arranque bloqueado por formatos de importacion duplicados. -### Bugs y desviaciones corregidos -- La ruta `/alertas` ya no queda bloqueada solo para `ADMIN`: cualquier usuario autenticado puede ver sus alertas activas, y `ADMIN` mantiene la configuracion. -- El sidebar ya no oculta `/alertas` a usuarios no admin, asi que el badge y el acceso a alertas activas dejan de ser un callejon sin salida. -- `alertasStore.loadAlertasActivas()` ya no rompe el bootstrap de sesion si falla `/api/alertas/activas`; ahora limpia estado obsoleto y guarda el error. -- El backend ya no evalua alertas sobre cuentas inactivas ni permite crear alertas para cuentas inactivas. -- Se agregaron restricciones unicas en base de datos para impedir datos duplicados que la API no estaba blindando por si sola: - - una sola alerta global (`cuenta_id IS NULL`) - - una sola alerta por cuenta - - un solo destinatario por par `alerta_id` + `usuario_id` -- Se añadieron tests para cubrir override de alerta por cuenta sobre alerta global y la exclusion de cuentas inactivas. +**Archivos tocados:** +- `Atlas Balance/scripts/update.ps1` +- `Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/SeedDataTests.cs` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/Versiones/v-01.05.md` + +**Cambios implementados:** +- `update.ps1` declara explicitamente `InstallPath` y `SkipBackup`, y los reenvia a `Actualizar-AtlasBalance.ps1` sin depender de argumentos residuales. +- `SeedData` comprueba IDs fijos existentes antes de insertar formatos de importacion por defecto. +- Agregado test de regresion para una fila legacy de `FORMATOS_IMPORTACION` con ID fijo ya existente pero datos de banco/divisa incompletos. + +**Comandos ejecutados:** +- Parser PowerShell sobre `Atlas Balance/scripts/update.ps1` y `Atlas Balance/scripts/Actualizar-AtlasBalance.ps1`. +- `dotnet test "Atlas Balance/backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj" --filter SeedDataTests` + +**Resultado de verificacion:** +- Parser PowerShell OK para `update.ps1` y `Actualizar-AtlasBalance.ps1`. +- `SeedDataTests`: 5/5 OK. + +**Pendientes:** +- Regenerar paquete `V-01.05` antes de publicarlo o usarlo para actualizar servidores. + +## 2026-04-15 - Fase 12 (Integracion OpenClaw) + +### Resumen +- Se reviso Fase 12 contra la especificacion de backend y frontend. +- Frontend revisado: existen `TokenList`, `CreateTokenModal`, `TokenCreatedModal`, `TokenPermissionsEditor` e `IntegrationAuditTable`. No hizo falta tocar UI en esta sesion. +- Backend corregido en puntos que estaban mal o incompletos: + - el scope de permisos ahora filtra por `acceso_tipo` para que un permiso de escritura no abra lectura por accidente; + - los endpoints OpenClaw devuelven envelope consistente (`exito`, `datos`, `errores`, `advertencias`, `metadata`); + - `extractos` ahora devuelve `tipo_movimiento`; + - los endpoints aceptan parametros snake_case alineados con la spec (`titular_id`, `cuenta_id`, `fecha_desde`, `fecha_hasta`, `limite`, `pagina`, etc.); + - `grafica-evolucion` recalcula la serie con agregacion diaria para `1m` y semanal para el resto, con estadisticas; + - JWT ya no intenta parsear los Bearer tokens de OpenClaw, evitando ruido falso en logs; + - el token plano ahora sale en formato base64url; + - los tokens de solo escritura dejaban de ser validos en autenticacion base; eso se corrigio para que autentiquen y luego el endpoint de lectura responda 403 por permisos, que es lo correcto. +- Se anadieron tests de regresion para no volver a romper esto. + +### Figma +- No se sincronizo Figma en esta sesion porque no hubo cambios de UI. ### Archivos tocados -- backend/src/GestionCaja.API/Data/AppDbContext.cs -- backend/src/GestionCaja.API/Services/AlertaService.cs -- backend/src/GestionCaja.API/Controllers/AlertasController.cs -- backend/src/GestionCaja.API/Migrations/20260414200917_AlertasSaldoConstraints.cs -- backend/src/GestionCaja.API/Migrations/20260414200917_AlertasSaldoConstraints.Designer.cs -- backend/src/GestionCaja.API/Migrations/AppDbContextModelSnapshot.cs -- backend/tests/GestionCaja.API.Tests/AlertaServiceTests.cs -- frontend/src/stores/alertasStore.ts -- frontend/src/components/layout/Sidebar.tsx -- frontend/src/App.tsx -- frontend/src/pages/AlertasPage.tsx -- atlas-blance/DOCUMENTACION_CAMBIOS.md +- backend/src/GestionCaja.API/Controllers/IntegrationOpenClawController.cs +- backend/src/GestionCaja.API/DTOs/IntegracionesDtos.cs +- backend/src/GestionCaja.API/Middleware/IntegrationAuthMiddleware.cs +- backend/src/GestionCaja.API/Program.cs +- backend/src/GestionCaja.API/Services/IntegrationAuthorizationService.cs +- backend/src/GestionCaja.API/Services/IntegrationTokenService.cs +- backend/tests/GestionCaja.API.Tests/IntegrationAuthorizationServiceTests.cs +- backend/tests/GestionCaja.API.Tests/IntegrationOpenClawControllerTests.cs +- backend/tests/GestionCaja.API.Tests/IntegrationTokenServiceTests.cs +- DOCUMENTACION_CAMBIOS.md ### Comandos ejecutados -- `dotnet build` en `backend/src/GestionCaja.API` -- `dotnet test` en `backend/tests/GestionCaja.API.Tests` -- `dotnet ef migrations add AlertasSaldoConstraints` -- `dotnet ef database update` -- `npm.cmd run build` en `frontend` -- `docker exec gestion_caja_db psql ...` para verificar indices unicos -- `curl.exe -k ...` para login y smoke de: - - `GET /api/alertas` - - `GET /api/alertas/activas` - - `GET /api/alertas/contexto` +- `dotnet build backend/GestionCaja.sln` +- `dotnet test backend/GestionCaja.sln` +- `npm.cmd run build` +- smoke manual sobre API: + - `curl.exe -k https://localhost:5000/api/health` + - login admin via `/api/auth/login` + - creacion de tokens via `/api/integraciones/tokens` + - llamadas a `/api/integration/openclaw/titulares` + - revocacion via `/api/integraciones/tokens/{id}/revocar` + - consulta de metricas y auditoria de integraciones + - rafaga de 101 requests para confirmar rate limit 429 ### Resultado de verificacion -- Backend compila OK. -- Tests backend OK (`20/20`). -- Frontend build OK. -- Migracion aplicada OK. -- Restricciones unicas verificadas en PostgreSQL. -- Endpoints de Fase 7 responden `200` tras autenticacion. -- La implementacion anterior no estaba cerrada del todo; esta revision elimina fallos funcionales y endurece integridad de datos. +- Backend tests OK: `41/41`. +- Frontend build OK con `npm.cmd run build`. +- Smoke backend OK: + - token invalido devuelve `401` con envelope OpenClaw; + - token con scope limitado devuelve solo el titular/cuenta autorizados; + - token revocado devuelve `401`; + - rate limit corta en `429` al request 101; + - auditoria de integraciones registra requests, status e IP; + - metricas calculan total requests, porcentaje de exito y tiempo promedio; + - token de solo escritura ya no se trata como token invalido: autentica y un endpoint de lectura devuelve `403` por falta de permiso de lectura. ### Pendientes -- Falta validacion visual manual completa del flujo de alertas en navegador tras los cambios de acceso. -- Falta revalidar envio SMTP real en esta sesion; no fue necesario para corregir los bugs detectados. -- Sigue bloqueada la sincronizacion en Figma en esta sesion por falta de capacidad de escritura del conector. +- No hay cambios frontend en esta sesion, asi que no hay sincronizacion Figma pendiente por este bloque concreto. +- Recomendable, pero no bloqueante: sumar tests HTTP end-to-end del middleware para dejar cubierto el flujo completo fuera de unit tests. + +## 2026-04-18 - Tickets de seguimiento (TICKETS.md) + +### Implementado +- `TICKET-004` completado: + - Nuevo modelo y tabla `PREFERENCIAS_USUARIO_CUENTA` para separar preferencias UI de `PERMISOS_USUARIO`. + - `PermisoUsuario` deja de almacenar `columnas_visibles` y `columnas_editables`. + - `ExtractosController` ahora guarda/lee columnas visibles desde `PreferenciasUsuarioCuenta`. + - `ExtractosController.GetPermission` ahora resuelve `columnas_editables` desde preferencias (no desde permisos). + - `UsuariosController` y `AuthService` fueron adaptados para mapear columnas visibles/editables desde preferencias al DTO sin romper contrato API. + - Nueva migracion EF Core `SplitPermisosPreferencias` con copia de datos legacy (`PERMISOS_USUARIO` -> `PREFERENCIAS_USUARIO_CUENTA`) antes de eliminar columnas antiguas. +- `TICKET-005` completado: + - Auditoria de creacion de `PermisoUsuario` y entrega de informe en `TICKET-005_AUDITORIA_PERMISOS_USUARIO.md`. + - Resultado: solo queda creacion en `UsuariosController` bajo endpoints admin. +- Ajuste extra frontend: + - Corregido tipado de `deleted_at` en `PapeleraPage.tsx` para evitar error de TypeScript en build. + +### Archivos tocados +- `backend/src/GestionCaja.API/Models/Entities.cs` +- `backend/src/GestionCaja.API/Data/AppDbContext.cs` +- `backend/src/GestionCaja.API/Controllers/ExtractosController.cs` +- `backend/src/GestionCaja.API/Controllers/UsuariosController.cs` +- `backend/src/GestionCaja.API/Services/AuthService.cs` +- `backend/src/GestionCaja.API/Services/BackupService.cs` +- `backend/src/GestionCaja.API/Migrations/20260418173448_SplitPermisosPreferencias.cs` +- `backend/src/GestionCaja.API/Migrations/20260418173448_SplitPermisosPreferencias.Designer.cs` +- `backend/src/GestionCaja.API/Migrations/AppDbContextModelSnapshot.cs` +- `backend/tests/GestionCaja.API.Tests/ExtractosControllerTests.cs` +- `frontend/src/pages/PapeleraPage.tsx` +- `TICKET-005_AUDITORIA_PERMISOS_USUARIO.md` +- `DOCUMENTACION_CAMBIOS.md` + +### Comandos ejecutados +- `Get-Content -Raw TICKETS.md` +- busquedas de auditoria: + - `Get-ChildItem -Path backend/src -Recurse -Include *.cs | Select-String -Pattern 'new PermisoUsuario\\s*\\{|PermisosUsuario\\.Add\\('` +- `dotnet build` en: + - `backend/src/GestionCaja.API` + - `backend/src/GestionCaja.Watchdog` +- `dotnet ef migrations add SplitPermisosPreferencias` +- `npm.cmd run lint` +- `npm.cmd run build` (fallo por entorno Vite `spawn EPERM`) +- `npx.cmd tsc --noEmit` + +### Resultado de verificacion +- Backend API compila OK. +- Watchdog compila OK. +- Migracion EF generada y compilada. +- Frontend lint OK (`--max-warnings 0`). +- TypeScript frontend OK (`npx tsc --noEmit`). +- `vite build` no se pudo completar por error de entorno (`spawn EPERM`) al cargar `vite.config.ts` (no error de tipado de aplicacion). +- `dotnet test` del proyecto de tests no devolvio salida util en este entorno (se queda colgado o finaliza sin diagnostico), por lo que no hay corrida de tests confirmada en esta sesion. -## 2026-04-14 - Fase 8 (Auditoría UI) completada end-to-end +### Pendientes +- Ejecutar `dotnet test backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj` en entorno estable para validar regresion completa. +- Ejecutar `npm run build` en entorno donde Vite pueda spawnear procesos sin `EPERM`. +## 2026-04-15 - Fase 13 re-auditada y corregida ### Implementado - Backend: - - Nuevo `AuditoriaController` (`/api/auditoria`) con: - - `GET /api/auditoria` paginado con filtros combinables por `usuarioId`, `cuentaId`, `tipoAccion`, `fechaDesde`, `fechaHasta`. - - `GET /api/auditoria/filtros` para poblar combos (usuarios, cuentas y tipos de acción). - - `GET /api/auditoria/exportar-csv` con los mismos filtros aplicados. - - Enriquecimiento de filas de auditoría con `usuario_nombre`, `cuenta_nombre`, `titular_nombre` para mostrar contexto legible en UI. - - Fix crítico: filtro por `tipoAccion` ahora es case-insensitive (antes fallaba al mezclar acciones en mayúscula/minúscula). + - Corregido `IntegrationOpenClawController` para que el backend vuelva a compilar en Debug. + - Endurecido `WatchdogClientService` para no volcar cuerpos completos de error en logs. - Frontend: - - Nueva `AuditoriaPage` real (reemplaza placeholder): - - tabla paginada - - filtros por usuario/fecha/tipo/cuenta - - expansión por fila para ver `valor_anterior` / `valor_nuevo` / `detalles_json` - - referencia de celda legible (`A1 (Fecha)`, etc.) - - botón de exportación CSV con descarga real. - - Ruta `/auditoria` protegida para `ADMIN`. - - Sidebar ajustado para ocultar `Auditoría` a no-admin. - - Estilos CSS añadidos para la nueva pantalla. - -### Decisiones visuales -- Se reutilizó el lenguaje visual existente de tablas/cards (`users-*`) para evitar deuda de diseño. -- La expansión se resolvió inline por fila en vez de modal para acelerar revisión comparativa de cambios. -- La celda muestra referencia + nombre de columna para que la lectura sea inmediata sin contexto externo. + - `AppErrorBoundary` ahora se resetea por cambio de ruta y ofrece recuperacion explicita. + - Se reemplazo el pseudo-bottom-nav horizontal por un bottom nav real en mobile con 4 accesos fijos + hoja `Mas`. + - `Sidebar` reutiliza un catalogo de navegacion compartido con la nueva navegacion mobile. + - `TopBar` mejora accesibilidad del toggle de tema y evita overflow en pantallas pequenas. + - `AlertBanner` deja de usar la franja lateral prohibida y pasa a un patron limpio con pill. + - Se eliminaron varios colores hardcodeados de badges/estados para que dark-light mode no quede inconsistente. + - Corregido el regex de nombre de archivo en exportacion CSV de auditoria. + - Refrescado el build de frontend servido por `wwwroot`. ### Figma -- Pendiente de sincronización. -- Motivo: en esta sesión no se ejecutó escritura sobre Figma (bloqueo de permisos ya reportado en fases previas). +- No se sincronizo Figma en esta sesion. +- Pendiente abierto: actualizar en Figma la navegacion mobile nueva (bottom nav + hoja `Mas`) y el fallback visual del error boundary. ### Archivos tocados -- backend/src/GestionCaja.API/Controllers/AuditoriaController.cs -- backend/src/GestionCaja.API/DTOs/AuditoriaDtos.cs -- frontend/src/pages/AuditoriaPage.tsx +- backend/src/GestionCaja.API/Controllers/IntegrationOpenClawController.cs +- backend/src/GestionCaja.API/Services/WatchdogClientService.cs - frontend/src/App.tsx +- frontend/src/components/common/AppErrorBoundary.tsx +- frontend/src/components/layout/AlertBanner.tsx +- frontend/src/components/layout/BottomNav.tsx +- frontend/src/components/layout/Layout.tsx - frontend/src/components/layout/Sidebar.tsx +- frontend/src/components/layout/TopBar.tsx +- frontend/src/components/layout/navigation.ts +- frontend/src/pages/AuditoriaPage.tsx +- frontend/src/styles/global.css - frontend/src/styles/layout.css -- frontend/src/types/index.ts - backend/src/GestionCaja.API/wwwroot/* -- atlas-blance/DOCUMENTACION_CAMBIOS.md +- DOCUMENTACION_CAMBIOS.md ### Comandos ejecutados -- `dotnet build` (backend API) -- `npm.cmd run build` (frontend) -- `dotnet test --no-build` (backend tests) +- `dotnet build backend/GestionCaja.sln` +- `dotnet test backend/GestionCaja.sln --no-build` +- `npm.cmd run build` +- `npm.cmd run lint` - `docker compose up -d` -- smoke real contra `https://localhost:5000`: - - `POST /api/auth/login` - - `GET /api/auditoria/filtros` - - `GET /api/auditoria?page=1&pageSize=25` - - `GET /api/auditoria` con filtros combinados - - `GET /api/auditoria/exportar-csv` (general y filtrado) -- copia `frontend/dist/*` -> `backend/src/GestionCaja.API/wwwroot/` +- copia build frontend -> `backend/src/GestionCaja.API/wwwroot/` +- smoke HTTP: + - `curl.exe -k https://localhost:5000/api/health` + - `curl.exe -k -I https://localhost:5000/` + - login admin + logout con/sin `X-CSRF-Token` +- verificacion visual headless: + - script temporal con Playwright cacheado (`NODE_PATH=...\\playwright`) para desktop, mobile, dark mode y 404 -### Resultado de verificación -- Backend compila OK (`0 errores`). +### Resultado de verificacion +- Backend compila OK. +- Tests backend OK (`41/41`). - Frontend build OK. -- Tests backend OK (`20/20`). -- Endpoints de Fase 8 responden correctamente con autenticación admin. -- Filtros combinados verificados en ejecución real (incluyendo `tipoAccion` + `cuentaId` + rango de fechas). -- Export CSV verificado con archivo real generado y contenido filtrado correcto. +- `npm.cmd run lint` sigue fallando, pero ya no por errores: quedan `72` warnings heredados (`any` y dependencias de hooks) fuera del alcance de esta correccion. +- Smoke de seguridad confirmado: + - login admin OK + - `POST /api/auth/logout` sin CSRF -> `403` + - `POST /api/auth/logout` con CSRF correcto -> `200` +- Verificacion visual headless OK: + - desktop: sidebar visible, bottom nav oculto, sin scroll horizontal, toggle de tema funcional + - mobile: sidebar oculto, bottom nav visible (`display: grid`), 5 acciones visibles, hoja `Mas` operativa, sin scroll horizontal + - ruta inexistente renderiza `404` + - sin errores de consola en desktop, mobile ni 404 ### Pendientes -- Pendiente de proceso: sincronizar el nodo/pantalla de Auditoría en Figma cuando haya permisos de escritura del conector. +- Resolver el lote historico de warnings de ESLint para volver a tener `lint` en verde. +- Sincronizacion Figma pendiente por falta de conector de escritura disponible en esta sesion. -## 2026-04-14 - Revision critica Fase 8 (auditoria) +--- +## 2026-04-26 - Actualizacion post-instalacion endurecida -### Implementado -- Backend: - - Corregido el filtro `cuentaId` de `/api/auditoria` y `/api/auditoria/exportar-csv`: ahora incluye tanto auditoria de extractos como auditoria de la propia cuenta. - - Enriquecidas las filas de auditoria de `CUENTAS` y `TITULARES` para devolver `cuenta_nombre` y `titular_nombre` cuando exista contexto relacionado. - - Eliminado el limite artificial de `10000` filas en la exportacion CSV para que la exportacion respete el historial filtrado completo. -- Testing: - - Nuevos tests para cubrir el bug del filtro por cuenta y la ausencia de truncado en CSV. -- Infraestructura de auditoria: - - Anadidas constantes faltantes en `AuditActions` para desbloquear compilacion real del backend durante la verificacion. +**Version:** V-01.05 -### Figma -- Sin cambios en esta sesion. -- No hubo modificaciones de UI/UX; por tanto no correspondia nueva sincronizacion visual. -- Sigue pendiente el gap historico ya documentado de la implementacion original de Fase 8. +**Trabajo realizado:** Corregir los dos fallos detectados al actualizar una instalacion real desde `V-01.03` con paquete `V-01.04`: reenvio roto de `-InstallPath` y arranque bloqueado por formatos de importacion duplicados. -### Archivos tocados -- backend/src/GestionCaja.API/Controllers/AuditoriaController.cs -- backend/src/GestionCaja.API/Services/AuditActions.cs -- backend/tests/GestionCaja.API.Tests/AuditoriaControllerTests.cs -- DOCUMENTACION_CAMBIOS.md +**Archivos tocados:** +- `Atlas Balance/scripts/update.ps1` +- `Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/SeedDataTests.cs` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/Versiones/v-01.05.md` -### Comandos ejecutados -- `dotnet build backend/src/GestionCaja.API/GestionCaja.API.csproj` -- `dotnet test backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj --no-restore` -- `npm.cmd run build` +**Cambios implementados:** +- `update.ps1` declara explicitamente `InstallPath` y `SkipBackup`, y los reenvia a `Actualizar-AtlasBalance.ps1` sin depender de argumentos residuales. +- `SeedData` comprueba IDs fijos existentes antes de insertar formatos de importacion por defecto. +- Agregado test de regresion para una fila legacy de `FORMATOS_IMPORTACION` con ID fijo ya existente pero datos de banco/divisa incompletos. -### Resultado de verificacion -- Backend compila OK tras la correccion (`0 errores`, advertencias existentes fuera de Fase 8 en `BackupsController`). -- Frontend build OK. -- Tests backend OK (`22/22`). -- Verificado por test que el filtro por cuenta ya no oculta auditoria de la entidad `CUENTAS`. -- Verificado por test que la exportacion CSV ya no corta el resultado al pasar de `10000` filas. +**Comandos ejecutados:** +- Parser PowerShell sobre `Atlas Balance/scripts/update.ps1` y `Atlas Balance/scripts/Actualizar-AtlasBalance.ps1`. +- `dotnet test "Atlas Balance/backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj" --filter SeedDataTests` -### Pendientes -- Pendiente de proceso: sincronizar en Figma la pantalla de Auditoria de la implementacion original de Fase 8 cuando el conector permita escritura. -- Pendiente tecnico fuera de Fase 8: warnings de nullability en `BackupsController`. -## 2026-04-14 - Fase 9 (Backups, Exportaciones y Watchdog) completada end-to-end +**Resultado de verificacion:** +- Parser PowerShell OK para `update.ps1` y `Actualizar-AtlasBalance.ps1`. +- `SeedDataTests`: 5/5 OK. -### Implementado -- Backend API: - - Nuevos endpoints: - - `GET /api/backups` - - `POST /api/backups/manual` - - `POST /api/backups/{id}/restaurar` (confirmación doble con payload `confirmacion=RESTAURAR`) - - `GET /api/exportaciones` - - `POST /api/exportaciones/manual` - - `GET /api/exportaciones/{id}/descargar` - - `GET /api/sistema/estado` (polling de estado del Watchdog) - - Nuevos servicios: - - `BackupService` con ejecución de `pg_dump`, fallback automático a Docker (`gestion_caja_db`) en dev, auditoría y retención automática de backups. - - `ExportacionService` con generación XLSX (ClosedXML), registro en `EXPORTACIONES`, descarga y notificación admin. - - `WatchdogClientService` para comunicación segura API -> Watchdog con `X-Watchdog-Secret`. - - Nuevos jobs Hangfire: - - `BackupWeeklyJob` (domingo 02:00) - - `ExportMensualJob` (día 1 a las 01:00) - - Registro de jobs/servicios y cliente HTTP de Watchdog en `Program.cs`. +**Pendientes:** +- Regenerar paquete `V-01.05` antes de publicarlo o usarlo para actualizar servidores. -- Watchdog Service: - - Endpoints operativos implementados: - - `POST /watchdog/restaurar-backup` - - `POST /watchdog/actualizar-app` - - `GET /watchdog/estado` - - Persistencia de estado en JSON compartido (`watchdog-state.json`) y autenticación por header `X-Watchdog-Secret`. - - Restauración con `pg_restore` y fallback automático a Docker en dev. - - Control de ciclo de servicio API (stop/start) con degradación segura en entornos no-Windows. +## 2026-04-19 - Correccion auth dark mode: cambio obligatorio de password -- Frontend: - - `BackupsPage` real: - - listado paginado - - botón de backup manual - - restauración con confirmación doble - - overlay de carga + polling a `/api/sistema/estado` - - redirección a login tras restauración exitosa - - `ExportacionesPage` real: - - listado paginado - - selector de cuenta - - exportación manual - - descarga de XLSX - - Rutas actualizadas en `App.tsx` y control de visibilidad de navegación en `Sidebar.tsx`. - - Build actualizado y sincronizado a `backend/src/GestionCaja.API/wwwroot`. +### Fase +- Ajuste puntual de frontend en pantalla de autenticacion. + +### Implementado +- `ChangePasswordPage` ahora usa las mismas clases `auth-card-title`, `auth-form-group`, `auth-label`, `auth-input` y `auth-button` que el login. +- `auth.css` ahora respeta `[data-theme="dark"]` ademas de `prefers-color-scheme`. +- Textos, labels, inputs, errores y boton de la pantalla de cambio de password usan tokens globales de color con fallback local. ### Archivos tocados -- backend/src/GestionCaja.API/Program.cs -- backend/src/GestionCaja.API/appsettings.json -- backend/src/GestionCaja.API/appsettings.Development.json -- backend/src/GestionCaja.API/appsettings.Production.json.template -- backend/src/GestionCaja.API/Controllers/BackupsController.cs -- backend/src/GestionCaja.API/Controllers/ExportacionesController.cs -- backend/src/GestionCaja.API/Controllers/SistemaController.cs -- backend/src/GestionCaja.API/DTOs/BackupsDtos.cs -- backend/src/GestionCaja.API/DTOs/ExportacionesDtos.cs -- backend/src/GestionCaja.API/Jobs/BackupWeeklyJob.cs -- backend/src/GestionCaja.API/Jobs/ExportMensualJob.cs -- backend/src/GestionCaja.API/Services/BackupService.cs -- backend/src/GestionCaja.API/Services/ExportacionService.cs -- backend/src/GestionCaja.API/Services/WatchdogClientService.cs -- backend/src/GestionCaja.API/Services/AuditActions.cs -- backend/src/GestionCaja.Watchdog/Program.cs -- backend/src/GestionCaja.Watchdog/appsettings.json -- backend/src/GestionCaja.Watchdog/Controllers/WatchdogController.cs -- backend/src/GestionCaja.Watchdog/Models/WatchdogContracts.cs -- backend/src/GestionCaja.Watchdog/Services/WatchdogStateStore.cs -- backend/src/GestionCaja.Watchdog/Services/WatchdogOperationsService.cs -- frontend/src/App.tsx -- frontend/src/components/layout/Sidebar.tsx -- frontend/src/pages/BackupsPage.tsx -- frontend/src/pages/ExportacionesPage.tsx -- frontend/src/styles/layout.css -- frontend/src/types/index.ts -- backend/src/GestionCaja.API/wwwroot/* -- atlas-blance/DOCUMENTACION_CAMBIOS.md +- frontend/src/pages/ChangePasswordPage.tsx +- frontend/src/styles/auth.css +- DOCUMENTACION_CAMBIOS.md ### Comandos ejecutados -- `dotnet build backend/GestionCaja.sln` -- `dotnet test backend/GestionCaja.sln --no-build` -- `npm.cmd run build` -- `docker compose up -d` -- Copia de `frontend/dist/*` -> `backend/src/GestionCaja.API/wwwroot/` -- Arranque temporal en background: - - `dotnet run --no-build` (Watchdog) - - `dotnet run --no-build` (API) -- Smoke real Fase 9 por HTTPS: - - `POST /api/auth/login` - - `POST /api/backups/manual` - - `GET /api/backups` - - `POST /api/exportaciones/manual` - - `GET /api/exportaciones` - - `GET /api/exportaciones/{id}/descargar` - - `POST /api/backups/{id}/restaurar` - - polling `GET /api/sistema/estado` - -### Resultado de verificación -- Backend: compila OK (0 errores). -- Tests backend: OK (`22/22`). -- Frontend: build OK. -- Watchdog: endpoints activos y autenticados por secret. -- Backup manual: OK (archivo dump generado y registro `SUCCESS`). -- Exportación manual: OK (XLSX generado y descargable). -- Restauración via Watchdog: OK (request aceptada y estado `SUCCESS` reportado en `/api/sistema/estado`). -- Integración API->Watchdog validada con fallback Docker para desarrollo. +- Busqueda de rutas y componentes con `Get-ChildItem` + `Select-String`. +- Lectura puntual de `ChangePasswordPage.tsx`, `auth.css`, `variables.css` y `global.css`. + +### Resultado de verificacion +- Verificacion estatica por lectura de estilos: la pantalla ya no depende de estilos por defecto del navegador y hereda los tokens dark/light del proyecto. +- No se ejecuto build ni prueba visual en navegador en esta correccion puntual. ### Pendientes -- Validación de retención (>6 semanas) cubierta por implementación y ejecución en flujo de backup, pero no se cerró con una prueba SQL sintética completamente automatizada en esta sesión por fricción de quoting contra PostgreSQL en shell Windows. -- Pendiente de proceso: sincronizar en Figma las nuevas pantallas `Backups` y `Exportaciones` cuando haya capacidad de escritura del conector en sesión. +- Sincronizar Figma si se considera cambio visual formal de la pantalla. -## 2026-04-15 - Fase 10 (Actualización de App) completada end-to-end +--- +## 2026-04-26 - Actualizacion post-instalacion endurecida -### Implementado -- Backend: - - Nuevo `ActualizacionService` con: - - `GetVersionActualAsync()` - - `CheckVersionDisponibleAsync()` (consulta `app_update_check_url`) - - `IniciarActualizacionAsync()` (disparo de update vía Watchdog) - - `SistemaController` ampliado con endpoints admin: - - `GET /api/sistema/version-actual` - - `GET /api/sistema/version-disponible` - - `POST /api/sistema/actualizar` - - `GET /api/sistema/estado` (se mantiene) - - `WatchdogClientService` ampliado con `SolicitarActualizacionAsync()` contra `/watchdog/actualizar-app`. - - Registro DI en `Program.cs` para `IActualizacionService`. +**Version:** V-01.05 -- Frontend: - - Nuevo store `updateStore` para check de versión disponible con cache corta. - - Sidebar admin con badge de actualización en navegación (`Configuración`) cuando hay update disponible. - - `ConfiguracionPage` ampliada con sección de sistema: - - versión actual - - versión disponible - - estado de actualización - - botón `Verificar actualización` - - botón `Actualizar ahora` - - Flujo de actualización en frontend: - - llama `POST /api/sistema/actualizar` - - hace polling a `GET /api/sistema/estado` - - al `SUCCESS` redirige a login con mensaje de confirmación. - - `LoginPage` muestra mensaje post-update al volver desde el flujo de actualización. +**Trabajo realizado:** Corregir los dos fallos detectados al actualizar una instalacion real desde `V-01.03` con paquete `V-01.04`: reenvio roto de `-InstallPath` y arranque bloqueado por formatos de importacion duplicados. + +**Archivos tocados:** +- `Atlas Balance/scripts/update.ps1` +- `Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/SeedDataTests.cs` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/Versiones/v-01.05.md` + +**Cambios implementados:** +- `update.ps1` declara explicitamente `InstallPath` y `SkipBackup`, y los reenvia a `Actualizar-AtlasBalance.ps1` sin depender de argumentos residuales. +- `SeedData` comprueba IDs fijos existentes antes de insertar formatos de importacion por defecto. +- Agregado test de regresion para una fila legacy de `FORMATOS_IMPORTACION` con ID fijo ya existente pero datos de banco/divisa incompletos. + +**Comandos ejecutados:** +- Parser PowerShell sobre `Atlas Balance/scripts/update.ps1` y `Atlas Balance/scripts/Actualizar-AtlasBalance.ps1`. +- `dotnet test "Atlas Balance/backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj" --filter SeedDataTests` + +**Resultado de verificacion:** +- Parser PowerShell OK para `update.ps1` y `Actualizar-AtlasBalance.ps1`. +- `SeedDataTests`: 5/5 OK. + +**Pendientes:** +- Regenerar paquete `V-01.05` antes de publicarlo o usarlo para actualizar servidores. + +## 2026-04-19 - Importacion: wizard de 2 pasos + +### Fase +- Ajuste puntual de frontend en el flujo de importacion. + +### Implementado +- `ImportacionPage` pasa de 4 pasos a 2: `Pegar` y `Validar y confirmar`. +- Se elimina la pantalla de mapeo manual. +- El mapeo se deriva automaticamente de `formato_predefinido` de la cuenta seleccionada. +- Si la cuenta no tiene formato activo, la validacion queda bloqueada con mensaje explicito. +- La pantalla final combina preview validado, seleccion de filas validas, resumen y confirmacion. +- El indicador visual de pasos se ajusta a 2 columnas. ### Figma -- No se sincronizó Figma en esta sesión. -- Motivo: esta sesión cerró lógica de sistema (backend + wiring UI de configuración existente), sin iteración visual de layouts nuevos. -- Sigue pendiente operativo del proyecto: mantener sincronía de Figma cuando el conector permita escritura en sesión. +- Intento realizado sobre archivo fuente `cFYBwjPLqAArvgg04DJLmp`, nodo `0:1`. +- Resultado: el conector disponible en esta sesion expone lectura/metadata, pero no herramienta de escritura para actualizar nodos de diseño. +- Pendiente abierto: actualizar la pantalla de Importacion en Figma para reflejar el wizard de 2 pasos y retirar el paso de mapeo manual. ### Archivos tocados -- backend/src/GestionCaja.API/Controllers/SistemaController.cs -- backend/src/GestionCaja.API/DTOs/SistemaDtos.cs -- backend/src/GestionCaja.API/Program.cs -- backend/src/GestionCaja.API/Services/ActualizacionService.cs -- backend/src/GestionCaja.API/Services/WatchdogClientService.cs -- frontend/src/components/layout/Sidebar.tsx -- frontend/src/pages/ConfiguracionPage.tsx -- frontend/src/pages/LoginPage.tsx -- frontend/src/stores/updateStore.ts +- frontend/src/pages/ImportacionPage.tsx - frontend/src/styles/layout.css -- frontend/src/types/index.ts -- backend/src/GestionCaja.API/wwwroot/* - DOCUMENTACION_CAMBIOS.md ### Comandos ejecutados -- `dotnet build backend/GestionCaja.sln` -- `dotnet test backend/GestionCaja.sln --no-build` +- `Get-ChildItem` + `Select-String` para localizar flujo de importacion y estilos relacionados. +- `Get-Content` de `ImportacionPage.tsx`, tipos compartidos, controller/service de importacion y `layout.css`. - `npm.cmd run build` -- `docker compose up -d` -- arranque local: - - `dotnet run --no-build` (Watchdog) - - `dotnet run --no-build` (API) -- smoke real fase 10: - - `POST /api/auth/login` - - `GET /api/sistema/version-actual` - - `GET /api/sistema/version-disponible` - - `POST /api/sistema/actualizar` - - polling `GET /api/sistema/estado` -- actualización de config de test para smoke: - - `CONFIGURACION.app_update_check_url = http://localhost:5088/update.json` -- copia de `frontend/dist/*` -> `backend/src/GestionCaja.API/wwwroot/` +- `npx.cmd eslint src/pages/ImportacionPage.tsx --max-warnings 0` +- Figma metadata read: archivo `cFYBwjPLqAArvgg04DJLmp`, nodo `0:1`. -### Resultado de verificación -- Backend compila OK (`0 errores`). -- Tests backend OK (`22/22`). -- Frontend build OK. -- Smoke real fase 10 OK: - - `version-disponible` reporta update cuando existe versión mayor. - - `POST /api/sistema/actualizar` responde `Accepted`. - - polling en `/api/sistema/estado` termina en `SUCCESS` con `operacion = UPDATE_APP`. - - flujo frontend preparado para volver a login con mensaje al completar. -- Migraciones automáticas al reiniciar: se mantienen activas vía `db.Database.Migrate()` en `Program.cs` (ya existente y verificado). +### Resultado de verificacion +- Frontend build OK: `tsc && vite build`. +- ESLint puntual OK sobre `src/pages/ImportacionPage.tsx`. +- Busqueda estatica OK: no quedan textos/ramas del paso manual de mapeo (`Mapeo de columnas`, `Precargar formato`, `Agregar columna extra`) en `ImportacionPage`. ### Pendientes -- Pendiente de proceso: sincronización en Figma de los cambios de UI de configuración/sidebar cuando haya capacidad de escritura del conector en sesión. +- Sincronizar Figma cuando haya herramienta de escritura disponible. -## 2026-04-15 - Fase 9 - Auditoria y correccion de backups, exportaciones y watchdog +--- +## 2026-04-26 - Actualizacion post-instalacion endurecida + +**Version:** V-01.05 + +**Trabajo realizado:** Corregir los dos fallos detectados al actualizar una instalacion real desde `V-01.03` con paquete `V-01.04`: reenvio roto de `-InstallPath` y arranque bloqueado por formatos de importacion duplicados. + +**Archivos tocados:** +- `Atlas Balance/scripts/update.ps1` +- `Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/SeedDataTests.cs` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/Versiones/v-01.05.md` + +**Cambios implementados:** +- `update.ps1` declara explicitamente `InstallPath` y `SkipBackup`, y los reenvia a `Actualizar-AtlasBalance.ps1` sin depender de argumentos residuales. +- `SeedData` comprueba IDs fijos existentes antes de insertar formatos de importacion por defecto. +- Agregado test de regresion para una fila legacy de `FORMATOS_IMPORTACION` con ID fijo ya existente pero datos de banco/divisa incompletos. + +**Comandos ejecutados:** +- Parser PowerShell sobre `Atlas Balance/scripts/update.ps1` y `Atlas Balance/scripts/Actualizar-AtlasBalance.ps1`. +- `dotnet test "Atlas Balance/backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj" --filter SeedDataTests` + +**Resultado de verificacion:** +- Parser PowerShell OK para `update.ps1` y `Actualizar-AtlasBalance.ps1`. +- `SeedDataTests`: 5/5 OK. + +**Pendientes:** +- Regenerar paquete `V-01.05` antes de publicarlo o usarlo para actualizar servidores. + +## 2026-04-19 - Ajuste branding en layout principal + +### Fase +- Ajuste puntual de frontend en shell de navegacion. ### Implementado -- Backend: - - Corregido `ExportacionService` para generar un XLSX distinto por ejecucion y no pisar historico de exportaciones manuales del mismo mes. - - Corregida la carga de columnas extra en exportacion para agrupar en memoria y evitar consultas LINQ fragiles. - - Añadido `NotificacionesAdminController` con: - - `GET /api/notificaciones-admin/resumen` - - `POST /api/notificaciones-admin/marcar-leidas` - - Corregido `WatchdogClientService` para parsear respuestas HTTP camelCase sin depender del state file. - - Endurecido `WatchdogController` en `POST /watchdog/actualizar-app`: - - valida `source_path` - - valida `target_path` - - rechaza source/target iguales - - Corregido `WatchdogOperationsService`: - - publica estado `RUNNING` antes de devolver `202 Accepted` - - reinicia la API tambien cuando restore/update fallan - - evita aceptar updates con rutas invalidas o iguales -- Frontend: - - Nuevo store `notificacionesAdminStore` para resumen y marcado de notificaciones admin. - - Sidebar admin ahora muestra badge en `Exportaciones` cuando hay exportaciones pendientes de revisar. - - `ExportacionesPage` marca como leidas las notificaciones de exportacion al entrar y tras generar exportacion manual. - - Rebuild de frontend y copia a `backend/src/GestionCaja.API/wwwroot/`. -- Tests: - - Nuevo test para exportaciones con rutas de archivo distintas por ejecucion. - - Nuevo test para resumen/marcado de `NOTIFICACIONES_ADMIN`. - - Nuevo test para fallback HTTP de `WatchdogClientService` con payload camelCase. +- Se elimino el texto `Tesoreria` visible del `TopBar`, manteniendo el boton de colapso del sidebar. +- Se sustituyo el monograma `AB` del brand del sidebar por el logo de Atlas Balance. +- El logo del sidebar se renderiza como mascara CSS con `currentColor`, asi queda del mismo color que el texto `Atlas Balance` y respeta el estado visual del sidebar. +- Se regenero el build de frontend y se copio a `backend/src/GestionCaja.API/wwwroot`. ### Figma -- No se sincronizo Figma en esta sesion. -- Pendiente abierto: reflejar el badge de exportaciones en el archivo fuente cuando se haga una pasada de UI/Figma con el conector activo. +- No se pudo sincronizar Figma en esta sesion: las herramientas Figma disponibles son de lectura, screenshot, contexto y Code Connect; no hay herramienta de escritura de canvas expuesta. +- Pantalla/nodo pendiente: layout principal / sidebar + topbar del archivo fuente `Gestion-de-Caja` (`node-id=0-1`). +- Decision visual tomada: logo monocromo heredando el color de texto del sidebar; eliminacion del titulo redundante del topbar para limpiar la cabecera. ### Archivos tocados -- backend/src/GestionCaja.API/Controllers/BackupsController.cs -- backend/src/GestionCaja.API/Controllers/ExportacionesController.cs -- backend/src/GestionCaja.API/Controllers/NotificacionesAdminController.cs -- backend/src/GestionCaja.API/DTOs/NotificacionesAdminDtos.cs -- backend/src/GestionCaja.API/Services/ExportacionService.cs -- backend/src/GestionCaja.API/Services/WatchdogClientService.cs -- backend/src/GestionCaja.Watchdog/Controllers/WatchdogController.cs -- backend/src/GestionCaja.Watchdog/Services/WatchdogOperationsService.cs -- backend/tests/GestionCaja.API.Tests/ExportacionServiceTests.cs -- backend/tests/GestionCaja.API.Tests/ManualProcessResponseTests.cs -- backend/tests/GestionCaja.API.Tests/NotificacionesAdminControllerTests.cs -- backend/tests/GestionCaja.API.Tests/WatchdogClientServiceTests.cs - frontend/src/components/layout/Sidebar.tsx -- frontend/src/pages/ExportacionesPage.tsx -- frontend/src/stores/notificacionesAdminStore.ts +- frontend/src/components/layout/TopBar.tsx +- frontend/src/styles/layout.css +- frontend/dist/* - backend/src/GestionCaja.API/wwwroot/* - DOCUMENTACION_CAMBIOS.md ### Comandos ejecutados -- `dotnet test backend/GestionCaja.sln` +- Busqueda de componentes con `Get-ChildItem` + `Select-String`. +- Inspeccion del asset `frontend/public/logos/Atlas Balance.png` con `System.Drawing`. - `npm.cmd run build` -- `dotnet build backend/src/GestionCaja.API/GestionCaja.API.csproj` -- `dotnet build backend/src/GestionCaja.Watchdog/GestionCaja.Watchdog.csproj` -- `robocopy frontend/dist backend/src/GestionCaja.API/wwwroot /MIR` -- smoke manual fase 9: - - `POST /api/auth/login` - - `POST /api/exportaciones/manual` - - `POST /api/backups/manual` - - `GET /api/notificaciones-admin/resumen` - - `POST /api/notificaciones-admin/marcar-leidas` - - `POST /watchdog/actualizar-app` - - `GET /watchdog/estado` +- `npx.cmd eslint src/components/layout/Sidebar.tsx src/components/layout/TopBar.tsx --max-warnings 0` +- `robocopy frontend/dist backend/src/GestionCaja.API/wwwroot /MIR` (ejecutado dos veces para sincronizar el hash final del bundle) ### Resultado de verificacion -- Backend OK: `27/27` tests pasando. -- Frontend OK: `npm.cmd run build` sin errores. -- Exportaciones manuales consecutivas ya no comparten el mismo `ruta_archivo`. -- Resumen de notificaciones admin funciona y `marcar-leidas` deja `exportaciones_pendientes = 0`. -- Watchdog rechaza updates invalidos con `400`. -- Watchdog publica `RUNNING` de inmediato al aceptar update y termina en `SUCCESS` con archivos copiados al target. -- Backup manual verificado en runtime con archivo generado en disco. -- Respuestas inmediatas de POST /api/backups/manual y POST /api/exportaciones/manual normalizadas: estado ahora sale como string (SUCCESS/FAILED), no como entero. +- Frontend build OK (`tsc && vite build`). +- ESLint puntual OK sobre `Sidebar.tsx` y `TopBar.tsx`. +- Assets estaticos de backend actualizados correctamente desde `frontend/dist`. ### Pendientes -- Sincronizar Figma del badge de exportaciones en una sesion con conector operativo. +- Actualizar Figma manualmente o repetir la sincronizacion cuando este disponible el conector de escritura de Figma. -## 2026-04-15 - Auditoria Fase 10 (correcciones post-verificacion) +--- +## 2026-04-26 - Actualizacion post-instalacion endurecida -### Implementado -- Backend: - - Corregido `WatchdogOperationsService` para que la actualizacion haga reemplazo real del deploy: - - copia archivos nuevos/actualizados - - elimina archivos obsoletos del target - - conserva runtime local sensible (`appsettings*.json`, `logs`) - - Endurecido `WatchdogController` y `WatchdogOperationsService` para rechazar rutas solapadas/anidadas entre `source_path` y `target_path`. -- Frontend: - - Corregido `ConfiguracionPage` para hacer `POST /api/auth/logout` tras `SUCCESS` antes de redirigir a `/login`, evitando que la cookie `httpOnly` deje una sesion reutilizable despues de la actualizacion. -- Tests: - - Nuevo test de watchdog para verificar que el update elimina archivos obsoletos y preserva configuracion/logs. - - Nuevo test de watchdog para verificar rechazo de rutas anidadas. - - Corregido stub `RecordingEmailService` en tests para compilar con la interfaz actual de `IEmailService`. +**Version:** V-01.05 -### Archivos tocados -- backend/src/GestionCaja.Watchdog/Controllers/WatchdogController.cs -- backend/src/GestionCaja.Watchdog/Services/WatchdogOperationsService.cs -- backend/tests/GestionCaja.API.Tests/AlertaServiceTests.cs -- backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj -- backend/tests/GestionCaja.API.Tests/WatchdogOperationsServiceTests.cs -- frontend/src/pages/ConfiguracionPage.tsx -- DOCUMENTACION_CAMBIOS.md +**Trabajo realizado:** Corregir los dos fallos detectados al actualizar una instalacion real desde `V-01.03` con paquete `V-01.04`: reenvio roto de `-InstallPath` y arranque bloqueado por formatos de importacion duplicados. + +**Archivos tocados:** +- `Atlas Balance/scripts/update.ps1` +- `Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/SeedDataTests.cs` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/Versiones/v-01.05.md` -### Comandos ejecutados -- `dotnet test backend/GestionCaja.sln --no-restore` -- `dotnet build backend/GestionCaja.sln --no-restore` -- `npm.cmd run build` +**Cambios implementados:** +- `update.ps1` declara explicitamente `InstallPath` y `SkipBackup`, y los reenvia a `Actualizar-AtlasBalance.ps1` sin depender de argumentos residuales. +- `SeedData` comprueba IDs fijos existentes antes de insertar formatos de importacion por defecto. +- Agregado test de regresion para una fila legacy de `FORMATOS_IMPORTACION` con ID fijo ya existente pero datos de banco/divisa incompletos. -### Resultado de verificacion -- Backend compila OK. -- Frontend build OK. -- Tests backend OK (`29/29`). -- Verificacion funcional cubierta por tests nuevos del watchdog: - - el target ya no conserva binarios viejos tras actualizar - - se rechazan rutas `source/target` anidadas -- Flujo frontend endurecido: tras update exitoso se invalida sesion en backend antes de enviar al login. +**Comandos ejecutados:** +- Parser PowerShell sobre `Atlas Balance/scripts/update.ps1` y `Atlas Balance/scripts/Actualizar-AtlasBalance.ps1`. +- `dotnet test "Atlas Balance/backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj" --filter SeedDataTests` -### Pendientes -- Pendiente de proceso: reflejar en Figma los cambios de comportamiento/estado del flujo de actualizacion cuando haya conector de escritura disponible. +**Resultado de verificacion:** +- Parser PowerShell OK para `update.ps1` y `Actualizar-AtlasBalance.ps1`. +- `SeedDataTests`: 5/5 OK. -## 2026-04-15 - Fase 11 completada (Papelera + Configuracion completa + Integraciones) +**Pendientes:** +- Regenerar paquete `V-01.05` antes de publicarlo o usarlo para actualizar servidores. + +## 2026-04-19 - Titulares: dashboard integrado + formulario minimo + +### Fase +- Ajuste puntual de frontend/backend en el apartado de titulares. ### Implementado -- Backend: - - Nuevo `ConfiguracionController` (`/api/configuracion`) con: - - `GET /api/configuracion` - - `PUT /api/configuracion` - - `POST /api/configuracion/smtp/test` - - Nuevo `IntegracionesController` (`/api/integraciones/tokens`) con: - - `GET /api/integraciones/tokens` - - `GET /api/integraciones/tokens/{id}` - - `POST /api/integraciones/tokens` - - `PUT /api/integraciones/tokens/{id}` - - `POST /api/integraciones/tokens/{id}/revocar` - - `DELETE /api/integraciones/tokens/{id}` - - `GET /api/integraciones/tokens/{id}/auditoria` - - Extendido `EmailService` con `SendTestEmailAsync`. - - Nuevas acciones de auditoria para configuracion/smtp/integraciones. -- Frontend: - - Nuevo `PapeleraPage` real con tabs por entidad (titulares, cuentas, extractos, usuarios) y restauracion. - - `App.tsx` actualizado para usar `PapeleraPage` en `/papelera`. - - `ConfiguracionPage` rehacida por secciones: - - General + SMTP (incluye envio de correo de prueba) - - Divisas y tipos de cambio - - Sistema (version/check/update) - - Integraciones (creacion/listado/revocacion/eliminacion de tokens) - - Tipos TS ampliados para configuracion e integraciones. - - Estilos CSS ampliados para tabs/config/integraciones/papelera. +- `TitularesPage` ahora muestra un bloque tipo dashboard con: + - grafica de barras de saldos por titular + - tabla resumen por titular con boton `Abrir` hacia `/dashboard/titular/:id` + - grafica de evolucion reutilizando el componente existente + - bloque inferior de saldos agregados por divisa (banners/cards) +- En titulares se simplifico el alta/edicion para manejar solo `Nombre`, `Tipo` y `Notas`. +- Al guardar titular desde esta pantalla, `identificacion`, `contacto_email` y `contacto_telefono` se envian como `null`. +- El listado backend de titulares ahora incluye `notas` para que la vista pueda mostrarla sin consultas extra por fila. ### Figma -- No se sincronizo Figma en esta sesion. -- Pendiente abierto: reflejar `PapeleraPage` y la nueva estructura de `ConfiguracionPage` en el archivo fuente cuando el conector de escritura este operativo. +- Pendiente: no hay conector de escritura de Figma disponible en esta sesion para sincronizar el nodo/pantalla de Titulares del archivo fuente. +- Decision visual: convertir Titulares en vista hibrida CRUD + dashboard, manteniendo acceso directo a dashboard por titular. ### Archivos tocados -- backend/src/GestionCaja.API/Controllers/ConfiguracionController.cs -- backend/src/GestionCaja.API/Controllers/IntegracionesController.cs -- backend/src/GestionCaja.API/DTOs/ConfiguracionDtos.cs -- backend/src/GestionCaja.API/DTOs/IntegracionesDtos.cs -- backend/src/GestionCaja.API/Services/AuditActions.cs -- backend/src/GestionCaja.API/Services/EmailService.cs -- frontend/src/App.tsx -- frontend/src/pages/ConfiguracionPage.tsx -- frontend/src/pages/PapeleraPage.tsx +- backend/src/GestionCaja.API/DTOs/TitularesDtos.cs +- backend/src/GestionCaja.API/Controllers/TitularesController.cs +- frontend/src/pages/TitularesPage.tsx - frontend/src/styles/layout.css -- frontend/src/types/index.ts -- backend/src/GestionCaja.API/wwwroot/* - DOCUMENTACION_CAMBIOS.md ### Comandos ejecutados -- `docker compose -f docker-compose.yml up -d` -- `dotnet build backend/GestionCaja.sln` -- `dotnet test backend/GestionCaja.sln --no-build` -- `npm.cmd run build` -- `robocopy frontend/dist backend/src/GestionCaja.API/wwwroot /MIR` -- `docker run -d --name gestion_caja_mailhog -p 1025:1025 -p 8025:8025 mailhog/mailhog` -- smoke runtime Fase 11: - - `POST /api/auth/login` - - `GET/PUT /api/configuracion` - - `POST /api/configuracion/smtp/test` - - `POST/POST revocar/DELETE /api/integraciones/tokens` - - flujo papelera: - - `POST/DELETE/POST restaurar /api/titulares` - - `POST/DELETE/POST restaurar /api/cuentas` - - `POST/DELETE/POST restaurar /api/extractos` - - `POST/DELETE/POST restaurar /api/usuarios` - - verificacion de listado en papelera con `incluirEliminados=true` para cada entidad +- Busqueda de archivos/referencias con `Get-ChildItem` + `Select-String`. +- Lectura de `TitularesPage.tsx`, `DashboardPage.tsx`, componentes dashboard y `TitularesController.cs`. +- Edicion de archivos con parche y escritura directa. ### Resultado de verificacion -- Backend compila OK (`0 errores`). -- Tests backend OK (`29/29`). -- Frontend build OK. -- SMTP test endpoint validado contra MailHog local (`localhost:1025`) con respuesta `200`. -- CRUD de tokens de integracion validado (crear con token plano visible una vez, revocar, eliminar). -- Papelera validada end-to-end para titulares, cuentas, extractos y usuarios (aparece eliminado y restaura). +- Verificacion estatica de codigo: la pantalla de Titulares consume endpoints de dashboard y renderiza grafica, tabla con `Abrir` y tarjetas por divisa en la zona inferior. +- Verificacion de contrato API: `GET /api/titulares` ya expone `notas` en cada item. +- Frontend build OK: `npm.cmd run build`. +- Backend build OK con salida alternativa para evitar bloqueo del binario en ejecucion: `dotnet build /p:OutDir=.tmp-build\\ /p:UseAppHost=false`. ### Pendientes -- Sincronizacion Figma pendiente por limitacion operativa del conector de escritura en esta sesion. +- Sincronizar cambios de UI en Figma cuando exista herramienta de escritura disponible. -## 2026-04-15 - Auditoria Fase 11 (correcciones post-verificacion) + +--- +## 2026-04-26 - Actualizacion post-instalacion endurecida + +**Version:** V-01.05 + +**Trabajo realizado:** Corregir los dos fallos detectados al actualizar una instalacion real desde `V-01.03` con paquete `V-01.04`: reenvio roto de `-InstallPath` y arranque bloqueado por formatos de importacion duplicados. + +**Archivos tocados:** +- `Atlas Balance/scripts/update.ps1` +- `Atlas Balance/backend/src/GestionCaja.API/Data/SeedData.cs` +- `Atlas Balance/backend/tests/GestionCaja.API.Tests/SeedDataTests.cs` +- `Documentacion/DOCUMENTACION_CAMBIOS.md` +- `Documentacion/DOCUMENTACION_TECNICA.md` +- `Documentacion/DOCUMENTACION_USUARIO.md` +- `Documentacion/LOG_ERRORES_INCIDENCIAS.md` +- `Documentacion/REGISTRO_BUGS.md` +- `Documentacion/Versiones/v-01.05.md` + +**Cambios implementados:** +- `update.ps1` declara explicitamente `InstallPath` y `SkipBackup`, y los reenvia a `Actualizar-AtlasBalance.ps1` sin depender de argumentos residuales. +- `SeedData` comprueba IDs fijos existentes antes de insertar formatos de importacion por defecto. +- Agregado test de regresion para una fila legacy de `FORMATOS_IMPORTACION` con ID fijo ya existente pero datos de banco/divisa incompletos. + +**Comandos ejecutados:** +- Parser PowerShell sobre `Atlas Balance/scripts/update.ps1` y `Atlas Balance/scripts/Actualizar-AtlasBalance.ps1`. +- `dotnet test "Atlas Balance/backend/tests/GestionCaja.API.Tests/GestionCaja.API.Tests.csproj" --filter SeedDataTests` + +**Resultado de verificacion:** +- Parser PowerShell OK para `update.ps1` y `Actualizar-AtlasBalance.ps1`. +- `SeedDataTests`: 5/5 OK. + +**Pendientes:** +- Regenerar paquete `V-01.05` antes de publicarlo o usarlo para actualizar servidores. + +## 2026-04-19 - Cuentas: vista en tarjetas alineada con Titulares + +### Fase +- Ajuste puntual de frontend en el apartado de cuentas. ### Implementado -- Backend: - - Endurecida validacion de `IntegracionesController` para bloquear tokens OpenClaw inutiles o incoherentes: - - obliga a definir al menos lectura o escritura a nivel de token - - obliga a definir al menos un permiso de alcance - - rechaza `acceso_tipo` invalidos - - rechaza permisos de escritura si el token no tiene escritura global - - normaliza `acceso_tipo` a lowercase al persistir -- Frontend: - - `PapeleraPage` corregida para usar nombres singulares correctos al restaurar. - - Ruta `/papelera` protegida con `RoleGuard` de admin; antes estaba oculta en sidebar pero accesible por URL directa. - - `ConfiguracionPage` ampliada para cubrir mejor la especificacion: - - listado editable de divisas registradas (nombre, simbolo, activa, base) - - tabla visible de tipos de cambio vigentes - - sincronizacion de tipos con manejo de error/feedback - - validacion de tasa manual cuando no hay dos divisas activas - - logout real contra backend tras actualizacion satisfactoria - - `CreateTokenModal` y `TokenPermissionsEditor` endurecidos: - - no permite crear tokens sin permisos de alcance - - no permite crear tokens sin lectura/escritura global - - no permite scopes de escritura si el token no tiene escritura - - mensaje explicito cuando un token no tendria acceso a ningun dato -- Tests: - - Nuevos tests backend para validar rechazo de tokens sin scope, rechazo de accesos invalidos y normalizacion de `acceso_tipo`. +- `CuentasPage` cambia el listado principal de tabla a tarjetas para mantener el mismo patron visual de `TitularesPage`. +- Cada tarjeta de cuenta ahora muestra: `Nombre`, `Tipo` (EFECTIVO/BANCARIA), `Titular`, `Divisa`, `Banco` y `Estado`. +- Se mantienen sin cambios las acciones por tarjeta para admin: `Editar`, `Eliminar` y `Restaurar`. +- Se mantiene sin cambios el formulario lateral de alta/edicion. ### Figma -- No se sincronizo Figma en esta sesion. -- Pendiente abierto: reflejar en el archivo fuente la proteccion de `/papelera`, los nuevos bloques de divisas/tipos en `ConfiguracionPage` y los estados de validacion del modal de tokens. +- Pendiente: en esta sesion no hay herramienta de escritura en Figma para sincronizar el nodo de Cuentas. +- Decision visual: homologar Cuentas al lenguaje de tarjetas ya usado en Titulares para consistencia de UX. ### Archivos tocados -- backend/src/GestionCaja.API/Controllers/IntegracionesController.cs -- backend/tests/GestionCaja.API.Tests/IntegracionesControllerTests.cs -- frontend/src/App.tsx -- frontend/src/pages/ConfiguracionPage.tsx -- frontend/src/pages/PapeleraPage.tsx -- frontend/src/components/integraciones/CreateTokenModal.tsx -- frontend/src/components/integraciones/TokenPermissionsEditor.tsx +- frontend/src/pages/CuentasPage.tsx - DOCUMENTACION_CAMBIOS.md ### Comandos ejecutados -- `docker compose up -d` -- `docker run -d --name gestion_caja_mailhog -p 1025:1025 -p 8025:8025 mailhog/mailhog` -- `dotnet build backend/GestionCaja.sln -c Release` -- `dotnet test backend/GestionCaja.sln -c Release --no-build` +- `Get-ChildItem` + filtros para localizar `TitularesPage.tsx`, `CuentasPage.tsx` y estilos. +- `Get-Content` de `TitularesPage.tsx`, `CuentasPage.tsx` y `frontend/src/styles/layout.css`. - `npm.cmd run build` -- smoke backend: - - `POST /api/auth/login` - - `PUT /api/configuracion` - - `POST /api/configuracion/smtp/test` - - `POST /api/integraciones/tokens` (valido e invalidos) +- `npx.cmd eslint src/pages/CuentasPage.tsx --max-warnings 0` ### Resultado de verificacion -- Backend compila OK en `Release`. -- Tests backend OK (`36/36`). -- Frontend build OK. -- Smoke manual: - - configuracion persiste correctamente - - correo de prueba SMTP responde `200` usando MailHog local - - token valido se crea - - token sin scope devuelve `400` - - token con scope de escritura sin permiso global devuelve `400` +- Ajuste visual aplicado en codigo: `CuentasPage` ya renderiza tarjetas en lugar de tabla. +- ESLint puntual OK para `src/pages/CuentasPage.tsx`. +- Build global del frontend falla por error preexistente ajeno al cambio en `src/pages/CuentaDetailPage.tsx`: + - `TS2345` en lineas 27 y 28 por `string | undefined` donde se espera `string`. ### Pendientes -- Sincronizacion Figma pendiente por limitacion operativa del conector de escritura en esta sesion. -- Hallazgo fuera de Fase 11: `appsettings.Development.json` fija Kestrel en `https://0.0.0.0:5000` y pisa `ASPNETCORE_URLS`, lo que complica arrancar una segunda instancia en otro puerto para smoke aislado. +- Corregir el tipado en `CuentaDetailPage.tsx` para recuperar build global verde. +- Sincronizar el cambio de Cuentas en Figma cuando haya conector de escritura disponible. +## 2026-04-19 - Dashboard titular: enlace Abrir a dashboard por cuenta + importacion desde cuenta -## 2026-04-15 - Fase 12 completada (Integracion OpenClaw end-to-end) +### Fase +- Ajuste puntual de frontend (dashboard y navegacion entre vistas de cuenta). ### Implementado -- Backend: - - Nuevo `IntegrationAuthMiddleware` para rutas `GET /api/integration/openclaw/*`: - - valida Bearer token contra `INTEGRATION_TOKENS` (SHA-256 hash) - - aplica rate limit por token (`integration_rate_limit_per_minute`, default 100 req/min) - - registra cada request en `AUDITORIA_INTEGRACIONES` con endpoint, metodo, codigo, IP y tiempo - - Nuevo `IntegrationTokenService`: - - generacion de token plano (`sk_gestion_caja_*`) - - hash SHA-256 - - validacion de token activo - - revocacion de token - - Nuevo `IntegrationAuthorizationService`: - - resuelve alcance por `INTEGRATION_PERMISSIONS` - - filtra titulares/cuentas/extractos segun permiso global, por titular o por cuenta - - Nuevo `IntegrationOpenClawController` con endpoints: - - `GET /api/integration/openclaw/titulares` - - `GET /api/integration/openclaw/saldos` - - `GET /api/integration/openclaw/extractos` - - `GET /api/integration/openclaw/grafica-evolucion` - - `GET /api/integration/openclaw/alertas` - - `GET /api/integration/openclaw/auditoria` - - `IntegracionesController` actualizado para usar `IntegrationTokenService` y añadir: - - `GET /api/integraciones/tokens/{id}/metricas` (total requests, % exitoso, tiempo promedio) - - `GET /api/integraciones/tokens/auditoria` (tabla paginada global) - - Registro DI y pipeline actualizado en `Program.cs`. +- `DashboardTitularPage` ahora agrega columna `Abrir` en `Desglose por cuenta`. +- El boton `Abrir` navega a `/dashboard/cuenta/:id` (bloqueado como `Sin acceso` si el usuario no puede ver esa cuenta). +- Se habilita nueva ruta protegida `/dashboard/cuenta/:id` en `App.tsx` reutilizando `CuentaDetailPage`. +- `CuentaDetailPage` se convierte en dashboard por cuenta: + - KPIs: `Saldo total`, `Ingresos mes`, `Egresos mes`. + - Tabla de lineas de extracto de la cuenta (incluye columnas extra dinamicas). + - CTA `Importar movimientos` que abre `/importacion?cuentaId=`. + - CTA `Ver en extractos` y `Volver al titular`. +- `ImportacionPage` ahora soporta `?cuentaId=` para abrir con la cuenta preseleccionada. +- Se anaden estilos de accion reutilizables para el link/boton `dashboard-open-link`. + +### Figma +- Pendiente: no se sincronizo el cambio en Figma en esta sesion. +- Nodo/pantalla objetivo: dashboard por titular (tabla de desglose por cuenta) y dashboard por cuenta en el archivo fuente indicado en AGENTS. +- Decision visual: mantener tabla actual y sumar accion `Abrir` con patron de boton consistente del sistema. + +### Archivos tocados +- frontend/src/pages/DashboardTitularPage.tsx +- frontend/src/pages/CuentaDetailPage.tsx +- frontend/src/pages/ImportacionPage.tsx +- frontend/src/App.tsx +- frontend/src/styles/layout.css +- DOCUMENTACION_CAMBIOS.md -- Frontend: - - Integraciones en componentes separados: - - `TokenList` - - `CreateTokenModal` - - `TokenCreatedModal` - - `TokenPermissionsEditor` - - `ConfiguracionPage` refactorizada para usar esos componentes y mostrar metricas por token. - - Nueva tabla `IntegrationAuditTable` integrada en `AuditoriaPage` como pestaña "Auditoria Integraciones". - - Estilos de modal añadidos en `layout.css`. +### Comandos ejecutados +- `Get-ChildItem` y `Get-Content` para localizar rutas/paginas/estilos. +- `npm.cmd run build` -- Testing backend: - - `IntegrationTokenServiceTests` - - `IntegrationAuthorizationServiceTests` +### Resultado de verificacion +- Build frontend OK (`tsc && vite build`). +- Verificacion estatica OK: + - existe columna `Abrir` en dashboard por titular; + - existe ruta `/dashboard/cuenta/:id`; + - dashboard por cuenta muestra KPIs + lineas; + - importacion soporta preseleccion por query `cuentaId`. + +### Pendientes +- Sincronizar en Figma cuando haya conector de escritura disponible. + +## 2026-04-19 - Titulares: boton Abrir en tarjetas inferiores + +### Fase +- Ajuste puntual de frontend en la seccion de titulares. + +### Implementado +- En `TitularesPage`, la accion `Abrir` del bloque inferior de tarjetas de titulares ya no se renderiza como texto/link plano. +- Ahora se renderiza como boton real (`