diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 0f33b98e4..91e0e6ed5 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -35,11 +35,20 @@ "true": "True", "false": "False", "unknown": "Unknown", + "app": { + "description": "The Free Adobe Acrobat alternative (10M+ Downloads)" + }, "save": "Save", "saveToBrowser": "Save to Browser", + "download": "Download", + "editYourNewFiles": "Edit your new file(s)", "close": "Close", "fileSelected": "Selected: {{filename}}", "filesSelected": "{{count}} files selected", + "files": { + "title": "Files", + "placeholder": "Select a PDF file in the main view to get started" + }, "noFavourites": "No favourites added", "downloadComplete": "Download Complete", "bored": "Bored Waiting?", @@ -119,6 +128,7 @@ "page": "Page", "pages": "Pages", "loading": "Loading...", + "review": "Review", "addToDoc": "Add to Document", "reset": "Reset", "apply": "Apply", @@ -811,16 +821,6 @@ "removePages": { "tags": "Remove pages,delete pages" }, - "removePassword": { - "tags": "secure,Decrypt,security,unpassword,delete password", - "title": "Remove password", - "header": "Remove password (Decrypt)", - "selectText": { - "1": "Select PDF to Decrypt", - "2": "Password" - }, - "submit": "Remove" - }, "compressPdfs": { "tags": "squish,small,tiny" }, @@ -863,6 +863,7 @@ "ocr": { "tags": "recognition,text,image,scan,read,identify,detection,editable", "title": "OCR / Scan Cleanup", + "desc": "Cleanup scans and detects text from images within a PDF and re-adds it as text.", "header": "Cleanup Scans / OCR (Optical Character Recognition)", "selectText": { "1": "Select languages that are to be detected within the PDF (Ones listed are the ones currently detected):", @@ -1423,6 +1424,7 @@ }, "compress": { "title": "Compress", + "desc": "Compress PDFs to reduce their file size.", "header": "Compress PDF", "credit": "This service uses qpdf for PDF Compress/Optimisation.", "grayscale": { @@ -1781,8 +1783,8 @@ "error": { "failed": "An error occurred while encrypting the PDF." }, - "title": "Passwords & Encryption", "passwords": { + "stepTitle": "Passwords & Encryption", "completed": "Passwords configured", "user": { "label": "User Password", @@ -1822,6 +1824,7 @@ "bullet3": "256-bit: Maximum security, requires modern viewers" }, "permissions": { + "title": "Change Permissions", "text": "These permissions control what users can do with the PDF. Most effective when combined with an owner password." } } @@ -1831,7 +1834,6 @@ "desc": "Change document restrictions and permissions.", "completed": "Permissions changed", "submit": "Change Permissions", - "title": "Document Permissions", "error": { "failed": "An error occurred while changing PDF permissions." }, @@ -1875,5 +1877,27 @@ "text": "To make these permissions unchangeable, use the Add Password tool to set an owner password." } } + }, + "removePassword": { + "title": "Remove Password", + "desc": "Remove password protection from your PDF document.", + "tags": "secure,Decrypt,security,unpassword,delete password", + "password": { + "stepTitle": "Remove Password", + "label": "Current Password", + "placeholder": "Enter current password", + "completed": "Password configured" + }, + "filenamePrefix": "decrypted", + "error": { + "failed": "An error occurred while removing the password from the PDF." + }, + "tooltip": { + "description": "Removing password protection requires the password that was used to encrypt the PDF. This will decrypt the document, making it accessible without a password." + }, + "submit": "Remove Password", + "results": { + "title": "Decrypted PDFs" + } } } diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 944a1df22..967d7746d 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -35,11 +35,20 @@ "true": "True", "false": "False", "unknown": "Unknown", + "app": { + "description": "The Free Adobe Acrobat alternative (10M+ Downloads)" + }, "save": "Save", "saveToBrowser": "Save to Browser", + "download": "Download", + "editYourNewFiles": "Edit your new file(s)", "close": "Close", "fileSelected": "Selected: {{filename}}", "filesSelected": "{{count}} files selected", + "files": { + "title": "Files", + "placeholder": "Select a PDF file in the main view to get started" + }, "noFavourites": "No favorites added", "downloadComplete": "Download Complete", "bored": "Bored Waiting?", @@ -119,6 +128,7 @@ "page": "Page", "pages": "Pages", "loading": "Loading...", + "review": "Review", "addToDoc": "Add to Document", "reset": "Reset", "apply": "Apply", @@ -752,16 +762,6 @@ "removePages": { "tags": "Remove pages,delete pages" }, - "removePassword": { - "tags": "secure,Decrypt,security,unpassword,delete password", - "title": "Remove password", - "header": "Remove password (Decrypt)", - "selectText": { - "1": "Select PDF to Decrypt", - "2": "Password" - }, - "submit": "Remove" - }, "compressPdfs": { "tags": "squish,small,tiny" }, @@ -804,6 +804,7 @@ "ocr": { "tags": "recognition,text,image,scan,read,identify,detection,editable", "title": "OCR / Scan Cleanup", + "desc": "Cleanup scans and detects text from images within a PDF and re-adds it as text.", "header": "Cleanup Scans / OCR (Optical Character Recognition)", "selectText": { "1": "Select languages that are to be detected within the PDF (Ones listed are the ones currently detected):", @@ -1635,6 +1636,8 @@ } }, "addPassword": { + "title": "Add Password", + "desc": "Encrypt your PDF document with a password.", "completed": "Password protection applied", "submit": "Encrypt", "filenamePrefix": "encrypted", @@ -1686,6 +1689,7 @@ "bullet3": "256-bit: Maximum security, requires modern viewers" }, "permissions": { + "title": "Change Permissions", "text": "These permissions control what users can do with the PDF. Most effective when combined with an owner password." } } @@ -1737,5 +1741,27 @@ "text": "To make these permissions unchangeable, use the Add Password tool to set an owner password." } } + }, + "removePassword": { + "title": "Remove Password", + "desc": "Remove password protection from your PDF document.", + "tags": "secure,Decrypt,security,unpassword,delete password", + "password": { + "stepTitle": "Remove Password", + "label": "Current Password", + "placeholder": "Enter current password", + "completed": "Password configured" + }, + "filenamePrefix": "decrypted", + "error": { + "failed": "An error occurred while removing the password from the PDF." + }, + "tooltip": { + "description": "Removing password protection requires the password that was used to encrypt the PDF. This will decrypt the document, making it accessible without a password." + }, + "submit": "Remove Password", + "results": { + "title": "Decrypted PDFs" + } } } diff --git a/frontend/src/components/shared/LandingPage.tsx b/frontend/src/components/shared/LandingPage.tsx index 52d410cdb..4af3d1202 100644 --- a/frontend/src/components/shared/LandingPage.tsx +++ b/frontend/src/components/shared/LandingPage.tsx @@ -49,7 +49,7 @@ const LandingPage = () => { activateOnClick={false} styles={{ root: { - '&[data-accept]': { + '&[dataAccept]': { backgroundColor: 'var(--landing-drop-paper-bg)', }, }, diff --git a/frontend/src/components/tools/removePassword/RemovePasswordSettings.test.tsx b/frontend/src/components/tools/removePassword/RemovePasswordSettings.test.tsx new file mode 100644 index 000000000..9aac9cd31 --- /dev/null +++ b/frontend/src/components/tools/removePassword/RemovePasswordSettings.test.tsx @@ -0,0 +1,127 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MantineProvider } from '@mantine/core'; +import RemovePasswordSettings from './RemovePasswordSettings'; +import { defaultParameters } from '../../../hooks/tools/removePassword/useRemovePasswordParameters'; + +// Mock useTranslation with predictable return values +const mockT = vi.fn((key: string) => `mock-${key}`); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: mockT }) +})); + +// Wrapper component to provide Mantine context +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('RemovePasswordSettings', () => { + const mockOnParameterChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('should render password input field', () => { + render( + + + + ); + + expect(screen.getByText('mock-removePassword.password.label')).toBeInTheDocument(); + }); + + test('should call onParameterChange when password is entered', () => { + render( + + + + ); + + const passwordInput = screen.getByPlaceholderText('mock-removePassword.password.placeholder'); + fireEvent.change(passwordInput, { target: { value: 'test-password' } }); + + expect(mockOnParameterChange).toHaveBeenCalledWith('password', 'test-password'); + }); + + test('should display current password value', () => { + const parametersWithPassword = { ...defaultParameters, password: 'current-password' }; + + render( + + + + ); + + const passwordInput = screen.getByPlaceholderText('mock-removePassword.password.placeholder') as HTMLInputElement; + expect(passwordInput.value).toBe('current-password'); + }); + + test('should disable password input when disabled prop is true', () => { + render( + + + + ); + + const passwordInput = screen.getByPlaceholderText('mock-removePassword.password.placeholder'); + expect(passwordInput).toBeDisabled(); + }); + + test('should enable password input when disabled prop is false', () => { + render( + + + + ); + + const passwordInput = screen.getByPlaceholderText('mock-removePassword.password.placeholder'); + expect(passwordInput).not.toBeDisabled(); + }); + + test('should show password input as required', () => { + render( + + + + ); + + const passwordInput = screen.getByPlaceholderText('mock-removePassword.password.placeholder'); + expect(passwordInput).toHaveAttribute('required'); + }); + + test('should call translation function with correct keys', () => { + render( + + + + ); + + expect(mockT).toHaveBeenCalledWith('removePassword.password.label', 'Current Password'); + expect(mockT).toHaveBeenCalledWith('removePassword.password.placeholder', 'Enter current password'); + }); +}); diff --git a/frontend/src/components/tools/removePassword/RemovePasswordSettings.tsx b/frontend/src/components/tools/removePassword/RemovePasswordSettings.tsx new file mode 100644 index 000000000..4079ec21a --- /dev/null +++ b/frontend/src/components/tools/removePassword/RemovePasswordSettings.tsx @@ -0,0 +1,30 @@ +import { Stack, Text, PasswordInput } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { RemovePasswordParameters } from "../../../hooks/tools/removePassword/useRemovePasswordParameters"; + +interface RemovePasswordSettingsProps { + parameters: RemovePasswordParameters; + onParameterChange: (key: keyof RemovePasswordParameters, value: string) => void; + disabled?: boolean; +} + +const RemovePasswordSettings = ({ parameters, onParameterChange, disabled = false }: RemovePasswordSettingsProps) => { + const { t } = useTranslation(); + + return ( + + + onParameterChange('password', e.target.value)} + disabled={disabled} + required + /> + + + ); +}; + +export default RemovePasswordSettings; diff --git a/frontend/src/components/tools/shared/SuggestedToolsSection.tsx b/frontend/src/components/tools/shared/SuggestedToolsSection.tsx index eaedafaba..b1f91fc39 100644 --- a/frontend/src/components/tools/shared/SuggestedToolsSection.tsx +++ b/frontend/src/components/tools/shared/SuggestedToolsSection.tsx @@ -11,9 +11,9 @@ export function SuggestedToolsSection(): React.ReactElement { return ( - + - {t('editYourNewFiles', 'Edit your new File(s)')} + {t('editYourNewFiles', 'Edit your new file(s)')} @@ -39,4 +39,4 @@ export function SuggestedToolsSection(): React.ReactElement { ); -} \ No newline at end of file +} diff --git a/frontend/src/components/tooltips/useAddPasswordPermissionsTips.ts b/frontend/src/components/tooltips/useAddPasswordPermissionsTips.ts index b521e7045..696a4f9f3 100644 --- a/frontend/src/components/tooltips/useAddPasswordPermissionsTips.ts +++ b/frontend/src/components/tooltips/useAddPasswordPermissionsTips.ts @@ -6,7 +6,7 @@ export const useAddPasswordPermissionsTips = (): TooltipContent => { return { header: { - title: t("addPassword.tooltip.permissions.title", "Document Permissions") + title: t("addPassword.tooltip.permissions.title", "Change Permissions") }, tips: [ { diff --git a/frontend/src/components/tooltips/useRemovePasswordTips.ts b/frontend/src/components/tooltips/useRemovePasswordTips.ts new file mode 100644 index 000000000..1ec303cd6 --- /dev/null +++ b/frontend/src/components/tooltips/useRemovePasswordTips.ts @@ -0,0 +1,20 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const useRemovePasswordTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("removePassword.title", "Remove Password") + }, + tips: [ + { + description: t( + "removePassword.tooltip.description", + "Removing password protection requires the current password that was used to encrypt the PDF. This will decrypt the document, making it accessible without a password." + ) + } + ] + }; +}; diff --git a/frontend/src/contexts/FileContext.tsx b/frontend/src/contexts/FileContext.tsx index 7beedb5f2..13b1a77be 100644 --- a/frontend/src/contexts/FileContext.tsx +++ b/frontend/src/contexts/FileContext.tsx @@ -338,19 +338,19 @@ function fileContextReducer(state: FileContextState, action: FileContextAction): case 'CONSUME_FILES': { const { inputFiles, outputFiles } = action.payload; const unpinnedInputFiles = inputFiles.filter(file => !state.pinnedFiles.has(file)); - + // Remove unpinned input files and add output files const newActiveFiles = [ ...state.activeFiles.filter(file => !unpinnedInputFiles.includes(file)), ...outputFiles ]; - + // Update processed files map - remove consumed files, keep pinned ones const newProcessedFiles = new Map(state.processedFiles); unpinnedInputFiles.forEach(file => { newProcessedFiles.delete(file); }); - + return { ...state, activeFiles: newActiveFiles, @@ -617,7 +617,7 @@ export function FileContextProvider({ // File consumption function const consumeFiles = useCallback(async (inputFiles: File[], outputFiles: File[]): Promise => { dispatch({ type: 'CONSUME_FILES', payload: { inputFiles, outputFiles } }); - + // Store new output files if persistence is enabled if (enablePersistence) { for (const file of outputFiles) { @@ -625,7 +625,7 @@ export function FileContextProvider({ const fileId = getFileId(file); if (!fileId) { try { - const thumbnail = await (thumbnailGenerationService as any).generateThumbnail(file); + const thumbnail = await (thumbnailGenerationService as any /* FIX ME */).generateThumbnail(file); const storedFile = await fileStorage.storeFile(file, thumbnail); Object.defineProperty(file, 'id', { value: storedFile.id, writable: false }); } catch (thumbnailError) { diff --git a/frontend/src/hooks/tools/addPassword/useAddPasswordOperation.test.ts b/frontend/src/hooks/tools/addPassword/useAddPasswordOperation.test.ts index 5382f2f99..747cd67b7 100644 --- a/frontend/src/hooks/tools/addPassword/useAddPasswordOperation.test.ts +++ b/frontend/src/hooks/tools/addPassword/useAddPasswordOperation.test.ts @@ -49,25 +49,6 @@ describe('useAddPasswordOperation', () => { mockUseToolOperation.mockReturnValue(mockToolOperationReturn); }); - test('should configure useToolOperation with correct parameters', () => { - renderHook(() => useAddPasswordOperation()); - - expect(mockUseToolOperation).toHaveBeenCalledWith({ - operationType: 'addPassword', - endpoint: '/api/v1/security/add-password', - buildFormData: expect.any(Function), - filePrefix: 'translated-addPassword.filenamePrefix_', - multiFileEndpoint: false, - getErrorMessage: 'error-handler-function' - }); - }); - - test('should return the result from useToolOperation', () => { - const { result } = renderHook(() => useAddPasswordOperation()); - - expect(result.current).toBe(mockToolOperationReturn); - }); - test.each([ { description: 'with all parameters filled', diff --git a/frontend/src/hooks/tools/changePermissions/useChangePermissionsOperation.test.ts b/frontend/src/hooks/tools/changePermissions/useChangePermissionsOperation.test.ts index f6cedc0c3..845403841 100644 --- a/frontend/src/hooks/tools/changePermissions/useChangePermissionsOperation.test.ts +++ b/frontend/src/hooks/tools/changePermissions/useChangePermissionsOperation.test.ts @@ -48,25 +48,6 @@ describe('useChangePermissionsOperation', () => { mockUseToolOperation.mockReturnValue(mockToolOperationReturn); }); - test('should configure useToolOperation with correct parameters', () => { - renderHook(() => useChangePermissionsOperation()); - - expect(mockUseToolOperation).toHaveBeenCalledWith({ - operationType: 'changePermissions', - endpoint: '/api/v1/security/add-password', - buildFormData: expect.any(Function), - filePrefix: 'permissions_', - multiFileEndpoint: false, - getErrorMessage: 'error-handler-function' - }); - }); - - test('should return the result from useToolOperation', () => { - const { result } = renderHook(() => useChangePermissionsOperation()); - - expect(result.current).toBe(mockToolOperationReturn); - }); - test.each([ { preventAssembly: false, diff --git a/frontend/src/hooks/tools/removePassword/useRemovePasswordOperation.test.ts b/frontend/src/hooks/tools/removePassword/useRemovePasswordOperation.test.ts new file mode 100644 index 000000000..66cd240fc --- /dev/null +++ b/frontend/src/hooks/tools/removePassword/useRemovePasswordOperation.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useRemovePasswordOperation } from './useRemovePasswordOperation'; +import type { RemovePasswordParameters } from './useRemovePasswordParameters'; + +// Mock the useToolOperation hook +vi.mock('../shared/useToolOperation', () => ({ + useToolOperation: vi.fn() +})); + +// Mock the translation hook +const mockT = vi.fn((key: string) => `translated-${key}`); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: mockT }) +})); + +// Mock the error handler +vi.mock('../../../utils/toolErrorHandler', () => ({ + createStandardErrorHandler: vi.fn(() => 'error-handler-function') +})); + +// Import the mocked function +import { ToolOperationConfig, ToolOperationHook, useToolOperation } from '../shared/useToolOperation'; + +describe('useRemovePasswordOperation', () => { + const mockUseToolOperation = vi.mocked(useToolOperation); + + const getToolConfig = (): ToolOperationConfig => mockUseToolOperation.mock.calls[0][0]; + + const mockToolOperationReturn: ToolOperationHook = { + files: [], + thumbnails: [], + downloadUrl: null, + downloadFilename: '', + isLoading: false, + errorMessage: null, + status: '', + isGeneratingThumbnails: false, + progress: null, + executeOperation: vi.fn(), + resetResults: vi.fn(), + clearError: vi.fn(), + cancelOperation: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseToolOperation.mockReturnValue(mockToolOperationReturn); + }); + + test.each([ + { + description: 'with valid password', + password: 'test-password' + }, + { + description: 'with complex password', + password: 'C0mpl3x@P@ssw0rd!' + }, + { + description: 'with single character password', + password: 'a' + } + ])('should create form data correctly $description', ({ password }) => { + renderHook(() => useRemovePasswordOperation()); + + const callArgs = getToolConfig(); + const buildFormData = callArgs.buildFormData; + + const testParameters: RemovePasswordParameters = { + password + }; + + const testFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' }); + const formData = buildFormData(testParameters, testFile as any); + + // Verify the form data contains the file + expect(formData.get('fileInput')).toBe(testFile); + + // Verify password parameter + expect(formData.get('password')).toBe(password); + }); + + test('should use correct translation for error messages', () => { + renderHook(() => useRemovePasswordOperation()); + + expect(mockT).toHaveBeenCalledWith( + 'removePassword.error.failed', + 'An error occurred while removing the password from the PDF.' + ); + }); + + test.each([ + { property: 'multiFileEndpoint' as const, expectedValue: false }, + { property: 'endpoint' as const, expectedValue: '/api/v1/security/remove-password' }, + { property: 'filePrefix' as const, expectedValue: 'translated-removePassword.filenamePrefix_' }, + { property: 'operationType' as const, expectedValue: 'removePassword' } + ])('should configure $property correctly', ({ property, expectedValue }) => { + renderHook(() => useRemovePasswordOperation()); + + const callArgs = getToolConfig(); + expect(callArgs[property]).toBe(expectedValue); + }); +}); diff --git a/frontend/src/hooks/tools/removePassword/useRemovePasswordOperation.ts b/frontend/src/hooks/tools/removePassword/useRemovePasswordOperation.ts new file mode 100644 index 000000000..37f8fa0db --- /dev/null +++ b/frontend/src/hooks/tools/removePassword/useRemovePasswordOperation.ts @@ -0,0 +1,24 @@ +import { useTranslation } from 'react-i18next'; +import { useToolOperation } from '../shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; +import { RemovePasswordParameters } from './useRemovePasswordParameters'; + +export const useRemovePasswordOperation = () => { + const { t } = useTranslation(); + + const buildFormData = (parameters: RemovePasswordParameters, file: File): FormData => { + const formData = new FormData(); + formData.append("fileInput", file); + formData.append("password", parameters.password); + return formData; + }; + + return useToolOperation({ + operationType: 'removePassword', + endpoint: '/api/v1/security/remove-password', + buildFormData, + filePrefix: t('removePassword.filenamePrefix', 'decrypted') + '_', + multiFileEndpoint: false, + getErrorMessage: createStandardErrorHandler(t('removePassword.error.failed', 'An error occurred while removing the password from the PDF.')) + }); +}; diff --git a/frontend/src/hooks/tools/removePassword/useRemovePasswordParameters.test.ts b/frontend/src/hooks/tools/removePassword/useRemovePasswordParameters.test.ts new file mode 100644 index 000000000..f7310847b --- /dev/null +++ b/frontend/src/hooks/tools/removePassword/useRemovePasswordParameters.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useRemovePasswordParameters, defaultParameters } from './useRemovePasswordParameters'; + +describe('useRemovePasswordParameters', () => { + test('should initialize with default parameters', () => { + const { result } = renderHook(() => useRemovePasswordParameters()); + + expect(result.current.parameters).toStrictEqual(defaultParameters); + }); + + test('should update password parameter', () => { + const { result } = renderHook(() => useRemovePasswordParameters()); + + act(() => { + result.current.updateParameter('password', 'test-password'); + }); + + expect(result.current.parameters.password).toBe('test-password'); + }); + + test('should reset parameters to defaults', () => { + const { result } = renderHook(() => useRemovePasswordParameters()); + + // First, change the password + act(() => { + result.current.updateParameter('password', 'test-password'); + }); + + expect(result.current.parameters.password).toBe('test-password'); + + // Then reset + act(() => { + result.current.resetParameters(); + }); + + expect(result.current.parameters).toStrictEqual(defaultParameters); + }); + + test('should return correct endpoint name', () => { + const { result } = renderHook(() => useRemovePasswordParameters()); + + expect(result.current.getEndpointName()).toBe('remove-password'); + }); + + test.each([ + { + description: 'with valid password', + password: 'valid-password', + expectedValid: true + }, + { + description: 'with empty password', + password: '', + expectedValid: false + }, + { + description: 'with whitespace only password', + password: ' \t ', + expectedValid: true + }, + { + description: 'with password containing special characters', + password: 'p@ssw0rd!', + expectedValid: true + }, + { + description: 'with single character password', + password: 'a', + expectedValid: true + } + ])('should validate parameters correctly $description', ({ password, expectedValid }) => { + const { result } = renderHook(() => useRemovePasswordParameters()); + + act(() => { + result.current.updateParameter('password', password); + }); + + expect(result.current.validateParameters()).toBe(expectedValid); + }); +}); diff --git a/frontend/src/hooks/tools/removePassword/useRemovePasswordParameters.ts b/frontend/src/hooks/tools/removePassword/useRemovePasswordParameters.ts new file mode 100644 index 000000000..df6b117f2 --- /dev/null +++ b/frontend/src/hooks/tools/removePassword/useRemovePasswordParameters.ts @@ -0,0 +1,49 @@ +import { useState } from 'react'; + +export interface RemovePasswordParameters { + password: string; +} + +export interface RemovePasswordParametersHook { + parameters: RemovePasswordParameters; + updateParameter: (parameter: K, value: RemovePasswordParameters[K]) => void; + resetParameters: () => void; + validateParameters: () => boolean; + getEndpointName: () => string; +} + +export const defaultParameters: RemovePasswordParameters = { + password: '', +}; + +export const useRemovePasswordParameters = (): RemovePasswordParametersHook => { + const [parameters, setParameters] = useState(defaultParameters); + + const updateParameter = (parameter: K, value: RemovePasswordParameters[K]) => { + setParameters(prev => ({ + ...prev, + [parameter]: value, + }) + ); + }; + + const resetParameters = () => { + setParameters(defaultParameters); + }; + + const validateParameters = () => { + return parameters.password !== ''; + }; + + const getEndpointName = () => { + return 'remove-password'; + }; + + return { + parameters, + updateParameter, + resetParameters, + validateParameters, + getEndpointName, + }; +}; diff --git a/frontend/src/hooks/useToolManagement.tsx b/frontend/src/hooks/useToolManagement.tsx index 35a93765f..20da97e44 100644 --- a/frontend/src/hooks/useToolManagement.tsx +++ b/frontend/src/hooks/useToolManagement.tsx @@ -6,8 +6,9 @@ import SwapHorizIcon from "@mui/icons-material/SwapHoriz"; import ApiIcon from "@mui/icons-material/Api"; import CleaningServicesIcon from "@mui/icons-material/CleaningServices"; import LockIcon from "@mui/icons-material/Lock"; +import LockOpenIcon from "@mui/icons-material/LockOpen"; import { useMultipleEndpointsEnabled } from "./useEndpointConfig"; -import { Tool, ToolDefinition, BaseToolProps, ToolRegistry } from "../types/tool"; +import { Tool, ToolDefinition, ToolRegistry } from "../types/tool"; // Add entry here with maxFiles, endpoints, and lazy component @@ -104,6 +105,15 @@ const toolDefinitions: Record = { description: "Change document restrictions and permissions", endpoints: ["add-password"] }, + removePassword: { + id: "removePassword", + icon: , + component: React.lazy(() => import("../tools/RemovePassword")), + maxFiles: -1, + category: "security", + description: "Remove password protection from PDF files", + endpoints: ["remove-password"] + }, }; diff --git a/frontend/src/tools/AddPassword.tsx b/frontend/src/tools/AddPassword.tsx index 1b926aa4a..35e97e1b4 100644 --- a/frontend/src/tools/AddPassword.tsx +++ b/frontend/src/tools/AddPassword.tsx @@ -95,6 +95,7 @@ const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { disabled={endpointLoading} /> ), + tooltip: addPasswordPermissionsTips, }, ], executeButton: { diff --git a/frontend/src/tools/RemovePassword.tsx b/frontend/src/tools/RemovePassword.tsx new file mode 100644 index 000000000..42f22ba9c --- /dev/null +++ b/frontend/src/tools/RemovePassword.tsx @@ -0,0 +1,98 @@ +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useEndpointEnabled } from "../hooks/useEndpointConfig"; +import { useFileContext } from "../contexts/FileContext"; +import { useToolFileSelection } from "../contexts/FileSelectionContext"; + +import { createToolFlow } from "../components/tools/shared/createToolFlow"; + +import RemovePasswordSettings from "../components/tools/removePassword/RemovePasswordSettings"; + +import { useRemovePasswordParameters } from "../hooks/tools/removePassword/useRemovePasswordParameters"; +import { useRemovePasswordOperation } from "../hooks/tools/removePassword/useRemovePasswordOperation"; +import { useRemovePasswordTips } from "../components/tooltips/useRemovePasswordTips"; +import { BaseToolProps } from "../types/tool"; + +const RemovePassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { + const { t } = useTranslation(); + const { setCurrentMode } = useFileContext(); + const { selectedFiles } = useToolFileSelection(); + + const removePasswordParams = useRemovePasswordParameters(); + const removePasswordOperation = useRemovePasswordOperation(); + const removePasswordTips = useRemovePasswordTips(); + + // Endpoint validation + const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(removePasswordParams.getEndpointName()); + + useEffect(() => { + removePasswordOperation.resetResults(); + onPreviewFile?.(null); + }, [removePasswordParams.parameters]); + + const handleRemovePassword = async () => { + try { + await removePasswordOperation.executeOperation(removePasswordParams.parameters, selectedFiles); + if (removePasswordOperation.files && onComplete) { + onComplete(removePasswordOperation.files); + } + } catch (error) { + if (onError) { + onError(error instanceof Error ? error.message : t("removePassword.error.failed", "Remove password operation failed")); + } + } + }; + + const handleThumbnailClick = (file: File) => { + onPreviewFile?.(file); + sessionStorage.setItem("previousMode", "removePassword"); + setCurrentMode("viewer"); + }; + + const handleSettingsReset = () => { + removePasswordOperation.resetResults(); + onPreviewFile?.(null); + setCurrentMode("removePassword"); + }; + + const hasFiles = selectedFiles.length > 0; + const hasResults = removePasswordOperation.files.length > 0 || removePasswordOperation.downloadUrl !== null; + const passwordCollapsed = !hasFiles || hasResults; + + return createToolFlow({ + files: { + selectedFiles, + isCollapsed: hasFiles || hasResults, + }, + steps: [ + { + title: t("removePassword.password.stepTitle", "Remove Password"), + isCollapsed: passwordCollapsed, + onCollapsedClick: hasResults ? handleSettingsReset : undefined, + tooltip: removePasswordTips, + content: ( + + ), + }, + ], + executeButton: { + text: t("removePassword.submit", "Remove Password"), + isVisible: !hasResults, + loadingText: t("loading"), + onClick: handleRemovePassword, + disabled: !removePasswordParams.validateParameters() || !hasFiles || !endpointEnabled, + }, + review: { + isVisible: hasResults, + operation: removePasswordOperation, + title: t("removePassword.results.title", "Decrypted PDFs"), + onFileClick: handleThumbnailClick, + }, + }); +}; + +export default RemovePassword; diff --git a/frontend/src/types/fileContext.ts b/frontend/src/types/fileContext.ts index cdde6b51c..7a8c4d2ed 100644 --- a/frontend/src/types/fileContext.ts +++ b/frontend/src/types/fileContext.ts @@ -5,18 +5,19 @@ import { ProcessedFile } from './processing'; import { PDFDocument, PDFPage, PageOperation } from './pageEditor'; -export type ModeType = - | 'viewer' - | 'pageEditor' - | 'fileEditor' - | 'merge' - | 'split' - | 'compress' - | 'ocr' - | 'convert' +export type ModeType = + | 'viewer' + | 'pageEditor' + | 'fileEditor' + | 'merge' + | 'split' + | 'compress' + | 'ocr' + | 'convert' | 'sanitize' | 'addPassword' - | 'changePermissions'; + | 'changePermissions' + | 'removePassword'; export type ViewType = 'viewer' | 'pageEditor' | 'fileEditor'; diff --git a/frontend/src/utils/thumbnailUtils.ts b/frontend/src/utils/thumbnailUtils.ts index 6f69279d5..72e1bc392 100644 --- a/frontend/src/utils/thumbnailUtils.ts +++ b/frontend/src/utils/thumbnailUtils.ts @@ -1,5 +1,15 @@ import { getDocument } from "pdfjs-dist"; +interface ColorScheme { + bgTop: string; + bgBottom: string; + border: string; + icon: string; + badge: string; + textPrimary: string; + textSecondary: string; +} + /** * Calculate thumbnail scale based on file size * Smaller files get higher quality, larger files get lower quality @@ -14,6 +24,54 @@ export function calculateScaleFromFileSize(fileSize: number): number { return 0.15; // 30MB+: Low quality } +/** + * Generate encrypted PDF thumbnail with lock icon + */ +function generateEncryptedPDFThumbnail(file: File): string { + const canvas = document.createElement('canvas'); + canvas.width = 120; + canvas.height = 150; + const ctx = canvas.getContext('2d')!; + + // Use PDF color scheme but with encrypted styling + const colorScheme = getFileTypeColorScheme('PDF'); + + // Create gradient background + const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height); + gradient.addColorStop(0, colorScheme.bgTop); + gradient.addColorStop(1, colorScheme.bgBottom); + + // Rounded rectangle background + drawRoundedRect(ctx, 8, 8, canvas.width - 16, canvas.height - 16, 8); + ctx.fillStyle = gradient; + ctx.fill(); + + // Border with dashed pattern for encrypted indicator + ctx.strokeStyle = colorScheme.border; + ctx.lineWidth = 2; + ctx.setLineDash([4, 4]); + ctx.stroke(); + ctx.setLineDash([]); // Reset dash pattern + + // Large lock icon as main element + drawLargeLockIcon(ctx, canvas.width / 2, canvas.height / 2 - 10, colorScheme); + + // "PDF" text under the lock + ctx.font = 'bold 14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; + ctx.fillStyle = colorScheme.icon; + ctx.textAlign = 'center'; + ctx.fillText('PDF', canvas.width / 2, canvas.height / 2 + 35); + + // File size with subtle styling + const sizeText = formatFileSize(file.size); + ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; + ctx.fillStyle = colorScheme.textSecondary; + ctx.textAlign = 'center'; + ctx.fillText(sizeText, canvas.width / 2, canvas.height - 15); + + return canvas.toDataURL(); +} + /** * Generate modern placeholder thumbnail with file extension */ @@ -22,71 +80,71 @@ function generatePlaceholderThumbnail(file: File): string { canvas.width = 120; canvas.height = 150; const ctx = canvas.getContext('2d')!; - + // Get file extension for color theming const extension = file.name.split('.').pop()?.toUpperCase() || 'FILE'; const colorScheme = getFileTypeColorScheme(extension); - + // Create gradient background const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height); gradient.addColorStop(0, colorScheme.bgTop); gradient.addColorStop(1, colorScheme.bgBottom); - + // Rounded rectangle background drawRoundedRect(ctx, 8, 8, canvas.width - 16, canvas.height - 16, 8); ctx.fillStyle = gradient; ctx.fill(); - + // Subtle shadow/border ctx.strokeStyle = colorScheme.border; ctx.lineWidth = 1.5; ctx.stroke(); - + // Modern document icon drawModernDocumentIcon(ctx, canvas.width / 2, 45, colorScheme.icon); - + // Extension badge drawExtensionBadge(ctx, canvas.width / 2, canvas.height / 2 + 15, extension, colorScheme); - + // File size with subtle styling const sizeText = formatFileSize(file.size); ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; ctx.fillStyle = colorScheme.textSecondary; ctx.textAlign = 'center'; ctx.fillText(sizeText, canvas.width / 2, canvas.height - 15); - + return canvas.toDataURL(); } /** * Get color scheme based on file extension */ -function getFileTypeColorScheme(extension: string) { - const schemes: Record = { +function getFileTypeColorScheme(extension: string): ColorScheme { + const schemes: Record = { // Documents 'PDF': { bgTop: '#FF6B6B20', bgBottom: '#FF6B6B10', border: '#FF6B6B40', icon: '#FF6B6B', badge: '#FF6B6B', textPrimary: '#FFFFFF', textSecondary: '#666666' }, 'DOC': { bgTop: '#4ECDC420', bgBottom: '#4ECDC410', border: '#4ECDC440', icon: '#4ECDC4', badge: '#4ECDC4', textPrimary: '#FFFFFF', textSecondary: '#666666' }, 'DOCX': { bgTop: '#4ECDC420', bgBottom: '#4ECDC410', border: '#4ECDC440', icon: '#4ECDC4', badge: '#4ECDC4', textPrimary: '#FFFFFF', textSecondary: '#666666' }, 'TXT': { bgTop: '#95A5A620', bgBottom: '#95A5A610', border: '#95A5A640', icon: '#95A5A6', badge: '#95A5A6', textPrimary: '#FFFFFF', textSecondary: '#666666' }, - + // Spreadsheets 'XLS': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' }, 'XLSX': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' }, 'CSV': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' }, - + // Presentations 'PPT': { bgTop: '#E67E2220', bgBottom: '#E67E2210', border: '#E67E2240', icon: '#E67E22', badge: '#E67E22', textPrimary: '#FFFFFF', textSecondary: '#666666' }, 'PPTX': { bgTop: '#E67E2220', bgBottom: '#E67E2210', border: '#E67E2240', icon: '#E67E22', badge: '#E67E22', textPrimary: '#FFFFFF', textSecondary: '#666666' }, - + // Archives 'ZIP': { bgTop: '#9B59B620', bgBottom: '#9B59B610', border: '#9B59B640', icon: '#9B59B6', badge: '#9B59B6', textPrimary: '#FFFFFF', textSecondary: '#666666' }, 'RAR': { bgTop: '#9B59B620', bgBottom: '#9B59B610', border: '#9B59B640', icon: '#9B59B6', badge: '#9B59B6', textPrimary: '#FFFFFF', textSecondary: '#666666' }, '7Z': { bgTop: '#9B59B620', bgBottom: '#9B59B610', border: '#9B59B640', icon: '#9B59B6', badge: '#9B59B6', textPrimary: '#FFFFFF', textSecondary: '#666666' }, - + // Default 'DEFAULT': { bgTop: '#74B9FF20', bgBottom: '#74B9FF10', border: '#74B9FF40', icon: '#74B9FF', badge: '#74B9FF', textPrimary: '#FFFFFF', textSecondary: '#666666' } }; - + return schemes[extension] || schemes['DEFAULT']; } @@ -115,11 +173,11 @@ function drawModernDocumentIcon(ctx: CanvasRenderingContext2D, centerX: number, ctx.fillStyle = color; ctx.strokeStyle = color; ctx.lineWidth = 2; - + // Document body drawRoundedRect(ctx, centerX - size/2, centerY - size/2, size, size * 1.2, 3); ctx.fill(); - + // Folded corner ctx.beginPath(); ctx.moveTo(centerX + size/2 - 6, centerY - size/2); @@ -130,18 +188,73 @@ function drawModernDocumentIcon(ctx: CanvasRenderingContext2D, centerX: number, ctx.fill(); } +/** + * Draw large lock icon for encrypted PDFs + */ +function drawLargeLockIcon(ctx: CanvasRenderingContext2D, centerX: number, centerY: number, colorScheme: ColorScheme) { + const size = 48; + ctx.fillStyle = colorScheme.icon; + ctx.strokeStyle = colorScheme.icon; + ctx.lineWidth = 3; + + // Lock body (rectangle) + const bodyWidth = size; + const bodyHeight = size * 0.75; + const bodyX = centerX - bodyWidth / 2; + const bodyY = centerY - bodyHeight / 4; + + drawRoundedRect(ctx, bodyX, bodyY, bodyWidth, bodyHeight, 4); + ctx.fill(); + + // Lock shackle (semicircle) + const shackleRadius = size * 0.32; + const shackleY = centerY - size * 0.25; + + ctx.beginPath(); + ctx.arc(centerX, shackleY, shackleRadius, Math.PI, 2 * Math.PI); + ctx.stroke(); + + // Keyhole + const keyholeX = centerX; + const keyholeY = bodyY + bodyHeight * 0.4; + ctx.fillStyle = colorScheme.textPrimary; + ctx.beginPath(); + ctx.arc(keyholeX, keyholeY, 4, 0, 2 * Math.PI); + ctx.fill(); + ctx.fillRect(keyholeX - 2, keyholeY, 4, 8); +} + +/** + * Generate standard PDF thumbnail by rendering first page + */ +async function generateStandardPDFThumbnail(pdf: any, scale: number): Promise { + const page = await pdf.getPage(1); + const viewport = page.getViewport({ scale }); + const canvas = document.createElement("canvas"); + canvas.width = viewport.width; + canvas.height = viewport.height; + const context = canvas.getContext("2d"); + + if (!context) { + throw new Error('Could not get canvas context'); + } + + await page.render({ canvasContext: context, viewport }).promise; + return canvas.toDataURL(); +} + /** * Draw extension badge */ -function drawExtensionBadge(ctx: CanvasRenderingContext2D, centerX: number, centerY: number, extension: string, colorScheme: any) { +function drawExtensionBadge(ctx: CanvasRenderingContext2D, centerX: number, centerY: number, extension: string, colorScheme: ColorScheme) { const badgeWidth = Math.max(extension.length * 8 + 16, 40); const badgeHeight = 22; - + // Badge background drawRoundedRect(ctx, centerX - badgeWidth/2, centerY - badgeHeight/2, badgeWidth, badgeHeight, 11); ctx.fillStyle = colorScheme.badge; ctx.fill(); - + // Badge text ctx.font = 'bold 11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; ctx.fillStyle = colorScheme.textPrimary; @@ -160,6 +273,29 @@ function formatFileSize(bytes: number): string { return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; } +async function generatePDFThumbnail(arrayBuffer: ArrayBuffer, file: File, scale: number): Promise { + try { + const pdf = await getDocument({ + data: arrayBuffer, + disableAutoFetch: true, + disableStream: true + }).promise; + + const thumbnail = await generateStandardPDFThumbnail(pdf, scale); + + // Immediately clean up memory after thumbnail generation + pdf.destroy(); + return thumbnail; + } catch (error) { + if (error instanceof Error) { + // Check if PDF is encrypted + if (error.name === "PasswordException") { + return generateEncryptedPDFThumbnail(file); + } + } + throw error; // Not an encryption issue, re-throw + } +} /** * Generate thumbnail for any file type @@ -187,77 +323,27 @@ export async function generateThumbnailForFile(file: File): Promise