From 24a0c8a0dd93cb657f9fb7e7af1be93dd0b616a8 Mon Sep 17 00:00:00 2001 From: Jaume Garriga Maestre Date: Sun, 21 Jun 2026 13:11:34 +0200 Subject: [PATCH] feat: cuentas de familia (multi-tenant), registro/login y preferencias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convierte recordaLexia de una sola familia a multi-familia, con cuentas propias y persistencia de preferencias. Backend: - Tenant Family (email único + contraseña BCrypt + PIN + prefs de cuenta); family_id en child/activity/material_item/reward; aislamiento por familia (acceso cruzado responde 404). - Auth propia (sin Keycloak): registro/login email+contraseña, sesiones de familia persistidas en BD (sobreviven a reinicios), panel de padres tras PIN. - Liquibase 002-multitenant; seeder crea una familia demo. - Tests de aislamiento entre familias, registro/login y gate del panel. Frontend: - Login, registro y pantalla de cuenta; guards (sesion + PIN) e interceptor de sesion global; perfiles scopeados a la familia. Preferencias: - OpenDyslexic persistida por nino (child.dyslexiaFont) y default de cuenta. Decisiones en docs/adr/0004. --- .../recordalexia/bootstrap/DataSeeder.java | 120 +++++++------- .../asepeyo/recordalexia/domain/Activity.java | 13 ++ .../es/asepeyo/recordalexia/domain/Child.java | 28 ++++ .../asepeyo/recordalexia/domain/Family.java | 121 ++++++++++++++ .../recordalexia/domain/FamilySession.java | 89 +++++++++++ .../recordalexia/domain/MaterialItem.java | 15 ++ .../recordalexia/domain/ParentUser.java | 53 ------- .../asepeyo/recordalexia/domain/Reward.java | 15 ++ .../exception/ConflictException.java | 9 ++ .../exception/GlobalExceptionHandler.java | 6 + .../repository/ActivityRepository.java | 6 + .../repository/ChildRepository.java | 10 ++ .../repository/FamilyRepository.java | 12 ++ .../repository/FamilySessionRepository.java | 10 ++ .../repository/MaterialItemRepository.java | 6 + .../repository/ParentUserRepository.java | 11 -- .../repository/RewardRepository.java | 10 +- .../recordalexia/security/AuthService.java | 57 +++++++ .../recordalexia/security/FamilyContext.java | 21 +++ .../security/ParentAuthService.java | 54 ------- .../security/ParentSessionStore.java | 52 ------ .../recordalexia/security/SecurityConfig.java | 28 ++-- ...AuthFilter.java => SessionAuthFilter.java} | 31 ++-- .../security/SessionAuthService.java | 72 +++++++++ .../recordalexia/service/ChildService.java | 33 ++-- .../recordalexia/service/StoreService.java | 11 +- .../recordalexia/service/TaskService.java | 8 + .../recordalexia/service/TodayService.java | 10 +- .../recordalexia/service/WalletService.java | 8 +- .../recordalexia/web/AccountController.java | 90 +++++++++++ .../recordalexia/web/AuthController.java | 86 ++++++++++ .../recordalexia/web/PanelController.java | 42 +++++ .../web/ParentAuthController.java | 40 ----- .../web/ParentCatalogController.java | 38 +++-- .../web/ParentEventController.java | 18 ++- .../web/ParentRewardController.java | 27 +++- .../web/ParentScheduleController.java | 43 +++-- .../recordalexia/web/dto/AuthDtos.java | 34 ++++ .../recordalexia/web/dto/ChildDtos.java | 1 + .../recordalexia/web/dto/TodayResponse.java | 3 +- .../db/changelog/changes/002-multitenant.yaml | 103 ++++++++++++ .../recordalexia/bootstrap/DataSeederIT.java | 15 +- .../service/DayGenerationServiceTest.java | 11 +- .../service/StoreServiceTest.java | 37 ++++- .../recordalexia/service/TaskServiceTest.java | 39 +++-- .../es/asepeyo/recordalexia/web/AuthIT.java | 105 +++++++++++++ .../recordalexia/web/ParentAuthIT.java | 68 -------- .../asepeyo/recordalexia/web/TodayFlowIT.java | 46 ++++-- docs/adr/0004-multi-tenant-y-auth.md | 42 +++++ frontend/src/app/app.component.spec.ts | 8 - frontend/src/app/app.component.ts | 11 +- frontend/src/app/app.config.ts | 6 +- frontend/src/app/app.routes.ts | 28 ++-- frontend/src/app/core/auth.guard.ts | 20 +++ frontend/src/app/core/auth.interceptor.ts | 43 +++++ frontend/src/app/core/auth.service.ts | 92 +++++++++++ .../src/app/core/font-preference.service.ts | 79 ++-------- frontend/src/app/core/models.ts | 11 ++ .../app/core/parent-session.interceptor.ts | 16 -- .../src/app/core/parent-session.service.ts | 57 ------- frontend/src/app/core/parent.guard.ts | 10 -- .../app/features/auth/account.component.ts | 148 ++++++++++++++++++ frontend/src/app/features/auth/auth.scss | 84 ++++++++++ .../src/app/features/auth/login.component.ts | 54 +++++++ .../app/features/auth/register.component.ts | 64 ++++++++ .../src/app/features/home/home.component.ts | 4 + .../app/features/parents/keypad.component.ts | 6 +- .../app/features/parents/parents.component.ts | 7 +- .../features/parents/rewards-tab.component.ts | 11 +- .../profiles/profile-select.component.html | 4 + .../profiles/profile-select.component.scss | 16 ++ .../profiles/profile-select.component.ts | 20 +++ 72 files changed, 1959 insertions(+), 647 deletions(-) create mode 100644 backend/src/main/java/es/asepeyo/recordalexia/domain/Family.java create mode 100644 backend/src/main/java/es/asepeyo/recordalexia/domain/FamilySession.java delete mode 100644 backend/src/main/java/es/asepeyo/recordalexia/domain/ParentUser.java create mode 100644 backend/src/main/java/es/asepeyo/recordalexia/exception/ConflictException.java create mode 100644 backend/src/main/java/es/asepeyo/recordalexia/repository/FamilyRepository.java create mode 100644 backend/src/main/java/es/asepeyo/recordalexia/repository/FamilySessionRepository.java delete mode 100644 backend/src/main/java/es/asepeyo/recordalexia/repository/ParentUserRepository.java create mode 100644 backend/src/main/java/es/asepeyo/recordalexia/security/AuthService.java create mode 100644 backend/src/main/java/es/asepeyo/recordalexia/security/FamilyContext.java delete mode 100644 backend/src/main/java/es/asepeyo/recordalexia/security/ParentAuthService.java delete mode 100644 backend/src/main/java/es/asepeyo/recordalexia/security/ParentSessionStore.java rename backend/src/main/java/es/asepeyo/recordalexia/security/{ParentAuthFilter.java => SessionAuthFilter.java} (50%) create mode 100644 backend/src/main/java/es/asepeyo/recordalexia/security/SessionAuthService.java create mode 100644 backend/src/main/java/es/asepeyo/recordalexia/web/AccountController.java create mode 100644 backend/src/main/java/es/asepeyo/recordalexia/web/AuthController.java create mode 100644 backend/src/main/java/es/asepeyo/recordalexia/web/PanelController.java delete mode 100644 backend/src/main/java/es/asepeyo/recordalexia/web/ParentAuthController.java create mode 100644 backend/src/main/java/es/asepeyo/recordalexia/web/dto/AuthDtos.java create mode 100644 backend/src/main/resources/db/changelog/changes/002-multitenant.yaml create mode 100644 backend/src/test/java/es/asepeyo/recordalexia/web/AuthIT.java delete mode 100644 backend/src/test/java/es/asepeyo/recordalexia/web/ParentAuthIT.java create mode 100644 docs/adr/0004-multi-tenant-y-auth.md create mode 100644 frontend/src/app/core/auth.guard.ts create mode 100644 frontend/src/app/core/auth.interceptor.ts create mode 100644 frontend/src/app/core/auth.service.ts delete mode 100644 frontend/src/app/core/parent-session.interceptor.ts delete mode 100644 frontend/src/app/core/parent-session.service.ts delete mode 100644 frontend/src/app/core/parent.guard.ts create mode 100644 frontend/src/app/features/auth/account.component.ts create mode 100644 frontend/src/app/features/auth/auth.scss create mode 100644 frontend/src/app/features/auth/login.component.ts create mode 100644 frontend/src/app/features/auth/register.component.ts diff --git a/backend/src/main/java/es/asepeyo/recordalexia/bootstrap/DataSeeder.java b/backend/src/main/java/es/asepeyo/recordalexia/bootstrap/DataSeeder.java index 9fde217..381adda 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/bootstrap/DataSeeder.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/bootstrap/DataSeeder.java @@ -4,16 +4,16 @@ import es.asepeyo.recordalexia.domain.Activity; import es.asepeyo.recordalexia.domain.AfternoonRoutine; import es.asepeyo.recordalexia.domain.Child; import es.asepeyo.recordalexia.domain.EventType; +import es.asepeyo.recordalexia.domain.Family; import es.asepeyo.recordalexia.domain.MaterialItem; -import es.asepeyo.recordalexia.domain.ParentUser; import es.asepeyo.recordalexia.domain.Reward; import es.asepeyo.recordalexia.domain.SpecialEvent; import es.asepeyo.recordalexia.domain.WeeklyTemplateEntry; import es.asepeyo.recordalexia.repository.ActivityRepository; import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository; import es.asepeyo.recordalexia.repository.ChildRepository; +import es.asepeyo.recordalexia.repository.FamilyRepository; import es.asepeyo.recordalexia.repository.MaterialItemRepository; -import es.asepeyo.recordalexia.repository.ParentUserRepository; import es.asepeyo.recordalexia.repository.RewardRepository; import es.asepeyo.recordalexia.repository.SpecialEventRepository; import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository; @@ -30,22 +30,23 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; /** - * Siembra los datos de ejemplo del prototipo (niños, material, actividades, horario, - * rutinas, eventos, premios y el PIN de padres por defecto). Solo actúa si la base - * de datos está vacía, así que es seguro arrancar varias veces. + * Siembra una FAMILIA DEMO con los datos de ejemplo del prototipo (niños, material, + * actividades, horario, rutinas, eventos y premios). Solo actúa si no hay familias, + * así que es seguro arrancar varias veces. Se desactiva con + * recordalexia.seed.enabled=false (lo hacen los tests). * - * Se desactiva con recordalexia.seed.enabled=false (lo hacen los tests, que montan - * sus propios datos deterministas). + * Credenciales demo: demo@recordalexia.local / demo1234 · PIN 1234. */ @Component @ConditionalOnProperty(name = "recordalexia.seed.enabled", havingValue = "true", matchIfMissing = true) public class DataSeeder implements ApplicationRunner { - /** PIN de padres por defecto; configurable (el panel permite cambiarlo). */ - private static final String DEFAULT_PIN = "1234"; + private static final String DEMO_EMAIL = "demo@recordalexia.local"; + private static final String DEMO_PASSWORD = "demo1234"; + private static final String DEMO_PIN = "1234"; + private final FamilyRepository familyRepository; private final ChildRepository childRepository; - private final ParentUserRepository parentUserRepository; private final MaterialItemRepository materialRepository; private final ActivityRepository activityRepository; private final WeeklyTemplateEntryRepository templateRepository; @@ -55,14 +56,14 @@ public class DataSeeder implements ApplicationRunner { private final PasswordEncoder passwordEncoder; private final Clock clock; - public DataSeeder(ChildRepository childRepository, ParentUserRepository parentUserRepository, + public DataSeeder(FamilyRepository familyRepository, ChildRepository childRepository, MaterialItemRepository materialRepository, ActivityRepository activityRepository, WeeklyTemplateEntryRepository templateRepository, AfternoonRoutineRepository routineRepository, SpecialEventRepository eventRepository, RewardRepository rewardRepository, PasswordEncoder passwordEncoder, Clock clock) { + this.familyRepository = familyRepository; this.childRepository = childRepository; - this.parentUserRepository = parentUserRepository; this.materialRepository = materialRepository; this.activityRepository = activityRepository; this.templateRepository = templateRepository; @@ -76,26 +77,26 @@ public class DataSeeder implements ApplicationRunner { @Override @Transactional public void run(ApplicationArguments args) { - if (childRepository.count() > 0) { + if (familyRepository.count() > 0) { return; // Ya sembrado: no duplicar. } - seedParent(); - var materials = seedMaterials(); - var activities = seedActivities(materials); - seedRewards(); + Family family = familyRepository.save(new Family(DEMO_EMAIL, + passwordEncoder.encode(DEMO_PASSWORD), "Familia demo", passwordEncoder.encode(DEMO_PIN))); - // Tres niños del prototipo, cada uno con su horario y rutinas. - Child nora = childRepository.save(child("Nora", "🦊", "#F2A65A", 7, 42, LocalTime.of(8, 30))); - Child leo = childRepository.save(child("Leo", "🐢", "#5BC0BE", 9, 28, LocalTime.of(8, 30))); - Child mia = childRepository.save(child("Mía", "🦉", "#A78BD0", 6, 55, LocalTime.of(8, 15))); + var materials = seedMaterials(family); + var activities = seedActivities(family, materials); + seedRewards(family); + + Child nora = childRepository.save(child(family, "Nora", "🦊", "#F2A65A", 7, 42, LocalTime.of(8, 30))); + Child leo = childRepository.save(child(family, "Leo", "🐢", "#5BC0BE", 9, 28, LocalTime.of(8, 30))); + Child mia = childRepository.save(child(family, "Mía", "🦉", "#A78BD0", 6, 55, LocalTime.of(8, 15))); for (Child c : List.of(nora, leo, mia)) { seedWeeklyMornings(c, activities); seedAfternoonRoutines(c); } - // Un par de eventos para Nora alrededor de hoy, para poder probar /today. LocalDate today = LocalDate.now(clock); eventRepository.save(new SpecialEvent(nora, today, EventType.EXAM, "Examen de Lengua", "Examen de Llengua", "📋", "#EC8FA4")); @@ -103,60 +104,66 @@ public class DataSeeder implements ApplicationRunner { "Ficha de mates", "Fitxa de mates", "📎", "#5B8DEF")); } - private void seedParent() { - parentUserRepository.save(new ParentUser(passwordEncoder.encode(DEFAULT_PIN))); - } - - private MaterialsCatalog seedMaterials() { + private MaterialsCatalog seedMaterials(Family family) { return new MaterialsCatalog( - material("Estuche", "Estoig", "✏️", "#F4C95D", "general"), - material("Libro de mates", "Llibre de mates", "📘", "#5B8DEF", "matematicas"), - material("Regla", "Regle", "📏", "#5B8DEF", "matematicas"), - material("Flauta", "Flauta", "🎵", "#A78BD0", "musica"), - material("Libreta", "Llibreta", "📓", "#A78BD0", "musica"), - material("Ropa de gimnasia", "Roba d'EF", "👕", "#7FBF6B", "gimnasia"), - material("Zapatillas", "Sabatilles", "👟", "#7FBF6B", "gimnasia"), - material("Toalla", "Tovallola", "🧖", "#5BC0BE", "gimnasia"), - material("Agua", "Aigua", "💧", "#5BC0BE", "gimnasia"), - material("Lectura", "Lectura", "📖", "#F2A65A", "lengua"), - material("Cuaderno", "Quadern", "📒", "#F2A65A", "lengua"), - material("Almuerzo", "Esmorzar", "🍎", "#EC8FA4", "general")); + material(family, "Estuche", "Estoig", "✏️", "#F4C95D", "general"), + material(family, "Libro de mates", "Llibre de mates", "📘", "#5B8DEF", "matematicas"), + material(family, "Regla", "Regle", "📏", "#5B8DEF", "matematicas"), + material(family, "Flauta", "Flauta", "🎵", "#A78BD0", "musica"), + material(family, "Libreta", "Llibreta", "📓", "#A78BD0", "musica"), + material(family, "Ropa de gimnasia", "Roba d'EF", "👕", "#7FBF6B", "gimnasia"), + material(family, "Zapatillas", "Sabatilles", "👟", "#7FBF6B", "gimnasia"), + material(family, "Toalla", "Tovallola", "🧖", "#5BC0BE", "gimnasia"), + material(family, "Agua", "Aigua", "💧", "#5BC0BE", "gimnasia"), + material(family, "Lectura", "Lectura", "📖", "#F2A65A", "lengua"), + material(family, "Cuaderno", "Quadern", "📒", "#F2A65A", "lengua"), + material(family, "Almuerzo", "Esmorzar", "🍎", "#EC8FA4", "general")); } - private MaterialItem material(String es, String ca, String icon, String color, String category) { - return materialRepository.save(new MaterialItem(es, ca, icon, color, category)); + private MaterialItem material(Family family, String es, String ca, String icon, String color, String category) { + MaterialItem m = new MaterialItem(es, ca, icon, color, category); + m.setFamily(family); + return materialRepository.save(m); } - private ActivitiesCatalog seedActivities(MaterialsCatalog m) { - Activity gimnasia = activity("Gimnasia", "Gimnàstica", "🤸", "#7FBF6B", + private ActivitiesCatalog seedActivities(Family family, MaterialsCatalog m) { + Activity gimnasia = activity(family, "Gimnasia", "Gimnàstica", "🤸", "#7FBF6B", m.ropaGimnasia, m.zapatillas, m.toalla, m.agua); - Activity musica = activity("Música", "Música", "🎵", "#A78BD0", m.flauta, m.libreta); - Activity mates = activity("Matemáticas", "Matemàtiques", "📘", "#5B8DEF", + Activity musica = activity(family, "Música", "Música", "🎵", "#A78BD0", m.flauta, m.libreta); + Activity mates = activity(family, "Matemáticas", "Matemàtiques", "📘", "#5B8DEF", m.libroMates, m.regla, m.estuche); - Activity lengua = activity("Lengua", "Llengua", "📖", "#F2A65A", m.lectura, m.cuaderno); + Activity lengua = activity(family, "Lengua", "Llengua", "📖", "#F2A65A", m.lectura, m.cuaderno); return new ActivitiesCatalog(gimnasia, musica, mates, lengua); } - private Activity activity(String es, String ca, String icon, String color, MaterialItem... mats) { + private Activity activity(Family family, String es, String ca, String icon, String color, MaterialItem... mats) { Activity activity = new Activity(es, ca, icon, color); + activity.setFamily(family); for (MaterialItem mat : mats) { activity.addMaterial(mat); } return activityRepository.save(activity); } - private void seedRewards() { + private void seedRewards(Family family) { rewardRepository.saveAll(List.of( - new Reward("30 min de tablet", "30 min de tauleta", "🎮", "#5B8DEF", 20), - new Reward("Elijo la cena", "Trio el sopar", "🍕", "#F2A65A", 30), - new Reward("Tarde en el parque", "Tarda al parc", "🛝", "#7FBF6B", 40), - new Reward("Peli en familia", "Pel·lícula en família", "🍿", "#A78BD0", 50), - new Reward("30 min más despierto", "30 min més despert", "🌙", "#5BC0BE", 60), - new Reward("Sorpresa dino", "Sorpresa dino", "🦖", "#EC8FA4", 80))); + reward(family, "30 min de tablet", "30 min de tauleta", "🎮", "#5B8DEF", 20), + reward(family, "Elijo la cena", "Trio el sopar", "🍕", "#F2A65A", 30), + reward(family, "Tarde en el parque", "Tarda al parc", "🛝", "#7FBF6B", 40), + reward(family, "Peli en familia", "Pel·lícula en família", "🍿", "#A78BD0", 50), + reward(family, "30 min más despierto", "30 min més despert", "🌙", "#5BC0BE", 60), + reward(family, "Sorpresa dino", "Sorpresa dino", "🦖", "#EC8FA4", 80))); } - private Child child(String name, String mascot, String color, int age, int coins, LocalTime departure) { + private Reward reward(Family family, String es, String ca, String icon, String color, int cost) { + Reward r = new Reward(es, ca, icon, color, cost); + r.setFamily(family); + return r; + } + + private Child child(Family family, String name, String mascot, String color, int age, int coins, LocalTime departure) { Child c = new Child(); + c.setFamily(family); c.setName(name); c.setMascot(mascot); c.setAccentColor(color); @@ -166,7 +173,6 @@ public class DataSeeder implements ApplicationRunner { return c; } - /** Plantilla de mañana de lunes a viernes (cada día, una actividad del cole). */ private void seedWeeklyMornings(Child child, ActivitiesCatalog a) { addWeekly(child, DayOfWeek.MONDAY, a.matematicas); addWeekly(child, DayOfWeek.TUESDAY, a.gimnasia); @@ -179,7 +185,6 @@ public class DataSeeder implements ApplicationRunner { templateRepository.save(new WeeklyTemplateEntry(child, day, activity, 0)); } - /** Rutinas de tarde, iguales de lunes a viernes. */ private void seedAfternoonRoutines(Child child) { for (DayOfWeek day : List.of(DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY)) { @@ -196,7 +201,6 @@ public class DataSeeder implements ApplicationRunner { } } - // Pequeños contenedores para pasar el catálogo sembrado con nombres claros. private record MaterialsCatalog( MaterialItem estuche, MaterialItem libroMates, MaterialItem regla, MaterialItem flauta, MaterialItem libreta, MaterialItem ropaGimnasia, MaterialItem zapatillas, MaterialItem toalla, diff --git a/backend/src/main/java/es/asepeyo/recordalexia/domain/Activity.java b/backend/src/main/java/es/asepeyo/recordalexia/domain/Activity.java index aad3c42..41410cb 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/domain/Activity.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/domain/Activity.java @@ -9,6 +9,7 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinTable; import jakarta.persistence.ManyToMany; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import java.util.LinkedHashSet; import java.util.Set; @@ -25,6 +26,10 @@ public class Activity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "family_id") + private Family family; + @Column(name = "label_es") private String labelEs; @@ -60,6 +65,14 @@ public class Activity { return id; } + public Family getFamily() { + return family; + } + + public void setFamily(Family family) { + this.family = family; + } + public String getLabelEs() { return labelEs; } diff --git a/backend/src/main/java/es/asepeyo/recordalexia/domain/Child.java b/backend/src/main/java/es/asepeyo/recordalexia/domain/Child.java index e74e802..fc23a95 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/domain/Child.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/domain/Child.java @@ -4,9 +4,12 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import java.time.LocalTime; @@ -22,6 +25,11 @@ public class Child { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** Familia (tenant) a la que pertenece este niño. */ + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "family_id") + private Family family; + private String name; private String mascot; @@ -50,6 +58,10 @@ public class Child { @Enumerated(EnumType.STRING) private Language language = Language.ES; + /** Preferencia de accesibilidad: usar la tipografía OpenDyslexic. */ + @Column(name = "dyslexia_font") + private boolean dyslexiaFont = true; + // --- Parámetros de gamificación (configurables por niño) --- @Column(name = "coins_per_task") private int coinsPerTask = 5; @@ -104,6 +116,22 @@ public class Child { return id; } + public Family getFamily() { + return family; + } + + public void setFamily(Family family) { + this.family = family; + } + + public boolean isDyslexiaFont() { + return dyslexiaFont; + } + + public void setDyslexiaFont(boolean dyslexiaFont) { + this.dyslexiaFont = dyslexiaFont; + } + public String getName() { return name; } diff --git a/backend/src/main/java/es/asepeyo/recordalexia/domain/Family.java b/backend/src/main/java/es/asepeyo/recordalexia/domain/Family.java new file mode 100644 index 0000000..54ba616 --- /dev/null +++ b/backend/src/main/java/es/asepeyo/recordalexia/domain/Family.java @@ -0,0 +1,121 @@ +package es.asepeyo.recordalexia.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; + +/** + * Cuenta de FAMILIA: el "inquilino" (tenant) de la app. Cada familia tiene sus + * propios niños, catálogos y datos, aislados del resto. + * + * Guarda el hash de la contraseña (BCrypt) para el login y el hash del PIN para el + * gate del panel de padres. Nunca se almacenan en claro. + */ +@Entity +@Table(name = "family") +public class Family { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** Identificador de acceso (único). */ + @Column(nullable = false, unique = true) + private String email; + + /** Hash BCrypt de la contraseña de acceso. */ + @Column(name = "pass_hash", nullable = false) + private String passHash; + + /** Nombre visible de la familia. */ + private String name; + + /** Hash BCrypt del PIN que protege el panel de padres. */ + @Column(name = "pin_hash", nullable = false) + private String pinHash; + + // --- Preferencias de cuenta --- + @Enumerated(EnumType.STRING) + @Column(name = "ui_language") + private Language uiLanguage = Language.ES; + + @Column(name = "default_dyslexia_font") + private boolean defaultDyslexiaFont = true; + + @Column(name = "created_at") + private Instant createdAt; + + public Family() { + // JPA / creación desde el servicio. + } + + public Family(String email, String passHash, String name, String pinHash) { + this.email = email; + this.passHash = passHash; + this.name = name; + this.pinHash = pinHash; + this.createdAt = Instant.now(); + } + + public Long getId() { + return id; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPassHash() { + return passHash; + } + + public void setPassHash(String passHash) { + this.passHash = passHash; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getPinHash() { + return pinHash; + } + + public void setPinHash(String pinHash) { + this.pinHash = pinHash; + } + + public Language getUiLanguage() { + return uiLanguage; + } + + public void setUiLanguage(Language uiLanguage) { + this.uiLanguage = uiLanguage; + } + + public boolean isDefaultDyslexiaFont() { + return defaultDyslexiaFont; + } + + public void setDefaultDyslexiaFont(boolean defaultDyslexiaFont) { + this.defaultDyslexiaFont = defaultDyslexiaFont; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/es/asepeyo/recordalexia/domain/FamilySession.java b/backend/src/main/java/es/asepeyo/recordalexia/domain/FamilySession.java new file mode 100644 index 0000000..5ace9c4 --- /dev/null +++ b/backend/src/main/java/es/asepeyo/recordalexia/domain/FamilySession.java @@ -0,0 +1,89 @@ +package es.asepeyo.recordalexia.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.Instant; + +/** + * Sesión de una familia en un dispositivo. Persistida en BD para sobrevivir a + * reinicios del backend (el kiosko no se desautentica). Se identifica por un + * "handle" opaco aleatorio que viaja en la cabecera X-Auth-Session. + * + * Lleva además panelUnlockedUntil: cuándo expira el desbloqueo del panel de padres + * (tras introducir el PIN), para que el niño no entre al panel. + */ +@Entity +@Table(name = "family_session") +public class FamilySession { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** Valor opaco aleatorio que identifica la sesión. */ + @Column(nullable = false, unique = true) + private String handle; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "family_id") + private Family family; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + /** Hasta cuándo el panel de padres está desbloqueado (null = bloqueado). */ + @Column(name = "panel_unlocked_until") + private Instant panelUnlockedUntil; + + public FamilySession() { + } + + public FamilySession(String handle, Family family, Instant expiresAt) { + this.handle = handle; + this.family = family; + this.expiresAt = expiresAt; + } + + public boolean isExpired(Instant now) { + return expiresAt.isBefore(now); + } + + public boolean isPanelUnlocked(Instant now) { + return panelUnlockedUntil != null && panelUnlockedUntil.isAfter(now); + } + + public Long getId() { + return id; + } + + public String getHandle() { + return handle; + } + + public Family getFamily() { + return family; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } + + public Instant getPanelUnlockedUntil() { + return panelUnlockedUntil; + } + + public void setPanelUnlockedUntil(Instant panelUnlockedUntil) { + this.panelUnlockedUntil = panelUnlockedUntil; + } +} diff --git a/backend/src/main/java/es/asepeyo/recordalexia/domain/MaterialItem.java b/backend/src/main/java/es/asepeyo/recordalexia/domain/MaterialItem.java index 448df65..e034778 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/domain/MaterialItem.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/domain/MaterialItem.java @@ -2,9 +2,12 @@ package es.asepeyo.recordalexia.domain; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; /** Material concreto del cole (estuche, flauta...). Texto bilingüe + emoji + color. */ @@ -16,6 +19,10 @@ public class MaterialItem { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "family_id") + private Family family; + @Column(name = "label_es") private String labelEs; @@ -41,6 +48,14 @@ public class MaterialItem { return id; } + public Family getFamily() { + return family; + } + + public void setFamily(Family family) { + this.family = family; + } + public String getLabelEs() { return labelEs; } diff --git a/backend/src/main/java/es/asepeyo/recordalexia/domain/ParentUser.java b/backend/src/main/java/es/asepeyo/recordalexia/domain/ParentUser.java deleted file mode 100644 index 07bccb9..0000000 --- a/backend/src/main/java/es/asepeyo/recordalexia/domain/ParentUser.java +++ /dev/null @@ -1,53 +0,0 @@ -package es.asepeyo.recordalexia.domain; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import java.time.Instant; - -/** - * Credenciales del panel de padres. Guarda únicamente el HASH del PIN (BCrypt), - * nunca el PIN en claro. El PIN es configurable; no se hardcodea. - */ -@Entity -@Table(name = "parent_user") -public class ParentUser { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "pin_hash") - private String pinHash; - - @Column(name = "updated_at") - private Instant updatedAt; - - protected ParentUser() { - } - - public ParentUser(String pinHash) { - this.pinHash = pinHash; - this.updatedAt = Instant.now(); - } - - public Long getId() { - return id; - } - - public String getPinHash() { - return pinHash; - } - - public void setPinHash(String pinHash) { - this.pinHash = pinHash; - this.updatedAt = Instant.now(); - } - - public Instant getUpdatedAt() { - return updatedAt; - } -} diff --git a/backend/src/main/java/es/asepeyo/recordalexia/domain/Reward.java b/backend/src/main/java/es/asepeyo/recordalexia/domain/Reward.java index 74b0343..ae7346d 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/domain/Reward.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/domain/Reward.java @@ -2,9 +2,12 @@ package es.asepeyo.recordalexia.domain; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; /** Premio canjeable en la tienda. Compartido por todos los niños. */ @@ -16,6 +19,10 @@ public class Reward { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "family_id") + private Family family; + @Column(name = "label_es") private String labelEs; @@ -42,6 +49,14 @@ public class Reward { return id; } + public Family getFamily() { + return family; + } + + public void setFamily(Family family) { + this.family = family; + } + public String getLabelEs() { return labelEs; } diff --git a/backend/src/main/java/es/asepeyo/recordalexia/exception/ConflictException.java b/backend/src/main/java/es/asepeyo/recordalexia/exception/ConflictException.java new file mode 100644 index 0000000..4ef1a3a --- /dev/null +++ b/backend/src/main/java/es/asepeyo/recordalexia/exception/ConflictException.java @@ -0,0 +1,9 @@ +package es.asepeyo.recordalexia.exception; + +/** Conflicto de estado, p. ej. email ya registrado. Se traduce a HTTP 409. */ +public class ConflictException extends RuntimeException { + + public ConflictException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/es/asepeyo/recordalexia/exception/GlobalExceptionHandler.java b/backend/src/main/java/es/asepeyo/recordalexia/exception/GlobalExceptionHandler.java index 6f907ce..2216bc8 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/exception/GlobalExceptionHandler.java @@ -24,6 +24,12 @@ public class GlobalExceptionHandler { "message", ex.getMessage())); } + @ExceptionHandler(ConflictException.class) + public ResponseEntity> handleConflict(ConflictException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "conflict", "message", ex.getMessage())); + } + @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity> handleBadRequest(IllegalArgumentException ex) { return ResponseEntity.badRequest() diff --git a/backend/src/main/java/es/asepeyo/recordalexia/repository/ActivityRepository.java b/backend/src/main/java/es/asepeyo/recordalexia/repository/ActivityRepository.java index 1436f32..e370105 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/repository/ActivityRepository.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/repository/ActivityRepository.java @@ -1,7 +1,13 @@ package es.asepeyo.recordalexia.repository; import es.asepeyo.recordalexia.domain.Activity; +import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface ActivityRepository extends JpaRepository { + + List findByFamilyId(Long familyId); + + Optional findByIdAndFamilyId(Long id, Long familyId); } diff --git a/backend/src/main/java/es/asepeyo/recordalexia/repository/ChildRepository.java b/backend/src/main/java/es/asepeyo/recordalexia/repository/ChildRepository.java index fb908e9..86ee841 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/repository/ChildRepository.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/repository/ChildRepository.java @@ -1,7 +1,17 @@ package es.asepeyo.recordalexia.repository; import es.asepeyo.recordalexia.domain.Child; +import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface ChildRepository extends JpaRepository { + + /** Niños de una familia (tenant). */ + List findByFamilyIdOrderByIdAsc(Long familyId); + + /** Carga un niño solo si pertenece a la familia (aislamiento). */ + Optional findByIdAndFamilyId(Long id, Long familyId); + + boolean existsByIdAndFamilyId(Long id, Long familyId); } diff --git a/backend/src/main/java/es/asepeyo/recordalexia/repository/FamilyRepository.java b/backend/src/main/java/es/asepeyo/recordalexia/repository/FamilyRepository.java new file mode 100644 index 0000000..732c349 --- /dev/null +++ b/backend/src/main/java/es/asepeyo/recordalexia/repository/FamilyRepository.java @@ -0,0 +1,12 @@ +package es.asepeyo.recordalexia.repository; + +import es.asepeyo.recordalexia.domain.Family; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FamilyRepository extends JpaRepository { + + Optional findByEmailIgnoreCase(String email); + + boolean existsByEmailIgnoreCase(String email); +} diff --git a/backend/src/main/java/es/asepeyo/recordalexia/repository/FamilySessionRepository.java b/backend/src/main/java/es/asepeyo/recordalexia/repository/FamilySessionRepository.java new file mode 100644 index 0000000..e3b5a28 --- /dev/null +++ b/backend/src/main/java/es/asepeyo/recordalexia/repository/FamilySessionRepository.java @@ -0,0 +1,10 @@ +package es.asepeyo.recordalexia.repository; + +import es.asepeyo.recordalexia.domain.FamilySession; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FamilySessionRepository extends JpaRepository { + + Optional findByHandle(String handle); +} diff --git a/backend/src/main/java/es/asepeyo/recordalexia/repository/MaterialItemRepository.java b/backend/src/main/java/es/asepeyo/recordalexia/repository/MaterialItemRepository.java index ddf3cf6..c9e0769 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/repository/MaterialItemRepository.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/repository/MaterialItemRepository.java @@ -1,7 +1,13 @@ package es.asepeyo.recordalexia.repository; import es.asepeyo.recordalexia.domain.MaterialItem; +import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface MaterialItemRepository extends JpaRepository { + + List findByFamilyId(Long familyId); + + Optional findByIdAndFamilyId(Long id, Long familyId); } diff --git a/backend/src/main/java/es/asepeyo/recordalexia/repository/ParentUserRepository.java b/backend/src/main/java/es/asepeyo/recordalexia/repository/ParentUserRepository.java deleted file mode 100644 index 448cf45..0000000 --- a/backend/src/main/java/es/asepeyo/recordalexia/repository/ParentUserRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package es.asepeyo.recordalexia.repository; - -import es.asepeyo.recordalexia.domain.ParentUser; -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ParentUserRepository extends JpaRepository { - - /** Solo hay un usuario de padres en el hogar; devuelve el primero. */ - Optional findFirstByOrderByIdAsc(); -} diff --git a/backend/src/main/java/es/asepeyo/recordalexia/repository/RewardRepository.java b/backend/src/main/java/es/asepeyo/recordalexia/repository/RewardRepository.java index 28a5240..98da89b 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/repository/RewardRepository.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/repository/RewardRepository.java @@ -2,10 +2,16 @@ package es.asepeyo.recordalexia.repository; import es.asepeyo.recordalexia.domain.Reward; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface RewardRepository extends JpaRepository { - /** Premios activos para mostrar en la tienda. */ - List findByActiveTrueOrderByCostAsc(); + /** Premios activos de una familia, para la tienda. */ + List findByFamilyIdAndActiveTrueOrderByCostAsc(Long familyId); + + /** Catálogo completo de premios de una familia (panel). */ + List findByFamilyId(Long familyId); + + Optional findByIdAndFamilyId(Long id, Long familyId); } diff --git a/backend/src/main/java/es/asepeyo/recordalexia/security/AuthService.java b/backend/src/main/java/es/asepeyo/recordalexia/security/AuthService.java new file mode 100644 index 0000000..4159caa --- /dev/null +++ b/backend/src/main/java/es/asepeyo/recordalexia/security/AuthService.java @@ -0,0 +1,57 @@ +package es.asepeyo.recordalexia.security; + +import es.asepeyo.recordalexia.domain.Family; +import es.asepeyo.recordalexia.exception.ConflictException; +import es.asepeyo.recordalexia.repository.FamilyRepository; +import java.util.Optional; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Registro y acceso de familias. La contraseña y el PIN se guardan con hash BCrypt. + * Encapsulado para poder sustituirlo por un IdP externo en el futuro sin tocar el + * resto del código. + */ +@Service +public class AuthService { + + private final FamilyRepository familyRepository; + private final SessionAuthService sessions; + private final PasswordEncoder encoder; + + public AuthService(FamilyRepository familyRepository, SessionAuthService sessions, + PasswordEncoder encoder) { + this.familyRepository = familyRepository; + this.sessions = sessions; + this.encoder = encoder; + } + + /** Registra una familia nueva y abre sesión (auto-login). Devuelve el handle. */ + @Transactional + public String register(String email, String rawPass, String name, String rawPin) { + String normalized = email.trim().toLowerCase(); + if (familyRepository.existsByEmailIgnoreCase(normalized)) { + throw new ConflictException("Ese email ya está registrado"); + } + Family family = familyRepository.save( + new Family(normalized, encoder.encode(rawPass), name, encoder.encode(rawPin))); + return sessions.openSession(family); + } + + /** Valida credenciales; si son correctas abre sesión y devuelve el handle. */ + @Transactional + public Optional login(String email, String rawPass) { + return familyRepository.findByEmailIgnoreCase(email.trim().toLowerCase()) + .filter(family -> encoder.matches(rawPass, family.getPassHash())) + .map(sessions::openSession); + } + + /** Comprueba el PIN del panel para una familia. */ + @Transactional(readOnly = true) + public boolean checkPin(Long familyId, String rawPin) { + return familyRepository.findById(familyId) + .filter(family -> encoder.matches(rawPin, family.getPinHash())) + .isPresent(); + } +} diff --git a/backend/src/main/java/es/asepeyo/recordalexia/security/FamilyContext.java b/backend/src/main/java/es/asepeyo/recordalexia/security/FamilyContext.java new file mode 100644 index 0000000..c08b62d --- /dev/null +++ b/backend/src/main/java/es/asepeyo/recordalexia/security/FamilyContext.java @@ -0,0 +1,21 @@ +package es.asepeyo.recordalexia.security; + +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +/** + * Acceso a la familia (tenant) de la petición actual. La sesión la resuelve + * {@link SessionAuthFilter}, que deja el id de familia como principal. + */ +@Component +public class FamilyContext { + + /** Id de la familia autenticada. Lanza si no hay sesión (no debería pasar tras el filtro). */ + public Long currentFamilyId() { + var auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getPrincipal() instanceof Long familyId) { + return familyId; + } + throw new IllegalStateException("No hay familia en la sesión actual"); + } +} diff --git a/backend/src/main/java/es/asepeyo/recordalexia/security/ParentAuthService.java b/backend/src/main/java/es/asepeyo/recordalexia/security/ParentAuthService.java deleted file mode 100644 index 80ec518..0000000 --- a/backend/src/main/java/es/asepeyo/recordalexia/security/ParentAuthService.java +++ /dev/null @@ -1,54 +0,0 @@ -package es.asepeyo.recordalexia.security; - -import es.asepeyo.recordalexia.domain.ParentUser; -import es.asepeyo.recordalexia.repository.ParentUserRepository; -import java.util.Optional; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -/** - * Autenticación del panel de padres por PIN. - * - * Encapsula la verificación del PIN (hash BCrypt) y la apertura de sesión. Está - * aislada a propósito para poder sustituirla por un proveedor externo (Keycloak) - * sin afectar a controladores ni servicios de negocio. - */ -@Service -public class ParentAuthService { - - private final ParentUserRepository parentUserRepository; - private final ParentSessionStore sessionStore; - private final PasswordEncoder passwordEncoder; - - public ParentAuthService(ParentUserRepository parentUserRepository, - ParentSessionStore sessionStore, - PasswordEncoder passwordEncoder) { - this.parentUserRepository = parentUserRepository; - this.sessionStore = sessionStore; - this.passwordEncoder = passwordEncoder; - } - - /** - * Valida el PIN y, si es correcto, abre sesión y devuelve su identificador. - * Devuelve vacío si el PIN no es válido (el controlador responde 401). - */ - @Transactional(readOnly = true) - public Optional login(String pin) { - return parentUserRepository.findFirstByOrderByIdAsc() - .filter(parent -> passwordEncoder.matches(pin, parent.getPinHash())) - .map(parent -> sessionStore.issue()); - } - - /** Cambia el PIN si el actual es correcto. Devuelve true si se cambió. */ - @Transactional - public boolean changePin(String currentPin, String newPin) { - Optional parent = parentUserRepository.findFirstByOrderByIdAsc() - .filter(p -> passwordEncoder.matches(currentPin, p.getPinHash())); - if (parent.isEmpty()) { - return false; - } - parent.get().setPinHash(passwordEncoder.encode(newPin)); - return true; - } -} diff --git a/backend/src/main/java/es/asepeyo/recordalexia/security/ParentSessionStore.java b/backend/src/main/java/es/asepeyo/recordalexia/security/ParentSessionStore.java deleted file mode 100644 index 89ff7f9..0000000 --- a/backend/src/main/java/es/asepeyo/recordalexia/security/ParentSessionStore.java +++ /dev/null @@ -1,52 +0,0 @@ -package es.asepeyo.recordalexia.security; - -import java.time.Duration; -import java.time.Instant; -import java.util.Map; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import org.springframework.stereotype.Component; - -/** - * Almacén de sesiones del panel de padres, en memoria. - * - * Cada sesión se identifica por un valor opaco aleatorio (el "token" que viaja en - * la cabecera X-Parent-Token). Es deliberadamente simple (homelab, instancia - * única) y está encapsulado para que, si en el futuro se externaliza la auth - * (p. ej. Keycloak), se sustituya sin tocar el resto del código. - */ -@Component -public class ParentSessionStore { - - /** Vigencia de la sesión de padres. */ - private static final Duration TTL = Duration.ofHours(2); - - private final Map sessions = new ConcurrentHashMap<>(); - - /** Abre una sesión nueva y devuelve su identificador opaco. */ - public String issue() { - String id = UUID.randomUUID().toString(); - sessions.put(id, Instant.now().plus(TTL)); - return id; - } - - /** ¿La sesión existe y no ha caducado? Limpia las caducadas de paso. */ - public boolean isValid(String id) { - if (id == null) { - return false; - } - Instant expiry = sessions.get(id); - if (expiry == null) { - return false; - } - if (expiry.isBefore(Instant.now())) { - sessions.remove(id); - return false; - } - return true; - } - - public void revoke(String id) { - sessions.remove(id); - } -} diff --git a/backend/src/main/java/es/asepeyo/recordalexia/security/SecurityConfig.java b/backend/src/main/java/es/asepeyo/recordalexia/security/SecurityConfig.java index 0a3d5e8..779039f 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/security/SecurityConfig.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/security/SecurityConfig.java @@ -15,12 +15,14 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; /** - * Seguridad ligera y doméstica: - * - Kiosko (niño): el resto de la API es de acceso libre (lectura, marcar, canjear). - * - Panel de padres: /api/parents/** exige sesión válida (cabecera X-Parent-Session), - * salvo el login que abre la sesión. + * Seguridad multi-tenant: + * - Públicas: registro y login (abren sesión) y el health. + * - Toda la API requiere sesión de familia válida (rol FAMILY), incluido el kiosko: + * el dispositivo guarda la sesión tras el login del adulto y el niño no se loguea. + * - El panel de padres (/api/parents/**) exige además el rol PARENT, que se obtiene + * desbloqueando con el PIN (POST /api/parents/unlock, accesible con rol FAMILY). * - * Sin Keycloak/OAuth2 en esta fase; la auth queda encapsulada en el paquete security. + * Sin Keycloak: auth propia encapsulada en este paquete. */ @Configuration public class SecurityConfig { @@ -31,21 +33,23 @@ public class SecurityConfig { } @Bean - public SecurityFilterChain filterChain(HttpSecurity http, ParentAuthFilter parentAuthFilter) + public SecurityFilterChain filterChain(HttpSecurity http, SessionAuthFilter sessionAuthFilter) throws Exception { http .csrf(csrf -> csrf.disable()) .cors(cors -> cors.configurationSource(corsConfigurationSource())) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - // El login de padres es público: es lo que abre la sesión. - .requestMatchers(HttpMethod.POST, "/api/parents/login").permitAll() + // Públicas: abrir cuenta / sesión. + .requestMatchers(HttpMethod.POST, "/api/auth/register", "/api/auth/login").permitAll() .requestMatchers("/actuator/health").permitAll() - // El resto del panel de padres exige rol PARENT. + // Desbloqueo del panel: basta con tener sesión de familia. + .requestMatchers(HttpMethod.POST, "/api/parents/unlock").hasRole("FAMILY") + // Resto del panel de padres: requiere PIN desbloqueado. .requestMatchers("/api/parents/**").hasRole("PARENT") - // Todo lo demás (kiosko del niño) es de acceso libre. - .anyRequest().permitAll()) - .addFilterBefore(parentAuthFilter, UsernamePasswordAuthenticationFilter.class); + // Todo lo demás (incluido el kiosko del niño): requiere sesión de familia. + .anyRequest().hasRole("FAMILY")) + .addFilterBefore(sessionAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/backend/src/main/java/es/asepeyo/recordalexia/security/ParentAuthFilter.java b/backend/src/main/java/es/asepeyo/recordalexia/security/SessionAuthFilter.java similarity index 50% rename from backend/src/main/java/es/asepeyo/recordalexia/security/ParentAuthFilter.java rename to backend/src/main/java/es/asepeyo/recordalexia/security/SessionAuthFilter.java index dd292f7..966099a 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/security/ParentAuthFilter.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/security/SessionAuthFilter.java @@ -5,6 +5,7 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import java.util.ArrayList; import java.util.List; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -13,30 +14,36 @@ import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; /** - * Filtro que reconoce la sesión de padres a partir de la cabecera X-Parent-Session. - * Si la sesión es válida, marca la petición como autenticada con rol PARENT; la - * autorización por ruta la decide {@link SecurityConfig}. + * Reconoce la sesión de familia a partir de la cabecera X-Auth-Session. Si es válida, + * autentica la petición con el id de familia como principal y el rol FAMILY; añade + * PARENT si el panel está desbloqueado (PIN introducido). La autorización por ruta la + * decide {@link SecurityConfig}. */ @Component -public class ParentAuthFilter extends OncePerRequestFilter { +public class SessionAuthFilter extends OncePerRequestFilter { - public static final String HEADER = "X-Parent-Session"; + public static final String HEADER = "X-Auth-Session"; - private final ParentSessionStore sessionStore; + private final SessionAuthService sessions; - public ParentAuthFilter(ParentSessionStore sessionStore) { - this.sessionStore = sessionStore; + public SessionAuthFilter(SessionAuthService sessions) { + this.sessions = sessions; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - String sessionId = request.getHeader(HEADER); - if (sessionStore.isValid(sessionId)) { + String handle = request.getHeader(HEADER); + sessions.resolve(handle).ifPresent(info -> { + List authorities = new ArrayList<>(); + authorities.add(new SimpleGrantedAuthority("ROLE_FAMILY")); + if (info.panelUnlocked()) { + authorities.add(new SimpleGrantedAuthority("ROLE_PARENT")); + } var authentication = new UsernamePasswordAuthenticationToken( - "parent", null, List.of(new SimpleGrantedAuthority("ROLE_PARENT"))); + info.familyId(), null, authorities); SecurityContextHolder.getContext().setAuthentication(authentication); - } + }); filterChain.doFilter(request, response); } } diff --git a/backend/src/main/java/es/asepeyo/recordalexia/security/SessionAuthService.java b/backend/src/main/java/es/asepeyo/recordalexia/security/SessionAuthService.java new file mode 100644 index 0000000..164196f --- /dev/null +++ b/backend/src/main/java/es/asepeyo/recordalexia/security/SessionAuthService.java @@ -0,0 +1,72 @@ +package es.asepeyo.recordalexia.security; + +import es.asepeyo.recordalexia.domain.Family; +import es.asepeyo.recordalexia.domain.FamilySession; +import es.asepeyo.recordalexia.repository.FamilySessionRepository; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Sesiones de familia persistidas en BD. Sobreviven a reinicios del backend (clave + * para el kiosko, que no debe desautenticarse). El "handle" opaco viaja en la + * cabecera X-Auth-Session. + */ +@Service +public class SessionAuthService { + + /** Vigencia de la sesión del dispositivo (larga, para el kiosko). */ + private static final Duration SESSION_TTL = Duration.ofDays(30); + /** Vigencia del desbloqueo del panel de padres tras el PIN. */ + private static final Duration PANEL_TTL = Duration.ofMinutes(30); + + private final FamilySessionRepository sessionRepository; + + public SessionAuthService(FamilySessionRepository sessionRepository) { + this.sessionRepository = sessionRepository; + } + + /** Datos mínimos de sesión que necesita el filtro (sin tocar entidades lazy fuera de tx). */ + public record SessionInfo(Long familyId, boolean panelUnlocked) { + } + + /** Abre una sesión nueva para una familia y devuelve su handle. */ + @Transactional + public String openSession(Family family) { + String handle = UUID.randomUUID().toString().replace("-", ""); + sessionRepository.save(new FamilySession(handle, family, Instant.now().plus(SESSION_TTL))); + return handle; + } + + /** Resuelve una sesión válida; limpia las caducadas. */ + @Transactional + public Optional resolve(String handle) { + if (handle == null || handle.isBlank()) { + return Optional.empty(); + } + Instant now = Instant.now(); + return sessionRepository.findByHandle(handle).flatMap(session -> { + if (session.isExpired(now)) { + sessionRepository.delete(session); + return Optional.empty(); + } + return Optional.of(new SessionInfo(session.getFamily().getId(), session.isPanelUnlocked(now))); + }); + } + + /** Marca el panel de padres como desbloqueado durante PANEL_TTL. */ + @Transactional + public void unlockPanel(String handle) { + sessionRepository.findByHandle(handle).ifPresent(session -> + session.setPanelUnlockedUntil(Instant.now().plus(PANEL_TTL))); + } + + /** Cierra (revoca) una sesión. */ + @Transactional + public void revoke(String handle) { + sessionRepository.findByHandle(handle).ifPresent(sessionRepository::delete); + } +} diff --git a/backend/src/main/java/es/asepeyo/recordalexia/service/ChildService.java b/backend/src/main/java/es/asepeyo/recordalexia/service/ChildService.java index fce3f5f..a93864e 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/service/ChildService.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/service/ChildService.java @@ -5,6 +5,8 @@ import es.asepeyo.recordalexia.domain.Language; import es.asepeyo.recordalexia.domain.ViewMode; import es.asepeyo.recordalexia.exception.NotFoundException; import es.asepeyo.recordalexia.repository.ChildRepository; +import es.asepeyo.recordalexia.repository.FamilyRepository; +import es.asepeyo.recordalexia.security.FamilyContext; import es.asepeyo.recordalexia.web.dto.ChildDtos.ChildRequest; import es.asepeyo.recordalexia.web.dto.ChildDtos.ChildSummary; import es.asepeyo.recordalexia.web.dto.ChildDtos.SettingsRequest; @@ -13,14 +15,22 @@ import java.util.List; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -/** Perfiles de niños: consulta, ajustes, parámetros de gamificación y CRUD. */ +/** + * Perfiles de niños, SIEMPRE scopeados a la familia (tenant) de la sesión. Un niño + * solo es accesible si pertenece a la familia actual; si no, se trata como inexistente. + */ @Service public class ChildService { private final ChildRepository childRepository; + private final FamilyRepository familyRepository; + private final FamilyContext familyContext; - public ChildService(ChildRepository childRepository) { + public ChildService(ChildRepository childRepository, FamilyRepository familyRepository, + FamilyContext familyContext) { this.childRepository = childRepository; + this.familyRepository = familyRepository; + this.familyContext = familyContext; } @Transactional(readOnly = true) @@ -30,13 +40,12 @@ public class ChildService { @Transactional(readOnly = true) public List listChildren() { - return childRepository.findAll().stream() + return childRepository.findByFamilyIdOrderByIdAsc(familyContext.currentFamilyId()).stream() .map(c -> new ChildSummary(c.getId(), c.getName(), c.getMascot(), c.getAccentColor(), c.getAge(), c.getCoins(), c.getViewMode().name(), c.getLanguage().name())) .toList(); } - /** Aplica solo los ajustes presentes (no nulos) en la petición. */ @Transactional public void updateSettings(Long childId, SettingsRequest req) { Child child = requireChild(childId); @@ -52,12 +61,14 @@ public class ChildService { if (req.language() != null) { child.setLanguage(Language.valueOf(req.language())); } + if (req.dyslexiaFont() != null) { + child.setDyslexiaFont(req.dyslexiaFont()); + } if (req.departureTime() != null) { child.setDepartureTime(LocalTime.parse(req.departureTime())); } } - /** Actualiza los parámetros de gamificación de un niño (panel de padres). */ @Transactional public void updateGamification(Long childId, Integer perTask, Integer perBlock, Integer perDay) { Child child = requireChild(childId); @@ -75,21 +86,20 @@ public class ChildService { @Transactional public Child create(ChildRequest req) { Child child = new Child(); + // El niño nace en la familia de la sesión. + child.setFamily(familyRepository.getReferenceById(familyContext.currentFamilyId())); applyRequest(child, req); return childRepository.save(child); } @Transactional public void update(Long childId, ChildRequest req) { - Child child = requireChild(childId); - applyRequest(child, req); + applyRequest(requireChild(childId), req); } @Transactional public void delete(Long childId) { - if (!childRepository.existsById(childId)) { - throw new NotFoundException("No existe el niño con id " + childId); - } + requireChild(childId); // valida pertenencia antes de borrar childRepository.deleteById(childId); } @@ -114,8 +124,9 @@ public class ChildService { } } + /** Carga un niño SOLO si pertenece a la familia actual. */ private Child requireChild(Long childId) { - return childRepository.findById(childId) + return childRepository.findByIdAndFamilyId(childId, familyContext.currentFamilyId()) .orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId)); } } diff --git a/backend/src/main/java/es/asepeyo/recordalexia/service/StoreService.java b/backend/src/main/java/es/asepeyo/recordalexia/service/StoreService.java index a76ff49..956c9a5 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/service/StoreService.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/service/StoreService.java @@ -10,6 +10,7 @@ import es.asepeyo.recordalexia.repository.ChildRepository; import es.asepeyo.recordalexia.repository.CoinTransactionRepository; import es.asepeyo.recordalexia.repository.RewardRedemptionRepository; import es.asepeyo.recordalexia.repository.RewardRepository; +import es.asepeyo.recordalexia.security.FamilyContext; import es.asepeyo.recordalexia.web.dto.StoreDtos.RedeemResult; import es.asepeyo.recordalexia.web.dto.StoreDtos.RewardView; import java.time.Clock; @@ -26,17 +27,20 @@ public class StoreService { private final RewardRepository rewardRepository; private final CoinTransactionRepository coinTransactionRepository; private final RewardRedemptionRepository rewardRedemptionRepository; + private final FamilyContext familyContext; private final Clock clock; public StoreService(ChildRepository childRepository, RewardRepository rewardRepository, CoinTransactionRepository coinTransactionRepository, RewardRedemptionRepository rewardRedemptionRepository, + FamilyContext familyContext, Clock clock) { this.childRepository = childRepository; this.rewardRepository = rewardRepository; this.coinTransactionRepository = coinTransactionRepository; this.rewardRedemptionRepository = rewardRedemptionRepository; + this.familyContext = familyContext; this.clock = clock; } @@ -44,7 +48,8 @@ public class StoreService { @Transactional(readOnly = true) public List listRewards(Long childId) { Child child = requireChild(childId); - return rewardRepository.findByActiveTrueOrderByCostAsc().stream() + return rewardRepository + .findByFamilyIdAndActiveTrueOrderByCostAsc(familyContext.currentFamilyId()).stream() .map(r -> new RewardView(r.getId(), r.getLabelEs(), r.getLabelCa(), r.getIcon(), r.getColor(), r.getCost(), child.canAfford(r.getCost()), Math.max(0, r.getCost() - child.getCoins()))) @@ -58,7 +63,7 @@ public class StoreService { @Transactional public RedeemResult redeem(Long childId, Long rewardId) { Child child = requireChild(childId); - Reward reward = rewardRepository.findById(rewardId) + Reward reward = rewardRepository.findByIdAndFamilyId(rewardId, familyContext.currentFamilyId()) .orElseThrow(() -> new NotFoundException("No existe el premio con id " + rewardId)); if (!reward.isActive()) { @@ -80,7 +85,7 @@ public class StoreService { } private Child requireChild(Long childId) { - return childRepository.findById(childId) + return childRepository.findByIdAndFamilyId(childId, familyContext.currentFamilyId()) .orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId)); } } diff --git a/backend/src/main/java/es/asepeyo/recordalexia/service/TaskService.java b/backend/src/main/java/es/asepeyo/recordalexia/service/TaskService.java index 4a6d5c8..9527394 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/service/TaskService.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/service/TaskService.java @@ -8,6 +8,7 @@ import es.asepeyo.recordalexia.exception.NotFoundException; import es.asepeyo.recordalexia.repository.ChildRepository; import es.asepeyo.recordalexia.repository.CoinTransactionRepository; import es.asepeyo.recordalexia.repository.DailyTaskRepository; +import es.asepeyo.recordalexia.security.FamilyContext; import es.asepeyo.recordalexia.web.dto.ToggleResult; import java.time.Clock; import java.time.Instant; @@ -35,15 +36,18 @@ public class TaskService { private final DailyTaskRepository dailyTaskRepository; private final ChildRepository childRepository; private final CoinTransactionRepository coinTransactionRepository; + private final FamilyContext familyContext; private final Clock clock; public TaskService(DailyTaskRepository dailyTaskRepository, ChildRepository childRepository, CoinTransactionRepository coinTransactionRepository, + FamilyContext familyContext, Clock clock) { this.dailyTaskRepository = dailyTaskRepository; this.childRepository = childRepository; this.coinTransactionRepository = coinTransactionRepository; + this.familyContext = familyContext; this.clock = clock; } @@ -53,6 +57,10 @@ public class TaskService { .orElseThrow(() -> new NotFoundException("No existe la tarea con id " + taskId)); Child child = task.getChild(); + // Aislamiento: la tarea debe pertenecer a un niño de la familia de la sesión. + if (!child.getFamily().getId().equals(familyContext.currentFamilyId())) { + throw new NotFoundException("No existe la tarea con id " + taskId); + } Long childId = child.getId(); LocalDate date = task.getTaskDate(); Slot slot = task.getSlot(); diff --git a/backend/src/main/java/es/asepeyo/recordalexia/service/TodayService.java b/backend/src/main/java/es/asepeyo/recordalexia/service/TodayService.java index ba0139c..5743889 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/service/TodayService.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/service/TodayService.java @@ -7,6 +7,7 @@ import es.asepeyo.recordalexia.domain.SpecialEvent; import es.asepeyo.recordalexia.exception.NotFoundException; import es.asepeyo.recordalexia.repository.ChildRepository; import es.asepeyo.recordalexia.repository.SpecialEventRepository; +import es.asepeyo.recordalexia.security.FamilyContext; import es.asepeyo.recordalexia.web.dto.TodayResponse; import es.asepeyo.recordalexia.web.dto.TodayResponse.ChildInfo; import es.asepeyo.recordalexia.web.dto.TodayResponse.EventView; @@ -31,21 +32,25 @@ public class TodayService { private final DayGenerationService dayGenerationService; private final ChildRepository childRepository; private final SpecialEventRepository specialEventRepository; + private final FamilyContext familyContext; private final Clock clock; public TodayService(DayGenerationService dayGenerationService, ChildRepository childRepository, SpecialEventRepository specialEventRepository, + FamilyContext familyContext, Clock clock) { this.dayGenerationService = dayGenerationService; this.childRepository = childRepository; this.specialEventRepository = specialEventRepository; + this.familyContext = familyContext; this.clock = clock; } @Transactional public TodayResponse getToday(Long childId) { - Child child = childRepository.findById(childId) + // Solo el niño de la familia de la sesión (aislamiento). + Child child = childRepository.findByIdAndFamilyId(childId, familyContext.currentFamilyId()) .orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId)); LocalDate today = LocalDate.now(clock); @@ -86,7 +91,8 @@ public class TodayService { private ChildInfo toChildInfo(Child c) { return new ChildInfo(c.getId(), c.getName(), c.getMascot(), c.getAccentColor(), - c.getViewMode().name(), c.getLanguage().name(), c.isSoundEnabled(), c.isTtsEnabled()); + c.getViewMode().name(), c.getLanguage().name(), c.isSoundEnabled(), c.isTtsEnabled(), + c.isDyslexiaFont()); } /** Temporizador de salida: minutos que faltan hasta departureTime (>= 0). */ diff --git a/backend/src/main/java/es/asepeyo/recordalexia/service/WalletService.java b/backend/src/main/java/es/asepeyo/recordalexia/service/WalletService.java index d89eb1a..dbf052d 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/service/WalletService.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/service/WalletService.java @@ -4,6 +4,7 @@ import es.asepeyo.recordalexia.domain.Child; import es.asepeyo.recordalexia.exception.NotFoundException; import es.asepeyo.recordalexia.repository.ChildRepository; import es.asepeyo.recordalexia.repository.CoinTransactionRepository; +import es.asepeyo.recordalexia.security.FamilyContext; import es.asepeyo.recordalexia.web.dto.WalletResponse; import es.asepeyo.recordalexia.web.dto.WalletResponse.CoinTxView; import java.util.List; @@ -16,16 +17,19 @@ public class WalletService { private final ChildRepository childRepository; private final CoinTransactionRepository coinTransactionRepository; + private final FamilyContext familyContext; public WalletService(ChildRepository childRepository, - CoinTransactionRepository coinTransactionRepository) { + CoinTransactionRepository coinTransactionRepository, + FamilyContext familyContext) { this.childRepository = childRepository; this.coinTransactionRepository = coinTransactionRepository; + this.familyContext = familyContext; } @Transactional(readOnly = true) public WalletResponse getWallet(Long childId) { - Child child = childRepository.findById(childId) + Child child = childRepository.findByIdAndFamilyId(childId, familyContext.currentFamilyId()) .orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId)); List history = coinTransactionRepository diff --git a/backend/src/main/java/es/asepeyo/recordalexia/web/AccountController.java b/backend/src/main/java/es/asepeyo/recordalexia/web/AccountController.java new file mode 100644 index 0000000..f018608 --- /dev/null +++ b/backend/src/main/java/es/asepeyo/recordalexia/web/AccountController.java @@ -0,0 +1,90 @@ +package es.asepeyo.recordalexia.web; + +import es.asepeyo.recordalexia.domain.Family; +import es.asepeyo.recordalexia.domain.Language; +import es.asepeyo.recordalexia.exception.NotFoundException; +import es.asepeyo.recordalexia.repository.FamilyRepository; +import es.asepeyo.recordalexia.security.FamilyContext; +import es.asepeyo.recordalexia.web.dto.AuthDtos.AccountPrefsRequest; +import es.asepeyo.recordalexia.web.dto.AuthDtos.ChangePasswordRequest; +import es.asepeyo.recordalexia.web.dto.AuthDtos.ChangePinRequest; +import es.asepeyo.recordalexia.web.dto.AuthDtos.MeResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** Preferencias de la cuenta de familia y cambio de credenciales. */ +@RestController +@RequestMapping("/api/account") +public class AccountController { + + private final FamilyContext familyContext; + private final FamilyRepository familyRepository; + private final PasswordEncoder encoder; + + public AccountController(FamilyContext familyContext, FamilyRepository familyRepository, + PasswordEncoder encoder) { + this.familyContext = familyContext; + this.familyRepository = familyRepository; + this.encoder = encoder; + } + + @GetMapping + public MeResponse get() { + Family f = current(); + return new MeResponse(f.getId(), f.getEmail(), f.getName(), + f.getUiLanguage().name(), f.isDefaultDyslexiaFont()); + } + + @PutMapping("/prefs") + @Transactional + public ResponseEntity updatePrefs(@RequestBody AccountPrefsRequest req) { + Family f = current(); + if (req.uiLanguage() != null) { + f.setUiLanguage(Language.valueOf(req.uiLanguage())); + } + if (req.defaultDyslexiaFont() != null) { + f.setDefaultDyslexiaFont(req.defaultDyslexiaFont()); + } + return ResponseEntity.noContent().build(); + } + + @PutMapping("/password") + @Transactional + public ResponseEntity changePassword(@RequestBody ChangePasswordRequest req) { + Family f = current(); + if (!encoder.matches(req.currentPassword(), f.getPassHash())) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + if (req.newPassword() == null || req.newPassword().length() < 6) { + throw new IllegalArgumentException("La contraseña debe tener al menos 6 caracteres"); + } + f.setPassHash(encoder.encode(req.newPassword())); + return ResponseEntity.noContent().build(); + } + + @PutMapping("/pin") + @Transactional + public ResponseEntity changePin(@RequestBody ChangePinRequest req) { + Family f = current(); + if (!encoder.matches(req.currentPin(), f.getPinHash())) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + if (req.newPin() == null || !req.newPin().matches("\\d{4}")) { + throw new IllegalArgumentException("El PIN debe ser de 4 dígitos"); + } + f.setPinHash(encoder.encode(req.newPin())); + return ResponseEntity.noContent().build(); + } + + private Family current() { + return familyRepository.findById(familyContext.currentFamilyId()) + .orElseThrow(() -> new NotFoundException("Familia no encontrada")); + } +} diff --git a/backend/src/main/java/es/asepeyo/recordalexia/web/AuthController.java b/backend/src/main/java/es/asepeyo/recordalexia/web/AuthController.java new file mode 100644 index 0000000..838de60 --- /dev/null +++ b/backend/src/main/java/es/asepeyo/recordalexia/web/AuthController.java @@ -0,0 +1,86 @@ +package es.asepeyo.recordalexia.web; + +import es.asepeyo.recordalexia.domain.Family; +import es.asepeyo.recordalexia.exception.NotFoundException; +import es.asepeyo.recordalexia.repository.FamilyRepository; +import es.asepeyo.recordalexia.security.AuthService; +import es.asepeyo.recordalexia.security.FamilyContext; +import es.asepeyo.recordalexia.security.SessionAuthFilter; +import es.asepeyo.recordalexia.security.SessionAuthService; +import es.asepeyo.recordalexia.web.dto.AuthDtos.LoginRequest; +import es.asepeyo.recordalexia.web.dto.AuthDtos.MeResponse; +import es.asepeyo.recordalexia.web.dto.AuthDtos.RegisterRequest; +import es.asepeyo.recordalexia.web.dto.AuthDtos.SessionResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** Registro, acceso y sesión de familias. */ +@RestController +@RequestMapping("/api/auth") +public class AuthController { + + private final AuthService authService; + private final SessionAuthService sessions; + private final FamilyContext familyContext; + private final FamilyRepository familyRepository; + + public AuthController(AuthService authService, SessionAuthService sessions, + FamilyContext familyContext, FamilyRepository familyRepository) { + this.authService = authService; + this.sessions = sessions; + this.familyContext = familyContext; + this.familyRepository = familyRepository; + } + + /** Alta de familia + auto-login. */ + @PostMapping("/register") + public ResponseEntity register(@RequestBody RegisterRequest req) { + requireText(req.email(), "email"); + if (req.password() == null || req.password().length() < 6) { + throw new IllegalArgumentException("La contraseña debe tener al menos 6 caracteres"); + } + if (req.pin() == null || !req.pin().matches("\\d{4}")) { + throw new IllegalArgumentException("El PIN debe ser de 4 dígitos"); + } + String session = authService.register(req.email(), req.password(), req.name(), req.pin()); + return ResponseEntity.status(HttpStatus.CREATED).body(new SessionResponse(session)); + } + + /** Acceso con email + contraseña. 200 con sesión, 401 si no. */ + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequest req) { + return authService.login(req.email(), req.password()) + .map(session -> ResponseEntity.ok(new SessionResponse(session))) + .orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()); + } + + /** Cierra la sesión del dispositivo. */ + @PostMapping("/logout") + public ResponseEntity logout(@RequestHeader(value = SessionAuthFilter.HEADER, required = false) String handle) { + if (handle != null) { + sessions.revoke(handle); + } + return ResponseEntity.noContent().build(); + } + + /** Datos de la familia autenticada (para el frontend). */ + @GetMapping("/me") + public MeResponse me() { + Family f = familyRepository.findById(familyContext.currentFamilyId()) + .orElseThrow(() -> new NotFoundException("Familia no encontrada")); + return new MeResponse(f.getId(), f.getEmail(), f.getName(), + f.getUiLanguage().name(), f.isDefaultDyslexiaFont()); + } + + private void requireText(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Falta el campo " + field); + } + } +} diff --git a/backend/src/main/java/es/asepeyo/recordalexia/web/PanelController.java b/backend/src/main/java/es/asepeyo/recordalexia/web/PanelController.java new file mode 100644 index 0000000..992a7fb --- /dev/null +++ b/backend/src/main/java/es/asepeyo/recordalexia/web/PanelController.java @@ -0,0 +1,42 @@ +package es.asepeyo.recordalexia.web; + +import es.asepeyo.recordalexia.security.AuthService; +import es.asepeyo.recordalexia.security.FamilyContext; +import es.asepeyo.recordalexia.security.SessionAuthFilter; +import es.asepeyo.recordalexia.security.SessionAuthService; +import es.asepeyo.recordalexia.web.dto.AuthDtos.UnlockRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** Gate del panel de padres: desbloquea con el PIN de la familia. */ +@RestController +@RequestMapping("/api/parents") +public class PanelController { + + private final AuthService authService; + private final SessionAuthService sessions; + private final FamilyContext familyContext; + + public PanelController(AuthService authService, SessionAuthService sessions, + FamilyContext familyContext) { + this.authService = authService; + this.sessions = sessions; + this.familyContext = familyContext; + } + + /** Valida el PIN y desbloquea el panel para esta sesión. 204 si OK, 401 si no. */ + @PostMapping("/unlock") + public ResponseEntity unlock(@RequestBody UnlockRequest req, + @RequestHeader(SessionAuthFilter.HEADER) String handle) { + if (authService.checkPin(familyContext.currentFamilyId(), req.pin())) { + sessions.unlockPanel(handle); + return ResponseEntity.noContent().build(); + } + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } +} diff --git a/backend/src/main/java/es/asepeyo/recordalexia/web/ParentAuthController.java b/backend/src/main/java/es/asepeyo/recordalexia/web/ParentAuthController.java deleted file mode 100644 index 73e261c..0000000 --- a/backend/src/main/java/es/asepeyo/recordalexia/web/ParentAuthController.java +++ /dev/null @@ -1,40 +0,0 @@ -package es.asepeyo.recordalexia.web; - -import es.asepeyo.recordalexia.security.ParentAuthService; -import es.asepeyo.recordalexia.web.dto.ParentDtos.ChangePinRequest; -import es.asepeyo.recordalexia.web.dto.ParentDtos.LoginRequest; -import es.asepeyo.recordalexia.web.dto.ParentDtos.LoginResponse; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -/** Autenticación del panel de padres: abrir sesión con PIN y cambiar el PIN. */ -@RestController -@RequestMapping("/api/parents") -public class ParentAuthController { - - private final ParentAuthService parentAuthService; - - public ParentAuthController(ParentAuthService parentAuthService) { - this.parentAuthService = parentAuthService; - } - - /** Valida el PIN. 200 con la sesión si es correcto; 401 si no. */ - @PostMapping("/login") - public ResponseEntity login(@RequestBody LoginRequest request) { - return parentAuthService.login(request.pin()) - .map(session -> ResponseEntity.ok(new LoginResponse(session))) - .orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()); - } - - /** Cambia el PIN (requiere sesión de padres válida). */ - @PostMapping("/change-pin") - public ResponseEntity changePin(@RequestBody ChangePinRequest request) { - boolean changed = parentAuthService.changePin(request.currentPin(), request.newPin()); - return changed ? ResponseEntity.noContent().build() - : ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } -} diff --git a/backend/src/main/java/es/asepeyo/recordalexia/web/ParentCatalogController.java b/backend/src/main/java/es/asepeyo/recordalexia/web/ParentCatalogController.java index 6b1c464..b6b4cc1 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/web/ParentCatalogController.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/web/ParentCatalogController.java @@ -4,7 +4,9 @@ import es.asepeyo.recordalexia.domain.Activity; import es.asepeyo.recordalexia.domain.MaterialItem; import es.asepeyo.recordalexia.exception.NotFoundException; import es.asepeyo.recordalexia.repository.ActivityRepository; +import es.asepeyo.recordalexia.repository.FamilyRepository; import es.asepeyo.recordalexia.repository.MaterialItemRepository; +import es.asepeyo.recordalexia.security.FamilyContext; import es.asepeyo.recordalexia.web.dto.ParentDtos.ActivityRequest; import es.asepeyo.recordalexia.web.dto.ParentDtos.MaterialRequest; import es.asepeyo.recordalexia.web.dto.ParentViews.ActivityView; @@ -20,25 +22,34 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -/** CRUD de actividades del cole y su material (panel de padres). */ +/** CRUD de actividades del cole y su material, por familia (panel de padres). */ @RestController @RequestMapping("/api/parents/catalog") public class ParentCatalogController { private final ActivityRepository activityRepository; private final MaterialItemRepository materialRepository; + private final FamilyRepository familyRepository; + private final FamilyContext familyContext; public ParentCatalogController(ActivityRepository activityRepository, - MaterialItemRepository materialRepository) { + MaterialItemRepository materialRepository, + FamilyRepository familyRepository, FamilyContext familyContext) { this.activityRepository = activityRepository; this.materialRepository = materialRepository; + this.familyRepository = familyRepository; + this.familyContext = familyContext; + } + + private Long fid() { + return familyContext.currentFamilyId(); } // --- Materiales --- @GetMapping("/materials") public List listMaterials() { - return materialRepository.findAll().stream() + return materialRepository.findByFamilyId(fid()).stream() .map(m -> new MaterialView(m.getId(), m.getLabelEs(), m.getLabelCa(), m.getIcon(), m.getColor(), m.getCategory())) .toList(); @@ -46,17 +57,18 @@ public class ParentCatalogController { @PostMapping("/materials") public ResponseEntity createMaterial(@RequestBody MaterialRequest req) { - MaterialItem saved = materialRepository.save( - new MaterialItem(req.labelEs(), req.labelCa(), req.icon(), req.color(), req.category())); + MaterialItem material = new MaterialItem(req.labelEs(), req.labelCa(), req.icon(), + req.color(), req.category()); + material.setFamily(familyRepository.getReferenceById(fid())); + MaterialItem saved = materialRepository.save(material); return ResponseEntity.ok(new MaterialView(saved.getId(), saved.getLabelEs(), saved.getLabelCa(), saved.getIcon(), saved.getColor(), saved.getCategory())); } @DeleteMapping("/materials/{id}") public ResponseEntity deleteMaterial(@PathVariable Long id) { - if (!materialRepository.existsById(id)) { - throw new NotFoundException("No existe el material con id " + id); - } + materialRepository.findByIdAndFamilyId(id, fid()) + .orElseThrow(() -> new NotFoundException("No existe el material con id " + id)); materialRepository.deleteById(id); return ResponseEntity.noContent().build(); } @@ -66,13 +78,14 @@ public class ParentCatalogController { @GetMapping("/activities") @Transactional(readOnly = true) public List listActivities() { - return activityRepository.findAll().stream().map(this::toActivityView).toList(); + return activityRepository.findByFamilyId(fid()).stream().map(this::toActivityView).toList(); } @PostMapping("/activities") @Transactional public ResponseEntity createActivity(@RequestBody ActivityRequest req) { Activity activity = new Activity(req.labelEs(), req.labelCa(), req.icon(), req.color()); + activity.setFamily(familyRepository.getReferenceById(fid())); attachMaterials(activity, req.materialIds()); Activity saved = activityRepository.save(activity); return ResponseEntity.ok(toActivityView(saved)); @@ -80,9 +93,8 @@ public class ParentCatalogController { @DeleteMapping("/activities/{id}") public ResponseEntity deleteActivity(@PathVariable Long id) { - if (!activityRepository.existsById(id)) { - throw new NotFoundException("No existe la actividad con id " + id); - } + activityRepository.findByIdAndFamilyId(id, fid()) + .orElseThrow(() -> new NotFoundException("No existe la actividad con id " + id)); activityRepository.deleteById(id); return ResponseEntity.noContent().build(); } @@ -92,7 +104,7 @@ public class ParentCatalogController { return; } for (Long materialId : materialIds) { - MaterialItem material = materialRepository.findById(materialId) + MaterialItem material = materialRepository.findByIdAndFamilyId(materialId, fid()) .orElseThrow(() -> new NotFoundException("No existe el material con id " + materialId)); activity.addMaterial(material); } diff --git a/backend/src/main/java/es/asepeyo/recordalexia/web/ParentEventController.java b/backend/src/main/java/es/asepeyo/recordalexia/web/ParentEventController.java index 1da1e40..dc64214 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/web/ParentEventController.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/web/ParentEventController.java @@ -6,12 +6,14 @@ import es.asepeyo.recordalexia.domain.SpecialEvent; import es.asepeyo.recordalexia.exception.NotFoundException; import es.asepeyo.recordalexia.repository.ChildRepository; import es.asepeyo.recordalexia.repository.SpecialEventRepository; +import es.asepeyo.recordalexia.security.FamilyContext; import es.asepeyo.recordalexia.web.dto.ParentDtos.SpecialEventRequest; import es.asepeyo.recordalexia.web.dto.ParentViews.EventAdminView; import java.net.URI; import java.time.LocalDate; import java.util.List; import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -28,15 +30,18 @@ public class ParentEventController { private final SpecialEventRepository eventRepository; private final ChildRepository childRepository; + private final FamilyContext familyContext; public ParentEventController(SpecialEventRepository eventRepository, - ChildRepository childRepository) { + ChildRepository childRepository, FamilyContext familyContext) { this.eventRepository = eventRepository; this.childRepository = childRepository; + this.familyContext = familyContext; } @GetMapping public List list(@RequestParam Long childId) { + requireChild(childId); // aislamiento: el niño debe ser de la familia return eventRepository.findByChildIdOrderByEventDateAsc(childId).stream() .map(this::toView).toList(); } @@ -52,16 +57,17 @@ public class ParentEventController { } @DeleteMapping("/{id}") + @Transactional public ResponseEntity delete(@PathVariable Long id) { - if (!eventRepository.existsById(id)) { - throw new NotFoundException("No existe el evento con id " + id); - } - eventRepository.deleteById(id); + SpecialEvent event = eventRepository.findById(id) + .filter(e -> e.getChild().getFamily().getId().equals(familyContext.currentFamilyId())) + .orElseThrow(() -> new NotFoundException("No existe el evento con id " + id)); + eventRepository.delete(event); return ResponseEntity.noContent().build(); } private Child requireChild(Long childId) { - return childRepository.findById(childId) + return childRepository.findByIdAndFamilyId(childId, familyContext.currentFamilyId()) .orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId)); } diff --git a/backend/src/main/java/es/asepeyo/recordalexia/web/ParentRewardController.java b/backend/src/main/java/es/asepeyo/recordalexia/web/ParentRewardController.java index 775077c..abd7a7e 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/web/ParentRewardController.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/web/ParentRewardController.java @@ -2,7 +2,9 @@ package es.asepeyo.recordalexia.web; import es.asepeyo.recordalexia.domain.Reward; import es.asepeyo.recordalexia.exception.NotFoundException; +import es.asepeyo.recordalexia.repository.FamilyRepository; import es.asepeyo.recordalexia.repository.RewardRepository; +import es.asepeyo.recordalexia.security.FamilyContext; import es.asepeyo.recordalexia.web.dto.ParentViews.RewardAdminView; import es.asepeyo.recordalexia.web.dto.StoreDtos.RewardRequest; import java.net.URI; @@ -17,26 +19,33 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -/** CRUD del catálogo de premios desde el panel de padres. */ +/** CRUD del catálogo de premios de la familia (panel de padres). */ @RestController @RequestMapping("/api/parents/rewards") public class ParentRewardController { private final RewardRepository rewardRepository; + private final FamilyRepository familyRepository; + private final FamilyContext familyContext; - public ParentRewardController(RewardRepository rewardRepository) { + public ParentRewardController(RewardRepository rewardRepository, + FamilyRepository familyRepository, FamilyContext familyContext) { this.rewardRepository = rewardRepository; + this.familyRepository = familyRepository; + this.familyContext = familyContext; } @GetMapping public List list() { - return rewardRepository.findAll().stream().map(this::toView).toList(); + return rewardRepository.findByFamilyId(familyContext.currentFamilyId()).stream() + .map(this::toView).toList(); } @PostMapping public ResponseEntity create(@RequestBody RewardRequest req) { Reward reward = new Reward(req.labelEs(), req.labelCa(), req.icon(), req.color(), req.cost() != null ? req.cost() : 0); + reward.setFamily(familyRepository.getReferenceById(familyContext.currentFamilyId())); if (req.active() != null) { reward.setActive(req.active()); } @@ -47,8 +56,7 @@ public class ParentRewardController { @PutMapping("/{id}") public ResponseEntity update(@PathVariable Long id, @RequestBody RewardRequest req) { - Reward reward = rewardRepository.findById(id) - .orElseThrow(() -> new NotFoundException("No existe el premio con id " + id)); + Reward reward = requireOwned(id); if (req.labelEs() != null) { reward.setLabelEs(req.labelEs()); } @@ -73,13 +81,16 @@ public class ParentRewardController { @DeleteMapping("/{id}") public ResponseEntity delete(@PathVariable Long id) { - if (!rewardRepository.existsById(id)) { - throw new NotFoundException("No existe el premio con id " + id); - } + requireOwned(id); rewardRepository.deleteById(id); return ResponseEntity.noContent().build(); } + private Reward requireOwned(Long id) { + return rewardRepository.findByIdAndFamilyId(id, familyContext.currentFamilyId()) + .orElseThrow(() -> new NotFoundException("No existe el premio con id " + id)); + } + private RewardAdminView toView(Reward r) { return new RewardAdminView(r.getId(), r.getLabelEs(), r.getLabelCa(), r.getIcon(), r.getColor(), r.getCost(), r.isActive()); diff --git a/backend/src/main/java/es/asepeyo/recordalexia/web/ParentScheduleController.java b/backend/src/main/java/es/asepeyo/recordalexia/web/ParentScheduleController.java index b733b22..2d0f71f 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/web/ParentScheduleController.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/web/ParentScheduleController.java @@ -9,6 +9,7 @@ import es.asepeyo.recordalexia.repository.ActivityRepository; import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository; import es.asepeyo.recordalexia.repository.ChildRepository; import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository; +import es.asepeyo.recordalexia.security.FamilyContext; import es.asepeyo.recordalexia.web.dto.ParentDtos.AfternoonRoutineRequest; import es.asepeyo.recordalexia.web.dto.ParentDtos.RoutineReorderRequest; import es.asepeyo.recordalexia.web.dto.ParentDtos.WeeklyEntryRequest; @@ -28,7 +29,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -/** CRUD del horario semanal de mañana y de las rutinas de tarde (panel de padres). */ +/** CRUD del horario semanal de mañana y de las rutinas de tarde (scopeado a la familia). */ @RestController @RequestMapping("/api/parents/schedule") public class ParentScheduleController { @@ -37,15 +38,22 @@ public class ParentScheduleController { private final AfternoonRoutineRepository routineRepository; private final ChildRepository childRepository; private final ActivityRepository activityRepository; + private final FamilyContext familyContext; public ParentScheduleController(WeeklyTemplateEntryRepository templateRepository, AfternoonRoutineRepository routineRepository, ChildRepository childRepository, - ActivityRepository activityRepository) { + ActivityRepository activityRepository, + FamilyContext familyContext) { this.templateRepository = templateRepository; this.routineRepository = routineRepository; this.childRepository = childRepository; this.activityRepository = activityRepository; + this.familyContext = familyContext; + } + + private Long fid() { + return familyContext.currentFamilyId(); } // --- Plantilla de mañana --- @@ -53,6 +61,7 @@ public class ParentScheduleController { @GetMapping("/weekly") @Transactional(readOnly = true) public List listWeekly(@RequestParam Long childId) { + requireChild(childId); // aislamiento: el niño debe ser de la familia return templateRepository.findByChildIdOrderByDayOfWeekAscOrderIndexAsc(childId).stream() .map(this::toWeeklyView).toList(); } @@ -60,7 +69,7 @@ public class ParentScheduleController { @PostMapping("/weekly") public ResponseEntity createWeekly(@RequestBody WeeklyEntryRequest req) { Child child = requireChild(req.childId()); - Activity activity = activityRepository.findById(req.activityId()) + Activity activity = activityRepository.findByIdAndFamilyId(req.activityId(), fid()) .orElseThrow(() -> new NotFoundException("No existe la actividad con id " + req.activityId())); WeeklyTemplateEntry entry = new WeeklyTemplateEntry(child, DayOfWeek.valueOf(req.dayOfWeek()), activity, req.orderIndex() != null ? req.orderIndex() : 0); @@ -69,18 +78,21 @@ public class ParentScheduleController { } @DeleteMapping("/weekly/{id}") + @Transactional public ResponseEntity deleteWeekly(@PathVariable Long id) { - if (!templateRepository.existsById(id)) { - throw new NotFoundException("No existe la entrada de horario con id " + id); - } - templateRepository.deleteById(id); + WeeklyTemplateEntry entry = templateRepository.findById(id) + .filter(e -> e.getChild().getFamily().getId().equals(fid())) + .orElseThrow(() -> new NotFoundException("No existe la entrada de horario con id " + id)); + templateRepository.delete(entry); return ResponseEntity.noContent().build(); } // --- Rutinas de tarde --- @GetMapping("/routines") + @Transactional(readOnly = true) public List listRoutines(@RequestParam Long childId) { + requireChild(childId); return routineRepository.findByChildIdOrderByDayOfWeekAscOrderIndexAsc(childId).stream() .map(this::toRoutineView).toList(); } @@ -95,29 +107,32 @@ public class ParentScheduleController { return ResponseEntity.ok(toRoutineView(routineRepository.save(routine))); } - /** Reordena las rutinas: asigna orderIndex según la posición en la lista recibida. */ + /** Reordena rutinas (solo las de la familia): orderIndex según la posición recibida. */ @PutMapping("/routines/reorder") @Transactional public ResponseEntity reorderRoutines(@RequestBody RoutineReorderRequest req) { List ids = req.orderedIds(); for (int i = 0; i < ids.size(); i++) { int orderIndex = i; - routineRepository.findById(ids.get(i)).ifPresent(r -> r.setOrderIndex(orderIndex)); + routineRepository.findById(ids.get(i)) + .filter(r -> r.getChild().getFamily().getId().equals(fid())) + .ifPresent(r -> r.setOrderIndex(orderIndex)); } return ResponseEntity.noContent().build(); } @DeleteMapping("/routines/{id}") + @Transactional public ResponseEntity deleteRoutine(@PathVariable Long id) { - if (!routineRepository.existsById(id)) { - throw new NotFoundException("No existe la rutina con id " + id); - } - routineRepository.deleteById(id); + AfternoonRoutine routine = routineRepository.findById(id) + .filter(r -> r.getChild().getFamily().getId().equals(fid())) + .orElseThrow(() -> new NotFoundException("No existe la rutina con id " + id)); + routineRepository.delete(routine); return ResponseEntity.noContent().build(); } private Child requireChild(Long childId) { - return childRepository.findById(childId) + return childRepository.findByIdAndFamilyId(childId, fid()) .orElseThrow(() -> new NotFoundException("No existe el niño con id " + childId)); } diff --git a/backend/src/main/java/es/asepeyo/recordalexia/web/dto/AuthDtos.java b/backend/src/main/java/es/asepeyo/recordalexia/web/dto/AuthDtos.java new file mode 100644 index 0000000..82f30b7 --- /dev/null +++ b/backend/src/main/java/es/asepeyo/recordalexia/web/dto/AuthDtos.java @@ -0,0 +1,34 @@ +package es.asepeyo.recordalexia.web.dto; + +/** DTOs de autenticación y cuenta. El campo de contraseña se llama "password" en el JSON. */ +public final class AuthDtos { + + private AuthDtos() { + } + + public record RegisterRequest(String email, String password, String name, String pin) { + } + + public record LoginRequest(String email, String password) { + } + + /** Identificador de sesión a enviar luego en la cabecera X-Auth-Session. */ + public record SessionResponse(String session) { + } + + public record MeResponse(Long familyId, String email, String name, String uiLanguage, + boolean defaultDyslexiaFont) { + } + + public record UnlockRequest(String pin) { + } + + public record ChangePasswordRequest(String currentPassword, String newPassword) { + } + + public record ChangePinRequest(String currentPin, String newPin) { + } + + public record AccountPrefsRequest(String uiLanguage, Boolean defaultDyslexiaFont) { + } +} diff --git a/backend/src/main/java/es/asepeyo/recordalexia/web/dto/ChildDtos.java b/backend/src/main/java/es/asepeyo/recordalexia/web/dto/ChildDtos.java index f86c00c..fa232a7 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/web/dto/ChildDtos.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/web/dto/ChildDtos.java @@ -27,6 +27,7 @@ public final class ChildDtos { Boolean soundEnabled, Boolean ttsEnabled, String language, + Boolean dyslexiaFont, String departureTime) { } diff --git a/backend/src/main/java/es/asepeyo/recordalexia/web/dto/TodayResponse.java b/backend/src/main/java/es/asepeyo/recordalexia/web/dto/TodayResponse.java index 1bf52aa..a7c1b7c 100644 --- a/backend/src/main/java/es/asepeyo/recordalexia/web/dto/TodayResponse.java +++ b/backend/src/main/java/es/asepeyo/recordalexia/web/dto/TodayResponse.java @@ -24,7 +24,8 @@ public record TodayResponse( String viewMode, String language, boolean soundEnabled, - boolean ttsEnabled) { + boolean ttsEnabled, + boolean dyslexiaFont) { } public record TaskView( diff --git a/backend/src/main/resources/db/changelog/changes/002-multitenant.yaml b/backend/src/main/resources/db/changelog/changes/002-multitenant.yaml new file mode 100644 index 0000000..433078e --- /dev/null +++ b/backend/src/main/resources/db/changelog/changes/002-multitenant.yaml @@ -0,0 +1,103 @@ +# Multi-tenant: cuentas de familia + aislamiento de datos por familia. +# Nota: family_id se añade como NOT NULL directamente; en una BD nueva las tablas +# están vacías cuando corre este changeset (el seeder corre después), así que es +# seguro. Para migrar una BD ya poblada habría que hacer backfill antes. +databaseChangeLog: + - changeSet: + id: 100-create-family + author: recordalexia + changes: + - createTable: + tableName: family + columns: + - column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } } + - column: { name: email, type: VARCHAR(160), constraints: { nullable: false, unique: true } } + - column: { name: pass_hash, type: VARCHAR(100), constraints: { nullable: false } } + - column: { name: name, type: VARCHAR(120) } + - column: { name: pin_hash, type: VARCHAR(100), constraints: { nullable: false } } + - column: { name: ui_language, type: VARCHAR(2), defaultValue: 'ES', constraints: { nullable: false } } + - column: { name: default_dyslexia_font, type: BOOLEAN, defaultValueBoolean: true, constraints: { nullable: false } } + - column: { name: created_at, type: TIMESTAMP } + + - changeSet: + id: 101-create-family-session + author: recordalexia + changes: + - createTable: + tableName: family_session + columns: + - column: { name: id, type: BIGINT, autoIncrement: true, constraints: { primaryKey: true, nullable: false } } + - column: { name: handle, type: VARCHAR(80), constraints: { nullable: false, unique: true } } + - column: { name: family_id, type: BIGINT, constraints: { nullable: false } } + - column: { name: expires_at, type: TIMESTAMP, constraints: { nullable: false } } + - column: { name: panel_unlocked_until, type: TIMESTAMP } + - addForeignKeyConstraint: + baseTableName: family_session + baseColumnNames: family_id + referencedTableName: family + referencedColumnNames: id + constraintName: fk_session_family + onDelete: CASCADE + + - changeSet: + id: 102-add-family-to-child + author: recordalexia + changes: + - addColumn: + tableName: child + columns: + - column: { name: family_id, type: BIGINT, constraints: { nullable: false } } + - column: { name: dyslexia_font, type: BOOLEAN, defaultValueBoolean: true, constraints: { nullable: false } } + - addForeignKeyConstraint: + baseTableName: child + baseColumnNames: family_id + referencedTableName: family + referencedColumnNames: id + constraintName: fk_child_family + onDelete: CASCADE + + - changeSet: + id: 103-add-family-to-catalog + author: recordalexia + changes: + - addColumn: + tableName: activity + columns: + - column: { name: family_id, type: BIGINT, constraints: { nullable: false } } + - addForeignKeyConstraint: + baseTableName: activity + baseColumnNames: family_id + referencedTableName: family + referencedColumnNames: id + constraintName: fk_activity_family + onDelete: CASCADE + - addColumn: + tableName: material_item + columns: + - column: { name: family_id, type: BIGINT, constraints: { nullable: false } } + - addForeignKeyConstraint: + baseTableName: material_item + baseColumnNames: family_id + referencedTableName: family + referencedColumnNames: id + constraintName: fk_material_family + onDelete: CASCADE + - addColumn: + tableName: reward + columns: + - column: { name: family_id, type: BIGINT, constraints: { nullable: false } } + - addForeignKeyConstraint: + baseTableName: reward + baseColumnNames: family_id + referencedTableName: family + referencedColumnNames: id + constraintName: fk_reward_family + onDelete: CASCADE + + - changeSet: + id: 104-drop-parent-user + author: recordalexia + changes: + # La auth por PIN único se sustituye por cuentas de familia. + - dropTable: + tableName: parent_user diff --git a/backend/src/test/java/es/asepeyo/recordalexia/bootstrap/DataSeederIT.java b/backend/src/test/java/es/asepeyo/recordalexia/bootstrap/DataSeederIT.java index 877451a..cae4cbc 100644 --- a/backend/src/test/java/es/asepeyo/recordalexia/bootstrap/DataSeederIT.java +++ b/backend/src/test/java/es/asepeyo/recordalexia/bootstrap/DataSeederIT.java @@ -4,7 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat; import es.asepeyo.recordalexia.repository.ActivityRepository; import es.asepeyo.recordalexia.repository.ChildRepository; -import es.asepeyo.recordalexia.repository.ParentUserRepository; +import es.asepeyo.recordalexia.repository.FamilyRepository; import es.asepeyo.recordalexia.repository.RewardRepository; import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository; import org.junit.jupiter.api.Test; @@ -13,8 +13,8 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.TestPropertySource; /** - * Verifica que el sembrado del prototipo se ejecuta correctamente. Usa una BD H2 - * propia y el seeder activado (al contrario que el resto de tests). + * Verifica que el sembrado del prototipo (familia demo + sus datos) se ejecuta + * correctamente. Usa una BD H2 propia y el seeder activado. */ @SpringBootTest @TestPropertySource(properties = { @@ -23,19 +23,18 @@ import org.springframework.test.context.TestPropertySource; }) class DataSeederIT { + @Autowired private FamilyRepository familyRepository; @Autowired private ChildRepository childRepository; - @Autowired private ParentUserRepository parentUserRepository; @Autowired private RewardRepository rewardRepository; @Autowired private ActivityRepository activityRepository; @Autowired private WeeklyTemplateEntryRepository templateRepository; @Test - void siembraLosDatosDelPrototipo() { + void siembraLaFamiliaDemoConSusDatos() { + assertThat(familyRepository.findByEmailIgnoreCase("demo@recordalexia.local")).isPresent(); assertThat(childRepository.count()).isEqualTo(3); // Nora, Leo, Mía - assertThat(parentUserRepository.findFirstByOrderByIdAsc()).isPresent(); assertThat(rewardRepository.count()).isEqualTo(6); // 6 premios assertThat(activityRepository.count()).isEqualTo(4); // 4 actividades - // Cada niño tiene 5 entradas de mañana (L-V): 3 niños x 5 = 15. - assertThat(templateRepository.count()).isEqualTo(15); + assertThat(templateRepository.count()).isEqualTo(15); // 3 niños x 5 días } } diff --git a/backend/src/test/java/es/asepeyo/recordalexia/service/DayGenerationServiceTest.java b/backend/src/test/java/es/asepeyo/recordalexia/service/DayGenerationServiceTest.java index 7d3d8f4..83a7e9f 100644 --- a/backend/src/test/java/es/asepeyo/recordalexia/service/DayGenerationServiceTest.java +++ b/backend/src/test/java/es/asepeyo/recordalexia/service/DayGenerationServiceTest.java @@ -6,11 +6,13 @@ import es.asepeyo.recordalexia.domain.Activity; import es.asepeyo.recordalexia.domain.AfternoonRoutine; import es.asepeyo.recordalexia.domain.Child; import es.asepeyo.recordalexia.domain.DailyTask; +import es.asepeyo.recordalexia.domain.Family; import es.asepeyo.recordalexia.domain.Slot; import es.asepeyo.recordalexia.domain.WeeklyTemplateEntry; import es.asepeyo.recordalexia.repository.ActivityRepository; import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository; import es.asepeyo.recordalexia.repository.ChildRepository; +import es.asepeyo.recordalexia.repository.FamilyRepository; import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository; import java.time.DayOfWeek; import java.time.LocalDate; @@ -30,15 +32,21 @@ class DayGenerationServiceTest { @Autowired private ActivityRepository activityRepository; @Autowired private WeeklyTemplateEntryRepository templateRepository; @Autowired private AfternoonRoutineRepository routineRepository; + @Autowired private FamilyRepository familyRepository; + + private Family family; @Test void generaTareasDeMananaYTardeYesIdempotente() { + family = familyRepository.save(new Family("a@x.com", "h", "A", "p")); // Fecha fija; usamos su día de la semana para enganchar plantilla y rutina. LocalDate fecha = LocalDate.of(2026, 6, 22); DayOfWeek dia = fecha.getDayOfWeek(); Child nino = childRepository.save(nuevoNino()); - Activity mates = activityRepository.save(new Activity("Mates", "Mates", "📘", "#5B8DEF")); + Activity actividad = new Activity("Mates", "Mates", "📘", "#5B8DEF"); + actividad.setFamily(family); + Activity mates = activityRepository.save(actividad); templateRepository.save(new WeeklyTemplateEntry(nino, dia, mates, 0)); routineRepository.save(new AfternoonRoutine(nino, dia, "Deberes", "Deures", "📝", "#F2A65A", 0)); @@ -57,6 +65,7 @@ class DayGenerationServiceTest { private Child nuevoNino() { Child c = new Child(); + c.setFamily(family); c.setName("Nora"); c.setMascot("🦊"); c.setAccentColor("#F2A65A"); diff --git a/backend/src/test/java/es/asepeyo/recordalexia/service/StoreServiceTest.java b/backend/src/test/java/es/asepeyo/recordalexia/service/StoreServiceTest.java index 8bd5587..bfbd2f7 100644 --- a/backend/src/test/java/es/asepeyo/recordalexia/service/StoreServiceTest.java +++ b/backend/src/test/java/es/asepeyo/recordalexia/service/StoreServiceTest.java @@ -4,18 +4,26 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import es.asepeyo.recordalexia.domain.Child; +import es.asepeyo.recordalexia.domain.Family; import es.asepeyo.recordalexia.domain.Reward; import es.asepeyo.recordalexia.exception.InsufficientCoinsException; import es.asepeyo.recordalexia.repository.ChildRepository; +import es.asepeyo.recordalexia.repository.FamilyRepository; import es.asepeyo.recordalexia.repository.RewardRedemptionRepository; import es.asepeyo.recordalexia.repository.RewardRepository; import es.asepeyo.recordalexia.web.dto.StoreDtos.RedeemResult; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.transaction.annotation.Transactional; -/** Verifica el canje de premios: descuento correcto y error por saldo insuficiente. */ +/** Canje de premios: descuento correcto y error por saldo insuficiente. */ @SpringBootTest @Transactional class StoreServiceTest { @@ -24,11 +32,26 @@ class StoreServiceTest { @Autowired private ChildRepository childRepository; @Autowired private RewardRepository rewardRepository; @Autowired private RewardRedemptionRepository redemptionRepository; + @Autowired private FamilyRepository familyRepository; + + private Family family; + + @BeforeEach + void auth() { + family = familyRepository.save(new Family("a@x.com", "h", "A", "p")); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( + family.getId(), null, List.of(new SimpleGrantedAuthority("ROLE_FAMILY")))); + } + + @AfterEach + void clear() { + SecurityContextHolder.clearContext(); + } @Test void canjeaPremioYdescuentaMonedas() { Child nino = childRepository.save(ninoConSaldo(50)); - Reward premio = rewardRepository.save(new Reward("Tablet", "Tauleta", "🎮", "#5B8DEF", 20)); + Reward premio = rewardRepository.save(reward("Tablet", 20)); RedeemResult resultado = storeService.redeem(nino.getId(), premio.getId()); @@ -41,18 +64,18 @@ class StoreServiceTest { @Test void rechazaCanjeSiNoHaySaldoSuficiente() { Child nino = childRepository.save(ninoConSaldo(30)); - Reward caro = rewardRepository.save(new Reward("Dino", "Dino", "🦖", "#EC8FA4", 80)); + Reward caro = rewardRepository.save(reward("Dino", 80)); assertThatThrownBy(() -> storeService.redeem(nino.getId(), caro.getId())) .isInstanceOf(InsufficientCoinsException.class) .satisfies(ex -> assertThat(((InsufficientCoinsException) ex).getMissing()).isEqualTo(50)); - // No se ha tocado el saldo. assertThat(childRepository.findById(nino.getId()).orElseThrow().getCoins()).isEqualTo(30); } private Child ninoConSaldo(int saldo) { Child c = new Child(); + c.setFamily(family); c.setName("Mía"); c.setMascot("🦉"); c.setAccentColor("#A78BD0"); @@ -60,4 +83,10 @@ class StoreServiceTest { c.setCoins(saldo); return c; } + + private Reward reward(String es, int cost) { + Reward r = new Reward(es, es, "🎮", "#5B8DEF", cost); + r.setFamily(family); + return r; + } } diff --git a/backend/src/test/java/es/asepeyo/recordalexia/service/TaskServiceTest.java b/backend/src/test/java/es/asepeyo/recordalexia/service/TaskServiceTest.java index 4676bf0..d32ec4b 100644 --- a/backend/src/test/java/es/asepeyo/recordalexia/service/TaskServiceTest.java +++ b/backend/src/test/java/es/asepeyo/recordalexia/service/TaskServiceTest.java @@ -4,22 +4,27 @@ import static org.assertj.core.api.Assertions.assertThat; import es.asepeyo.recordalexia.domain.Child; import es.asepeyo.recordalexia.domain.DailyTask; +import es.asepeyo.recordalexia.domain.Family; import es.asepeyo.recordalexia.domain.Slot; import es.asepeyo.recordalexia.domain.TaskOrigin; import es.asepeyo.recordalexia.repository.ChildRepository; import es.asepeyo.recordalexia.repository.DailyTaskRepository; +import es.asepeyo.recordalexia.repository.FamilyRepository; import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.transaction.annotation.Transactional; /** - * Verifica el marcado/desmarcado de tareas, las monedas por tarea y los bonos de - * bloque y de día, incluida su reversión coherente al desmarcar. - * - * Niño con coinsPerTask=5, coinsPerBlock=10, coinsPerDay=20. Día con 2 tareas de - * mañana y 1 de tarde. + * Monedas y bonos de bloque/día (con reversión). Monta una familia y la deja como + * sesión actual (lo que lee FamilyContext) para que el aislamiento no estorbe. */ @SpringBootTest @Transactional @@ -30,6 +35,21 @@ class TaskServiceTest { @Autowired private TaskService taskService; @Autowired private ChildRepository childRepository; @Autowired private DailyTaskRepository dailyTaskRepository; + @Autowired private FamilyRepository familyRepository; + + private Family family; + + @BeforeEach + void auth() { + family = familyRepository.save(new Family("a@x.com", "h", "A", "p")); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( + family.getId(), null, List.of(new SimpleGrantedAuthority("ROLE_FAMILY")))); + } + + @AfterEach + void clear() { + SecurityContextHolder.clearContext(); + } @Test void monedasYbonosDeBloqueYdiaConReversion() { @@ -38,33 +58,28 @@ class TaskServiceTest { DailyTask m2 = nuevaTarea(nino, Slot.MORNING, "Lengua", 1); DailyTask t1 = nuevaTarea(nino, Slot.AFTERNOON, "Deberes", 0); - // 1ª tarea de mañana: +5 (bloque aún incompleto). var r1 = taskService.toggle(m1.getId()); assertThat(r1.coinsEarned()).isEqualTo(5); assertThat(r1.newBalance()).isEqualTo(5); - // 2ª tarea de mañana: +5 tarea +10 bono de bloque (mañana completa). var r2 = taskService.toggle(m2.getId()); assertThat(r2.coinsEarned()).isEqualTo(15); assertThat(r2.newBalance()).isEqualTo(20); - // Tarea de tarde: +5 tarea +10 bono de tarde +20 bono de día (todo hecho). var r3 = taskService.toggle(t1.getId()); assertThat(r3.coinsEarned()).isEqualTo(35); assertThat(r3.newBalance()).isEqualTo(55); - // Desmarcar la de tarde: -5 tarea, -10 bono de tarde, -20 bono de día. var r4 = taskService.toggle(t1.getId()); assertThat(r4.coinsEarned()).isEqualTo(-35); assertThat(r4.newBalance()).isEqualTo(20); - // El bono de mañana se conserva (la mañana sigue completa). - Child recargado = childRepository.findById(nino.getId()).orElseThrow(); - assertThat(recargado.getCoins()).isEqualTo(20); + assertThat(childRepository.findById(nino.getId()).orElseThrow().getCoins()).isEqualTo(20); } private Child nuevoNino() { Child c = new Child(); + c.setFamily(family); c.setName("Leo"); c.setMascot("🐢"); c.setAccentColor("#5BC0BE"); diff --git a/backend/src/test/java/es/asepeyo/recordalexia/web/AuthIT.java b/backend/src/test/java/es/asepeyo/recordalexia/web/AuthIT.java new file mode 100644 index 0000000..71ad0ad --- /dev/null +++ b/backend/src/test/java/es/asepeyo/recordalexia/web/AuthIT.java @@ -0,0 +1,105 @@ +package es.asepeyo.recordalexia.web; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import es.asepeyo.recordalexia.domain.Child; +import es.asepeyo.recordalexia.repository.ChildRepository; +import es.asepeyo.recordalexia.repository.FamilyRepository; +import es.asepeyo.recordalexia.security.SessionAuthFilter; +import es.asepeyo.recordalexia.web.dto.AuthDtos.LoginRequest; +import es.asepeyo.recordalexia.web.dto.AuthDtos.RegisterRequest; +import es.asepeyo.recordalexia.web.dto.AuthDtos.UnlockRequest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +/** Registro/login, gate del panel por PIN y aislamiento entre familias (multi-tenant). */ +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class AuthIT { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private FamilyRepository familyRepository; + @Autowired private ChildRepository childRepository; + + private String register(String email) throws Exception { + String body = mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new RegisterRequest(email, "secret123", "Familia", "1234")))) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString(); + return objectMapper.readTree(body).path("session").asText(); + } + + @Test + void registroAbreSesionYmeDevuelveLaFamilia() throws Exception { + String session = register("uno@x.com"); + mockMvc.perform(get("/api/auth/me").header(SessionAuthFilter.HEADER, session)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.email").value("uno@x.com")); + } + + @Test + void loginIncorrectoDevuelve401() throws Exception { + register("dos@x.com"); + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new LoginRequest("dos@x.com", "malo")))) + .andExpect(status().isUnauthorized()); + } + + @Test + void panelExigePinDesbloqueado() throws Exception { + String session = register("tres@x.com"); + // Con sesión pero sin desbloquear el panel: prohibido. + mockMvc.perform(get("/api/parents/children").header(SessionAuthFilter.HEADER, session)) + .andExpect(status().isForbidden()); + // Desbloquear con el PIN. + mockMvc.perform(post("/api/parents/unlock") + .header(SessionAuthFilter.HEADER, session) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new UnlockRequest("1234")))) + .andExpect(status().isNoContent()); + // Ahora sí. + mockMvc.perform(get("/api/parents/children").header(SessionAuthFilter.HEADER, session)) + .andExpect(status().isOk()); + } + + @Test + void sinSesionTodoEstaProhibido() throws Exception { + mockMvc.perform(get("/api/children")).andExpect(status().isForbidden()); + } + + @Test + void unaFamiliaNoVeLosDatosDeOtra() throws Exception { + // Familia A con un niño. + String sessionA = register("a@x.com"); + Long familyAId = objectMapper.readTree(mockMvc.perform(get("/api/auth/me") + .header(SessionAuthFilter.HEADER, sessionA)) + .andReturn().getResponse().getContentAsString()).path("familyId").asLong(); + Child child = new Child(); + child.setFamily(familyRepository.getReferenceById(familyAId)); + child.setName("Nora"); + child.setMascot("🦊"); + child.setAccentColor("#F2A65A"); + child.setAge(7); + Long childAId = childRepository.save(child).getId(); + + // Familia B no puede ver el día del niño de A (404, no 403, para no filtrar existencia). + String sessionB = register("b@x.com"); + mockMvc.perform(get("/api/children/{id}/today", childAId).header(SessionAuthFilter.HEADER, sessionB)) + .andExpect(status().isNotFound()); + } +} diff --git a/backend/src/test/java/es/asepeyo/recordalexia/web/ParentAuthIT.java b/backend/src/test/java/es/asepeyo/recordalexia/web/ParentAuthIT.java deleted file mode 100644 index 405f84b..0000000 --- a/backend/src/test/java/es/asepeyo/recordalexia/web/ParentAuthIT.java +++ /dev/null @@ -1,68 +0,0 @@ -package es.asepeyo.recordalexia.web; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import es.asepeyo.recordalexia.domain.ParentUser; -import es.asepeyo.recordalexia.repository.ParentUserRepository; -import es.asepeyo.recordalexia.security.ParentAuthFilter; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.MediaType; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.transaction.annotation.Transactional; - -/** Verifica el login por PIN y que el panel de padres queda protegido. */ -@SpringBootTest -@AutoConfigureMockMvc -@Transactional -class ParentAuthIT { - - @Autowired private MockMvc mockMvc; - @Autowired private ObjectMapper objectMapper; - @Autowired private ParentUserRepository parentUserRepository; - @Autowired private PasswordEncoder passwordEncoder; - - @BeforeEach - void setUp() { - parentUserRepository.save(new ParentUser(passwordEncoder.encode("1234"))); - } - - @Test - void loginCorrectoAbreSesionYpermiteAccederAlPanel() throws Exception { - // Login con PIN correcto -> 200 con identificador de sesión. - String body = mockMvc.perform(post("/api/parents/login") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"pin\":\"1234\"}")) - .andExpect(status().isOk()) - .andReturn().getResponse().getContentAsString(); - JsonNode json = objectMapper.readTree(body); - String session = json.path("session").asText(); - - // Con la sesión, el panel responde. - mockMvc.perform(get("/api/parents/children").header(ParentAuthFilter.HEADER, session)) - .andExpect(status().isOk()); - } - - @Test - void pinIncorrectoDevuelve401() throws Exception { - mockMvc.perform(post("/api/parents/login") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"pin\":\"0000\"}")) - .andExpect(status().isUnauthorized()); - } - - @Test - void panelSinSesionEstaProhibido() throws Exception { - mockMvc.perform(get("/api/parents/children")) - .andExpect(status().isForbidden()); - } -} diff --git a/backend/src/test/java/es/asepeyo/recordalexia/web/TodayFlowIT.java b/backend/src/test/java/es/asepeyo/recordalexia/web/TodayFlowIT.java index 86a2fe7..48510b7 100644 --- a/backend/src/test/java/es/asepeyo/recordalexia/web/TodayFlowIT.java +++ b/backend/src/test/java/es/asepeyo/recordalexia/web/TodayFlowIT.java @@ -11,27 +11,33 @@ import com.fasterxml.jackson.databind.ObjectMapper; import es.asepeyo.recordalexia.domain.Activity; import es.asepeyo.recordalexia.domain.AfternoonRoutine; import es.asepeyo.recordalexia.domain.Child; +import es.asepeyo.recordalexia.domain.Family; +import es.asepeyo.recordalexia.domain.FamilySession; import es.asepeyo.recordalexia.domain.Reward; import es.asepeyo.recordalexia.domain.WeeklyTemplateEntry; import es.asepeyo.recordalexia.repository.ActivityRepository; import es.asepeyo.recordalexia.repository.AfternoonRoutineRepository; import es.asepeyo.recordalexia.repository.ChildRepository; +import es.asepeyo.recordalexia.repository.FamilyRepository; +import es.asepeyo.recordalexia.repository.FamilySessionRepository; import es.asepeyo.recordalexia.repository.RewardRepository; import es.asepeyo.recordalexia.repository.WeeklyTemplateEntryRepository; +import es.asepeyo.recordalexia.security.SessionAuthFilter; import java.time.DayOfWeek; +import java.time.Instant; import java.time.LocalDate; +import java.time.temporal.ChronoUnit; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; -/** - * Test de integración del flujo del kiosko a través de la API REST: - * ver el día de hoy, marcar una tarea (ganar monedas) y canjear un premio. - */ +/** Flujo del kiosko vía API (con sesión de familia): ver día, marcar, canjear. */ @SpringBootTest @AutoConfigureMockMvc +@Transactional class TodayFlowIT { @Autowired private MockMvc mockMvc; @@ -41,19 +47,28 @@ class TodayFlowIT { @Autowired private WeeklyTemplateEntryRepository templateRepository; @Autowired private AfternoonRoutineRepository routineRepository; @Autowired private RewardRepository rewardRepository; + @Autowired private FamilyRepository familyRepository; + @Autowired private FamilySessionRepository sessionRepository; @Test void flujoVerDiaMarcarTareaYcanjear() throws Exception { - // Datos para el día de hoy (engancha plantilla y rutina al día de la semana actual). + Family family = familyRepository.save(new Family("flow@x.com", "h", "Flow", "p")); + String session = "flow-session"; + sessionRepository.save(new FamilySession(session, family, Instant.now().plus(1, ChronoUnit.DAYS))); + DayOfWeek hoy = LocalDate.now().getDayOfWeek(); - Child nino = childRepository.save(nuevoNino()); - Activity mates = activityRepository.save(new Activity("Mates", "Mates", "📘", "#5B8DEF")); + Child nino = childRepository.save(nuevoNino(family)); + Activity mates = new Activity("Mates", "Mates", "📘", "#5B8DEF"); + mates.setFamily(family); + mates = activityRepository.save(mates); templateRepository.save(new WeeklyTemplateEntry(nino, hoy, mates, 0)); routineRepository.save(new AfternoonRoutine(nino, hoy, "Deberes", "Deures", "📝", "#F2A65A", 0)); - Reward premio = rewardRepository.save(new Reward("Tablet", "Tauleta", "🎮", "#5B8DEF", 5)); + Reward premio = new Reward("Tablet", "Tauleta", "🎮", "#5B8DEF", 5); + premio.setFamily(family); + premio = rewardRepository.save(premio); - // 1) Ver el día: una tarea de mañana y una de tarde. - String body = mockMvc.perform(get("/api/children/{id}/today", nino.getId())) + String body = mockMvc.perform(get("/api/children/{id}/today", nino.getId()) + .header(SessionAuthFilter.HEADER, session)) .andExpect(status().isOk()) .andExpect(jsonPath("$.morning.length()").value(1)) .andExpect(jsonPath("$.afternoon.length()").value(1)) @@ -63,25 +78,24 @@ class TodayFlowIT { JsonNode json = objectMapper.readTree(body); long taskId = json.path("morning").get(0).path("id").asLong(); - // 2) Marcar la única tarea de mañana: completa el bloque mañana, así que - // gana +5 (tarea) +10 (bono de bloque) = 15. El día no se completa (queda la tarde). - mockMvc.perform(post("/api/tasks/{taskId}/toggle", taskId)) + mockMvc.perform(post("/api/tasks/{taskId}/toggle", taskId).header(SessionAuthFilter.HEADER, session)) .andExpect(status().isOk()) .andExpect(jsonPath("$.done").value(true)) .andExpect(jsonPath("$.coinsEarned").value(15)) .andExpect(jsonPath("$.newBalance").value(65)); - // 3) Canjear un premio de coste 5. mockMvc.perform(post("/api/rewards/{rewardId}/redeem", premio.getId()) - .param("childId", String.valueOf(nino.getId()))) + .param("childId", String.valueOf(nino.getId())) + .header(SessionAuthFilter.HEADER, session)) .andExpect(status().isOk()) .andExpect(jsonPath("$.newBalance").value(60)); assertThat(childRepository.findById(nino.getId()).orElseThrow().getCoins()).isEqualTo(60); } - private Child nuevoNino() { + private Child nuevoNino(Family family) { Child c = new Child(); + c.setFamily(family); c.setName("Nora"); c.setMascot("🦊"); c.setAccentColor("#F2A65A"); diff --git a/docs/adr/0004-multi-tenant-y-auth.md b/docs/adr/0004-multi-tenant-y-auth.md new file mode 100644 index 0000000..a3d36ae --- /dev/null +++ b/docs/adr/0004-multi-tenant-y-auth.md @@ -0,0 +1,42 @@ +# ADR 0004 — Multi-tenant + registro/login propio (email/contraseña) + +- **Estado:** aceptada +- **Fecha:** 2026-06-21 +- **Supersede (parcialmente):** ADR 0002/0003 en lo relativo a "auth ligera sin cuentas". + +## Contexto + +El contrato original definía la app como **una sola familia**, con auth ligera +(PIN de padres) y datos globales. El usuario pide convertirla en **multi-familia +(multi-tenant)** con **registro/login propio** y persistencia de preferencias. + +## Decisión + +1. **Tenant = `Family`** (cuenta con email único + contraseña BCrypt + PIN + prefs). + Las entidades raíz (`child`, `activity`, `material_item`, `reward`) llevan + `family_id`. El resto cuelga del niño. +2. **Aislamiento**: toda consulta raíz se filtra por la familia de la sesión; las + operaciones por id verifican pertenencia y, si no, responden **404** (no 403, + para no filtrar existencia). +3. **Sesión de familia ligada al dispositivo**, persistida en BD (`family_session`) + para sobrevivir a reinicios (clave para el kiosko). Cabecera `X-Auth-Session`. + Toda la API exige sesión válida; el niño NO se loguea (el adulto deja la sesión + abierta en la tablet). +4. **Panel de padres**: además de la sesión, exige **desbloqueo con PIN** + (`POST /api/parents/unlock`), que concede el rol PARENT durante 30 min. +5. **Sin Keycloak**: auth propia encapsulada en el paquete `security` + (`AuthService`, `SessionAuthService`, `SessionAuthFilter`). +6. **Preferencia OpenDyslexic** pasa a campo por niño (`child.dyslexia_font`) + un + default de cuenta (`family.default_dyslexia_font`). + +## Consecuencias + +- Registro abierto (cualquiera crea una familia). Rate-limiting y verificación de + email quedan como mejora futura (homelab). +- La migración del esquema (`002-multitenant.yaml`) añade `family_id` NOT NULL; en + BD ya poblada habría que hacer backfill (en este proyecto se parte de BD limpia + con `docker compose down -v`). +- La sesión de 30 días en el dispositivo es un compromiso UX/seguridad razonable + para un kiosko doméstico; revocable borrando la fila de `family_session`. +- Sustituir la auth por un IdP externo (Keycloak) solo afectaría al paquete + `security`. diff --git a/frontend/src/app/app.component.spec.ts b/frontend/src/app/app.component.spec.ts index 2fdf5c0..495d811 100644 --- a/frontend/src/app/app.component.spec.ts +++ b/frontend/src/app/app.component.spec.ts @@ -1,7 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; import { AppComponent } from './app.component'; -import { FontPreferenceService } from './core/font-preference.service'; describe('AppComponent', () => { beforeEach(async () => { @@ -15,11 +14,4 @@ describe('AppComponent', () => { const fixture = TestBed.createComponent(AppComponent); expect(fixture.componentInstance).toBeTruthy(); }); - - it('debe aplicar OpenDyslexic por defecto al arrancar', () => { - TestBed.createComponent(AppComponent); // fuerza la inicialización del servicio - const fontPreference = TestBed.inject(FontPreferenceService); - expect(fontPreference.enabled()).toBe(true); - expect(document.documentElement.getAttribute('data-dyslexia-font')).toBe('on'); - }); }); diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index 7fd8223..28e3e9f 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -1,15 +1,10 @@ -import { Component, inject } from '@angular/core'; +import { Component } from '@angular/core'; import { RouterOutlet } from '@angular/router'; -import { FontPreferenceService } from './core/font-preference.service'; -/** Componente raíz: monta el router. La navegación arranca en Perfiles. */ +/** Componente raíz: monta el router. La navegación arranca en Perfiles (o /login). */ @Component({ selector: 'app-root', imports: [RouterOutlet], template: '', }) -export class AppComponent { - // Inyectar el servicio fuerza su inicialización: aplica la preferencia de - // tipografía (OpenDyslexic por defecto) sobre al arrancar la app. - private readonly fontPreference = inject(FontPreferenceService); -} +export class AppComponent {} diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index b651baa..4c9836a 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -3,13 +3,13 @@ import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/ import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; -import { parentSessionInterceptor } from './core/parent-session.interceptor'; +import { authInterceptor } from './core/auth.interceptor'; export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), - // Cliente HTTP (fetch) con el interceptor de sesión de padres. - provideHttpClient(withFetch(), withInterceptors([parentSessionInterceptor])), + // Cliente HTTP (fetch) con el interceptor de sesión de familia. + provideHttpClient(withFetch(), withInterceptors([authInterceptor])), ], }; diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 0c1982a..fa95cb7 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -4,17 +4,25 @@ import { HomeComponent } from './features/home/home.component'; import { StoreComponent } from './features/store/store.component'; import { KeypadComponent } from './features/parents/keypad.component'; import { ParentsComponent } from './features/parents/parents.component'; -import { parentGuard } from './core/parent.guard'; +import { LoginComponent } from './features/auth/login.component'; +import { RegisterComponent } from './features/auth/register.component'; +import { AccountComponent } from './features/auth/account.component'; +import { authGuard, parentGuard } from './core/auth.guard'; export const routes: Routes = [ - // Selección de perfil: la pantalla de entrada del kiosko. - { path: '', component: ProfileSelectComponent }, - // Día de hoy del niño (Tablero / Foco). - { path: 'home/:childId', component: HomeComponent }, - // Tienda de recompensas. - { path: 'store/:childId', component: StoreComponent }, - // PIN de padres y panel protegido por sesión. - { path: 'pin', component: KeypadComponent }, - { path: 'parents', component: ParentsComponent, canActivate: [parentGuard] }, + // Públicas. + { path: 'login', component: LoginComponent }, + { path: 'register', component: RegisterComponent }, + + // Requieren sesión de familia. + { path: '', component: ProfileSelectComponent, canActivate: [authGuard] }, + { path: 'home/:childId', component: HomeComponent, canActivate: [authGuard] }, + { path: 'store/:childId', component: StoreComponent, canActivate: [authGuard] }, + { path: 'account', component: AccountComponent, canActivate: [authGuard] }, + { path: 'pin', component: KeypadComponent, canActivate: [authGuard] }, + + // Panel de padres: sesión + PIN desbloqueado. + { path: 'parents', component: ParentsComponent, canActivate: [authGuard, parentGuard] }, + { path: '**', redirectTo: '' }, ]; diff --git a/frontend/src/app/core/auth.guard.ts b/frontend/src/app/core/auth.guard.ts new file mode 100644 index 0000000..7a31f77 --- /dev/null +++ b/frontend/src/app/core/auth.guard.ts @@ -0,0 +1,20 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { AuthService } from './auth.service'; + +/** Exige sesión de familia; si no, al login. */ +export const authGuard: CanActivateFn = () => { + const auth = inject(AuthService); + const router = inject(Router); + return auth.isAuthenticated() ? true : router.createUrlTree(['/login']); +}; + +/** Exige sesión + panel desbloqueado (PIN); si no, al PIN (o al login si no hay sesión). */ +export const parentGuard: CanActivateFn = () => { + const auth = inject(AuthService); + const router = inject(Router); + if (!auth.isAuthenticated()) { + return router.createUrlTree(['/login']); + } + return auth.panelUnlocked() ? true : router.createUrlTree(['/pin']); +}; diff --git a/frontend/src/app/core/auth.interceptor.ts b/frontend/src/app/core/auth.interceptor.ts new file mode 100644 index 0000000..69f9bb3 --- /dev/null +++ b/frontend/src/app/core/auth.interceptor.ts @@ -0,0 +1,43 @@ +import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { catchError, throwError } from 'rxjs'; +import { AuthService } from './auth.service'; + +/** Rutas públicas o gestionadas por su componente: no llevan auto-manejo de error. */ +const PUBLIC = ['/api/auth/register', '/api/auth/login']; +const SELF_HANDLED = ['/api/parents/unlock']; // el error de PIN lo muestra el teclado + +/** + * Añade la cabecera X-Auth-Session a las llamadas a la API (salvo registro/login). + * Gestión de errores: + * - 401/403 en el panel (/api/parents/**) con sesión válida → falta desbloquear → /pin. + * - 401/403 en el resto → sesión inválida/caducada → limpia y va a /login. + */ +export const authInterceptor: HttpInterceptorFn = (req, next) => { + const auth = inject(AuthService); + const router = inject(Router); + + const isPublic = PUBLIC.some((p) => req.url.endsWith(p)); + const selfHandled = SELF_HANDLED.some((p) => req.url.endsWith(p)); + const request = + !isPublic && auth.sessionId + ? req.clone({ setHeaders: { 'X-Auth-Session': auth.sessionId } }) + : req; + + return next(request).pipe( + catchError((err: HttpErrorResponse) => { + const denied = err.status === 401 || err.status === 403; + if (denied && !isPublic && !selfHandled) { + if (req.url.includes('/api/parents/') && auth.isAuthenticated()) { + auth.panelUnlocked.set(false); + router.navigate(['/pin']); // panel bloqueado/caducado, mantener la sesión + } else { + auth.clearLocal(); + router.navigate(['/login']); + } + } + return throwError(() => err); + }), + ); +}; diff --git a/frontend/src/app/core/auth.service.ts b/frontend/src/app/core/auth.service.ts new file mode 100644 index 0000000..95e6926 --- /dev/null +++ b/frontend/src/app/core/auth.service.ts @@ -0,0 +1,92 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, computed, inject, signal } from '@angular/core'; +import { Observable, tap } from 'rxjs'; +import { MeResponse } from './models'; + +/** + * Sesión de familia (tenant). Guarda el identificador de sesión en localStorage + * para que el dispositivo (kiosko) siga autenticado tras recargar. Expone el estado + * de la familia y si el panel de padres está desbloqueado (tras el PIN). + */ +@Injectable({ providedIn: 'root' }) +export class AuthService { + private static readonly KEY = 'recordalexia.session'; + private readonly http = inject(HttpClient); + + private readonly sessionSignal = signal(this.read()); + readonly family = signal(null); + /** El desbloqueo del panel vive solo en memoria: al recargar se vuelve a pedir el PIN. */ + readonly panelUnlocked = signal(false); + + readonly isAuthenticated = computed(() => this.sessionSignal() !== null); + + get sessionId(): string | null { + return this.sessionSignal(); + } + + /** Alta de familia (auto-login). */ + register(email: string, password: string, name: string, pin: string): Observable<{ session: string }> { + return this.http + .post<{ session: string }>('/api/auth/register', { email, password, name, pin }) + .pipe(tap((res) => this.store(res.session))); + } + + /** Acceso con email + contraseña. */ + login(email: string, password: string): Observable<{ session: string }> { + return this.http + .post<{ session: string }>('/api/auth/login', { email, password }) + .pipe(tap((res) => this.store(res.session))); + } + + /** Carga los datos de la familia autenticada. */ + loadMe(): Observable { + return this.http.get('/api/auth/me').pipe(tap((me) => this.family.set(me))); + } + + /** Desbloquea el panel de padres validando el PIN. */ + unlockPanel(pin: string): Observable { + return this.http + .post('/api/parents/unlock', { pin }) + .pipe(tap(() => this.panelUnlocked.set(true))); + } + + /** Cierra sesión en el dispositivo. */ + logout(): Observable { + const obs = this.http.post('/api/auth/logout', {}); + obs.subscribe({ next: () => this.clear(), error: () => this.clear() }); + return obs; + } + + /** Limpia el estado local (sin llamar al backend). */ + clearLocal(): void { + this.clear(); + } + + private store(session: string): void { + this.sessionSignal.set(session); + try { + localStorage.setItem(AuthService.KEY, session); + } catch { + // localStorage no disponible: la sesión vivirá solo en memoria. + } + } + + private clear(): void { + this.sessionSignal.set(null); + this.family.set(null); + this.panelUnlocked.set(false); + try { + localStorage.removeItem(AuthService.KEY); + } catch { + // ignorar + } + } + + private read(): string | null { + try { + return localStorage.getItem(AuthService.KEY); + } catch { + return null; + } + } +} diff --git a/frontend/src/app/core/font-preference.service.ts b/frontend/src/app/core/font-preference.service.ts index 98a25ce..a6d68b7 100644 --- a/frontend/src/app/core/font-preference.service.ts +++ b/frontend/src/app/core/font-preference.service.ts @@ -2,79 +2,28 @@ import { DOCUMENT } from '@angular/common'; import { Injectable, inject, signal } from '@angular/core'; /** - * Gestiona la preferencia de tipografía OpenDyslexic. + * Aplica la preferencia de tipografía OpenDyslexic al DOM, alternando el atributo + * `data-dyslexia-font` en (interruptor que usa _theme.scss para cambiar + * entre OpenDyslexic y las tipografías de marca). * - * Es la "costura" de accesibilidad: aplica (o quita) el atributo - * `data-dyslexia-font` en el elemento , que es el interruptor que el - * fichero de tokens (_theme.scss) usa para alternar entre OpenDyslexic y las - * tipografías de marca del handoff (Fredoka/Nunito). - * - * Decisión de producto (Fase 1): OpenDyslexic activada POR DEFECTO y aplicada a - * TODO el texto. Es una preferencia por niño; de momento se persiste en - * localStorage. En la Fase 5 esta preferencia pasará a leerse/escribirse contra - * el backend (ajustes por niño), sustituyendo el almacenamiento local. + * La fuente de verdad está en el BACKEND: el ajuste por niño (`child.dyslexiaFont`, + * llega en /today) y el default de la cuenta (`family.defaultDyslexiaFont`). Este + * servicio solo refleja ese valor; quien decide lo aplica con apply(). */ @Injectable({ providedIn: 'root' }) export class FontPreferenceService { - /** Clave de persistencia temporal hasta el cableado con el backend. */ - private static readonly STORAGE_KEY = 'recordalexia.dyslexiaFont'; - private readonly document = inject(DOCUMENT); - /** Estado reactivo: ¿está activada OpenDyslexic? Por defecto, sí. */ - private readonly enabledSignal = signal(this.readInitialState()); + /** ¿Está activada OpenDyslexic ahora mismo? (para la UI que quiera mostrarlo). */ + readonly enabled = signal(this.readDom()); - /** Señal de solo lectura para que la consuma la UI. */ - readonly enabled = this.enabledSignal.asReadonly(); - - constructor() { - // Sincroniza el DOM con el estado inicial al arrancar la app. - this.applyToDom(this.enabledSignal()); + /** Aplica el valor al DOM y actualiza la señal. */ + apply(enabled: boolean): void { + this.enabled.set(enabled); + this.document.documentElement.setAttribute('data-dyslexia-font', enabled ? 'on' : 'off'); } - /** Activa o desactiva OpenDyslexic y propaga el cambio al DOM y a la persistencia. */ - setEnabled(enabled: boolean): void { - this.enabledSignal.set(enabled); - this.applyToDom(enabled); - this.persist(enabled); - } - - /** Alterna el estado actual. */ - toggle(): void { - this.setEnabled(!this.enabledSignal()); - } - - /** Lee el estado inicial de localStorage; si no hay nada guardado, ACTIVA por defecto. */ - private readInitialState(): boolean { - const stored = this.safeGetItem(FontPreferenceService.STORAGE_KEY); - return stored === null ? true : stored === 'true'; - } - - /** Refleja la preferencia en . */ - private applyToDom(enabled: boolean): void { - this.document.documentElement.setAttribute( - 'data-dyslexia-font', - enabled ? 'on' : 'off', - ); - } - - /** Guarda la preferencia, tolerando entornos sin localStorage. */ - private persist(enabled: boolean): void { - try { - this.document.defaultView?.localStorage.setItem( - FontPreferenceService.STORAGE_KEY, - String(enabled), - ); - } catch { - // localStorage no disponible (modo kiosko restringido): se ignora. - } - } - - private safeGetItem(key: string): string | null { - try { - return this.document.defaultView?.localStorage.getItem(key) ?? null; - } catch { - return null; - } + private readDom(): boolean { + return this.document.documentElement.getAttribute('data-dyslexia-font') !== 'off'; } } diff --git a/frontend/src/app/core/models.ts b/frontend/src/app/core/models.ts index 59b384f..bd8f2d9 100644 --- a/frontend/src/app/core/models.ts +++ b/frontend/src/app/core/models.ts @@ -26,6 +26,7 @@ export interface ChildInfo { language: Language; soundEnabled: boolean; ttsEnabled: boolean; + dyslexiaFont: boolean; } /** Tarea del día (mañana o tarde). Lleva texto ES y CA. */ @@ -94,6 +95,7 @@ export interface SettingsRequest { soundEnabled?: boolean; ttsEnabled?: boolean; language?: Language; + dyslexiaFont?: boolean; departureTime?: string; } @@ -185,6 +187,15 @@ export interface GamificationView { coinsPerDay: number; } +// ----- Auth / cuenta de familia ----- +export interface MeResponse { + familyId: number; + email: string; + name: string; + uiLanguage: string; + defaultDyslexiaFont: boolean; +} + // ----- Panel de padres: peticiones ----- export interface ChildRequest { name?: string; diff --git a/frontend/src/app/core/parent-session.interceptor.ts b/frontend/src/app/core/parent-session.interceptor.ts deleted file mode 100644 index 2d8dfbc..0000000 --- a/frontend/src/app/core/parent-session.interceptor.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { HttpInterceptorFn } from '@angular/common/http'; -import { inject } from '@angular/core'; -import { ParentSessionService } from './parent-session.service'; - -/** - * Añade la cabecera X-Parent-Session a las peticiones del panel de padres - * (/api/parents/**), salvo al propio login. El resto de la API (kiosko) no la lleva. - */ -export const parentSessionInterceptor: HttpInterceptorFn = (req, next) => { - const session = inject(ParentSessionService); - const isParentApi = req.url.includes('/api/parents/') && !req.url.endsWith('/parents/login'); - if (isParentApi && session.sessionId) { - return next(req.clone({ setHeaders: { 'X-Parent-Session': session.sessionId } })); - } - return next(req); -}; diff --git a/frontend/src/app/core/parent-session.service.ts b/frontend/src/app/core/parent-session.service.ts deleted file mode 100644 index f79813e..0000000 --- a/frontend/src/app/core/parent-session.service.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable, computed, inject, signal } from '@angular/core'; -import { Observable, tap } from 'rxjs'; - -/** - * Sesión del panel de padres. Guarda el identificador opaco devuelto por el login - * (cabecera X-Parent-Session) y lo mantiene en sessionStorage para sobrevivir a - * recargas mientras dura la pestaña. No es una credencial: es un ticket temporal. - */ -@Injectable({ providedIn: 'root' }) -export class ParentSessionService { - private static readonly KEY = 'recordalexia.parentSession'; - private readonly http = inject(HttpClient); - - private readonly currentId = signal(this.read()); - readonly isAuthenticated = computed(() => this.currentId() !== null); - - /** Identificador actual para la cabecera (o null si no hay sesión). */ - get sessionId(): string | null { - return this.currentId(); - } - - /** Valida el PIN; si es correcto guarda la sesión. */ - login(pin: string): Observable<{ session: string }> { - return this.http.post<{ session: string }>('/api/parents/login', { pin }).pipe( - tap((res) => { - this.currentId.set(res.session); - this.write(res.session); - }), - ); - } - - logout(): void { - this.currentId.set(null); - try { - sessionStorage.removeItem(ParentSessionService.KEY); - } catch { - // sessionStorage no disponible: nada que limpiar. - } - } - - private read(): string | null { - try { - return sessionStorage.getItem(ParentSessionService.KEY); - } catch { - return null; - } - } - - private write(value: string): void { - try { - sessionStorage.setItem(ParentSessionService.KEY, value); - } catch { - // Ignorar si no hay sessionStorage (modo kiosko restringido). - } - } -} diff --git a/frontend/src/app/core/parent.guard.ts b/frontend/src/app/core/parent.guard.ts deleted file mode 100644 index 4df0ab7..0000000 --- a/frontend/src/app/core/parent.guard.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { inject } from '@angular/core'; -import { CanActivateFn, Router } from '@angular/router'; -import { ParentSessionService } from './parent-session.service'; - -/** Protege el panel de padres: sin sesión, redirige al teclado del PIN. */ -export const parentGuard: CanActivateFn = () => { - const session = inject(ParentSessionService); - const router = inject(Router); - return session.isAuthenticated() ? true : router.createUrlTree(['/pin']); -}; diff --git a/frontend/src/app/features/auth/account.component.ts b/frontend/src/app/features/auth/account.component.ts new file mode 100644 index 0000000..57d63e3 --- /dev/null +++ b/frontend/src/app/features/auth/account.component.ts @@ -0,0 +1,148 @@ +import { Component, OnInit, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { HttpClient } from '@angular/common/http'; +import { Router } from '@angular/router'; +import { AuthService } from '../../core/auth.service'; + +/** Pantalla de cuenta: datos, preferencias, cambio de contraseña/PIN y cerrar sesión. */ +@Component({ + selector: 'app-account', + imports: [FormsModule], + template: ` +
+
+ +

⚙️ Mi cuenta

+ +
+ + @if (auth.family(); as f) { +
+

Familia

+

{{ f.name }} · {{ f.email }}

+
+ } + +
+

Preferencias

+
+ + + + +
+ @if (prefsSaved()) {

Preferencias guardadas ✓

} +
+ +
+

Cambiar contraseña

+
+ + + +
+ @if (passMsg()) {

{{ passMsg() }}

} +
+ +
+

Cambiar PIN del panel

+
+ + + +
+ @if (pinMsg()) {

{{ pinMsg() }}

} +
+
+ `, + styles: [ + ` + .acc { max-width: 720px; margin: 0 auto; padding: var(--space-5) var(--space-4); } + .acc__top { display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-5); } + .acc__title { flex: 1; margin: 0; font-size: 1.6rem; } + .acc__back { + all: unset; cursor: pointer; width: var(--touch-nav); height: var(--touch-nav); border-radius: 50%; + background: var(--surface); box-shadow: var(--shadow-btn); display: flex; align-items: center; + justify-content: center; font-size: 28px; color: var(--text-2); + } + .acc__logout { + all: unset; cursor: pointer; font-family: var(--font-display); font-weight: 700; + color: var(--accent-pink); background: color-mix(in srgb, var(--accent-pink) 14%, #fff); + padding: 10px 16px; border-radius: var(--radius-pill); + } + .acc__ok { margin: 10px 0 0; color: var(--accent-green); font-weight: 700; } + `, + ], +}) +export class AccountComponent implements OnInit { + protected readonly auth = inject(AuthService); + private readonly http = inject(HttpClient); + private readonly router = inject(Router); + + protected uiLanguage = 'ES'; + protected defaultDyslexiaFont = true; + protected curPass = ''; + protected newPass = ''; + protected curPin = ''; + protected newPin = ''; + protected readonly prefsSaved = signal(false); + protected readonly passMsg = signal(null); + protected readonly pinMsg = signal(null); + + ngOnInit(): void { + this.auth.loadMe().subscribe((me) => { + this.uiLanguage = me.uiLanguage; + this.defaultDyslexiaFont = me.defaultDyslexiaFont; + }); + } + + savePrefs(): void { + this.http + .put('/api/account/prefs', { + uiLanguage: this.uiLanguage, + defaultDyslexiaFont: this.defaultDyslexiaFont, + }) + .subscribe(() => { + this.prefsSaved.set(true); + setTimeout(() => this.prefsSaved.set(false), 2000); + }); + } + + savePassword(): void { + this.http + .put('/api/account/password', { currentPassword: this.curPass, newPassword: this.newPass }) + .subscribe({ + next: () => { + this.passMsg.set('Contraseña actualizada ✓'); + this.curPass = this.newPass = ''; + }, + error: () => this.passMsg.set('La contraseña actual no es correcta'), + }); + } + + savePin(): void { + this.http + .put('/api/account/pin', { currentPin: this.curPin, newPin: this.newPin }) + .subscribe({ + next: () => { + this.pinMsg.set('PIN actualizado ✓'); + this.curPin = this.newPin = ''; + }, + error: () => this.pinMsg.set('El PIN actual no es correcto'), + }); + } + + back(): void { + this.router.navigate(['/']); + } + + logout(): void { + this.auth.logout().subscribe(); + this.router.navigate(['/login']); + } +} diff --git a/frontend/src/app/features/auth/auth.scss b/frontend/src/app/features/auth/auth.scss new file mode 100644 index 0000000..ab4372d --- /dev/null +++ b/frontend/src/app/features/auth/auth.scss @@ -0,0 +1,84 @@ +:host { + display: block; +} + +.auth { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-5); +} + +.auth__card { + width: 100%; + max-width: 380px; + background: var(--surface); + border: 1px solid var(--border-1); + border-radius: var(--radius-card); + padding: var(--space-6); + box-shadow: var(--shadow-card); + display: flex; + flex-direction: column; + gap: var(--space-3); + text-align: center; + animation: slideUp 0.4s ease both; +} + +.auth__title { + margin: 0; + font-size: 2rem; +} + +.auth__sub { + margin: 0 0 var(--space-2); + color: var(--text-2); +} + +.auth__input { + font-family: var(--font-body); + font-size: 1rem; + padding: 12px 14px; + border: 2px solid var(--border-2); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text-strong); + width: 100%; + box-sizing: border-box; +} + +.auth__err { + margin: 0; + color: var(--accent-pink); + font-weight: 700; +} + +.auth__btn { + font-family: var(--font-display); + font-weight: 700; + font-size: 1.05rem; + border: 0; + border-radius: 18px; + padding: 14px; + min-height: var(--touch-nav); + background: var(--accent-blue); + color: #fff; + cursor: pointer; + margin-top: var(--space-2); +} + +.auth__btn:disabled { + opacity: 0.45; + cursor: default; +} + +.auth__alt { + margin: var(--space-2) 0 0; + color: var(--text-2); +} + +.auth__alt a { + color: var(--accent-blue); + font-weight: 700; + text-decoration: none; +} diff --git a/frontend/src/app/features/auth/login.component.ts b/frontend/src/app/features/auth/login.component.ts new file mode 100644 index 0000000..7b2ab05 --- /dev/null +++ b/frontend/src/app/features/auth/login.component.ts @@ -0,0 +1,54 @@ +import { Component, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import { AuthService } from '../../core/auth.service'; + +/** Acceso de familia con email + contraseña. */ +@Component({ + selector: 'app-login', + imports: [FormsModule, RouterLink], + template: ` +
+
+

recordaLexia 🦊

+

Entra con tu cuenta de familia

+ + + + + @if (error()) {

Email o contraseña incorrectos

} + + +

¿No tienes cuenta? Crear una

+
+
+ `, + styleUrl: './auth.scss', +}) +export class LoginComponent { + private readonly auth = inject(AuthService); + private readonly router = inject(Router); + + protected email = ''; + protected password = ''; + protected readonly loading = signal(false); + protected readonly error = signal(false); + + submit(): void { + if (!this.email || !this.password) { + return; + } + this.loading.set(true); + this.error.set(false); + this.auth.login(this.email.trim(), this.password).subscribe({ + next: () => this.auth.loadMe().subscribe(() => this.router.navigate(['/'])), + error: () => { + this.error.set(true); + this.loading.set(false); + }, + }); + } +} diff --git a/frontend/src/app/features/auth/register.component.ts b/frontend/src/app/features/auth/register.component.ts new file mode 100644 index 0000000..83ab27c --- /dev/null +++ b/frontend/src/app/features/auth/register.component.ts @@ -0,0 +1,64 @@ +import { Component, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { HttpErrorResponse } from '@angular/common/http'; +import { Router, RouterLink } from '@angular/router'; +import { AuthService } from '../../core/auth.service'; + +/** Alta de una familia nueva (email + contraseña + PIN del panel). */ +@Component({ + selector: 'app-register', + imports: [FormsModule, RouterLink], + template: ` +
+
+

Crear familia 🦊

+

Una cuenta para toda la familia

+ + + + + + + @if (error()) {

{{ error() }}

} + + +

¿Ya tienes cuenta? Entrar

+
+
+ `, + styleUrl: './auth.scss', +}) +export class RegisterComponent { + private readonly auth = inject(AuthService); + private readonly router = inject(Router); + + protected name = ''; + protected email = ''; + protected password = ''; + protected pin = ''; + protected readonly loading = signal(false); + protected readonly error = signal(null); + + valid(): boolean { + return !!this.email && this.password.length >= 6 && /^\d{4}$/.test(this.pin) && !!this.name; + } + + submit(): void { + if (!this.valid()) { + return; + } + this.loading.set(true); + this.error.set(null); + this.auth.register(this.email.trim(), this.password, this.name.trim(), this.pin).subscribe({ + next: () => this.auth.loadMe().subscribe(() => this.router.navigate(['/'])), + error: (e: HttpErrorResponse) => { + this.error.set(e.status === 409 ? 'Ese email ya está registrado' : 'No se pudo crear la cuenta'); + this.loading.set(false); + }, + }); + } +} diff --git a/frontend/src/app/features/home/home.component.ts b/frontend/src/app/features/home/home.component.ts index b528a65..4615ccd 100644 --- a/frontend/src/app/features/home/home.component.ts +++ b/frontend/src/app/features/home/home.component.ts @@ -1,6 +1,7 @@ import { Component, OnInit, ViewChild, computed, inject, signal } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { ApiService } from '../../core/api.service'; +import { FontPreferenceService } from '../../core/font-preference.service'; import { I18nService } from '../../core/i18n.service'; import { SoundService } from '../../core/sound.service'; import { TodayResponse, ViewMode } from '../../core/models'; @@ -47,6 +48,7 @@ export class HomeComponent implements OnInit { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly sound = inject(SoundService); + private readonly fontPreference = inject(FontPreferenceService); protected readonly i18n = inject(I18nService); @ViewChild('wallet') private wallet?: WalletComponent; @@ -88,6 +90,8 @@ export class HomeComponent implements OnInit { this.today.set(data); this.mode.set(data.child.viewMode); this.i18n.setLang(data.child.language); + // Aplica la preferencia de tipografía de ESTE niño. + this.fontPreference.apply(data.child.dyslexiaFont); this.loading.set(false); }, error: () => this.loading.set(false), diff --git a/frontend/src/app/features/parents/keypad.component.ts b/frontend/src/app/features/parents/keypad.component.ts index 23c06a1..415d516 100644 --- a/frontend/src/app/features/parents/keypad.component.ts +++ b/frontend/src/app/features/parents/keypad.component.ts @@ -1,7 +1,7 @@ import { Component, inject, signal } from '@angular/core'; import { Router } from '@angular/router'; import { I18nService } from '../../core/i18n.service'; -import { ParentSessionService } from '../../core/parent-session.service'; +import { AuthService } from '../../core/auth.service'; /** Teclado numérico 3×4 para el PIN de padres (4 dígitos, shake al fallar). */ @Component({ @@ -87,7 +87,7 @@ import { ParentSessionService } from '../../core/parent-session.service'; ], }) export class KeypadComponent { - private readonly session = inject(ParentSessionService); + private readonly auth = inject(AuthService); private readonly router = inject(Router); protected readonly i18n = inject(I18nService); @@ -122,7 +122,7 @@ export class KeypadComponent { } private submit(code: string): void { - this.session.login(code).subscribe({ + this.auth.unlockPanel(code).subscribe({ next: () => this.router.navigate(['/parents']), error: () => { this.error.set(true); diff --git a/frontend/src/app/features/parents/parents.component.ts b/frontend/src/app/features/parents/parents.component.ts index 31b7632..457e69e 100644 --- a/frontend/src/app/features/parents/parents.component.ts +++ b/frontend/src/app/features/parents/parents.component.ts @@ -2,7 +2,7 @@ import { Component, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; import { ParentApiService } from '../../core/parent-api.service'; -import { ParentSessionService } from '../../core/parent-session.service'; +import { AuthService } from '../../core/auth.service'; import { I18nService } from '../../core/i18n.service'; import { ChildSummary } from '../../core/models'; import { ScheduleTabComponent } from './schedule-tab.component'; @@ -90,7 +90,7 @@ type Tab = 'schedule' | 'materials' | 'events' | 'routines' | 'rewards'; }) export class ParentsComponent { private readonly parentApi = inject(ParentApiService); - private readonly session = inject(ParentSessionService); + private readonly auth = inject(AuthService); private readonly router = inject(Router); protected readonly i18n = inject(I18nService); @@ -107,8 +107,9 @@ export class ParentsComponent { }); } + /** "Salir" del panel: lo bloquea (vuelve a pedir PIN) y regresa al kiosko. */ logout(): void { - this.session.logout(); + this.auth.panelUnlocked.set(false); this.router.navigate(['/']); } } diff --git a/frontend/src/app/features/parents/rewards-tab.component.ts b/frontend/src/app/features/parents/rewards-tab.component.ts index e38afce..1822c1e 100644 --- a/frontend/src/app/features/parents/rewards-tab.component.ts +++ b/frontend/src/app/features/parents/rewards-tab.component.ts @@ -29,6 +29,9 @@ import { RewardAdminView } from '../../core/models'; + @@ -78,6 +81,7 @@ export class RewardsTabComponent { this.api.getToday(value).subscribe((t) => { this.soundEnabled = t.child.soundEnabled; this.ttsEnabled = t.child.ttsEnabled; + this.dyslexiaFont = t.child.dyslexiaFont; }); } private _childId!: number; @@ -94,6 +98,7 @@ export class RewardsTabComponent { protected perDay = 20; protected soundEnabled = true; protected ttsEnabled = true; + protected dyslexiaFont = true; protected icon = ''; protected labelEs = ''; @@ -121,7 +126,11 @@ export class RewardsTabComponent { saveSettings(): void { this.parentApi - .updateSettings(this._childId, { soundEnabled: this.soundEnabled, ttsEnabled: this.ttsEnabled }) + .updateSettings(this._childId, { + soundEnabled: this.soundEnabled, + ttsEnabled: this.ttsEnabled, + dyslexiaFont: this.dyslexiaFont, + }) .subscribe(); } diff --git a/frontend/src/app/features/profiles/profile-select.component.html b/frontend/src/app/features/profiles/profile-select.component.html index 747c6c5..5a2b9ae 100644 --- a/frontend/src/app/features/profiles/profile-select.component.html +++ b/frontend/src/app/features/profiles/profile-select.component.html @@ -1,4 +1,8 @@
+ +

{{ i18n.t('whoEntersToday') }}

@if (loading()) { diff --git a/frontend/src/app/features/profiles/profile-select.component.scss b/frontend/src/app/features/profiles/profile-select.component.scss index ae66dd2..7b68e2f 100644 --- a/frontend/src/app/features/profiles/profile-select.component.scss +++ b/frontend/src/app/features/profiles/profile-select.component.scss @@ -3,6 +3,7 @@ } .profiles { + position: relative; min-height: 100vh; display: flex; flex-direction: column; @@ -11,6 +12,21 @@ gap: var(--space-6); padding: var(--space-6) var(--space-4); + &__account { + all: unset; + cursor: pointer; + position: absolute; + top: 20px; + right: 24px; + padding: 8px 16px; + border-radius: var(--radius-pill); + background: var(--surface); + box-shadow: var(--shadow-card); + font-family: var(--font-display); + font-weight: 700; + color: var(--text-1); + } + &__title { margin: 0; font-size: 2.4rem; diff --git a/frontend/src/app/features/profiles/profile-select.component.ts b/frontend/src/app/features/profiles/profile-select.component.ts index 65f373b..2b15235 100644 --- a/frontend/src/app/features/profiles/profile-select.component.ts +++ b/frontend/src/app/features/profiles/profile-select.component.ts @@ -1,6 +1,8 @@ import { Component, OnInit, inject, signal } from '@angular/core'; import { Router } from '@angular/router'; import { ApiService } from '../../core/api.service'; +import { AuthService } from '../../core/auth.service'; +import { FontPreferenceService } from '../../core/font-preference.service'; import { I18nService } from '../../core/i18n.service'; import { KioskService } from '../../core/kiosk.service'; import { ChildSummary } from '../../core/models'; @@ -23,6 +25,8 @@ export class ProfileSelectComponent implements OnInit { private readonly api = inject(ApiService); private readonly router = inject(Router); private readonly kiosk = inject(KioskService); + protected readonly auth = inject(AuthService); + private readonly fontPreference = inject(FontPreferenceService); protected readonly i18n = inject(I18nService); protected readonly children = signal([]); @@ -30,6 +34,18 @@ export class ProfileSelectComponent implements OnInit { protected readonly error = signal(false); ngOnInit(): void { + // En la pantalla de perfiles aún no hay niño elegido: aplica el default de la cuenta. + const applyDefault = () => { + const f = this.auth.family(); + if (f) { + this.fontPreference.apply(f.defaultDyslexiaFont); + } + }; + if (!this.auth.family()) { + this.auth.loadMe().subscribe(applyDefault); + } else { + applyDefault(); + } this.api.getChildren().subscribe({ next: (list) => { this.children.set(list); @@ -52,4 +68,8 @@ export class ProfileSelectComponent implements OnInit { openParents(): void { this.router.navigate(['/parents']); } + + openAccount(): void { + this.router.navigate(['/account']); + } }