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:
@@ -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);
|
||||
|
||||
57
frontend/src/app/features/auth/forgot.component.ts
Normal file
57
frontend/src/app/features/auth/forgot.component.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
80
frontend/src/app/features/auth/reset.component.ts
Normal file
80
frontend/src/app/features/auth/reset.component.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
88
frontend/src/app/features/legal/privacy.component.ts
Normal file
88
frontend/src/app/features/legal/privacy.component.ts
Normal 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 {
|
||||
}
|
||||
Reference in New Issue
Block a user