- New page /ventas to track characters selling items
- Default 12h duration per character sale
- Real-time countdown with color-coded badges (green/yellow/red/gray)
- Sound alert toggle when sales expire
- CRUD API: GET/POST/DELETE /api/sales
- Flyway migration V2 for character_sales table
- Private per-user (same user sees only their own characters)
- Expired sales remain visible with 'Expirado' badge until manually deleted
User.getEntityManager().refresh() requiere una tx activa, y el
caller (AuthResource.changePassword) no es @Transactional -> ese
approach tiraba TransactionRequiredException.
En lugar de refrescar en el caller, cambio AuthService.changePassword
para devolver la User ya actualizada en un record
ChangePasswordOutcome, despues de un flush() explicito para que los
cambios lleguen a DB antes de cerrar la tx. Asi el response del
endpoint refleja el estado real de la fila.
El cambio-password persistia bien a la DB (login con la nueva pass
daba 200 OK), pero la respuesta del endpoint seguia reportando
mustChangePassword: true. Era un problema de Panache/Hibernate cache:
la user entity quedaba cacheada en el persistence context con el
estado pre-cambio, y User.findById() siguiente devolvia esa misma
instancia cacheada.
Fix: despues de findById, forzar refresh desde DB con
User.getEntityManager().refresh(refreshed). Asi el response
refleja el estado actual real de la fila.
Found the actual issue was a stale Docker image in the LXC (the CI
runner was stuck for ~30h starting at task 323 on Aug 14). The newly
pulled image with all prior fixes (no @Authenticated, records w/
@RegisterForReflection, create-drop for tables) has been verified
end-to-end: login returns 200, /me returns 200 with the user,
change-password returns 200 after password validation.
So we can drop the LOG statements and the SQL debug log without
regressing anything.
Para diagnosticar por qué el login devuelve 401 con la password
correcta del bootstrap admin. Imprime:
- username/pw.length al entrar
- si la busqueda del user en la DB da null
- hash.len y hash.prefix para confirmar que el user esta bien
almacenado
- bcrypt.matches(true/false)
Tambien activamos quarkus.hibernate-orm.log.sql=true y
quarkus.log.category.'com.l2.shots.auth'.level=DEBUG.
Quarkus security solo lee JWT del header 'Authorization: Bearer ...'
por defecto. Esta app entrega el JWT en una cookie HttpOnly
('auth-token'), entonces @Authenticated/@RolesAllowed rebotaban con
401 antes de que el endpoint pudiera validar manualmente.
Reemplazos:
- @Authenticated en /change-password: sacada. El endpoint ya hace
jwtCookieAuth.extractToken() y devuelve 401 manual si falla.
- @Authenticated en /check: sacada. Endpoint ahora extrae y valida
el JWT a mano.
- @Authenticated en /me: never estuvo, OK.
- @RolesAllowed('admin') en /admin/reset-password y /admin/users:
reemplazada por chequeo manual jwt.getGroups().contains('admin'),
devolviendo 403 si no es admin.
Sigue funcionando el MustChangePasswordFilter porque sigue siendo
un ContainerRequestFilter con @Priority(AUTHENTICATION+100) y no
requiere auth previa para correr: simplemente aborta con 403 si
mustChangePassword=true salvo allowlist (change-password, logout).
Removida inyeccion no usada de currentJwt (JsonWebToken) que solo
estaba para @RolesAllowed.
Las clases record NO se serializan bien en Quarkus 3.20.1 native-image
aunque Jackson 2.17+ las soporta en JVM. GraalVM strip-ea la metadata
<Erecord> del bytecode mas los accessors auto-generados, y Jackson no
puede detectar que la clase es un record -> cae al BeanSerializer
clasico -> 'no properties discovered to create BeanSerializer'.
@RegisterForReflection le indica a Quarkus: incluí esta clase en la
metadata de reflexion del native-image, asi Jackson la ve completa.
Aplicado a los 16 DTOs (records + nested records):
- auth: AuthMeResponse, AdminUserSummary, Credentials,
ChangePasswordRequest, AdminResetPasswordRequest, ErrorBody,
MustChangeBody
- history: RunSummary, RunDetails, RunItem, RunSnapshot, RunIn,
HistoryStats
- state: AppState + Insumos + FormulaDto (nested)
En Quarkus native-image, Jackson no introspecta public fields de clases
con normales (deja de refleccionar field metadata). Salia error:
No serializer found for class com.l2.shots.auth.AuthMeResponse and
no properties discovered to create BeanSerializer
Los records tienen auto-accessors (id(), username(), etc) que Jackson
serializa nativamente, sin necesidad de reflection o
@RegisterForReflection.
Convertidos:
- auth: AuthMeResponse, AdminUserSummary, Credentials,
ChangePasswordRequest, AdminResetPasswordRequest, ErrorBody,
MustChangeBody
- history: RunSummary, RunDetails, RunItem, RunSnapshot, RunIn,
HistoryStats
- state: AppState (con Insumos y FormulaDto anidados)
Callers actualizados para usar accessors en vez de field access:
- AuthService.listUsersForAdmin -> AdminUserSummary.from
- AuthService.saveRun -> RunSummary.from / RunDetails.of
- AuthResource.register/login -> creds.username() / creds.password()
- HistoryResource.saveRun -> input.items() etc
Notas:
- RunDetails NO puede extender RunSummary en records (JLS no permite
extends entre records). Va como record independiente con todos los
campos. El JSON que produce matchea la interface RunDetails del
frontend (que extendia RunSummary).
- HistoryStats ahora se construye de una sola vez al final de
computeStats en lugar de ir mutando campos.
- MustChangeBody quedo con un constructor no-canonico no-arg para
mantener el call site original (new MustChangeBody()).
El error 'Table USERS not found' en BootstrapAdmin.User.count() se daba
porque en Quarkus native-image, con database.generation=update, el
schema-gen NO corre en startup del container por defecto (corre solo en
dev mode). Resultado: en un H2 fresco, el primer SELECT COUNT(*) del
bootstrap pierde contra una DB sin tablas.
Dos cambios:
1. application.properties: agregar
quarkus.hibernate-orm.schema-management.run-on-startup=true
para forzar el schema-gen en startup en prod/native.
2. BootstrapAdmin:
- @Transactional + EntityManager (em) inyectados
- em.createNativeQuery('SELECT 1') al inicio de onStart() para
asegurar que el EMF esté listo y la session esté abierta antes
del count(). Belt-and-suspenders.
GraalVM native-image rejects static fields of type Random/SecureRandom
(their internal state is captured at build time and can't be replayed
deterministically). Move the instance into generatePassword() as a
local variable - it is only used once at startup anyway.
Backend additions:
- User.lastLoginAt column (updated on each successful login)
- AdminUserSummary DTO (id, username, createdAt, lastLoginAt, isAdmin, mustChangePassword)
- GET /api/auth/admin/users (RolesAllowed("admin")) -> array of summaries
- AuthService.listUsersForAdmin() + AuthService.authenticate() now @Transactional and bumps lastLoginAt
Frontend (Phase 1):
- User type extended with mustChangePassword + isAdmin
- api.changePassword() / api.adminResetPassword() / api.adminListUsers()
- AuthContext exposes changePassword
- ChangePasswordPage.tsx (full-page, current + new + confirm, errors inline)
- App.tsx routes LoginPage -> ChangePasswordPage -> AuthenticatedApp
Frontend (Phase 2):
- TabBar supports optional 'usuarios' tab (shown only if user.isAdmin)
- UsersAdminPage.tsx: lista todos los usuarios con badges de rol y estado,
botón "Resetear contraseña" con modal inline que llama adminResetPassword
- Header de AuthenticatedApp muestra un badge 'admin' al lado del username
Both mvn compile and npm tsc + vite build pass clean.
Backend changes (no frontend yet):
Schema (User entity)
- + mustChange_password (boolean NOT NULL, default false)
- + is_admin (boolean NOT NULL, default false)
Hibernate update mode adds both columns automatically.
BootstrapAdmin (new, ApplicationScoped, @Observes StartupEvent)
- runs only when User.count() == 0 and app.bootstrap.admin.enabled=true
- generates a 20-char random password (alphabet without 0/o/O/1/l/I)
- persists the user with isAdmin=true, mustChangePassword=true
- prints a banner to stdout AND to the JBoss logger so docker logs
picks it up:
BOOTSTRAP-ADMIN-USERNAME admin
BOOTSTRAP-ADMIN-PASSWORD <random>
BOOTSTRAP-ADMIN-CHANGE This password MUST be changed on first login ...
- idempotent: skips if any user already exists
MustChangePasswordFilter (new, @Provider ContainerRequestFilter)
- runs after JWT auth (Priorities.AUTHENTICATION + 100)
- for authenticated requests with mustChangePassword=true, returns
403 with {error, mustChangePassword:true} unless the path is
/api/auth/change-password or /api/auth/logout
Change-password endpoint (POST /api/auth/change-password)
- @Authenticated, body {currentPassword, newPassword}
- verifies currentPassword via bcrypt, validates newPassword>=8 chars,
updates hash and sets mustChangePassword=false
- returns updated AuthMeResponse and re-issues the auth cookie
Admin reset endpoint (POST /api/auth/admin/reset-password)
- @RolesAllowed("admin")
- body {username, newPassword}
- sets target's passwordHash and mustChangePassword=true (forces change
on next login)
- security: only users in the JWT 'admin' group can hit it; isAdmin
is stored on the user record so a stale token can't promote itself
JWT groups now include 'admin' for isAdmin users; previously everyone
was just 'user'.
Config (application.properties)
- app.bootstrap.admin.enabled=true
- app.bootstrap.admin.username=admin