From 76aa5c7e2f2606662a3695795fc665f2c4e155d8 Mon Sep 17 00:00:00 2001
From: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
Date: Mon, 13 Apr 2026 13:20:43 +0100
Subject: [PATCH] Fix encrypted pdf handling (#6088)
Fix and improve encrypted pdf handling
---
frontend/package-lock.json | 69 ++---
.../public/locales/en-GB/translation.toml | 5 +
.../shared/EncryptedPdfUnlockModal.tsx | 17 +-
.../core/components/viewer/EmbedPdfViewer.tsx | 29 +-
frontend/src/core/contexts/FileContext.tsx | 62 ++++
.../src/core/contexts/file/fileActions.ts | 16 +
.../hooks/tools/shared/useToolOperation.ts | 19 ++
frontend/src/core/services/fileAnalyzer.ts | 62 +++-
.../EncryptedPdfUnlockE2E.spec.ts | 285 ++++++++++++++++++
.../core/tests/test-fixtures/encrypted.pdf | Bin 0 -> 50417 bytes
frontend/src/core/utils/thumbnailUtils.ts | 9 +-
frontend/src/core/utils/toolErrorHandler.ts | 21 +-
12 files changed, 527 insertions(+), 67 deletions(-)
create mode 100644 frontend/src/core/tests/encryptedPdf/EncryptedPdfUnlockE2E.spec.ts
create mode 100644 frontend/src/core/tests/test-fixtures/encrypted.pdf
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 0000000000000000000000000000000000000000..28fb757bd2e15c6b6f9862a407b8f20e39c36f55
GIT binary patch
literal 50417
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