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).
@@ -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
@@ -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)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
488
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BIN
frontend/public/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/public/icons/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/public/icons/icon-152x152.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/public/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
frontend/public/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
frontend/public/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
frontend/public/icons/icon-72x72.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
frontend/public/icons/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
62
frontend/public/manifest.webmanifest
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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] },
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
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()">
|
<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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
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
@@ -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 {
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||