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);
},
});
}
}