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

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