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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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])),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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: '' },
|
||||
];
|
||||
|
||||
20
frontend/src/app/core/auth.guard.ts
Normal file
20
frontend/src/app/core/auth.guard.ts
Normal 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']);
|
||||
};
|
||||
43
frontend/src/app/core/auth.interceptor.ts
Normal file
43
frontend/src/app/core/auth.interceptor.ts
Normal 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);
|
||||
}),
|
||||
);
|
||||
};
|
||||
92
frontend/src/app/core/auth.service.ts
Normal file
92
frontend/src/app/core/auth.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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).
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
};
|
||||
148
frontend/src/app/features/auth/account.component.ts
Normal file
148
frontend/src/app/features/auth/account.component.ts
Normal 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']);
|
||||
}
|
||||
}
|
||||
84
frontend/src/app/features/auth/auth.scss
Normal file
84
frontend/src/app/features/auth/auth.scss
Normal 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;
|
||||
}
|
||||
54
frontend/src/app/features/auth/login.component.ts
Normal file
54
frontend/src/app/features/auth/login.component.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
64
frontend/src/app/features/auth/register.component.ts
Normal file
64
frontend/src/app/features/auth/register.component.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(['/']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user