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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user