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