diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d6a59cbed7..d6328aa1c9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -477,7 +477,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -526,7 +525,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -589,7 +587,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-2.9.1.tgz", "integrity": "sha512-DlFV2o+tv9S+j4TeBVkRaIjjE9o3Tq3+hvJNoIOFtl87cR77UVQqEIRqOf61yk85Y+T2LfmnVPWjNcMuiKUh8w==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/engines": "2.9.1", "@embedpdf/models": "2.9.1" @@ -685,7 +682,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-2.9.1.tgz", "integrity": "sha512-aNtXjI3NUwz7kdmWsQIWzuS1QdZmuHXGCc+Kwl9u5O0PAgoj74OLsgoNEcFzz9m1rljyq3WPVnLczO6ByiifpQ==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "2.9.1", "@embedpdf/utils": "2.9.1" @@ -775,7 +771,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-2.9.1.tgz", "integrity": "sha512-3AcvSTT7fmqe1ve/FvR3lJ5q7t5JYmnnAg8LKc9ATsDjS9J5b0WE03Omz9a8/sL19iKq8xeR1+W28phgvlcKNw==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "2.9.1" }, @@ -793,7 +788,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-2.9.1.tgz", "integrity": "sha512-/wpdStr1NeyMCvAEMVSCPC0a3zaMd+TSK4u8INsIo3b1RoFfb9iTlBB+qW/aaxvZJ/C7MChQ7cLX6VSKXK/6JQ==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "2.9.1" }, @@ -869,7 +863,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-2.9.1.tgz", "integrity": "sha512-mtfu6uDxlz3+j0xPXfKyvuu8iCFjapPkbnx8vGQ0z2PBNAMm+05hsNIzxJSGMP2VCFo09SOz2zCs7ch9J6NeNg==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "2.9.1" }, @@ -904,7 +897,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-2.9.1.tgz", "integrity": "sha512-+U3PSIUuNlIOTXzRhnPBP+Rx20sFOd3OPiowyI2EP/Kx/j5R/amgL/t2rjrpw9gjXEMEGsli9Fn4UqnVgMrPaQ==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "2.9.1" }, @@ -940,7 +932,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-2.9.1.tgz", "integrity": "sha512-dVLjiLGnZDo0xO7lZulLGl3cJ/mO7BcA3PGO2uMdhqSWK4tAF/DrakvwXdD581VBwXD/C25EJhxiNa2L7mU4wg==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "2.9.1", "@embedpdf/utils": "2.9.1" @@ -1015,7 +1006,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-2.9.1.tgz", "integrity": "sha512-bVhBuZHTppKV+OB5lBLqXQv+5oW1A7kAIc5UzsImBwl6NpwH+2PdVkelfrF37yEqnEF/mdxobriWSP0aOVl93w==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "2.9.1" }, @@ -1118,7 +1108,6 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1162,7 +1151,6 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2021,7 +2009,6 @@ "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.18.tgz", "integrity": "sha512-9tph1lTVogKPjTx02eUxDUOdXacPzK62UuSqb4TdGliI54/Xgxftq0Dfqu6XuhCxn9J5MDJaNiLDvL/1KRkYqA==", "license": "MIT", - "peer": true, "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", @@ -2072,7 +2059,6 @@ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.18.tgz", "integrity": "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "^18.x || ^19.x" } @@ -2149,7 +2135,6 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.9.tgz", "integrity": "sha512-I8yO3t4T0y7bvDiR1qhIN6iBWZOTBfVOnmLlM7K6h3dx5YX2a7rnkuXzc2UkZaqhxY9NgTnEbdPlokR1RxCNRQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.6", "@mui/core-downloads-tracker": "^7.3.9", @@ -2597,7 +2582,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -3513,7 +3497,6 @@ "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz", "integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=12.16" } @@ -3609,6 +3592,7 @@ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", "license": "MIT", + "peer": true, "peerDependencies": { "acorn": "^8.9.0" } @@ -4436,7 +4420,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -5024,7 +5007,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5035,7 +5017,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5116,7 +5097,6 @@ "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", @@ -5565,6 +5545,7 @@ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", "license": "MIT", + "peer": true, "dependencies": { "@vue/shared": "3.5.30" } @@ -5574,6 +5555,7 @@ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", "license": "MIT", + "peer": true, "dependencies": { "@vue/reactivity": "3.5.30", "@vue/shared": "3.5.30" @@ -5584,6 +5566,7 @@ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", "license": "MIT", + "peer": true, "dependencies": { "@vue/reactivity": "3.5.30", "@vue/runtime-core": "3.5.30", @@ -5596,6 +5579,7 @@ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-ssr": "3.5.30", "@vue/shared": "3.5.30" @@ -5622,7 +5606,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5883,6 +5866,7 @@ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">= 0.4" } @@ -6164,7 +6148,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7076,7 +7059,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -7539,15 +7521,15 @@ "version": "5.6.4", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/devtools-protocol": { "version": "0.0.1581282", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz", "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dezalgo": { "version": "1.0.4", @@ -7880,7 +7862,6 @@ "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -7990,7 +7971,8 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/espree": { "version": "11.2.0", @@ -8055,6 +8037,7 @@ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@typescript-eslint/types": "^8.2.0" @@ -8932,7 +8915,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.29.2" }, @@ -9213,6 +9195,7 @@ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.6" } @@ -9388,7 +9371,6 @@ "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -9950,7 +9932,8 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/locate-path": { "version": "6.0.0", @@ -10970,7 +10953,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11231,7 +11213,6 @@ "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.363.3.tgz", "integrity": "sha512-j1+MTbHO17kKXJMGDnaiW1EMOiA4AprE8EML6QnbSds+XbqHR2CdHa8T+/zIriZSoXlkZH4R+A4gY29lb5hdlA==", "license": "SEE LICENSE IN LICENSE", - "peer": true, "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.208.0", @@ -11253,7 +11234,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz", "integrity": "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -11645,7 +11625,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11655,7 +11634,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11738,8 +11716,7 @@ "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-number-format": { "version": "5.4.5", @@ -11756,7 +11733,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -12138,8 +12114,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -12989,6 +12964,7 @@ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">= 0.4" } @@ -13261,7 +13237,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13455,7 +13430,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -13523,7 +13497,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13774,7 +13747,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -13949,7 +13921,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13963,7 +13934,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -14474,7 +14444,8 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/zod": { "version": "3.25.76", diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 693144ffd6..94248c215e 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -44,6 +44,8 @@ downloadPdf = "Download PDF" downloadUnavailable = "Download unavailable for this item" edit = "Edit" editYourNewFiles = "Edit your new file(s)" +encryptedFileBlocked = "File is password-protected. Unlock it first." +encryptedFilesBlocked = "{{count}} files are password-protected. Unlock them first." exportAndContinue = "Export & Continue" false = "False" fileSavedToDisk = "File saved to disk" @@ -3341,6 +3343,9 @@ successBodyWithName = "Password removed from {{fileName}}" successTitle = "Password removed" title = "Remove password to continue" unlock = "Unlock & Continue" +unlockAll = "Use for all ({{count}})" +unlockAllPartialFail = "Wrong password for: {{names}}" +unlockAllSuccess = "Unlocked {{count}} file(s)." unlockPrompt = "Unlock PDF to continue" [encryptedPdfUnlock.password] diff --git a/frontend/src/core/components/shared/EncryptedPdfUnlockModal.tsx b/frontend/src/core/components/shared/EncryptedPdfUnlockModal.tsx index 765f90704b..e726e10823 100644 --- a/frontend/src/core/components/shared/EncryptedPdfUnlockModal.tsx +++ b/frontend/src/core/components/shared/EncryptedPdfUnlockModal.tsx @@ -9,8 +9,10 @@ interface EncryptedPdfUnlockModalProps { password: string; errorMessage?: string | null; isProcessing: boolean; + remainingCount: number; onPasswordChange: (value: string) => void; onUnlock: () => void; + onUnlockAll: () => void; onSkip: () => void; } @@ -20,8 +22,10 @@ const EncryptedPdfUnlockModal = ({ password, errorMessage, isProcessing, + remainingCount, onPasswordChange, onUnlock, + onUnlockAll, onSkip, }: EncryptedPdfUnlockModalProps) => { const { t } = useTranslation(); @@ -75,9 +79,16 @@ const EncryptedPdfUnlockModal = ({ - + + {remainingCount > 0 && ( + + )} + + diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index 7d40b2b4d7..2df8bd351a 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; -import { Box, Center, Text, ActionIcon } from "@mantine/core"; +import { Box, Center, Text, ActionIcon, Button, Stack } from "@mantine/core"; import CloseIcon from "@mui/icons-material/Close"; +import LockIcon from "@mui/icons-material/Lock"; import { useFileState, useFileActions } from "@app/contexts/FileContext"; import { useFileWithUrl } from "@app/hooks/useFileWithUrl"; @@ -350,6 +351,13 @@ const EmbedPdfViewerContent = ({ } }, [previewFile, fileWithUrl]); + // Check if the current file is encrypted (gate the viewer to prevent PDFium crash) + const isCurrentFileEncrypted = React.useMemo(() => { + if (!currentFile || !isStirlingFile(currentFile)) return false; + const stub = selectors.getStirlingFileStub(currentFile.fileId); + return stub?.processedFile?.isEncrypted === true; + }, [currentFile, selectors]); + const bookmarkCacheKey = React.useMemo(() => { if (currentFile && isStirlingFile(currentFile)) { return currentFile.fileId; @@ -1045,7 +1053,7 @@ const EmbedPdfViewerContent = ({ console.log("[FormFill] Fetching form fields for:", currentFileId); fetchFormFields(currentFile, currentFileId ?? undefined); } - }, [isFormFillToolActive, currentFile, currentFileId, fetchFormFields]); + }, [isFormFillToolActive, currentFile, currentFileId, fetchFormFields, isCurrentFileEncrypted]); const sidebarWidthRem = 15; const commentsSidebarWidthRem = 18; @@ -1087,6 +1095,23 @@ const EmbedPdfViewerContent = ({
Error: No file provided to viewer
+ ) : isCurrentFileEncrypted ? ( +
+ + + This PDF is password-protected + + +
) : ( <> {/* EmbedPDF Viewer */} diff --git a/frontend/src/core/contexts/FileContext.tsx b/frontend/src/core/contexts/FileContext.tsx index a4ec2ec1e1..88f43690cd 100644 --- a/frontend/src/core/contexts/FileContext.tsx +++ b/frontend/src/core/contexts/FileContext.tsx @@ -371,6 +371,66 @@ function FileContextInner({ children, enablePersistence = true }: FileContextPro } }, [activeEncryptedFileId, unlockPassword, runAutomaticPasswordRemoval, t]); + const handleUnlockAll = useCallback(async () => { + if (!activeEncryptedFileId) return; + const pw = unlockPassword.trim(); + if (!pw) { + setUnlockError(t("encryptedPdfUnlock.required", "Enter the password to continue.")); + return; + } + + setIsUnlocking(true); + setUnlockError(null); + + const allIds = [activeEncryptedFileId, ...encryptedQueue]; + let successCount = 0; + const failedNames: string[] = []; + + for (const fileId of allIds) { + try { + await runAutomaticPasswordRemoval(fileId, pw); + dismissedEncryptedFilesRef.current.delete(fileId); + successCount++; + } catch { + const name = stateRef.current.files.byId[fileId]?.name ?? fileId; + failedNames.push(name); + } + } + + if (successCount > 0) { + alert({ + alertType: "success", + title: t("encryptedPdfUnlock.successTitle", "Password removed"), + body: t("encryptedPdfUnlock.unlockAllSuccess", { + defaultValue: "Unlocked {{count}} file(s).", + count: successCount, + }), + expandable: false, + isPersistentPopup: false, + }); + } + + if (failedNames.length > 0) { + setUnlockError( + t("encryptedPdfUnlock.unlockAllPartialFail", { + defaultValue: "Wrong password for: {{names}}", + names: failedNames.join(", "), + }), + ); + const failedIds = allIds.filter((id) => { + const name = stateRef.current.files.byId[id]?.name ?? id; + return failedNames.includes(name); + }); + setEncryptedQueue(failedIds.slice(1)); + setActiveEncryptedFileId(failedIds[0]); + } else { + setEncryptedQueue([]); + setActiveEncryptedFileId(null); + } + + setIsUnlocking(false); + }, [activeEncryptedFileId, encryptedQueue, unlockPassword, runAutomaticPasswordRemoval, t]); + const undoConsumeFilesWrapper = useCallback( async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise => { return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, filesRef, dispatch, indexedDB); @@ -520,8 +580,10 @@ function FileContextInner({ children, enablePersistence = true }: FileContextPro password={unlockPassword} errorMessage={unlockError} isProcessing={isUnlocking} + remainingCount={encryptedQueue.length} onPasswordChange={setUnlockPassword} onUnlock={handleUnlockSubmit} + onUnlockAll={handleUnlockAll} onSkip={handleUnlockSkip} /> diff --git a/frontend/src/core/contexts/file/fileActions.ts b/frontend/src/core/contexts/file/fileActions.ts index 17caafbb38..ac6d71d691 100644 --- a/frontend/src/core/contexts/file/fileActions.ts +++ b/frontend/src/core/contexts/file/fileActions.ts @@ -19,6 +19,7 @@ import { buildQuickKeySet } from "@app/contexts/file/fileSelectors"; import { StirlingFile } from "@app/types/fileContext"; import { fileStorage } from "@app/services/fileStorage"; import { zipFileService } from "@app/services/zipFileService"; +import { FileAnalyzer } from "@app/services/fileAnalyzer"; const DEBUG = process.env.NODE_ENV === "development"; const HYDRATION_CONCURRENCY = 2; let activeHydrations = 0; @@ -328,6 +329,21 @@ export async function addFiles( // Create new filestub with minimal metadata; hydrate thumbnails/processedFile asynchronously const fileStub = createNewStirlingFileStub(file, fileId); + // Early encryption detection for PDFs — set the flag before dispatch so the + // viewer gate and modal queue pick it up immediately instead of after hydration + if (file.type === "application/pdf") { + try { + if (await FileAnalyzer.isPDFUserPasswordProtected(file)) { + fileStub.processedFile = (fileStub.processedFile || { pages: [] }) as any; + fileStub.processedFile!.isEncrypted = true; + } + } catch (error) { + // Never block upload on analysis failure — but log so it's debuggable + // if an unencrypted file later appears to "hang" during processing. + console.warn("[FileActions] Early encryption detection failed for", file.name, error); + } + } + // Check for pending file path mapping from Tauri file dialog (desktop only) try { const { pendingFilePathMappings } = await import("@app/services/pendingFilePathMappings"); diff --git a/frontend/src/core/hooks/tools/shared/useToolOperation.ts b/frontend/src/core/hooks/tools/shared/useToolOperation.ts index 064dc3aa9f..e92dd874f1 100644 --- a/frontend/src/core/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/core/hooks/tools/shared/useToolOperation.ts @@ -116,6 +116,25 @@ export const useToolOperation = (config: ToolOperationConfig): return; } + // Block encrypted files from being sent to backend tools + const encryptedFiles = validFiles.filter((f) => { + const stub = selectors.getStirlingFileStub(f.fileId); + return stub?.processedFile?.isEncrypted === true; + }); + if (encryptedFiles.length > 0) { + for (const ef of encryptedFiles) { + fileActions.openEncryptedUnlockPrompt(ef.fileId); + } + actions.setError( + encryptedFiles.length === 1 + ? t("encryptedFileBlocked", "File is password-protected. Unlock it first.") + : t("encryptedFilesBlocked", "{{count}} files are password-protected. Unlock them first.", { + count: encryptedFiles.length, + }), + ); + return; + } + // Resolve the runtime endpoint from params (static string or function result). // Custom processors may omit endpoint entirely — result is undefined in that case. const runtimeEndpoint: string | undefined = config.endpoint diff --git a/frontend/src/core/services/fileAnalyzer.ts b/frontend/src/core/services/fileAnalyzer.ts index 7c8e62ee3c..f2ac0e6405 100644 --- a/frontend/src/core/services/fileAnalyzer.ts +++ b/frontend/src/core/services/fileAnalyzer.ts @@ -1,5 +1,24 @@ import { FileAnalysis, ProcessingStrategy } from "@app/types/processing"; import { pdfWorkerManager } from "@app/services/pdfWorkerManager"; +import type { PDFDocumentProxy } from "pdfjs-dist"; + +// Scan the last ~8KB of the PDF for an /Encrypt entry. The trailer lives near +// the tail of the file, so this is enough in practice while staying cheap. +// For files smaller than the window, the whole file is scanned. +function hasEncryptMarker(buffer: ArrayBuffer): boolean { + const TAIL_BYTES = 8 * 1024; + const offset = Math.max(0, buffer.byteLength - TAIL_BYTES); + const view = new Uint8Array(buffer, offset); + // "/Encrypt" as ASCII bytes + const needle = [0x2f, 0x45, 0x6e, 0x63, 0x72, 0x79, 0x70, 0x74]; + outer: for (let i = 0; i <= view.length - needle.length; i++) { + for (let j = 0; j < needle.length; j++) { + if (view[i + j] !== needle[j]) continue outer; + } + return true; + } + return false; +} export class FileAnalyzer { private static readonly SIZE_THRESHOLDS = { @@ -55,30 +74,59 @@ export class FileAnalyzer { /** * Quick PDF analysis without full processing */ + /** + * Cheap encryption-only probe for the upload-time detection path. + * + * Looks for a /Encrypt entry in the last 8KB of the file (where the PDF + * trailer lives). If absent, the file is definitely not encrypted and we + * can skip a full pdf.js parse. If present, falls back to pdf.js so we can + * distinguish user-password (blocks open) from owner-password-only (opens + * fine) — only the former should prompt. + */ + static async isPDFUserPasswordProtected(file: File): Promise { + const arrayBuffer = await file.arrayBuffer(); + if (!hasEncryptMarker(arrayBuffer)) return false; + + let pdf: PDFDocumentProxy | undefined; + try { + pdf = await pdfWorkerManager.createDocument(arrayBuffer, { + stopAtErrors: false, + verbosity: 0, + }); + // pdf.js opened it — owner-password-only case, no prompt needed. + return false; + } catch (error) { + const errorMessage = error instanceof Error ? error.message.toLowerCase() : ""; + return errorMessage.includes("password") || errorMessage.includes("encrypted"); + } finally { + if (pdf) pdfWorkerManager.destroyDocument(pdf); + } + } + static async quickPDFAnalysis(file: File): Promise<{ pageCount: number; isEncrypted: boolean; isCorrupted: boolean; }> { + let pdf: PDFDocumentProxy | undefined; try { // For small files, read the whole file // For large files, try the whole file first (PDF.js needs the complete structure) const arrayBuffer = await file.arrayBuffer(); - const pdf = await pdfWorkerManager.createDocument(arrayBuffer, { + pdf = await pdfWorkerManager.createDocument(arrayBuffer, { stopAtErrors: false, // Don't stop at minor errors verbosity: 0, // Suppress PDF.js warnings }); const pageCount = pdf.numPages; - const isEncrypted = (pdf as any).isEncrypted; - - // Clean up using worker manager - pdfWorkerManager.destroyDocument(pdf); + // If pdf.js opened the document successfully, the user can view it — even if + // the PDF carries encryption dictionaries (owner-password-only case). We only + // flag isEncrypted when pdf.js *fails* to open the file (caught below). return { pageCount, - isEncrypted, + isEncrypted: false, isCorrupted: false, }; } catch (error) { @@ -91,6 +139,8 @@ export class FileAnalyzer { isEncrypted, isCorrupted: !isEncrypted, // If not encrypted, probably corrupted }; + } finally { + if (pdf) pdfWorkerManager.destroyDocument(pdf); } } diff --git a/frontend/src/core/tests/encryptedPdf/EncryptedPdfUnlockE2E.spec.ts b/frontend/src/core/tests/encryptedPdf/EncryptedPdfUnlockE2E.spec.ts new file mode 100644 index 0000000000..2bce945f24 --- /dev/null +++ b/frontend/src/core/tests/encryptedPdf/EncryptedPdfUnlockE2E.spec.ts @@ -0,0 +1,285 @@ +/** + * End-to-End Tests for Encrypted PDF Password Prompting + * + * Tests the EncryptedPdfUnlockModal flow when uploading password-protected PDFs. + * All backend API calls are mocked via page.route() — no real backend required. + * The Vite dev server must be running (handled by playwright.config.ts webServer). + */ + +import { test, expect, type Page } from "@playwright/test"; +import path from "path"; +import fs from "fs"; + +const FIXTURES_DIR = path.join(__dirname, "../test-fixtures"); +const ENCRYPTED_PDF = path.join(FIXTURES_DIR, "encrypted.pdf"); +const SAMPLE_PDF = path.join(FIXTURES_DIR, "sample.pdf"); + +// Minimal valid PDF returned by the mocked remove-password endpoint +const FAKE_UNLOCKED_PDF = Buffer.from( + "%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n" + + "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" + + "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n" + + "xref\n0 4\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n" + + "0000000115 00000 n \ntrailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n190\n%%EOF", +); + +// --------------------------------------------------------------------------- +// Helper: mock all standard app APIs needed to load the main UI +// --------------------------------------------------------------------------- +async function mockAppApis(page: Page) { + await page.route("**/api/v1/info/status", (route) => route.fulfill({ json: { status: "UP" } })); + + await page.route("**/api/v1/config/app-config", (route) => + route.fulfill({ + json: { enableLogin: false, languages: ["en-GB"], defaultLocale: "en-GB" }, + }), + ); + + await page.route("**/api/v1/auth/me", (route) => + route.fulfill({ + json: { id: 1, username: "testuser", email: "test@example.com", roles: ["ROLE_USER"] }, + }), + ); + + await page.route("**/api/v1/config/endpoints-availability", (route) => route.fulfill({ json: {} })); + + await page.route("**/api/v1/config/endpoint-enabled*", (route) => route.fulfill({ json: true })); + + await page.route("**/api/v1/config/group-enabled*", (route) => route.fulfill({ json: true })); + + await page.route("**/api/v1/ui-data/footer-info", (route) => route.fulfill({ json: {} })); + + await page.route("**/api/v1/proprietary/**", (route) => route.fulfill({ json: {} })); +} + +// --------------------------------------------------------------------------- +// Helper: mock the remove-password endpoint to succeed +// --------------------------------------------------------------------------- +function mockRemovePasswordSuccess(page: Page) { + return page.route("**/api/v1/security/remove-password", (route) => + route.fulfill({ + status: 200, + contentType: "application/pdf", + headers: { "Content-Disposition": 'attachment; filename="encrypted.pdf"' }, + body: FAKE_UNLOCKED_PDF, + }), + ); +} + +// --------------------------------------------------------------------------- +// Helper: mock the remove-password endpoint to fail with wrong password +// --------------------------------------------------------------------------- +function mockRemovePasswordWrongPassword(page: Page) { + return page.route("**/api/v1/security/remove-password", (route) => + route.fulfill({ + status: 400, + contentType: "application/problem+json", + body: JSON.stringify({ + type: "/errors/pdf-password", + title: "PDF password incorrect", + status: 400, + detail: "The PDF is passworded and requires the correct password to open.", + }), + }), + ); +} + +// --------------------------------------------------------------------------- +// Helper: upload a file through the Files modal and wait for it to close +// --------------------------------------------------------------------------- +async function uploadFile(page: Page, filePath: string) { + await page.getByTestId("files-button").click(); + await page.waitForSelector(".mantine-Modal-overlay", { state: "visible", timeout: 5000 }); + await page.locator('[data-testid="file-input"]').setInputFiles(filePath); + // Modal auto-closes after file is selected + await page.waitForSelector(".mantine-Modal-overlay", { state: "hidden", timeout: 10000 }); +} + +// --------------------------------------------------------------------------- +// Helper: upload encrypted file — the Files modal closes, then the unlock +// modal should appear on top. We don't wait for the Files modal to vanish +// since the unlock modal may appear while it is still closing. +// --------------------------------------------------------------------------- +async function uploadEncryptedFile(page: Page, filePath: string) { + await page.getByTestId("files-button").click(); + await page.waitForSelector(".mantine-Modal-overlay", { state: "visible", timeout: 5000 }); + await page.locator('[data-testid="file-input"]').setInputFiles(filePath); +} + +// --------------------------------------------------------------------------- +// Selectors for the unlock modal (Mantine Modal with known text content) +// --------------------------------------------------------------------------- +const MODAL_TITLE = "Remove password to continue"; +const PASSWORD_PLACEHOLDER = "Enter the PDF password"; +const UNLOCK_BUTTON_TEXT = "Unlock & Continue"; +const SKIP_BUTTON_TEXT = "Skip for now"; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +test.describe.configure({ mode: "serial" }); + +test.describe("Encrypted PDF Unlock Modal", () => { + test.beforeEach(async ({ page }) => { + await mockAppApis(page); + await page.goto("/?bypassOnboarding=true"); + await page.waitForSelector('[data-testid="files-button"]', { timeout: 10000 }); + + // Dismiss onboarding tooltip if it appears (can block clicks in Firefox/WebKit) + const tooltip = page.locator('button:has-text("Close tooltip")'); + if (await tooltip.isVisible({ timeout: 1000 }).catch(() => false)) { + await tooltip.click(); + } + }); + + test("uploading an encrypted PDF shows the unlock modal", async ({ page }) => { + await uploadEncryptedFile(page, ENCRYPTED_PDF); + + // The unlock modal should appear with the expected title + await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 10000 }); + await expect(page.getByPlaceholder(PASSWORD_PLACEHOLDER)).toBeVisible(); + await expect(page.getByRole("button", { name: UNLOCK_BUTTON_TEXT })).toBeVisible(); + await expect(page.getByRole("button", { name: SKIP_BUTTON_TEXT })).toBeVisible(); + }); + + test("unlock button is disabled when password field is empty", async ({ page }) => { + await uploadEncryptedFile(page, ENCRYPTED_PDF); + + await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 10000 }); + + const unlockBtn = page.getByRole("button", { name: UNLOCK_BUTTON_TEXT }); + await expect(unlockBtn).toBeDisabled(); + }); + + test("unlock button becomes enabled after entering a password", async ({ page }) => { + await uploadEncryptedFile(page, ENCRYPTED_PDF); + + await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 10000 }); + + const passwordInput = page.getByPlaceholder(PASSWORD_PLACEHOLDER); + await passwordInput.fill("somepassword"); + + const unlockBtn = page.getByRole("button", { name: UNLOCK_BUTTON_TEXT }); + await expect(unlockBtn).toBeEnabled(); + }); + + test("successful unlock removes the modal and shows success alert", async ({ page }) => { + await mockRemovePasswordSuccess(page); + + await uploadEncryptedFile(page, ENCRYPTED_PDF); + await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 10000 }); + + await page.getByPlaceholder(PASSWORD_PLACEHOLDER).fill("testpass123"); + await page.getByRole("button", { name: UNLOCK_BUTTON_TEXT }).click(); + + // Modal should close after successful unlock + await expect(page.getByText(MODAL_TITLE)).toBeHidden({ timeout: 10000 }); + + // Success alert should appear + await expect(page.getByText("Password removed", { exact: true })).toBeVisible({ timeout: 5000 }); + }); + + test("incorrect password shows error message in modal", async ({ page }) => { + await mockRemovePasswordWrongPassword(page); + + await uploadEncryptedFile(page, ENCRYPTED_PDF); + await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 10000 }); + + await page.getByPlaceholder(PASSWORD_PLACEHOLDER).fill("wrongpassword"); + await page.getByRole("button", { name: UNLOCK_BUTTON_TEXT }).click(); + + // Error message should appear within the modal + await expect(page.getByText("Incorrect password")).toBeVisible({ timeout: 5000 }); + + // Modal should remain open + await expect(page.getByText(MODAL_TITLE)).toBeVisible(); + }); + + test("skip button closes the modal without unlocking", async ({ page }) => { + await uploadEncryptedFile(page, ENCRYPTED_PDF); + await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 10000 }); + + await page.getByRole("button", { name: SKIP_BUTTON_TEXT }).click(); + + // Modal should close + await expect(page.getByText(MODAL_TITLE)).toBeHidden({ timeout: 5000 }); + }); + + test("pressing Enter in password field triggers unlock", async ({ page }) => { + await mockRemovePasswordSuccess(page); + + await uploadEncryptedFile(page, ENCRYPTED_PDF); + await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 10000 }); + + const passwordInput = page.getByPlaceholder(PASSWORD_PLACEHOLDER); + await passwordInput.fill("testpass123"); + await passwordInput.press("Enter"); + + // Modal should close after successful unlock via Enter key + await expect(page.getByText(MODAL_TITLE)).toBeHidden({ timeout: 10000 }); + }); + + test("uploading a normal PDF does not show the unlock modal", async ({ page }) => { + await uploadFile(page, SAMPLE_PDF); + + // Wait for the file to finish processing, then verify no unlock modal appeared + await page.waitForTimeout(3000); + await expect(page.getByText(MODAL_TITLE)).toBeHidden(); + }); + + test("unlock all button is hidden with only one encrypted file", async ({ page }) => { + await uploadEncryptedFile(page, ENCRYPTED_PDF); + await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 10000 }); + + // The "Use for all" button should NOT appear with only one file + await expect(page.getByRole("button", { name: /Use for all/ })).toBeHidden(); + }); + + test("unlock all button appears with multiple encrypted files and unlocks all", async ({ page }) => { + await mockRemovePasswordSuccess(page); + + // Upload two encrypted files at once (different names to avoid deduplication) + await page.getByTestId("files-button").click(); + await page.waitForSelector(".mantine-Modal-overlay", { state: "visible", timeout: 5000 }); + await page.locator('[data-testid="file-input"]').setInputFiles([ + { name: "encrypted-a.pdf", mimeType: "application/pdf", buffer: fs.readFileSync(ENCRYPTED_PDF) }, + { name: "encrypted-b.pdf", mimeType: "application/pdf", buffer: fs.readFileSync(ENCRYPTED_PDF) }, + ]); + + // The unlock modal should appear for the first file with "Use for all" visible + await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 10000 }); + const unlockAllBtn = page.getByRole("button", { name: /Use for all/ }); + await expect(unlockAllBtn).toBeVisible({ timeout: 10000 }); + + // Enter password and click unlock all + await page.getByPlaceholder(PASSWORD_PLACEHOLDER).fill("testpass123"); + await unlockAllBtn.click(); + + // Modal should close — all files unlocked + await expect(page.getByText(MODAL_TITLE)).toBeHidden({ timeout: 10000 }); + }); + + test("unlock all with wrong password shows which files failed", async ({ page }) => { + await mockRemovePasswordWrongPassword(page); + + // Upload two encrypted files at once (different names to avoid deduplication) + await page.getByTestId("files-button").click(); + await page.waitForSelector(".mantine-Modal-overlay", { state: "visible", timeout: 5000 }); + await page.locator('[data-testid="file-input"]').setInputFiles([ + { name: "encrypted-a.pdf", mimeType: "application/pdf", buffer: fs.readFileSync(ENCRYPTED_PDF) }, + { name: "encrypted-b.pdf", mimeType: "application/pdf", buffer: fs.readFileSync(ENCRYPTED_PDF) }, + ]); + + // The unlock modal should appear with "Use for all" + await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 10000 }); + const unlockAllBtn = page.getByRole("button", { name: /Use for all/ }); + await expect(unlockAllBtn).toBeVisible({ timeout: 10000 }); + + await page.getByPlaceholder(PASSWORD_PLACEHOLDER).fill("wrongpassword"); + await unlockAllBtn.click(); + + // Modal should remain open with error about failed files + await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 5000 }); + await expect(page.getByText(/Wrong password for/)).toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/frontend/src/core/tests/test-fixtures/encrypted.pdf b/frontend/src/core/tests/test-fixtures/encrypted.pdf new file mode 100644 index 0000000000..28fb757bd2 Binary files /dev/null and b/frontend/src/core/tests/test-fixtures/encrypted.pdf differ diff --git a/frontend/src/core/utils/thumbnailUtils.ts b/frontend/src/core/utils/thumbnailUtils.ts index c7970ed643..da25483d04 100644 --- a/frontend/src/core/utils/thumbnailUtils.ts +++ b/frontend/src/core/utils/thumbnailUtils.ts @@ -554,11 +554,8 @@ async function generatePDFThumbnail(arrayBuffer: ArrayBuffer, file: File, scale: pdfWorkerManager.destroyDocument(pdf); return thumbnail; } catch (error) { - if (error instanceof Error) { - // Check if PDF is encrypted - if (error.name === "PasswordException") { - return generateEncryptedPDFThumbnail(file); - } + if (error && typeof error === "object" && (error as any).name === "PasswordException") { + return generateEncryptedPDFThumbnail(file); } throw error; // Not an encryption issue, re-throw } @@ -693,7 +690,7 @@ export async function generateThumbnailWithMetadata( pdfWorkerManager.destroyDocument(pdf); return { thumbnail, pageCount, pageRotations, pageDimensions }; } catch (error) { - if (error instanceof Error && error.name === "PasswordException") { + if (error && typeof error === "object" && (error as any).name === "PasswordException") { // Handle encrypted PDFs const thumbnail = generateEncryptedPDFThumbnail(file); return { thumbnail, pageCount: 1, isEncrypted: true }; diff --git a/frontend/src/core/utils/toolErrorHandler.ts b/frontend/src/core/utils/toolErrorHandler.ts index 355033c290..95a6a8f063 100644 --- a/frontend/src/core/utils/toolErrorHandler.ts +++ b/frontend/src/core/utils/toolErrorHandler.ts @@ -98,10 +98,29 @@ export const handlePasswordError = async ( const status = error?.response?.status; // Handle specific error cases with user-friendly messages + // Backend returns 400 with PdfPasswordException for incorrect/missing PDF passwords if (status === 500) { - // 500 typically means incorrect password for encrypted PDFs return incorrectPasswordMessage; } + if (status === 400) { + const data = error?.response?.data; + // ProblemDetail JSON has type "/errors/pdf-password", blob needs parsing + const isPasswordError = await (async () => { + if (data instanceof Blob) { + try { + const text = await data.text(); + return text.includes("pdf-password") || text.includes("passworded"); + } catch { + return false; + } + } + const type = data?.type ?? ""; + return type.includes("pdf-password"); + })(); + if (isPasswordError) { + return incorrectPasswordMessage; + } + } // For other errors, try to extract the message const normalizedData = await normalizeAxiosErrorData(error?.response?.data);