From 528e55991bd5de8658e99221326d69647062eef7 Mon Sep 17 00:00:00 2001 From: BOHA Date: Fri, 24 Apr 2026 00:58:35 +0200 Subject: [PATCH] security: fix all Critical and High findings from FLAWS_REPORT audit - Auth: pessimistic locking on login tokens and refresh token rotation, backup code attempt counter, rate limiting verification - Schema: unique constraints on business numbers, FK relations, unsigned/signed alignment, attendance duplicate prevention - Invoices/PDFs: DOMPurify sanitization, bounded queries in stats and alerts, VAT rounding, Puppeteer error handling - Orders/Offers: transactional parent+child creation, Zod NaN refinement, status enums, uniqueness checks - Projects/Files: path traversal protection, streamed uploads, permission guards, query param validation - Attendance/HR: duplicate checks, ownership validation, GPS restrictions, trip distance validation - Frontend: modal lock reference counting, XSS escaping in print HTML, ref mutation fixes, accessibility attributes Co-Authored-By: Claude Opus 4.7 --- package-lock.json | 533 +++++++++++++++++- package.json | 2 + prisma/schema.prisma | 18 +- src/admin/components/AdminDatePicker.tsx | 2 +- src/admin/components/BulkAttendanceModal.tsx | 10 +- src/admin/components/ConfirmModal.tsx | 7 +- .../components/OrderConfirmationModal.tsx | 8 +- src/admin/components/ProjectFileManager.tsx | 23 +- src/admin/components/ShiftFormModal.tsx | 5 +- src/admin/context/AlertContext.tsx | 13 +- src/admin/context/AuthContext.tsx | 72 +-- src/admin/hooks/useApiCall.ts | 3 +- src/admin/hooks/useAttendanceAdmin.ts | 55 +- src/admin/hooks/useModalLock.ts | 14 +- src/admin/pages/InvoiceDetail.tsx | 13 +- src/admin/pages/Invoices.tsx | 18 +- src/admin/pages/OfferDetail.tsx | 34 +- src/admin/pages/Offers.tsx | 20 +- src/admin/pages/Settings.tsx | 22 +- src/admin/utils/api.ts | 61 +- src/config/env.ts | 9 +- src/routes/admin/attendance.ts | 31 + src/routes/admin/audit-log.ts | 15 + src/routes/admin/auth.ts | 110 ++-- src/routes/admin/company-settings.ts | 10 +- src/routes/admin/customers.ts | 90 +-- src/routes/admin/invoices-pdf.ts | 437 +++++++------- src/routes/admin/invoices.ts | 6 +- src/routes/admin/leave-requests.ts | 18 + src/routes/admin/offers-pdf.ts | 51 +- src/routes/admin/orders-pdf.ts | 320 ++++++----- src/routes/admin/orders.ts | 7 +- src/routes/admin/profile.ts | 8 + src/routes/admin/project-files.ts | 68 ++- src/routes/admin/projects.ts | 3 + src/routes/admin/quotations.ts | 12 +- src/routes/admin/roles.ts | 16 +- src/routes/admin/totp.ts | 180 ++++-- src/routes/admin/trips.ts | 16 + src/routes/admin/users.ts | 35 +- src/schemas/offers.schema.ts | 19 +- src/schemas/orders.schema.ts | 21 +- src/services/attendance.service.ts | 52 +- src/services/auth.ts | 130 +++-- src/services/exchange-rates.ts | 8 +- src/services/invoice-alerts.ts | 20 +- src/services/invoices.service.ts | 95 ++-- src/services/nas-file-manager.ts | 124 ++-- src/services/nas-financials-manager.ts | 8 +- src/services/numbering.service.ts | 12 +- src/services/offers.service.ts | 194 ++++--- src/services/orders.service.ts | 161 +++--- src/services/projects.service.ts | 60 +- src/services/users.service.ts | 75 ++- src/types/index.ts | 3 +- src/utils/html-to-pdf.ts | 6 +- vitest.config.ts | 2 +- 57 files changed, 2355 insertions(+), 1010 deletions(-) diff --git a/package-lock.json b/package-lock.json index 726b99b..20d0490 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "app-ts", - "version": "1.5.1", + "version": "1.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "app-ts", - "version": "1.5.1", + "version": "1.5.3", "license": "ISC", "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -19,6 +19,7 @@ "@fastify/rate-limit": "^10.3.0", "@fastify/static": "^9.0.0", "@prisma/client": "^6.19.2", + "@types/jsdom": "^28.0.1", "bcryptjs": "^3.0.3", "date-fns": "^4.1.0", "dompurify": "^3.3.3", @@ -27,6 +28,7 @@ "file-type": "^16.5.4", "framer-motion": "^12.38.0", "hi-base32": "^0.5.1", + "jsdom": "^29.0.2", "jsonwebtoken": "^9.0.3", "leaflet": "^1.9.4", "node-cron": "^4.2.1", @@ -64,6 +66,53 @@ "vitest": "^4.1.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -87,6 +136,152 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -630,6 +825,23 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@fastify/accept-negotiator": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", @@ -1513,6 +1725,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsdom": { + "version": "28.0.1", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-28.0.1.tgz", + "integrity": "sha512-GJq2QE4TAZ5ajSoCasn5DOFm8u1mI3tIFvM5tIq3W5U/RTB6gsHwc6Yhpl91X9VSDOUVblgXmG+2+sSvFQrdlw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0", + "undici-types": "^7.21.0" + } + }, + "node_modules/@types/jsdom/node_modules/undici-types": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.25.0.tgz", + "integrity": "sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==", + "license": "MIT" + }, "node_modules/@types/jsonwebtoken": { "version": "9.0.10", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", @@ -1562,7 +1792,6 @@ "version": "25.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -1639,6 +1868,12 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -2088,6 +2323,15 @@ "bcrypt": "bin/bcrypt" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", @@ -2494,6 +2738,19 @@ } } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2510,6 +2767,19 @@ "node": ">= 14" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -2546,6 +2816,12 @@ "node": ">=0.10.0" } }, + "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==", + "license": "MIT" + }, "node_modules/deepmerge-ts": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", @@ -2721,6 +2997,18 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -3496,6 +3784,18 @@ "integrity": "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA==", "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -3617,6 +3917,12 @@ "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==", + "license": "MIT" + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -3644,6 +3950,70 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", + "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.5", + "@asamuzakjp/dom-selector": "^7.0.6", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -4142,6 +4512,12 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "license": "CC0-1.0" + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -4477,6 +4853,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4741,6 +5129,15 @@ "once": "^1.3.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==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/puppeteer": { "version": "24.40.0", "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.40.0.tgz", @@ -5257,6 +5654,18 @@ "node": ">=10" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -5469,7 +5878,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -5635,6 +6043,12 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "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==", + "license": "MIT" + }, "node_modules/tabbable": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", @@ -5768,6 +6182,24 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "license": "MIT" + }, "node_modules/toad-cache": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", @@ -5803,6 +6235,30 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -5859,11 +6315,19 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "devOptional": true, "license": "MIT" }, "node_modules/vite": { @@ -6027,12 +6491,56 @@ } } }, + "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==", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/webdriver-bidi-protocol": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", "license": "Apache-2.0" }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", @@ -6100,6 +6608,21 @@ } } }, + "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==", + "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==", + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 4b70a10..498dae0 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@fastify/rate-limit": "^10.3.0", "@fastify/static": "^9.0.0", "@prisma/client": "^6.19.2", + "@types/jsdom": "^28.0.1", "bcryptjs": "^3.0.3", "date-fns": "^4.1.0", "dompurify": "^3.3.3", @@ -42,6 +43,7 @@ "file-type": "^16.5.4", "framer-motion": "^12.38.0", "hi-base32": "^0.5.1", + "jsdom": "^29.0.2", "jsonwebtoken": "^9.0.3", "leaflet": "^1.9.4", "node-cron": "^4.2.1", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 46e34bc..2e02053 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -32,7 +32,7 @@ model attendance { users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "attendance_ibfk_1") attendance_project_logs attendance_project_logs[] - @@index([user_id, shift_date], map: "idx_attendance_user_date") + @@unique([user_id, shift_date], map: "idx_attendance_user_date") @@index([user_id, departure_time], map: "idx_attendance_user_departure") @@index([project_id], map: "idx_project_id") } @@ -46,6 +46,7 @@ model attendance_project_logs { hours Int? @db.UnsignedInt minutes Int? @db.UnsignedInt attendance attendance @relation(fields: [attendance_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + projects projects? @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction) @@index([attendance_id], map: "idx_attendance_project_logs_aid") @@index([project_id], map: "idx_project_id") @@ -104,7 +105,7 @@ model company_settings { quotation_prefix String? @db.VarChar(20) default_currency String? @default("CZK") @db.VarChar(10) default_vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) - uuid String? @db.VarChar(36) + uuid String? @unique @db.VarChar(36) modified_at DateTime? @db.DateTime(0) is_deleted Boolean? @default(false) sync_version Int? @default(0) @@ -165,7 +166,7 @@ model invoice_items { model invoices { id Int @id @default(autoincrement()) - invoice_number String? @db.VarChar(50) + invoice_number String? @unique(map: "idx_invoices_number_unique") @db.VarChar(50) order_id Int? customer_id Int? status String? @default("issued") @db.VarChar(30) @@ -288,7 +289,7 @@ model order_sections { model orders { id Int @id @default(autoincrement()) - order_number String? @db.VarChar(50) + order_number String? @unique(map: "idx_orders_number_unique") @db.VarChar(50) customer_order_number String? @db.VarChar(100) attachment_data Bytes? attachment_name String? @db.VarChar(255) @@ -340,7 +341,7 @@ model project_notes { model projects { id Int @id @default(autoincrement()) - project_number String? @db.VarChar(50) + project_number String? @unique @db.VarChar(50) name String? @db.VarChar(255) customer_id Int? responsible_user_id Int? @@ -352,6 +353,7 @@ model projects { notes String? @db.Text created_at DateTime? @default(now()) @db.DateTime(0) modified_at DateTime? @db.DateTime(0) + attendance_project_logs attendance_project_logs[] project_notes project_notes[] users users? @relation(fields: [responsible_user_id], references: [id], onUpdate: NoAction, map: "fk_projects_responsible_user") customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_1") @@ -385,7 +387,7 @@ model quotation_items { model quotations { id Int @id @default(autoincrement()) - quotation_number String? @db.VarChar(50) + quotation_number String? @unique @db.VarChar(50) project_code String? @db.VarChar(50) customer_id Int? created_at DateTime? @default(now()) @db.DateTime(0) @@ -434,7 +436,7 @@ model received_invoices { file_mime String? @db.VarChar(100) file_size Int? @db.UnsignedInt notes String? @db.Text - uploaded_by Int? @db.UnsignedInt + uploaded_by Int? created_at DateTime @default(now()) @db.DateTime(0) modified_at DateTime @default(now()) @db.DateTime(0) @@ -446,7 +448,7 @@ model received_invoices { /// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments model refresh_tokens { id Int @id @default(autoincrement()) @db.UnsignedInt - user_id Int @db.UnsignedInt + user_id Int token_hash String @unique(map: "token_hash") @db.VarChar(64) expires_at DateTime @db.DateTime(0) replaced_at DateTime? @db.DateTime(0) diff --git a/src/admin/components/AdminDatePicker.tsx b/src/admin/components/AdminDatePicker.tsx index 40c6123..8912d8e 100644 --- a/src/admin/components/AdminDatePicker.tsx +++ b/src/admin/components/AdminDatePicker.tsx @@ -91,7 +91,7 @@ function NativeInput({ } interface AdminDatePickerProps { - mode?: "date" | "month" | "datetime" | "time"; + mode?: "date" | "month" | "time"; value: string; onChange: (value: string) => void; minDate?: string; diff --git a/src/admin/components/BulkAttendanceModal.tsx b/src/admin/components/BulkAttendanceModal.tsx index 5af113a..31f15c8 100644 --- a/src/admin/components/BulkAttendanceModal.tsx +++ b/src/admin/components/BulkAttendanceModal.tsx @@ -57,13 +57,21 @@ export default function BulkAttendanceModal({ />
-

Vyplnit docházku za měsíc

+

+ Vyplnit docházku za měsíc +

-

{title}

+

+ {title} +

{message}

diff --git a/src/admin/components/OrderConfirmationModal.tsx b/src/admin/components/OrderConfirmationModal.tsx index cd28286..ec79864 100644 --- a/src/admin/components/OrderConfirmationModal.tsx +++ b/src/admin/components/OrderConfirmationModal.tsx @@ -109,13 +109,19 @@ export default function OrderConfirmationModal({ className={ step === "edit" ? "admin-modal admin-modal-lg" : "admin-modal" } + role="dialog" + aria-modal="true" + aria-labelledby="order-confirmation-modal-title" initial={{ opacity: 0, scale: 0.95, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 20 }} transition={{ duration: 0.2 }} >
-

+

Potvrzení objednávky {orderNumber}

diff --git a/src/admin/components/ProjectFileManager.tsx b/src/admin/components/ProjectFileManager.tsx index 6c7f251..9b537c9 100644 --- a/src/admin/components/ProjectFileManager.tsx +++ b/src/admin/components/ProjectFileManager.tsx @@ -197,6 +197,7 @@ export default function ProjectFileManager({ }: ProjectFileManagerProps) { const alert = useAlert(); const fileInputRef = useRef(null); + const isCancelling = useRef(false); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); @@ -768,10 +769,26 @@ export default function ProjectFileManager({ }} autoFocus onKeyDown={(e) => { - if (e.key === "Enter") handleRename(item); - if (e.key === "Escape") setRenamingItem(null); + if (e.key === "Enter") { + e.preventDefault(); + handleRename(item); + } + if (e.key === "Escape") { + e.preventDefault(); + isCancelling.current = true; + setRenamingItem(null); + setRenameValue(item.name); + setTimeout(() => { + isCancelling.current = false; + }, 0); + } + }} + onBlur={() => { + if (isCancelling.current) { + return; + } + handleRename(item); }} - onBlur={() => handleRename(item)} /> ) : (
-

+

{isCreate ? "Přidat záznam docházky" : "Upravit docházku"}

{!isCreate && editingRecord && ( diff --git a/src/admin/context/AlertContext.tsx b/src/admin/context/AlertContext.tsx index 2a8e762..923f64b 100644 --- a/src/admin/context/AlertContext.tsx +++ b/src/admin/context/AlertContext.tsx @@ -5,6 +5,7 @@ import { useCallback, useMemo, useRef, + useEffect, type ReactNode, } from "react"; @@ -39,6 +40,15 @@ export function AlertProvider({ children }: { children: ReactNode }) { }, []); const counterRef = useRef(0); + const timeoutsRef = useRef>>(new Set()); + + useEffect(() => { + return () => { + timeoutsRef.current.forEach(clearTimeout); + timeoutsRef.current.clear(); + }; + }, []); + const addAlert = useCallback( (message: string, type = "success", duration = 4000) => { const id = `${Date.now()}-${counterRef.current++}`; @@ -47,7 +57,8 @@ export function AlertProvider({ children }: { children: ReactNode }) { { id, message, type: type as Alert["type"] }, ]); if (duration > 0) { - setTimeout(() => removeAlert(id), duration); + const timeoutId = setTimeout(() => removeAlert(id), duration); + timeoutsRef.current.add(timeoutId); } return id; }, diff --git a/src/admin/context/AuthContext.tsx b/src/admin/context/AuthContext.tsx index abe75ae..c3cffa9 100644 --- a/src/admin/context/AuthContext.tsx +++ b/src/admin/context/AuthContext.tsx @@ -84,32 +84,35 @@ function mapUser(u: Record | null): User | null { } as User; } -let accessToken: string | null = null; -let tokenExpiresAt: number | null = null; -let cachedUser: User | null = null; -let sessionFetched = false; -let silentRefreshInFlight: Promise | null = null; - export function AuthProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(cachedUser); - const [loading, setLoading] = useState(!sessionFetched); + const accessTokenRef = useRef(null); + const tokenExpiresAtRef = useRef(null); + const cachedUserRef = useRef(null); + const sessionFetchedRef = useRef(false); + const silentRefreshInFlightRef = useRef | null>(null); + const [user, setUser] = useState(cachedUserRef.current); + const [loading, setLoading] = useState(!sessionFetchedRef.current); const [error, setError] = useState(null); const refreshTimeoutRef = useRef | null>(null); useEffect(() => { - cachedUser = user; + cachedUserRef.current = user; }, [user]); const getAccessTokenFn = useCallback((): string | null => { - if (!tokenExpiresAt || Date.now() > tokenExpiresAt - 30000) return null; - return accessToken; + if ( + !tokenExpiresAtRef.current || + Date.now() > tokenExpiresAtRef.current - 30000 + ) + return null; + return accessTokenRef.current; }, []); const setAccessTokenFn = useCallback( (token: string | null, expiresIn?: number) => { const ttl = expiresIn ?? 900; // default 15 min matching backend config - accessToken = token; - tokenExpiresAt = token ? Date.now() + ttl * 1000 : null; + accessTokenRef.current = token; + tokenExpiresAtRef.current = token ? Date.now() + ttl * 1000 : null; if (refreshTimeoutRef.current) { clearTimeout(refreshTimeoutRef.current); refreshTimeoutRef.current = null; @@ -126,7 +129,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { const silentRefresh = useCallback(async (): Promise => { // Deduplicate concurrent refresh calls — token rotation means only one call can succeed - if (silentRefreshInFlight) return silentRefreshInFlight; + if (silentRefreshInFlightRef.current) + return silentRefreshInFlightRef.current; const promise = (async (): Promise => { try { @@ -140,21 +144,21 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUser(mapUser(data.data.user)); return true; } - accessToken = null; - tokenExpiresAt = null; + accessTokenRef.current = null; + tokenExpiresAtRef.current = null; setUser(null); - cachedUser = null; + cachedUserRef.current = null; setSessionExpired(); return false; } catch { // Network error — don't kick the user out, just return false return false; } finally { - silentRefreshInFlight = null; + silentRefreshInFlightRef.current = null; } })(); - silentRefreshInFlight = promise; + silentRefreshInFlightRef.current = promise; return promise; }, [setAccessTokenFn]); @@ -172,12 +176,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { headers, }); if (response.status === 429 || response.status >= 500) - return !!cachedUser; + return !!cachedUserRef.current; const data = await response.json(); if (data.success && data.data?.user) { if (data.data.access_token) setAccessTokenFn(data.data.access_token); setUser(mapUser(data.data.user)); - cachedUser = mapUser(data.data.user); + cachedUserRef.current = mapUser(data.data.user); return true; } } @@ -185,15 +189,15 @@ export function AuthProvider({ children }: { children: ReactNode }) { const refreshed = await silentRefresh(); if (refreshed) return true; setUser(null); - cachedUser = null; - accessToken = null; - tokenExpiresAt = null; + cachedUserRef.current = null; + accessTokenRef.current = null; + tokenExpiresAtRef.current = null; return false; } catch { - return !!cachedUser; + return !!cachedUserRef.current; } finally { setLoading(false); - sessionFetched = true; + sessionFetchedRef.current = true; } }, [getAccessTokenFn, setAccessTokenFn, silentRefresh]); @@ -231,8 +235,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { } setAccessTokenFn(data.data.access_token, data.data.expires_in); setUser(mapUser(data.data.user)); - cachedUser = mapUser(data.data.user); - sessionFetched = true; + cachedUserRef.current = mapUser(data.data.user); + sessionFetchedRef.current = true; return { success: true }; } setError(data.error); @@ -270,8 +274,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (data.success) { setAccessTokenFn(data.data.access_token, data.data.expires_in); setUser(mapUser(data.data.user)); - cachedUser = mapUser(data.data.user); - sessionFetched = true; + cachedUserRef.current = mapUser(data.data.user); + sessionFetchedRef.current = true; return { success: true }; } setError(data.error); @@ -296,11 +300,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { } catch { /* ignore */ } finally { - accessToken = null; - tokenExpiresAt = null; + accessTokenRef.current = null; + tokenExpiresAtRef.current = null; setUser(null); - cachedUser = null; - sessionFetched = false; + cachedUserRef.current = null; + sessionFetchedRef.current = false; if (refreshTimeoutRef.current) { clearTimeout(refreshTimeoutRef.current); refreshTimeoutRef.current = null; diff --git a/src/admin/hooks/useApiCall.ts b/src/admin/hooks/useApiCall.ts index fe0c721..11939e8 100644 --- a/src/admin/hooks/useApiCall.ts +++ b/src/admin/hooks/useApiCall.ts @@ -23,8 +23,9 @@ export default function useApiCall() { abortRef.current = controller; try { + const { signal: _, ...restOptions } = options; const response = await apiFetch(url, { - ...options, + ...restOptions, signal: controller.signal, }); const data = await response.json(); diff --git a/src/admin/hooks/useAttendanceAdmin.ts b/src/admin/hooks/useAttendanceAdmin.ts index 913a94a..2274136 100644 --- a/src/admin/hooks/useAttendanceAdmin.ts +++ b/src/admin/hooks/useAttendanceAdmin.ts @@ -224,11 +224,20 @@ function computeUserTotals( // Print helpers // --------------------------------------------------------------------------- +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + function renderFundStatus(userData: Record): string { if (userData.overtime > 0) - return `+${userData.overtime}h přesčas`; + return `+${escapeHtml(String(userData.overtime))}h přesčas`; if (userData.missing > 0) - return `−${userData.missing}h`; + return `−${escapeHtml(String(userData.missing))}h`; return 'splněno'; } @@ -255,11 +264,11 @@ function buildProjectLogsHtml(record: Record): string { h = 0; m = 0; } - return `
${log.project_name || `#${log.project_id}`} (${h}:${String(m).padStart(2, "0")}h)
`; + return `
${escapeHtml(log.project_name || `#${log.project_id}`)} (${h}:${String(m).padStart(2, "0")}h)
`; }) .join(""); } - return record.project_name || "—"; + return escapeHtml(record.project_name || "—"); } function buildLeaveSummaryHtml( @@ -268,15 +277,15 @@ function buildLeaveSummaryHtml( printData: Record, ): string { const bal = printData.leave_balances[userId]; - let parts = `Dovolená ${printData.year}: Zbývá ${bal.vacation_remaining.toFixed(1)}h z ${bal.vacation_total}h`; + let parts = `Dovolená ${escapeHtml(String(printData.year))}: Zbývá ${bal.vacation_remaining.toFixed(1)}h z ${bal.vacation_total}h`; if (userData.vacation_hours > 0) - parts += ` | Tento měsíc: ${userData.vacation_hours}h`; + parts += ` | Tento měsíc: ${escapeHtml(String(userData.vacation_hours))}h`; if (userData.sick_hours > 0) - parts += ` | Nemoc: ${userData.sick_hours}h`; + parts += ` | Nemoc: ${escapeHtml(String(userData.sick_hours))}h`; if (userData.holiday_hours > 0) - parts += ` | Svátek: ${userData.holiday_hours}h`; + parts += ` | Svátek: ${escapeHtml(String(userData.holiday_hours))}h`; if (userData.overtime > 0) - parts += ` | Přesčas: +${userData.overtime}h`; + parts += ` | Přesčas: +${escapeHtml(String(userData.overtime))}h`; return `
${parts}
`; } @@ -299,17 +308,17 @@ function buildUserSectionHtml( const breakCell = isLeave || !record.break_start || !record.break_end ? "—" - : `${formatTimeOrDatetimePrint(record.break_start, record.shift_date)} - ${formatTimeOrDatetimePrint(record.break_end, record.shift_date)}`; + : `${escapeHtml(formatTimeOrDatetimePrint(record.break_start, record.shift_date))} - ${escapeHtml(formatTimeOrDatetimePrint(record.break_end, record.shift_date))}`; return ` - ${formatDate(record.shift_date)} - ${getLeaveTypeName(leaveType)} - ${isLeave ? "—" : formatTimeOrDatetimePrint(record.arrival_time, record.shift_date)} + ${escapeHtml(formatDate(record.shift_date))} + ${escapeHtml(getLeaveTypeName(leaveType))} + ${isLeave ? "—" : escapeHtml(formatTimeOrDatetimePrint(record.arrival_time, record.shift_date))} ${breakCell} - ${isLeave ? "—" : formatTimeOrDatetimePrint(record.departure_time, record.shift_date)} + ${isLeave ? "—" : escapeHtml(formatTimeOrDatetimePrint(record.departure_time, record.shift_date))} ${workMinutes > 0 ? `${hours}:${String(mins).padStart(2, "0")}` : "—"} ${buildProjectLogsHtml(record)} - ${record.notes || ""} + ${escapeHtml(record.notes || "")} `; }) .join(""); @@ -318,15 +327,15 @@ function buildUserSectionHtml( userData.fund !== null ? ` Fond měsíce: - ${userData.covered}h / ${userData.fund}h + ${escapeHtml(String(userData.covered))}h / ${escapeHtml(String(userData.fund))}h ${renderFundStatus(userData)} ` : ""; return `
-

${userData.name}

- Odpracováno: ${formatMinutes(userData.minutes)} h +

${escapeHtml(userData.name)}

+ Odpracováno: ${escapeHtml(formatMinutes(userData.minutes))} h
${leaveHtml} @@ -344,7 +353,7 @@ function buildUserSectionHtml( - + ${fundRow} @@ -365,7 +374,7 @@ function buildPrintHtml( - Docházka - ${pData.month_name} + Docházka - ${escapeHtml(pData.month_name)}
Odpracováno:${formatMinutes(userData.minutes)} h${escapeHtml(formatMinutes(userData.minutes))} h