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)
This commit is contained in:
Anthony Stirling
2025-11-20 14:54:41 +00:00
committed by GitHub
parent 6c8d2c89fe
commit c87da6d5cc
11 changed files with 382 additions and 14 deletions
@@ -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<HTMLSpanElement | null>(null);
@@ -301,6 +310,21 @@ const FileEditorThumbnail = ({
{/* Action buttons group */}
<div className={styles.headerActions}>
{isEncrypted && (
<Tooltip label={t('encryptedPdfUnlock.unlockPrompt', 'Unlock PDF to continue')}>
<ActionIcon
aria-label={t('encryptedPdfUnlock.unlockPrompt', 'Unlock PDF to continue')}
variant="subtle"
className={styles.headerIconButton}
onClick={(e) => {
e.stopPropagation();
openEncryptedUnlockPrompt(file.id);
}}
>
<LockOpenIcon fontSize="small" />
</ActionIcon>
</Tooltip>
)}
{/* Pin/Unpin icon */}
<Tooltip label={isPinned ? t('unpin', 'Unpin File (replace after tool run)') : t('pin', 'Pin File (keep active after tool run)')}>
<ActionIcon
@@ -0,0 +1,85 @@
import { Modal, Stack, Text, Button, PasswordInput, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { type KeyboardEventHandler } from 'react';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
interface EncryptedPdfUnlockModalProps {
opened: boolean;
fileName?: string;
password: string;
errorMessage?: string | null;
isProcessing: boolean;
onPasswordChange: (value: string) => void;
onUnlock: () => void;
onSkip: () => void;
}
const EncryptedPdfUnlockModal = ({
opened,
fileName,
password,
errorMessage,
isProcessing,
onPasswordChange,
onUnlock,
onSkip,
}: EncryptedPdfUnlockModalProps) => {
const { t } = useTranslation();
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
if (event.key === 'Enter' && !isProcessing && password.trim().length > 0) {
onUnlock();
}
};
return (
<Modal
opened={opened}
onClose={onSkip}
title={t('encryptedPdfUnlock.title', 'Remove password to continue')}
centered
size="md"
closeOnClickOutside={!isProcessing}
closeOnEscape={!isProcessing}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
>
<Stack gap="md">
<Text fw={600} ta="center">{fileName}</Text>
<Text c="dimmed" ta="center">
{t(
'encryptedPdfUnlock.description',
'This PDF is password protected. Enter the password so you can continue working with it.'
)}
</Text>
<Stack gap={4}>
<PasswordInput
label={t('encryptedPdfUnlock.password.label', 'PDF password')}
placeholder={t('encryptedPdfUnlock.password.placeholder', 'Enter the PDF password')}
value={password}
onChange={(event) => onPasswordChange(event.currentTarget.value)}
onKeyDown={handleKeyDown}
disabled={isProcessing}
autoFocus
/>
{errorMessage ? (
<Text c="red" size="sm">
{errorMessage}
</Text>
) : null}
</Stack>
<Group justify="space-between">
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={onSkip} disabled={isProcessing}>
{t('encryptedPdfUnlock.skip', 'Skip for now')}
</Button>
<Button onClick={onUnlock} loading={isProcessing} disabled={password.trim().length === 0}>
{t('encryptedPdfUnlock.unlock', 'Unlock & Continue')}
</Button>
</Group>
</Stack>
</Modal>
);
};
export default EncryptedPdfUnlockModal;