feat(saas): PWA instalable y pantallas de recuperación y privacidad

Bloque B de la fase SaaS:
- @angular/pwa: service worker (solo assets; /api siempre fresco), manifest
  con identidad recordaLexia (standalone, tema de la paleta) e iconos
  propios 72-512px (mascota sobre azul de marca).
- Pantallas /forgot y /reset (?code=... del email): mensajes neutros que
  no revelan si una cuenta existe; validación de contraseña repetida.
- Política de privacidad ES/CA en /privacidad (pública) y casilla de
  consentimiento obligatoria en el registro (no premarcada).
- AuthService: forgotPassword/resetPassword y registro con privacyAccepted.
Verificado E2E en navegador: registro con gating del checkbox, enlace del
log, reset, login con la nueva contraseña (la vieja 401), SW 'activated',
y familia de prueba borrada con el endpoint RGPD (dogfooding).
This commit is contained in:
Jaume Garriga Maestre
2026-07-13 22:27:29 +02:00
parent 203d9b0b47
commit ddd1a96af9
23 changed files with 409 additions and 477 deletions

View File

@@ -1,15 +1,19 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { ApplicationConfig, provideZoneChangeDetection, isDevMode } from '@angular/core';
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { authInterceptor } from './core/auth.interceptor';
import { provideServiceWorker } from '@angular/service-worker';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
// Cliente HTTP (fetch) con el interceptor de sesión de familia.
provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
provideHttpClient(withFetch(), withInterceptors([authInterceptor])), provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000'
}),
],
};

View File

@@ -6,13 +6,19 @@ import { KeypadComponent } from './features/parents/keypad.component';
import { ParentsComponent } from './features/parents/parents.component';
import { LoginComponent } from './features/auth/login.component';
import { RegisterComponent } from './features/auth/register.component';
import { ForgotComponent } from './features/auth/forgot.component';
import { ResetComponent } from './features/auth/reset.component';
import { AccountComponent } from './features/auth/account.component';
import { PrivacyComponent } from './features/legal/privacy.component';
import { authGuard, parentGuard } from './core/auth.guard';
export const routes: Routes = [
// Públicas.
{ path: 'login', component: LoginComponent },
{ path: 'register', component: RegisterComponent },
{ path: 'forgot', component: ForgotComponent },
{ path: 'reset', component: ResetComponent },
{ path: 'privacidad', component: PrivacyComponent },
// Requieren sesión de familia.
{ path: '', component: ProfileSelectComponent, canActivate: [authGuard] },

View File

@@ -24,13 +24,27 @@ export class AuthService {
return this.sessionSignal();
}
/** Alta de familia (auto-login). */
register(email: string, password: string, name: string, pin: string): Observable<{ session: string }> {
/** Alta de familia (auto-login). Exige aceptar la política de privacidad (RGPD). */
register(email: string, password: string, name: string, pin: string,
privacyAccepted: boolean): Observable<{ session: string }> {
return this.http
.post<{ session: string }>('/api/auth/register', { email, password, name, pin })
.post<{ session: string }>('/api/auth/register', { email, password, name, pin, privacyAccepted })
.pipe(tap((res) => this.store(res.session)));
}
/** Solicita el email de recuperación. La respuesta es neutra a propósito. */
forgotPassword(email: string): Observable<unknown> {
return this.http.post('/api/auth/forgot-password', { email });
}
/** Restablece la contraseña con el código del enlace del email. */
resetPassword(code: string, nueva: string): Observable<void> {
// El DTO del backend espera la clave "newPassword".
const body: Record<string, string> = { code };
body['newPassword'] = nueva;
return this.http.post<void>('/api/auth/reset-password', body);
}
/** Acceso con email + contraseña. */
login(email: string, password: string): Observable<{ session: string }> {
return this.http

View File

@@ -72,6 +72,31 @@
cursor: default;
}
// Consentimiento RGPD: legible y tocable, casilla nunca premarcada.
.auth__consent {
display: flex;
align-items: flex-start;
gap: 10px;
text-align: left;
font-size: 0.9rem;
color: var(--text-1);
cursor: pointer;
input[type='checkbox'] {
width: 22px;
height: 22px;
flex: none;
accent-color: var(--accent-blue);
cursor: pointer;
}
a {
color: var(--accent-blue);
font-weight: 700;
text-decoration: none;
}
}
.auth__alt {
margin: var(--space-2) 0 0;
color: var(--text-2);

View File

@@ -0,0 +1,57 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { AuthService } from '../../core/auth.service';
/**
* Solicitud de recuperación de contraseña. El mensaje de confirmación es neutro
* a propósito (no revela si el email existe) y se muestra SIEMPRE, incluso si el
* backend limita por exceso de intentos: nada que un atacante pueda leer.
*/
@Component({
selector: 'app-forgot',
imports: [FormsModule, RouterLink],
template: `
<main class="auth">
<section class="auth__card">
<h1 class="auth__title">¿Contraseña olvidada? 🔑</h1>
@if (sent()) {
<p class="auth__sub">
Si el email existe, recibirás un enlace en unos minutos.
Revisa también la carpeta de spam.
</p>
} @else {
<p class="auth__sub">Te enviaremos un enlace para crear una nueva</p>
<input class="auth__input" type="email" placeholder="Email" [(ngModel)]="email"
autocomplete="username" (keyup.enter)="submit()" />
<button class="auth__btn" [disabled]="loading() || !email" (click)="submit()">
{{ loading() ? 'Enviando…' : 'Enviar enlace' }}
</button>
}
<p class="auth__alt"><a routerLink="/login">‹ Volver a entrar</a></p>
</section>
</main>
`,
styleUrl: './auth.scss',
})
export class ForgotComponent {
private readonly auth = inject(AuthService);
protected email = '';
protected readonly loading = signal(false);
protected readonly sent = signal(false);
submit(): void {
if (!this.email) {
return;
}
this.loading.set(true);
this.auth.forgotPassword(this.email.trim()).subscribe({
next: () => this.sent.set(true),
// También en error mostramos el mensaje neutro: no filtramos nada.
error: () => this.sent.set(true),
});
}
}

View File

@@ -22,6 +22,7 @@ import { AuthService } from '../../core/auth.service';
<button class="auth__btn" [disabled]="loading() || !email || !password" (click)="submit()">
{{ loading() ? 'Entrando…' : 'Entrar' }}
</button>
<p class="auth__alt"><a routerLink="/forgot">¿Has olvidado la contraseña?</a></p>
<p class="auth__alt">¿No tienes cuenta? <a routerLink="/register">Crear una</a></p>
</section>
</main>

View File

@@ -21,6 +21,12 @@ import { AuthService } from '../../core/auth.service';
<input class="auth__input" inputmode="numeric" maxlength="4" placeholder="PIN de padres (4 dígitos)"
[(ngModel)]="pin" />
<!-- RGPD: casilla NO premarcada; sin aceptación no hay cuenta. -->
<label class="auth__consent">
<input type="checkbox" [(ngModel)]="privacyAccepted" />
<span>He leído y acepto la <a routerLink="/privacidad">política de privacidad</a></span>
</label>
@if (error()) { <p class="auth__err">{{ error() }}</p> }
<button class="auth__btn" [disabled]="loading() || !valid()" (click)="submit()">
@@ -40,11 +46,13 @@ export class RegisterComponent {
protected email = '';
protected password = '';
protected pin = '';
protected privacyAccepted = false;
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;
return !!this.email && this.password.length >= 6 && /^\d{4}$/.test(this.pin) && !!this.name
&& this.privacyAccepted;
}
submit(): void {
@@ -53,7 +61,8 @@ export class RegisterComponent {
}
this.loading.set(true);
this.error.set(null);
this.auth.register(this.email.trim(), this.password, this.name.trim(), this.pin).subscribe({
this.auth.register(this.email.trim(), this.password, this.name.trim(), this.pin,
this.privacyAccepted).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');

View File

@@ -0,0 +1,80 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { AuthService } from '../../core/auth.service';
/**
* Restablecimiento de contraseña: llega desde el enlace del email con el código
* de un solo uso en la query (?code=...). Los errores son genéricos — el backend
* no distingue caducado/usado/inexistente y aquí tampoco.
*/
@Component({
selector: 'app-reset',
imports: [FormsModule, RouterLink],
template: `
<main class="auth">
<section class="auth__card">
<h1 class="auth__title">Nueva contraseña 🔒</h1>
@if (done()) {
<p class="auth__sub">¡Listo! Ya puedes entrar con tu nueva contraseña.</p>
<button class="auth__btn" routerLink="/login">Ir a entrar</button>
} @else if (!code) {
<p class="auth__err">Este enlace no es válido. Pide uno nuevo.</p>
<p class="auth__alt"><a routerLink="/forgot">Pedir otro enlace</a></p>
} @else {
<p class="auth__sub">Elige una contraseña nueva (mínimo 6 caracteres)</p>
<input class="auth__input" type="password" placeholder="Nueva contraseña"
[(ngModel)]="nueva" autocomplete="new-password" />
<input class="auth__input" type="password" placeholder="Repítela"
[(ngModel)]="repetida" autocomplete="new-password" (keyup.enter)="submit()" />
@if (distintas()) { <p class="auth__err">Las contraseñas no coinciden</p> }
@if (error()) { <p class="auth__err">El enlace no es válido o ha caducado. Pide uno nuevo.</p> }
<button class="auth__btn" [disabled]="loading() || !valida()" (click)="submit()">
{{ loading() ? 'Guardando…' : 'Guardar contraseña' }}
</button>
<p class="auth__alt"><a routerLink="/login">‹ Volver a entrar</a></p>
}
</section>
</main>
`,
styleUrl: './auth.scss',
})
export class ResetComponent {
private readonly auth = inject(AuthService);
private readonly router = inject(Router);
/** Código de un solo uso que viaja en el enlace del email. */
protected readonly code = inject(ActivatedRoute).snapshot.queryParamMap.get('code');
protected nueva = '';
protected repetida = '';
protected readonly loading = signal(false);
protected readonly error = signal(false);
protected readonly done = signal(false);
protected distintas(): boolean {
return this.repetida.length > 0 && this.nueva !== this.repetida;
}
protected valida(): boolean {
return this.nueva.length >= 6 && this.nueva === this.repetida;
}
submit(): void {
if (!this.code || !this.valida()) {
return;
}
this.loading.set(true);
this.error.set(false);
this.auth.resetPassword(this.code, this.nueva).subscribe({
next: () => this.done.set(true),
error: () => {
this.error.set(true);
this.loading.set(false);
},
});
}
}

View File

@@ -0,0 +1,88 @@
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
/**
* Política de privacidad (RGPD). Pública, sin sesión, bilingüe ES/CA en la misma
* página (texto legal: mejor ambas versiones visibles que un conmutador).
*/
@Component({
selector: 'app-privacy',
imports: [RouterLink],
template: `
<main class="privacy">
<article class="privacy__card">
<h1>Política de privacidad · Política de privacitat</h1>
<section>
<h2>🇪🇸 Español</h2>
<p><strong>Qué guardamos.</strong> Lo mínimo para que la app funcione: el email y
el nombre de la cuenta de familia, y de cada niño solo su nombre de pila, edad,
una mascota y un color. No pedimos apellidos, ni fotos, ni datos de salud, ni
ubicación. Las tareas, rutinas y monedas que se generan al usar la app.</p>
<p><strong>Para qué.</strong> Únicamente para mostrar a cada familia sus rutinas.
No usamos los datos para publicidad, no los analizamos con fines comerciales y
no los cedemos a nadie.</p>
<p><strong>Dónde.</strong> En un servidor gestionado por el responsable de la
aplicación, con copias de seguridad cifradas. Las contraseñas y el PIN se
guardan con hash seguro: nadie puede leerlos, ni siquiera nosotros.</p>
<p><strong>Tus derechos.</strong> Puedes borrar la cuenta desde
Cuenta → Borrar cuenta: se elimina TODO (niños, tareas, historial) de forma
inmediata e irreversible. Para cualquier otra petición sobre tus datos,
escríbenos al correo del pie de la aplicación.</p>
<p><strong>Menores.</strong> La cuenta la crea y administra siempre una persona
adulta. Los perfiles de los niños no contienen datos identificativos más allá
del nombre de pila que su familia decida.</p>
</section>
<section>
<h2>🏴 Català</h2>
<p><strong>Què guardem.</strong> El mínim perquè l'app funcioni: l'email i el nom
del compte de família, i de cada infant només el nom de pila, l'edat, una
mascota i un color. No demanem cognoms, ni fotos, ni dades de salut, ni
ubicació. Les tasques, rutines i monedes que es generen en fer servir l'app.</p>
<p><strong>Per a què.</strong> Únicament per mostrar a cada família les seves
rutines. No fem servir les dades per a publicitat, no les analitzem amb fins
comercials i no les cedim a ningú.</p>
<p><strong>On.</strong> En un servidor gestionat pel responsable de l'aplicació,
amb còpies de seguretat xifrades. Les contrasenyes i el PIN es guarden amb hash
segur: ningú no els pot llegir, ni tan sols nosaltres.</p>
<p><strong>Els teus drets.</strong> Pots esborrar el compte des de
Compte → Esborrar compte: s'elimina TOT (infants, tasques, historial) de manera
immediata i irreversible. Per a qualsevol altra petició sobre les teves dades,
escriu-nos al correu del peu de l'aplicació.</p>
<p><strong>Menors.</strong> El compte el crea i l'administra sempre una persona
adulta. Els perfils dels infants no contenen dades identificatives més enllà
del nom de pila que la seva família decideixi.</p>
</section>
<p class="privacy__back"><a routerLink="/register">‹ Volver · Tornar</a></p>
</article>
</main>
`,
styles: [
`
:host { display: block; }
.privacy {
min-height: 100vh;
display: flex;
justify-content: center;
padding: var(--space-5);
}
.privacy__card {
max-width: 720px;
background: var(--surface);
border: 1px solid var(--border-1);
border-radius: var(--radius-card);
padding: var(--space-6);
box-shadow: var(--shadow-card);
}
h1 { font-size: 1.5rem; margin-top: 0; }
h2 { font-size: 1.15rem; color: var(--accent-blue); }
p { line-height: 1.6; color: var(--text-1); }
strong { color: var(--text-strong); }
.privacy__back a { color: var(--accent-blue); font-weight: 700; text-decoration: none; }
`,
],
})
export class PrivacyComponent {
}

View File

@@ -9,8 +9,10 @@
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="manifest" href="manifest.webmanifest">
</head>
<body>
<app-root></app-root>
<noscript>Please enable JavaScript to continue using this application.</noscript>
</body>
</html>