feat: cuentas de familia (multi-tenant), registro/login y preferencias

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.
This commit is contained in:
Jaume Garriga Maestre
2026-06-21 13:11:34 +02:00
parent 52e559a159
commit 24a0c8a0dd
72 changed files with 1959 additions and 647 deletions

View File

@@ -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: `
<main class="acc">
<header class="acc__top">
<button type="button" class="acc__back" (click)="back()"></button>
<h1 class="acc__title">⚙️ Mi cuenta</h1>
<button type="button" class="acc__logout" (click)="logout()">Cerrar sesión</button>
</header>
@if (auth.family(); as f) {
<div class="adm-card">
<p class="adm-label">Familia</p>
<p>{{ f.name }} · {{ f.email }}</p>
</div>
}
<div class="adm-card">
<p class="adm-label">Preferencias</p>
<div class="adm-row">
<label class="adm-label">Idioma del panel</label>
<select class="adm-input" [(ngModel)]="uiLanguage">
<option value="ES">Español</option>
<option value="CA">Català</option>
</select>
<label class="adm-chip">
<input type="checkbox" [(ngModel)]="defaultDyslexiaFont" /> OpenDyslexic por defecto
</label>
<button class="adm-btn" (click)="savePrefs()">Guardar</button>
</div>
@if (prefsSaved()) { <p class="acc__ok">Preferencias guardadas ✓</p> }
</div>
<div class="adm-card">
<p class="adm-label">Cambiar contraseña</p>
<div class="adm-row">
<input class="adm-input" type="password" placeholder="Actual" [(ngModel)]="curPass" />
<input class="adm-input" type="password" placeholder="Nueva (mín. 6)" [(ngModel)]="newPass" />
<button class="adm-btn" [disabled]="!curPass || newPass.length < 6" (click)="savePassword()">Cambiar</button>
</div>
@if (passMsg()) { <p class="acc__ok">{{ passMsg() }}</p> }
</div>
<div class="adm-card">
<p class="adm-label">Cambiar PIN del panel</p>
<div class="adm-row">
<input class="adm-input" type="password" placeholder="PIN actual" maxlength="4" [(ngModel)]="curPin" />
<input class="adm-input" placeholder="PIN nuevo (4 díg.)" maxlength="4" [(ngModel)]="newPin" />
<button class="adm-btn" [disabled]="!curPin || newPin.length !== 4" (click)="savePin()">Cambiar</button>
</div>
@if (pinMsg()) { <p class="acc__ok">{{ pinMsg() }}</p> }
</div>
</main>
`,
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<string | null>(null);
protected readonly pinMsg = signal<string | null>(null);
ngOnInit(): void {
this.auth.loadMe().subscribe((me) => {
this.uiLanguage = me.uiLanguage;
this.defaultDyslexiaFont = me.defaultDyslexiaFont;
});
}
savePrefs(): void {
this.http
.put<void>('/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<void>('/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<void>('/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']);
}
}

View File

@@ -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;
}

View File

@@ -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: `
<main class="auth">
<section class="auth__card">
<h1 class="auth__title">recordaLexia 🦊</h1>
<p class="auth__sub">Entra con tu cuenta de familia</p>
<input class="auth__input" type="email" placeholder="Email" [(ngModel)]="email" autocomplete="username" />
<input class="auth__input" type="password" placeholder="Contraseña" [(ngModel)]="password"
autocomplete="current-password" (keyup.enter)="submit()" />
@if (error()) { <p class="auth__err">Email o contraseña incorrectos</p> }
<button class="auth__btn" [disabled]="loading() || !email || !password" (click)="submit()">
{{ loading() ? 'Entrando…' : 'Entrar' }}
</button>
<p class="auth__alt">¿No tienes cuenta? <a routerLink="/register">Crear una</a></p>
</section>
</main>
`,
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);
},
});
}
}

View File

@@ -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: `
<main class="auth">
<section class="auth__card">
<h1 class="auth__title">Crear familia 🦊</h1>
<p class="auth__sub">Una cuenta para toda la familia</p>
<input class="auth__input" placeholder="Nombre de la familia" [(ngModel)]="name" />
<input class="auth__input" type="email" placeholder="Email" [(ngModel)]="email" autocomplete="username" />
<input class="auth__input" type="password" placeholder="Contraseña (mín. 6)" [(ngModel)]="password"
autocomplete="new-password" />
<input class="auth__input" inputmode="numeric" maxlength="4" placeholder="PIN de padres (4 dígitos)"
[(ngModel)]="pin" />
@if (error()) { <p class="auth__err">{{ error() }}</p> }
<button class="auth__btn" [disabled]="loading() || !valid()" (click)="submit()">
{{ loading() ? 'Creando…' : 'Crear cuenta' }}
</button>
<p class="auth__alt">¿Ya tienes cuenta? <a routerLink="/login">Entrar</a></p>
</section>
</main>
`,
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<string | null>(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);
},
});
}
}

View File

@@ -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),

View File

@@ -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);

View File

@@ -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(['/']);
}
}

View File

@@ -29,6 +29,9 @@ import { RewardAdminView } from '../../core/models';
<label class="adm-chip">
<input type="checkbox" [(ngModel)]="ttsEnabled" (change)="saveSettings()" /> 🗣️ {{ i18n.t('readAloud') }}
</label>
<label class="adm-chip">
<input type="checkbox" [(ngModel)]="dyslexiaFont" (change)="saveSettings()" /> 🔤 OpenDyslexic
</label>
</div>
</div>
@@ -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();
}

View File

@@ -1,4 +1,8 @@
<main class="profiles">
<button type="button" class="profiles__account" (click)="openAccount()">
👨‍👩‍👧 {{ auth.family()?.name || 'Mi cuenta' }}
</button>
<h1 class="profiles__title">{{ i18n.t('whoEntersToday') }}</h1>
@if (loading()) {

View File

@@ -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;

View File

@@ -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<ChildSummary[]>([]);
@@ -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']);
}
}