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

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

View File

@@ -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: '<router-outlet />',
})
export class AppComponent {
// Inyectar el servicio fuerza su inicialización: aplica la preferencia de
// tipografía (OpenDyslexic por defecto) sobre <html> al arrancar la app.
private readonly fontPreference = inject(FontPreferenceService);
}
export class AppComponent {}

View File

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

View File

@@ -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: '' },
];

View File

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

View File

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

View File

@@ -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<string | null>(this.read());
readonly family = signal<MeResponse | null>(null);
/** El desbloqueo del panel vive solo en memoria: al recargar se vuelve a pedir el PIN. */
readonly panelUnlocked = signal<boolean>(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<MeResponse> {
return this.http.get<MeResponse>('/api/auth/me').pipe(tap((me) => this.family.set(me)));
}
/** Desbloquea el panel de padres validando el PIN. */
unlockPanel(pin: string): Observable<void> {
return this.http
.post<void>('/api/parents/unlock', { pin })
.pipe(tap(() => this.panelUnlocked.set(true)));
}
/** Cierra sesión en el dispositivo. */
logout(): Observable<void> {
const obs = this.http.post<void>('/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;
}
}
}

View File

@@ -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 <html> (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 <html>, 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<boolean>(this.readInitialState());
/** ¿Está activada OpenDyslexic ahora mismo? (para la UI que quiera mostrarlo). */
readonly enabled = signal<boolean>(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 <html data-dyslexia-font="on|off">. */
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';
}
}

View File

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

View File

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

View File

@@ -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<string | null>(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).
}
}
}

View File

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

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']);
}
}