From c87da6d5cccaac150f09c547c451eee6745770a8 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 20 Nov 2025 14:54:41 +0000 Subject: [PATCH] Add automatic unlock prompt for encrypted PDFs (#4912) ## Summary - propagate an `isEncrypted` flag from thumbnail generation into processed file metadata so uploads know when a password is still present - add queueing logic inside `FileContext` that detects encrypted uploads, prompts the user via a new modal, and automatically runs the Remove Password endpoint to replace the file and preserve history - introduce a dedicated `EncryptedPdfUnlockModal` component that mirrors existing styling and messaging for unlocking PDFs ## Testing - npm run typecheck:core ------ [Codex Task](https://chatgpt.com/codex/tasks/task_b_6919a0a418bc8328b886ec76a28170b7) --- .../public/locales/en-GB/translation.json | 18 ++ .../fileEditor/FileEditorThumbnail.tsx | 26 ++- .../shared/EncryptedPdfUnlockModal.tsx | 85 ++++++++ frontend/src/core/contexts/FileContext.tsx | 199 +++++++++++++++++- .../src/core/contexts/file/fileActions.ts | 6 +- frontend/src/core/contexts/file/fileHooks.ts | 1 + .../buildRemovePasswordFormData.ts | 12 ++ .../useRemovePasswordOperation.ts | 10 +- frontend/src/core/types/fileContext.ts | 2 + frontend/src/core/utils/thumbnailUtils.ts | 3 +- frontend/src/core/utils/toolErrorHandler.ts | 34 +++ 11 files changed, 382 insertions(+), 14 deletions(-) create mode 100644 frontend/src/core/components/shared/EncryptedPdfUnlockModal.tsx create mode 100644 frontend/src/core/hooks/tools/removePassword/buildRemovePasswordFormData.ts diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 2b70c36ca..321129b67 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -5557,6 +5557,24 @@ "starting": "Backend starting up...", "wait": "Please wait for the backend to finish launching and try again." }, + "encryptedPdfUnlock": { + "unlockPrompt": "Unlock PDF to continue", + "title": "Remove password to continue", + "description": "This PDF is password protected. Enter the password so you can continue working with it.", + "password": { + "label": "PDF password", + "placeholder": "Enter the PDF password" + }, + "skip": "Skip for now", + "unlock": "Unlock & Continue", + "incorrectPassword": "Incorrect password", + "missingFile": "The selected file is no longer available.", + "emptyResponse": "Password removal did not produce a file.", + "required": "Enter the password to continue.", + "successTitle": "Password removed", + "successBodyWithName": "Password removed from {{fileName}}", + "successBody": "Password removed successfully." + }, "setup": { "welcome": "Welcome to Stirling PDF", "description": "Get started by choosing how you want to use Stirling PDF", diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx index e268b941c..7e734b42f 100644 --- a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -9,6 +9,7 @@ import VisibilityIcon from '@mui/icons-material/Visibility'; import UnarchiveIcon from '@mui/icons-material/Unarchive'; import PushPinIcon from '@mui/icons-material/PushPin'; import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined'; +import LockOpenIcon from '@mui/icons-material/LockOpen'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { StirlingFileStub } from '@app/types/fileContext'; @@ -56,7 +57,14 @@ const FileEditorThumbnail = ({ isSupported = true, }: FileEditorThumbnailProps) => { const { t } = useTranslation(); - const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions } = useFileContext(); + const { + pinFile, + unpinFile, + isFilePinned, + activeFiles, + actions: fileActions, + openEncryptedUnlockPrompt, + } = useFileContext(); const { state } = useFileState(); const hasError = state.ui.errorFileIds.includes(file.id); @@ -77,6 +85,7 @@ const FileEditorThumbnail = ({ const isZipFile = zipFileService.isZipFileStub(file); const pageCount = file.processedFile?.totalPages || 0; + const isEncrypted = Boolean(file.processedFile?.isEncrypted); const handleRef = useRef(null); @@ -301,6 +310,21 @@ const FileEditorThumbnail = ({ {/* Action buttons group */}
+ {isEncrypted && ( + + { + e.stopPropagation(); + openEncryptedUnlockPrompt(file.id); + }} + > + + + + )} {/* Pin/Unpin icon */} void; + onUnlock: () => void; + onSkip: () => void; +} + +const EncryptedPdfUnlockModal = ({ + opened, + fileName, + password, + errorMessage, + isProcessing, + onPasswordChange, + onUnlock, + onSkip, +}: EncryptedPdfUnlockModalProps) => { + const { t } = useTranslation(); + + const handleKeyDown: KeyboardEventHandler = (event) => { + if (event.key === 'Enter' && !isProcessing && password.trim().length > 0) { + onUnlock(); + } + }; + + return ( + + + {fileName} + + {t( + 'encryptedPdfUnlock.description', + 'This PDF is password protected. Enter the password so you can continue working with it.' + )} + + + + onPasswordChange(event.currentTarget.value)} + onKeyDown={handleKeyDown} + disabled={isProcessing} + autoFocus + /> + {errorMessage ? ( + + {errorMessage} + + ) : null} + + + + + + + + + ); +}; + +export default EncryptedPdfUnlockModal; diff --git a/frontend/src/core/contexts/FileContext.tsx b/frontend/src/core/contexts/FileContext.tsx index 56f1dcabd..378d43050 100644 --- a/frontend/src/core/contexts/FileContext.tsx +++ b/frontend/src/core/contexts/FileContext.tsx @@ -12,7 +12,7 @@ * Memory management handled by FileLifecycleManager (PDF.js cleanup, blob URL revocation). */ -import { useReducer, useCallback, useEffect, useRef, useMemo } from 'react'; +import { useReducer, useCallback, useEffect, useRef, useMemo, useState } from 'react'; import { FileContextProviderProps, FileContextSelectors, @@ -22,17 +22,27 @@ import { FileId, StirlingFileStub, StirlingFile, + createStirlingFile, } from '@app/types/fileContext'; // Import modular components import { fileContextReducer, initialFileContextState } from '@app/contexts/file/FileReducer'; import { createFileSelectors } from '@app/contexts/file/fileSelectors'; -import { addFiles, addStirlingFileStubs, consumeFiles, undoConsumeFiles, createFileActions } from '@app/contexts/file/fileActions'; +import { addFiles, addStirlingFileStubs, consumeFiles, undoConsumeFiles, createFileActions, createChildStub, generateProcessedFileMetadata } from '@app/contexts/file/fileActions'; import { FileLifecycleManager } from '@app/contexts/file/lifecycle'; import { FileStateContext, FileActionsContext } from '@app/contexts/file/contexts'; import { IndexedDBProvider, useIndexedDB } from '@app/contexts/IndexedDBContext'; import { useZipConfirmation } from '@app/hooks/useZipConfirmation'; import ZipWarningModal from '@app/components/shared/ZipWarningModal'; +import EncryptedPdfUnlockModal from '@app/components/shared/EncryptedPdfUnlockModal'; +import { useTranslation } from 'react-i18next'; +import { alert } from '@app/components/toast'; +import { buildRemovePasswordFormData } from '@app/hooks/tools/removePassword/buildRemovePasswordFormData'; +import type { RemovePasswordParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters'; +import apiClient from '@app/services/apiClient'; +import { processResponse } from '@app/utils/toolResponseProcessor'; +import { ToolOperation } from '@app/types/file'; +import { handlePasswordError } from '@app/utils/toolErrorHandler'; const DEBUG = process.env.NODE_ENV === 'development'; @@ -63,6 +73,98 @@ function FileContextInner({ lifecycleManagerRef.current = new FileLifecycleManager(filesRef, dispatch); } const lifecycleManager = lifecycleManagerRef.current; + const { t } = useTranslation(); + + const [encryptedQueue, setEncryptedQueue] = useState([]); + const [activeEncryptedFileId, setActiveEncryptedFileId] = useState(null); + const [unlockPassword, setUnlockPassword] = useState(''); + const [unlockError, setUnlockError] = useState(null); + const [isUnlocking, setIsUnlocking] = useState(false); + const dismissedEncryptedFilesRef = useRef>(new Set()); + const observedFileIdsRef = useRef>(new Set()); + + const enqueueEncryptedFiles = useCallback((fileIds: FileId[]) => { + if (fileIds.length === 0) return; + setEncryptedQueue(prevQueue => { + const existing = new Set(prevQueue); + const next = [...prevQueue]; + for (const id of fileIds) { + if (dismissedEncryptedFilesRef.current.has(id)) continue; + if (id === activeEncryptedFileId) continue; + if (existing.has(id)) continue; + existing.add(id); + next.push(id); + } + return next; + }); + }, [activeEncryptedFileId]); + + useEffect(() => { + const previousIds = observedFileIdsRef.current; + const nextIds = new Set(state.files.ids); + const newEncryptedIds: FileId[] = []; + + for (const id of state.files.ids) { + if (!previousIds.has(id)) { + const stub = state.files.byId[id]; + if ((stub?.versionNumber ?? 1) <= 1 && stub?.processedFile?.isEncrypted) { + newEncryptedIds.push(id); + } + } + } + + if (newEncryptedIds.length > 0) { + enqueueEncryptedFiles(newEncryptedIds); + } + + observedFileIdsRef.current = nextIds; + }, [state.files.ids, state.files.byId, enqueueEncryptedFiles]); + + useEffect(() => { + if (!activeEncryptedFileId && encryptedQueue.length > 0) { + setActiveEncryptedFileId(encryptedQueue[0]); + setEncryptedQueue(prev => prev.slice(1)); + } + }, [activeEncryptedFileId, encryptedQueue]); + + useEffect(() => { + if (activeEncryptedFileId && !state.files.ids.includes(activeEncryptedFileId)) { + setActiveEncryptedFileId(null); + } + }, [activeEncryptedFileId, state.files.ids]); + + useEffect(() => { + setUnlockPassword(''); + setUnlockError(null); + }, [activeEncryptedFileId]); + + const handleUnlockSkip = useCallback(() => { + if (activeEncryptedFileId) { + dismissedEncryptedFilesRef.current.add(activeEncryptedFileId); + } + setActiveEncryptedFileId(null); + }, [activeEncryptedFileId]); + + const promptEncryptedUnlock = useCallback((fileId: FileId) => { + const stub = stateRef.current.files.byId[fileId]; + if (!stub?.processedFile?.isEncrypted) { + return; + } + + dismissedEncryptedFilesRef.current.delete(fileId); + + setEncryptedQueue(prevQueue => prevQueue.filter(id => id !== fileId)); + + setActiveEncryptedFileId(currentActiveId => { + if (currentActiveId && currentActiveId !== fileId) { + setEncryptedQueue(prevQueue => { + const withoutDuplicates = prevQueue.filter(id => id !== currentActiveId && id !== fileId); + return [currentActiveId, ...withoutDuplicates]; + }); + } + return fileId; + }); + }, []); // Create stable selectors (memoized once to avoid re-renders) const selectors = useMemo(() => @@ -131,6 +233,80 @@ function FileContextInner({ return consumeFiles(inputFileIds, outputStirlingFiles, outputStirlingFileStubs, filesRef, dispatch); }, []); + const runAutomaticPasswordRemoval = useCallback(async (fileId: FileId, password: string): Promise => { + const file = filesRef.current.get(fileId); + const parentStub = stateRef.current.files.byId[fileId]; + + if (!file || !parentStub) { + throw new Error(t('encryptedPdfUnlock.missingFile', 'The selected file is no longer available.')); + } + + const params: RemovePasswordParameters = { password }; + const formData = buildRemovePasswordFormData(params, file); + + const response = await apiClient.post('/api/v1/security/remove-password', formData, { + responseType: 'blob', + suppressErrorToast: true // Handle errors in modal UI instead of toast + }); + const responseFiles = await processResponse(response.data, [file]); + + const unlockedFile = responseFiles[0]; + if (!unlockedFile) { + throw new Error(t('encryptedPdfUnlock.emptyResponse', 'Password removal did not produce a file.')); + } + + const processedMetadata = await generateProcessedFileMetadata(unlockedFile); + const thumbnail = processedMetadata?.thumbnailUrl; + + const operation: ToolOperation = { + toolId: 'removePassword', + timestamp: Date.now() + }; + + const childStub = createChildStub(parentStub, operation, unlockedFile, thumbnail, processedMetadata); + const stirlingUnlockedFile = createStirlingFile(unlockedFile, childStub.id); + + await consumeFilesWrapper([fileId], [stirlingUnlockedFile], [childStub]); + }, [consumeFilesWrapper, t]); + + const handleUnlockSubmit = useCallback(async () => { + if (!activeEncryptedFileId) return; + if (!unlockPassword.trim()) { + setUnlockError(t('encryptedPdfUnlock.required', 'Enter the password to continue.')); + return; + } + + setIsUnlocking(true); + setUnlockError(null); + try { + await runAutomaticPasswordRemoval(activeEncryptedFileId, unlockPassword.trim()); + const fileName = stateRef.current.files.byId[activeEncryptedFileId]?.name; + alert({ + alertType: 'success', + title: t('encryptedPdfUnlock.successTitle', 'Password removed'), + body: fileName + ? t('encryptedPdfUnlock.successBodyWithName', { + defaultValue: 'Removed password from {{fileName}}', + fileName, + }) + : t('encryptedPdfUnlock.successBody', 'Password removed successfully.'), + expandable: false, + isPersistentPopup: false, + }); + dismissedEncryptedFilesRef.current.delete(activeEncryptedFileId); + setActiveEncryptedFileId(null); + } catch (error) { + const errorMessage = await handlePasswordError( + error, + t('encryptedPdfUnlock.incorrectPassword', 'Incorrect password'), + t('removePassword.error.failed', 'An error occurred while removing the password from the PDF.') + ); + setUnlockError(errorMessage); + } finally { + setIsUnlocking(false); + } + }, [activeEncryptedFileId, unlockPassword, runAutomaticPasswordRemoval, t]); + const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise => { return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, filesRef, dispatch, indexedDB); }, [indexedDB]); @@ -199,7 +375,8 @@ function FileContextInner({ trackBlobUrl: lifecycleManager.trackBlobUrl, cleanupFile: (fileId: FileId) => lifecycleManager.cleanupFile(fileId, stateRef), scheduleCleanup: (fileId: FileId, delay?: number) => - lifecycleManager.scheduleCleanup(fileId, delay, stateRef) + lifecycleManager.scheduleCleanup(fileId, delay, stateRef), + openEncryptedUnlockPrompt: promptEncryptedUnlock }), [ baseActions, addRawFiles, @@ -211,7 +388,8 @@ function FileContextInner({ pinFileWrapper, unpinFileWrapper, indexedDB, - enablePersistence + enablePersistence, + promptEncryptedUnlock ]); // Split context values to minimize re-renders @@ -225,6 +403,9 @@ function FileContextInner({ dispatch }), [actions]); + const activeEncryptedStub = activeEncryptedFileId ? state.files.byId[activeEncryptedFileId] : undefined; + const isUnlockModalOpen = Boolean(activeEncryptedFileId && activeEncryptedStub); + // Persistence loading disabled - files only loaded on explicit user action // useEffect(() => { // if (!enablePersistence || !indexedDB) return; @@ -251,6 +432,16 @@ function FileContextInner({ fileCount={confirmationState.fileCount} zipFileName={confirmationState.fileName} /> + ); diff --git a/frontend/src/core/contexts/file/fileActions.ts b/frontend/src/core/contexts/file/fileActions.ts index d209781c8..40f2a313d 100644 --- a/frontend/src/core/contexts/file/fileActions.ts +++ b/frontend/src/core/contexts/file/fileActions.ts @@ -63,7 +63,7 @@ export function createProcessedFile( thumbnail?: string, pageRotations?: number[], pageDimensions?: Array<{ width: number; height: number }> -) { +): ProcessedFileMetadata { return { totalPages: pageCount, pages: Array.from({ length: pageCount }, (_, index) => ({ @@ -106,6 +106,10 @@ export async function generateProcessedFileMetadata(file: File): Promise { + const formData = new FormData(); + formData.append("fileInput", file); + formData.append("password", parameters.password); + return formData; +}; diff --git a/frontend/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts b/frontend/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts index ce6140730..4f31e8078 100644 --- a/frontend/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts +++ b/frontend/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts @@ -2,14 +2,10 @@ import { useTranslation } from 'react-i18next'; import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation'; import { createStandardErrorHandler } from '@app/utils/toolErrorHandler'; import { RemovePasswordParameters, defaultParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters'; +import { buildRemovePasswordFormData } from '@app/hooks/tools/removePassword/buildRemovePasswordFormData'; -// Static function that can be used by both the hook and automation executor -export const buildRemovePasswordFormData = (parameters: RemovePasswordParameters, file: File): FormData => { - const formData = new FormData(); - formData.append("fileInput", file); - formData.append("password", parameters.password); - return formData; -}; +// Re-export for backwards compatibility with any other imports +export { buildRemovePasswordFormData }; // Static configuration object export const removePasswordOperationConfig = { diff --git a/frontend/src/core/types/fileContext.ts b/frontend/src/core/types/fileContext.ts index ed2734039..d28ea3a0d 100644 --- a/frontend/src/core/types/fileContext.ts +++ b/frontend/src/core/types/fileContext.ts @@ -23,6 +23,7 @@ export interface ProcessedFileMetadata { pages: ProcessedFilePage[]; totalPages?: number; lastProcessed?: number; + isEncrypted?: boolean; [key: string]: any; } @@ -301,6 +302,7 @@ export interface FileContextActions { trackBlobUrl: (url: string) => void; scheduleCleanup: (fileId: FileId, delay?: number) => void; cleanupFile: (fileId: FileId) => void; + openEncryptedUnlockPrompt: (fileId: FileId) => void; } // File selectors (separate from actions to avoid re-renders) diff --git a/frontend/src/core/utils/thumbnailUtils.ts b/frontend/src/core/utils/thumbnailUtils.ts index 88c4aeaef..eda782550 100644 --- a/frontend/src/core/utils/thumbnailUtils.ts +++ b/frontend/src/core/utils/thumbnailUtils.ts @@ -5,6 +5,7 @@ export interface ThumbnailWithMetadata { pageCount: number; pageRotations?: number[]; // Rotation for each page (0, 90, 180, 270) pageDimensions?: Array<{ width: number; height: number }>; + isEncrypted?: boolean; } interface ColorScheme { @@ -451,7 +452,7 @@ export async function generateThumbnailWithMetadata(file: File, applyRotation: b if (error instanceof Error && error.name === "PasswordException") { // Handle encrypted PDFs const thumbnail = generateEncryptedPDFThumbnail(file); - return { thumbnail, pageCount: 1 }; + return { thumbnail, pageCount: 1, isEncrypted: true }; } const thumbnail = generatePlaceholderThumbnail(file); diff --git a/frontend/src/core/utils/toolErrorHandler.ts b/frontend/src/core/utils/toolErrorHandler.ts index 637970adf..836f1e00c 100644 --- a/frontend/src/core/utils/toolErrorHandler.ts +++ b/frontend/src/core/utils/toolErrorHandler.ts @@ -2,6 +2,8 @@ * Standardized error handling utilities for tool operations */ +import { normalizeAxiosErrorData } from '@app/services/errorUtils'; + /** * Default error extractor that follows the standard pattern */ @@ -30,4 +32,36 @@ export const createStandardErrorHandler = (fallbackMessage: string) => { } return fallbackMessage; }; +}; + +/** + * Handles password-related errors with status code checking + * @param error - The error object from axios + * @param incorrectPasswordMessage - Message to show for incorrect password (typically 500 status) + * @param fallbackMessage - Message to show for other errors + * @returns Error message string + */ +export const handlePasswordError = async ( + error: any, + incorrectPasswordMessage: string, + fallbackMessage: string +): Promise => { + const status = error?.response?.status; + + // Handle specific error cases with user-friendly messages + if (status === 500) { + // 500 typically means incorrect password for encrypted PDFs + return incorrectPasswordMessage; + } + + // For other errors, try to extract the message + const normalizedData = await normalizeAxiosErrorData(error?.response?.data); + const errorWithNormalizedData = { + ...error, + response: { + ...error?.response, + data: normalizedData + } + }; + return extractErrorMessage(errorWithNormalizedData) || fallbackMessage; }; \ No newline at end of file