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

@@ -67,7 +67,8 @@
"maximumError": "8kB" "maximumError": "8kB"
} }
], ],
"outputHashing": "all" "outputHashing": "all",
"serviceWorker": "ngsw-config.json"
}, },
"development": { "development": {
"optimization": false, "optimization": false,

30
frontend/ngsw-config.json Normal file
View File

@@ -0,0 +1,30 @@
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.csr.html",
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/**/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
]
}
}
]
}

View File

@@ -15,6 +15,7 @@
"@angular/platform-browser": "^19.2.0", "@angular/platform-browser": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0", "@angular/platform-browser-dynamic": "^19.2.0",
"@angular/router": "^19.2.0", "@angular/router": "^19.2.0",
"@angular/service-worker": "^19.2.0",
"@fontsource/fredoka": "^5.2.10", "@fontsource/fredoka": "^5.2.10",
"@fontsource/nunito": "^5.2.7", "@fontsource/nunito": "^5.2.7",
"@fontsource/opendyslexic": "^5.2.5", "@fontsource/opendyslexic": "^5.2.5",
@@ -1185,6 +1186,25 @@
"rxjs": "^6.5.3 || ^7.4.0" "rxjs": "^6.5.3 || ^7.4.0"
} }
}, },
"node_modules/@angular/service-worker": {
"version": "19.2.25",
"resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-19.2.25.tgz",
"integrity": "sha512-NB4g5bA+f5Jx6UsLmEdrHUXLOQDtAjQqeM7JHRl7qXg/xESnNubNqM0VMKO1P5oqAjQ++5Vc1G3s59uOgHKnPw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"bin": {
"ngsw-config": "ngsw-config.js"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
},
"peerDependencies": {
"@angular/core": "19.2.25",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -14741,474 +14761,6 @@
} }
} }
}, },
"node_modules/vite/node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/esbuild": { "node_modules/vite/node_modules/esbuild": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",

View File

@@ -17,6 +17,7 @@
"@angular/platform-browser": "^19.2.0", "@angular/platform-browser": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0", "@angular/platform-browser-dynamic": "^19.2.0",
"@angular/router": "^19.2.0", "@angular/router": "^19.2.0",
"@angular/service-worker": "^19.2.0",
"@fontsource/fredoka": "^5.2.10", "@fontsource/fredoka": "^5.2.10",
"@fontsource/nunito": "^5.2.7", "@fontsource/nunito": "^5.2.7",
"@fontsource/opendyslexic": "^5.2.5", "@fontsource/opendyslexic": "^5.2.5",
@@ -37,4 +38,4 @@
"karma-jasmine-html-reporter": "~2.1.0", "karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.7.2" "typescript": "~5.7.2"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@@ -0,0 +1,62 @@
{
"name": "recordaLexia",
"short_name": "recordaLexia",
"description": "Rutinas visuales y material del cole para niños con TDAH",
"lang": "es",
"display": "standalone",
"orientation": "any",
"scope": "./",
"start_url": "./",
"theme_color": "#5B8DEF",
"background_color": "#EFF4F6",
"icons": [
{
"src": "icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
]
}

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 { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { authInterceptor } from './core/auth.interceptor'; import { authInterceptor } from './core/auth.interceptor';
import { provideServiceWorker } from '@angular/service-worker';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideZoneChangeDetection({ eventCoalescing: true }), provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes), provideRouter(routes),
// Cliente HTTP (fetch) con el interceptor de sesión de familia. // 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 { ParentsComponent } from './features/parents/parents.component';
import { LoginComponent } from './features/auth/login.component'; import { LoginComponent } from './features/auth/login.component';
import { RegisterComponent } from './features/auth/register.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 { AccountComponent } from './features/auth/account.component';
import { PrivacyComponent } from './features/legal/privacy.component';
import { authGuard, parentGuard } from './core/auth.guard'; import { authGuard, parentGuard } from './core/auth.guard';
export const routes: Routes = [ export const routes: Routes = [
// Públicas. // Públicas.
{ path: 'login', component: LoginComponent }, { path: 'login', component: LoginComponent },
{ path: 'register', component: RegisterComponent }, { path: 'register', component: RegisterComponent },
{ path: 'forgot', component: ForgotComponent },
{ path: 'reset', component: ResetComponent },
{ path: 'privacidad', component: PrivacyComponent },
// Requieren sesión de familia. // Requieren sesión de familia.
{ path: '', component: ProfileSelectComponent, canActivate: [authGuard] }, { path: '', component: ProfileSelectComponent, canActivate: [authGuard] },

View File

@@ -24,13 +24,27 @@ export class AuthService {
return this.sessionSignal(); return this.sessionSignal();
} }
/** Alta de familia (auto-login). */ /** Alta de familia (auto-login). Exige aceptar la política de privacidad (RGPD). */
register(email: string, password: string, name: string, pin: string): Observable<{ session: string }> { register(email: string, password: string, name: string, pin: string,
privacyAccepted: boolean): Observable<{ session: string }> {
return this.http 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))); .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. */ /** Acceso con email + contraseña. */
login(email: string, password: string): Observable<{ session: string }> { login(email: string, password: string): Observable<{ session: string }> {
return this.http return this.http

View File

@@ -72,6 +72,31 @@
cursor: default; 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 { .auth__alt {
margin: var(--space-2) 0 0; margin: var(--space-2) 0 0;
color: var(--text-2); 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()"> <button class="auth__btn" [disabled]="loading() || !email || !password" (click)="submit()">
{{ loading() ? 'Entrando…' : 'Entrar' }} {{ loading() ? 'Entrando…' : 'Entrar' }}
</button> </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> <p class="auth__alt">¿No tienes cuenta? <a routerLink="/register">Crear una</a></p>
</section> </section>
</main> </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)" <input class="auth__input" inputmode="numeric" maxlength="4" placeholder="PIN de padres (4 dígitos)"
[(ngModel)]="pin" /> [(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> } @if (error()) { <p class="auth__err">{{ error() }}</p> }
<button class="auth__btn" [disabled]="loading() || !valid()" (click)="submit()"> <button class="auth__btn" [disabled]="loading() || !valid()" (click)="submit()">
@@ -40,11 +46,13 @@ export class RegisterComponent {
protected email = ''; protected email = '';
protected password = ''; protected password = '';
protected pin = ''; protected pin = '';
protected privacyAccepted = false;
protected readonly loading = signal(false); protected readonly loading = signal(false);
protected readonly error = signal<string | null>(null); protected readonly error = signal<string | null>(null);
valid(): boolean { 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 { submit(): void {
@@ -53,7 +61,8 @@ export class RegisterComponent {
} }
this.loading.set(true); this.loading.set(true);
this.error.set(null); 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(['/'])), next: () => this.auth.loadMe().subscribe(() => this.router.navigate(['/'])),
error: (e: HttpErrorResponse) => { error: (e: HttpErrorResponse) => {
this.error.set(e.status === 409 ? 'Ese email ya está registrado' : 'No se pudo crear la cuenta'); 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="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="manifest" href="manifest.webmanifest">
</head> </head>
<body> <body>
<app-root></app-root> <app-root></app-root>
<noscript>Please enable JavaScript to continue using this application.</noscript>
</body> </body>
</html> </html>