feat: vínculo con usuario Monday en landing + alta prellenada desde leads

API:
- GET /api/monday/usuarios: usuarios activos no-guest del workspace
- PATCH /api/usuarios/me/monday: vincula/desvincula el mondayUserId del
  usuario autenticado (valida contra el workspace)
- GET /api/leads/buscar?q=: búsqueda de leads por nombre/teléfono/email
  (sin acentos, orden updatedAt desc, límite 15)
- aPublico/me ahora incluyen mondayUserId

Landing (/):
- Nueva sección 'Configuración de cuenta': login inline, selector del
  usuario de Monday, guardar/desvincular, cerrar sesión

Alta de clientes (/client):
- Búsqueda de leads de Monday con debounce en la pantalla de entrada;
  al elegir uno prellena nombre/email/celular y guarda leadMondayId
- El picker (borradores + alta nueva + búsqueda de lead) es ahora
  siempre la pantalla de entrada

Tests: api 294, landing 8, alta-clientes 96 — todos verdes
This commit is contained in:
2026-08-28 18:36:08 +00:00
parent d99d90d344
commit 1692dde887
28 changed files with 2501 additions and 41 deletions

View File

@@ -14,10 +14,13 @@
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.3",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^2.1.8",
"jsdom": "^26.1.0",
"tailwindcss": "^4.0.0",
"typescript": "~5.6.3",
"vite": "^6.0.7",
@@ -38,6 +41,27 @@
"node": ">=6.0.0"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^2.1.3",
"@csstools/css-color-parser": "^3.0.9",
"@csstools/css-parser-algorithms": "^3.0.4",
"@csstools/css-tokenizer": "^3.0.3",
"lru-cache": "^10.4.3"
}
},
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -272,6 +296,16 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -327,6 +361,121 @@
"dev": true,
"license": "MIT"
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=18"
}
},
"node_modules/@csstools/css-calc": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^5.1.0",
"@csstools/css-calc": "^2.1.4"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -1513,6 +1662,61 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/react": {
"version": "16.3.3",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
"integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@testing-library/dom": "^10.0.0",
"@types/react": "^18.0.0 || ^19.0.0",
"@types/react-dom": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1725,6 +1929,16 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ansi-regex": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
@@ -1751,6 +1965,16 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1931,6 +2155,20 @@
"node": ">= 8"
}
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^3.2.0",
"rrweb-cssom": "^0.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1938,6 +2176,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/data-urls": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1956,6 +2208,13 @@
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@@ -1966,6 +2225,16 @@
"node": ">=6"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1976,6 +2245,13 @@
"node": ">=8"
}
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -2011,6 +2287,19 @@
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
@@ -2222,6 +2511,19 @@
"node": ">=8"
}
},
"node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-encoding": "^3.1.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -2229,6 +2531,47 @@
"dev": true,
"license": "MIT"
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@@ -2239,6 +2582,13 @@
"node": ">=8"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -2333,6 +2683,46 @@
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
"decimal.js": "^10.5.0",
"html-encoding-sniffer": "^4.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1",
"nwsapi": "^2.2.16",
"parse5": "^7.2.1",
"rrweb-cssom": "^0.8.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^5.1.1",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^7.0.0",
"whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.1.1",
"ws": "^8.18.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -2637,6 +3027,16 @@
"yallist": "^3.0.2"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -2750,6 +3150,13 @@
"node": ">=18"
}
},
"node_modules/nwsapi": {
"version": "2.2.25",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.25.tgz",
"integrity": "sha512-kktSgNDIbyEIs/LrE6dtEMnfzxSDpXViFl6c4gFjz/CgtnL4GDCR3wyZXzjvAXsNB8dXU4dAfvIOgLMH2/vwLg==",
"dev": true,
"license": "MIT"
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -2757,6 +3164,19 @@
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -2857,6 +3277,54 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/pretty-format/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pretty-format/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
@@ -2878,6 +3346,13 @@
"react": "^19.2.8"
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -2934,6 +3409,33 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rrweb-cssom": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"dev": true,
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -3134,6 +3636,13 @@
"node": ">=8"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
@@ -3231,6 +3740,52 @@
"node": ">=14.0.0"
}
},
"node_modules/tldts": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^6.1.32"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/typescript": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
@@ -4447,6 +5002,67 @@
}
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -4578,6 +5194,45 @@
"node": ">=8"
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -17,10 +17,13 @@
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.3",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^2.1.8",
"jsdom": "^26.1.0",
"tailwindcss": "^4.0.0",
"typescript": "~5.6.3",
"vite": "^6.0.7",

View File

@@ -1,5 +1,6 @@
import { MODULOS } from './data/modulos'
import { TarjetaModulo } from './components/TarjetaModulo'
import { ConfiguracionCuenta } from './components/ConfiguracionCuenta'
export default function App() {
return (
@@ -19,6 +20,8 @@ export default function App() {
))}
</ul>
</nav>
<ConfiguracionCuenta />
</main>
<footer className="px-6 pt-24 pb-10 sm:px-12 lg:px-24">

View File

@@ -0,0 +1,134 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { ConfiguracionCuenta } from './ConfiguracionCuenta'
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
/** Stub sencillo de localStorage (jsdom no lo expone como global con vitest 2) */
function crearStorageStub(): Storage {
const mapa = new Map<string, string>()
return {
getItem: (k: string) => mapa.get(k) ?? null,
setItem: (k: string, v: string) => void mapa.set(k, v),
removeItem: (k: string) => void mapa.delete(k),
clear: () => mapa.clear(),
key: () => null,
get length() {
return mapa.size
},
} as Storage
}
const USUARIOS_MONDAY = [
{ id: 13676, nombre: 'Erick Peña', email: 'erick@frasa.mx' },
{ id: 20400, nombre: 'Cristina Barrera', email: 'cristina@frasa.mx' },
]
const sesionFake = {
token: 'jwt-de-prueba',
usuario: { nombre: 'ERICK ISRAEL PEÑA RAMOS', rol: 'vendedor', asignacion: 'MONTEBELLO', mondayUserId: null },
}
const respuestaOk = (data: unknown) =>
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
beforeEach(() => {
fetchMock = vi.fn<FetchFn>()
vi.stubGlobal('fetch', fetchMock)
vi.stubGlobal('localStorage', crearStorageStub())
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe('ConfiguracionCuenta sin sesión', () => {
it('muestra el formulario de login y no llama al API', () => {
render(<ConfiguracionCuenta />)
expect(screen.getByRole('heading', { name: 'Configuración de cuenta' })).toBeTruthy()
expect(screen.getByLabelText('Usuario')).toBeTruthy()
expect(screen.getByLabelText('Contraseña')).toBeTruthy()
expect(fetchMock).not.toHaveBeenCalled()
})
it('login fallido muestra el mensaje de error', async () => {
fetchMock.mockResolvedValue(new Response('{}', { status: 401 }))
render(<ConfiguracionCuenta />)
fireEvent.change(screen.getByLabelText('Usuario'), { target: { value: 'nadie' } })
fireEvent.change(screen.getByLabelText('Contraseña'), { target: { value: 'mala1' } })
fireEvent.click(screen.getByRole('button', { name: 'Entrar' }))
expect((await screen.findByRole('alert')).textContent).toContain('Usuario o contraseña incorrectos')
})
})
describe('ConfiguracionCuenta con sesión', () => {
beforeEach(() => {
localStorage.setItem('frasa_auth', JSON.stringify(sesionFake))
})
it('muestra el perfil, el dropdown de usuarios Monday y guarda el vínculo con PATCH', async () => {
fetchMock.mockImplementation((input, init) => {
const url = String(input)
if (url.endsWith('/auth/me')) return Promise.resolve(respuestaOk(sesionFake.usuario))
if (url.endsWith('/monday/usuarios')) return Promise.resolve(respuestaOk(USUARIOS_MONDAY))
if (url.endsWith('/usuarios/me/monday') && init?.method === 'PATCH') {
return Promise.resolve(respuestaOk({ ...sesionFake.usuario, mondayUserId: 13676 }))
}
return Promise.reject(new Error(`fetch inesperado: ${url}`))
})
render(<ConfiguracionCuenta />)
// Perfil y dropdown cargados
expect(await screen.findByText('ERICK ISRAEL PEÑA RAMOS')).toBeTruthy()
const dropdown = await screen.findByLabelText('Tu usuario de Monday')
expect(screen.getByText('sin vincular')).toBeTruthy()
fireEvent.change(dropdown, { target: { value: '13676' } })
fireEvent.click(screen.getByRole('button', { name: 'Guardar' }))
await waitFor(() => {
const llamadaPatch = fetchMock.mock.calls.find(
([url, init]) => String(url).endsWith('/usuarios/me/monday') && init?.method === 'PATCH',
)
expect(llamadaPatch).toBeTruthy()
expect(JSON.parse(String(llamadaPatch?.[1]?.body))).toEqual({ mondayUserId: 13676 })
})
expect((await screen.findByRole('status')).textContent).toContain('Vínculo con Monday guardado')
// El PATCH fue con Bearer
const [, initPatch] = fetchMock.mock.calls.find(
([url, init]) => String(url).endsWith('/usuarios/me/monday') && init?.method === 'PATCH',
) as [string, RequestInit]
expect((initPatch.headers as Record<string, string>)['Authorization']).toBe('Bearer jwt-de-prueba')
})
it('si falla /monday/usuarios muestra error reintentable y el reintento carga la lista', async () => {
let intentos = 0
fetchMock.mockImplementation((input) => {
const url = String(input)
if (url.endsWith('/auth/me')) return Promise.resolve(respuestaOk(sesionFake.usuario))
if (url.endsWith('/monday/usuarios')) {
intentos += 1
return intentos === 1
? Promise.resolve(new Response('{}', { status: 500 }))
: Promise.resolve(respuestaOk(USUARIOS_MONDAY))
}
return Promise.reject(new Error(`fetch inesperado: ${url}`))
})
render(<ConfiguracionCuenta />)
expect(
await screen.findByText('No se pudo cargar la lista de usuarios de Monday.'),
).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Reintentar' }))
expect(await screen.findByLabelText('Tu usuario de Monday')).toBeTruthy()
expect(intentos).toBe(2)
})
})

View File

@@ -0,0 +1,251 @@
import { useEffect, useState, type FormEvent } from 'react'
import { ErrorLogin, getSesion, login, logout, type UsuarioSesion } from '../services/auth'
import { getMe, getUsuariosMonday, guardarVinculoMonday, type UsuarioMonday } from '../services/api'
const labelEstilo = 'block text-xs font-bold tracking-widest text-neutral-500 uppercase'
const inputEstilo =
'mt-2 w-full border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-neutral-900'
/**
* Sección "Configuración de cuenta": login inline y, con sesión, el vínculo
* con el usuario de Monday (alimenta round-robin, mis-leads y alertas).
*/
export function ConfiguracionCuenta() {
const [conSesion, setConSesion] = useState(() => getSesion() !== null)
// ---------- Login ----------
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [entrando, setEntrando] = useState(false)
const [errorLogin, setErrorLogin] = useState('')
// ---------- Perfil + usuarios Monday ----------
const [perfil, setPerfil] = useState<UsuarioSesion | null>(null)
const [cargandoPerfil, setCargandoPerfil] = useState(false)
const [usuarios, setUsuarios] = useState<UsuarioMonday[] | null>(null)
const [errorUsuarios, setErrorUsuarios] = useState('')
const [seleccion, setSeleccion] = useState('')
const [guardando, setGuardando] = useState(false)
const [aviso, setAviso] = useState('')
const [errorGuardar, setErrorGuardar] = useState('')
useEffect(() => {
if (!conSesion) return
setCargandoPerfil(true)
getMe()
.then((u) => {
setPerfil(u)
setSeleccion(u.mondayUserId !== null ? String(u.mondayUserId) : '')
})
.catch(() => setErrorGuardar('No se pudo cargar tu perfil.'))
.finally(() => setCargandoPerfil(false))
cargarUsuarios()
}, [conSesion])
const cargarUsuarios = () => {
setErrorUsuarios('')
getUsuariosMonday()
.then(setUsuarios)
.catch(() => {
setUsuarios(null)
setErrorUsuarios('No se pudo cargar la lista de usuarios de Monday.')
})
}
const entrar = async (e: FormEvent) => {
e.preventDefault()
setEntrando(true)
setErrorLogin('')
try {
await login(username, password)
setUsername('')
setPassword('')
setConSesion(true)
} catch (err) {
setErrorLogin(err instanceof ErrorLogin ? err.message : 'No se pudo iniciar sesión')
} finally {
setEntrando(false)
}
}
const guardar = async (mondayUserId: number | null) => {
setGuardando(true)
setAviso('')
setErrorGuardar('')
try {
const actualizado = await guardarVinculoMonday(mondayUserId)
setPerfil(actualizado)
setSeleccion(actualizado.mondayUserId !== null ? String(actualizado.mondayUserId) : '')
setAviso(mondayUserId === null ? 'Vínculo con Monday eliminado' : 'Vínculo con Monday guardado')
} catch {
setErrorGuardar('No se pudo guardar el vínculo. Intenta de nuevo.')
} finally {
setGuardando(false)
}
}
const cerrarSesion = () => {
logout()
setPerfil(null)
setUsuarios(null)
setConSesion(false)
}
const vinculado =
perfil?.mondayUserId != null ? (usuarios?.find((u) => u.id === perfil.mondayUserId) ?? null) : null
return (
<section aria-labelledby="titulo-cuenta" className="mt-24 border-t border-neutral-200 pt-16">
<h2 id="titulo-cuenta" className="text-xs font-bold tracking-widest text-accent uppercase">
Configuración de cuenta
</h2>
{!conSesion ? (
<form onSubmit={(e) => void entrar(e)} className="mt-8 max-w-sm">
<p className="text-sm leading-relaxed text-neutral-500">
Inicia sesión para vincular tu usuario de Monday.
</p>
<div className="mt-6">
<label htmlFor="cuenta-username" className={labelEstilo}>
Usuario
</label>
<input
id="cuenta-username"
type="text"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className={inputEstilo}
required
/>
</div>
<div className="mt-4">
<label htmlFor="cuenta-password" className={labelEstilo}>
Contraseña
</label>
<input
id="cuenta-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={inputEstilo}
required
/>
</div>
{errorLogin && (
<p role="alert" className="mt-4 text-xs text-red-700">
{errorLogin}
</p>
)}
<button
type="submit"
disabled={entrando}
className="mt-6 bg-accent px-5 py-2.5 text-xs font-bold tracking-widest text-white uppercase transition-colors duration-150 hover:bg-neutral-900 disabled:opacity-50"
>
{entrando ? 'Entrando…' : 'Entrar'}
</button>
</form>
) : (
<div className="mt-8 max-w-md">
{cargandoPerfil && <p className="text-sm text-neutral-500">Cargando tu perfil…</p>}
{perfil && (
<>
<p className="text-sm text-neutral-900">
Sesión: <span className="font-medium">{perfil.nombre}</span>
</p>
<p className="mt-2 text-sm text-neutral-500">
Vínculo Monday actual:{' '}
{perfil.mondayUserId === null ? (
<span className="text-neutral-400">sin vincular</span>
) : vinculado ? (
<span className="text-neutral-900">
{vinculado.nombre} ({vinculado.email})
</span>
) : (
<span className="text-neutral-900">ID {perfil.mondayUserId}</span>
)}
</p>
{errorUsuarios ? (
<div className="mt-6">
<p role="alert" className="text-xs text-red-700">
{errorUsuarios}
</p>
<button
type="button"
onClick={cargarUsuarios}
className="mt-3 border border-neutral-900 bg-transparent px-4 py-2 text-xs font-bold tracking-widest text-neutral-900 uppercase transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
>
Reintentar
</button>
</div>
) : usuarios === null ? (
<p className="mt-6 text-sm text-neutral-500">Cargando usuarios de Monday…</p>
) : (
<div className="mt-6">
<label htmlFor="cuenta-monday" className={labelEstilo}>
Tu usuario de Monday
</label>
<select
id="cuenta-monday"
value={seleccion}
onChange={(e) => setSeleccion(e.target.value)}
className={inputEstilo}
>
<option value="">— Sin vincular —</option>
{usuarios.map((u) => (
<option key={u.id} value={String(u.id)}>
{u.nombre} ({u.email})
</option>
))}
</select>
<div className="mt-4 flex flex-wrap gap-3">
<button
type="button"
disabled={guardando}
onClick={() => void guardar(seleccion === '' ? null : Number(seleccion))}
className="bg-accent px-5 py-2.5 text-xs font-bold tracking-widest text-white uppercase transition-colors duration-150 hover:bg-neutral-900 disabled:opacity-50"
>
{guardando ? 'Guardando…' : 'Guardar'}
</button>
{perfil.mondayUserId !== null && (
<button
type="button"
disabled={guardando}
onClick={() => void guardar(null)}
className="border border-red-300 bg-white px-5 py-2.5 text-xs font-bold tracking-widest text-red-700 uppercase transition-colors duration-150 hover:bg-red-50 disabled:opacity-50"
>
Desvincular
</button>
)}
</div>
</div>
)}
{aviso && (
<p role="status" className="mt-4 text-xs font-bold tracking-widest text-accent uppercase">
{aviso}
</p>
)}
{errorGuardar && (
<p role="alert" className="mt-4 text-xs text-red-700">
{errorGuardar}
</p>
)}
</>
)}
<button
type="button"
onClick={cerrarSesion}
className="mt-8 border border-neutral-900 bg-transparent px-4 py-2 text-xs font-bold tracking-widest text-neutral-900 uppercase transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
>
Cerrar sesión
</button>
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,38 @@
import { API_URL, conAuth, manejar401, type UsuarioSesion } from './auth'
export interface UsuarioMonday {
id: number
nombre: string
email: string
}
/** GET /auth/me — perfil del usuario autenticado (incluye mondayUserId) */
export async function getMe(): Promise<UsuarioSesion> {
const resp = await fetch(`${API_URL}/auth/me`, conAuth())
manejar401(resp)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
return (await resp.json()) as UsuarioSesion
}
/** GET /monday/usuarios — usuarios activos del workspace para el dropdown */
export async function getUsuariosMonday(): Promise<UsuarioMonday[]> {
const resp = await fetch(`${API_URL}/monday/usuarios`, conAuth())
manejar401(resp)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
return (await resp.json()) as UsuarioMonday[]
}
/** PATCH /usuarios/me/monday — vincula (number) o desvincula (null) la cuenta Monday */
export async function guardarVinculoMonday(mondayUserId: number | null): Promise<UsuarioSesion> {
const resp = await fetch(
`${API_URL}/usuarios/me/monday`,
conAuth({
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mondayUserId }),
}),
)
manejar401(resp)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
return (await resp.json()) as UsuarioSesion
}

View File

@@ -0,0 +1,78 @@
// Sesión de autenticación contra el API Gateway (JWT guardado en localStorage)
export const API_URL = import.meta.env.VITE_API_URL ?? '/api'
const STORAGE_KEY = 'frasa_auth'
export interface UsuarioSesion {
nombre: string
rol: string
asignacion: string | null
mondayUserId: number | null
}
export interface Sesion {
token: string
usuario: UsuarioSesion
}
/** Error de login con mensaje genérico (no filtra si el usuario existe) */
export class ErrorLogin extends Error {
constructor(message = 'Usuario o contraseña incorrectos') {
super(message)
this.name = 'ErrorLogin'
}
}
export function getSesion(): Sesion | null {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const sesion = JSON.parse(raw) as Sesion
if (typeof sesion?.token !== 'string' || !sesion.usuario) return null
return sesion
} catch {
return null
}
}
export function getToken(): string | null {
return getSesion()?.token ?? null
}
export function logout(): void {
localStorage.removeItem(STORAGE_KEY)
}
/** POST /auth/login; guarda token + usuario en localStorage */
export async function login(username: string, password: string): Promise<UsuarioSesion> {
let res: Response
try {
res = await fetch(`${API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
} catch {
throw new ErrorLogin('No se pudo conectar con el servidor')
}
if (!res.ok) throw new ErrorLogin()
const data = (await res.json()) as Sesion
localStorage.setItem(STORAGE_KEY, JSON.stringify({ token: data.token, usuario: data.usuario }))
return data.usuario
}
/**
* Agrega Authorization: Bearer a un RequestInit si hay sesión.
* Sin token devuelve el init tal cual (las llamadas siguen iguales que antes).
*/
export function conAuth(init: RequestInit = {}): RequestInit {
const token = getToken()
if (!token) return init
return { ...init, headers: { ...(init.headers as Record<string, string>), Authorization: `Bearer ${token}` } }
}
/** Si el API responde 401: cierra sesión y recarga para volver al login */
export function manejar401(res: Response): void {
if (res.status !== 401) return
logout()
if (typeof window !== 'undefined') window.location.reload()
}

View File

@@ -3,7 +3,8 @@ import { defineConfig } from 'vitest/config'
// Config de tests separada para no mezclar los tipos de Vite y Vitest
export default defineConfig({
test: {
environment: 'node',
// jsdom para los tests de componentes; los tests puros corren igual
environment: 'jsdom',
coverage: {
provider: 'v8',
},