Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 8 additions & 5 deletions src/action/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { VaultProvider } from '@tetherto/pearpass-lib-vault'
import { createRoot } from 'react-dom/client'

import { AutoLockProvider } from '../hooks/useAutoLockPreferences'
import { ClipboardClearProvider } from '../hooks/useClipboardClearPreferences'
import { messages } from '../locales/en/messages.mjs'
import { createClient } from '../shared/client'
import { AppWithBlockingState } from '../shared/containers/AppWithBlockingState'
Expand Down Expand Up @@ -48,11 +49,13 @@ createRoot(document.getElementById('root')).render(
<I18nProvider i18n={i18n}>
<RouterProvider>
<AutoLockProvider>
<ModalProvider>
<AppHeaderContextProvider>
<AppWithBlockingState />
</AppHeaderContextProvider>
</ModalProvider>
<ClipboardClearProvider>
<ModalProvider>
<AppHeaderContextProvider>
<AppWithBlockingState />
</AppHeaderContextProvider>
</ModalProvider>
</ClipboardClearProvider>
</AutoLockProvider>
</RouterProvider>
</I18nProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,13 @@ jest.mock('@tetherto/pearpass-lib-constants', () => ({
SECONDS_30: { label: '30 seconds', value: 30_000 },
MINUTES_1: { label: '1 minute', value: 60_000 },
NEVER: { label: 'Never', value: null }
}
},
CLIPBOARD_CLEAR_TIMEOUT_OPTIONS: {
MINUTES_1: { label: '1 minute', value: 60_000 },
MINUTES_5: { label: '5 minutes', value: 300_000 },
NEVER: { label: 'Never', value: null }
},
DEFAULT_CLIPBOARD_CLEAR_TIMEOUT: 60_000
}))

const mockSetAutofill = jest.fn(async () => undefined)
Expand All @@ -142,10 +148,12 @@ jest.mock('@lingui/react', () => ({
}))

const mockSetTimeoutMs = jest.fn()
const mockSetClipboardClearTimeoutMs = jest.fn()
const mockSetAllowHttp = jest.fn()
const mockHandleCopyChange = jest.fn()

let mockTimeoutMs: number | null = 30_000
let mockClipboardClearTimeoutMs: number | null = 60_000
let mockIsAllowHttpEnabled = false
let mockIsCopyEnabled = true

Expand All @@ -157,6 +165,14 @@ jest.mock('../../../../../hooks/useAutoLockPreferences', () => ({
})
}))

jest.mock('../../../../../hooks/useClipboardClearPreferences', () => ({
__esModule: true,
useClipboardClearPreferences: () => ({
clipboardClearTimeoutMs: mockClipboardClearTimeoutMs,
setClipboardClearTimeoutMs: mockSetClipboardClearTimeoutMs
})
}))

jest.mock('../../../../../shared/hooks/useAllowHttpEnabled', () => ({
__esModule: true,
useAllowHttpEnabled: () => [mockIsAllowHttpEnabled, mockSetAllowHttp]
Expand All @@ -183,12 +199,14 @@ import { AppPreferencesContent } from './index'
describe('AppPreferencesContent', () => {
beforeEach(() => {
mockSetTimeoutMs.mockClear()
mockSetClipboardClearTimeoutMs.mockClear()
mockSetAllowHttp.mockClear()
mockHandleCopyChange.mockClear()
mockSetAutofill.mockClear()
mockGetAutofill.mockClear()
mockGetAutofill.mockResolvedValue(true)
mockTimeoutMs = 30_000
mockClipboardClearTimeoutMs = 60_000
mockIsAllowHttpEnabled = false
mockIsCopyEnabled = true
localStorage.clear()
Expand All @@ -205,11 +223,14 @@ describe('AppPreferencesContent', () => {
expect(screen.getByText('Security Awareness')).toBeInTheDocument()
})

it('renders only in-scope fields (no Clear Clipboard, no Unlock Method)', () => {
it('renders all expected fields (no Unlock Method)', () => {
render(<AppPreferencesContent />)

expect(screen.getByTestId('settings-autofill-toggle')).toBeInTheDocument()
expect(screen.getByTestId('settings-allow-http-toggle')).toBeInTheDocument()
expect(
screen.getByTestId('settings-clipboard-clear-select')
).toBeInTheDocument()
expect(screen.getByTestId('settings-auto-lock-select')).toBeInTheDocument()
expect(
screen.getByTestId('settings-copy-to-clipboard-toggle')
Expand All @@ -219,7 +240,6 @@ describe('AppPreferencesContent', () => {
screen.getByTestId('settings-passkey-validation')
).toBeInTheDocument()

expect(screen.queryByText('Clear Clipboard')).not.toBeInTheDocument()
expect(screen.queryByText('Unlock Method')).not.toBeInTheDocument()
expect(screen.queryByText('Master Password')).not.toBeInTheDocument()
expect(screen.queryByText('PIN Code')).not.toBeInTheDocument()
Expand Down Expand Up @@ -286,4 +306,13 @@ describe('AppPreferencesContent', () => {

expect(mockSetTimeoutMs).toHaveBeenCalledWith(60_000)
})

it('opens the clipboard clear dropdown and calls setClipboardClearTimeoutMs on selection', () => {
render(<AppPreferencesContent />)

fireEvent.click(screen.getByTestId('settings-clipboard-clear-select'))
fireEvent.click(screen.getByTestId('settings-clipboard-clear-option-never'))

expect(mockSetClipboardClearTimeoutMs).toHaveBeenCalledWith(null)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { t } from '@lingui/core/macro'
import { useLingui } from '@lingui/react'
import {
AUTO_LOCK_TIMEOUT_OPTIONS,
BE_AUTO_LOCK_ENABLED
BE_AUTO_LOCK_ENABLED,
CLIPBOARD_CLEAR_TIMEOUT_OPTIONS,
DEFAULT_CLIPBOARD_CLEAR_TIMEOUT
} from '@tetherto/pearpass-lib-constants'
import {
Button,
Expand All @@ -19,6 +21,7 @@ import {
import { KeyboardArrowBottom } from '@tetherto/pearpass-lib-ui-kit/icons'

import { useAutoLockPreferences } from '../../../../../hooks/useAutoLockPreferences'
import { useClipboardClearPreferences } from '../../../../../hooks/useClipboardClearPreferences'
import {
LOCAL_STORAGE_KEYS,
PASSKEY_VERIFICATION_OPTIONS
Expand All @@ -36,6 +39,8 @@ const TEST_IDS = {
root: 'settings-app-preferences',
autofillToggle: 'settings-autofill-toggle',
allowHttpToggle: 'settings-allow-http-toggle',
clipboardClearSelect: 'settings-clipboard-clear-select',
clipboardClearOption: 'settings-clipboard-clear-option',
autoLockSelect: 'settings-auto-lock-select',
autoLockOption: 'settings-auto-lock-option',
copyToClipboardToggle: 'settings-copy-to-clipboard-toggle',
Expand All @@ -56,6 +61,13 @@ const TIMEOUT_OPTIONS: TimeoutOption[] = Object.entries(
>
).map(([key, option]) => ({ key, label: option.label, value: option.value }))

const CLIPBOARD_TIMEOUT_OPTIONS: TimeoutOption[] = Object.entries(
CLIPBOARD_CLEAR_TIMEOUT_OPTIONS as Record<
string,
{ label: string; value: number | null }
>
).map(([key, option]) => ({ key, label: option.label, value: option.value }))

export const AppPreferencesContent = () => {
const { i18n } = useLingui()
const { theme } = useTheme()
Expand All @@ -65,6 +77,11 @@ export const AppPreferencesContent = () => {
timeoutMs: number | null
setTimeoutMs: (ms: number | null) => void
}
const { clipboardClearTimeoutMs, setClipboardClearTimeoutMs } =
useClipboardClearPreferences() as {
clipboardClearTimeoutMs: number | null
setClipboardClearTimeoutMs: (ms: number | null) => void
}
const { isCopyToClipboardEnabled, handleCopyToClipboardSettingChange } =
useCopyToClipboard()
const [isAllowHttpEnabled, setIsAllowHttpEnabled] = useAllowHttpEnabled() as [
Expand All @@ -73,6 +90,7 @@ export const AppPreferencesContent = () => {
]

const [isAutoLockDropdownOpen, setIsAutoLockDropdownOpen] = useState(false)
const [isClipboardDropdownOpen, setIsClipboardDropdownOpen] = useState(false)
const [isReminderDisabled, setIsReminderDisabled] = useState(() =>
isPasswordChangeReminderDisabled()
)
Expand Down Expand Up @@ -100,13 +118,34 @@ export const AppPreferencesContent = () => {
[i18n]
)

const translatedClipboardTimeoutOptions = useMemo(
() =>
CLIPBOARD_TIMEOUT_OPTIONS.map((option) => ({
...option,
label: i18n._(option.label)
})),
[i18n]
)

const selectedTimeoutOption = useMemo(
() =>
translatedTimeoutOptions.find((option) => option.value === timeoutMs) ??
translatedTimeoutOptions[0],
[translatedTimeoutOptions, timeoutMs]
)

const selectedClipboardTimeoutOption = useMemo(
() =>
translatedClipboardTimeoutOptions.find(
(option) => option.value === clipboardClearTimeoutMs
) ??
translatedClipboardTimeoutOptions.find(
(option) => option.value === DEFAULT_CLIPBOARD_CLEAR_TIMEOUT
) ??
translatedClipboardTimeoutOptions[0],
[translatedClipboardTimeoutOptions, clipboardClearTimeoutMs]
)

const handleTimeoutSelect = useCallback(
(option: TimeoutOption) => {
setTimeoutMs(option.value)
Expand All @@ -115,6 +154,14 @@ export const AppPreferencesContent = () => {
[setTimeoutMs]
)

const handleClipboardTimeoutSelect = useCallback(
(option: TimeoutOption) => {
setClipboardClearTimeoutMs(option.value)
setIsClipboardDropdownOpen(false)
},
[setClipboardClearTimeoutMs]
)

const handleAutofillToggle = useCallback(
async (isOn: boolean) => {
const prev = isAutofillEnabled
Expand Down Expand Up @@ -213,6 +260,38 @@ export const AppPreferencesContent = () => {
description={t`Allow autofill and access on HTTP websites. When disabled, only secure HTTPS sites are supported`}
/>
</div>
<div className="border-border-primary flex items-center justify-between gap-[12px] border-t p-[12px]">
<div className="flex min-w-0 flex-1 flex-col gap-[4px]">
<Text variant="labelEmphasized">{t`Clear Clipboard`}</Text>
<Text variant="caption" color={colors.colorTextSecondary}>
{t`Automatically clear clipboard after copying credentials`}
</Text>
</div>
<Dropdown
open={isClipboardDropdownOpen}
onOpenChange={setIsClipboardDropdownOpen}
trigger={
<Button
variant="secondary"
size="small"
iconAfter={<KeyboardArrowBottom />}
data-testid={TEST_IDS.clipboardClearSelect}
>
{selectedClipboardTimeoutOption?.label ?? t`Select a timeout`}
</Button>
}
>
{translatedClipboardTimeoutOptions.map((option) => (
<NavbarListItem
key={option.key}
testID={`${TEST_IDS.clipboardClearOption}-${option.key.toLowerCase()}`}
label={option.label}
selected={option.value === clipboardClearTimeoutMs}
onClick={() => handleClipboardTimeoutSelect(option)}
/>
))}
</Dropdown>
</div>
</div>
</section>

Expand Down
56 changes: 55 additions & 1 deletion src/background/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import './nativeMessaging'

import { DEFAULT_CLIPBOARD_CLEAR_TIMEOUT } from '@tetherto/pearpass-lib-constants'

import {
ensureClientKeypairUnlocked,
commitPendingClientKeystore
Expand Down Expand Up @@ -74,6 +76,17 @@ chrome.windows.onFocusChanged.addListener(async (windowId) => {
} catch (error) {
logger.error('[AutoLock] Failed to fetch settings on focus', error)
}

try {
const { clipboardClearTimeoutMs } =
await secureChannel.getClipboardClearTimeout()
await chrome.storage.local.set({ clipboardClearTimeoutMs })
} catch (error) {
logger.error(
'[Clipboard] Failed to fetch clipboard timeout on focus',
error
)
}
})

const ensureClipboardOffscreenDocument = async () => {
Expand Down Expand Up @@ -241,6 +254,25 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
return true
}

case MESSAGE_TYPES.SET_CLIPBOARD_CLEAR_TIMEOUT: {
void (async () => {
try {
await secureChannel.setClipboardClearTimeout(
msg.clipboardClearTimeoutMs
)
await chrome.storage.local.set({
clipboardClearTimeoutMs: msg.clipboardClearTimeoutMs
})
sendResponse({ ok: true })
} catch (err) {
logger.error(err)
sendResponse({ ok: false, error: String(err) })
}
})()

return true
}

case MESSAGE_TYPES.RESET_TIMER: {
void (async () => {
try {
Expand Down Expand Up @@ -422,6 +454,17 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
error
)
}

try {
const { clipboardClearTimeoutMs } =
await secureChannel.getClipboardClearTimeout()
await chrome.storage.local.set({ clipboardClearTimeoutMs })
} catch (error) {
logger.error(
'[Clipboard] Failed to sync clipboard timeout after pairing',
error
)
}
} catch (e) {
sendResponse({
success: false,
Expand Down Expand Up @@ -516,7 +559,18 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
case SCHEDULE_CLIPBOARD_CLEAR: {
void (async () => {
await chrome.alarms.clear(CLEAR_CLIPBOARD)
const when = Date.now() + msg.delayMs
const { clipboardClearTimeoutMs } = await chrome.storage.local.get(
'clipboardClearTimeoutMs'
)
const timeoutMs =
clipboardClearTimeoutMs !== undefined
? clipboardClearTimeoutMs
: DEFAULT_CLIPBOARD_CLEAR_TIMEOUT
if (timeoutMs === null) {
sendResponse({ success: true })
return
}
const when = Date.now() + timeoutMs
await chrome.alarms.create(CLEAR_CLIPBOARD, { when })
sendResponse({ success: true })
})()
Expand Down
Loading
Loading