From f9c8f27199eedd2f5af2fd305b1b9ad547940a1e Mon Sep 17 00:00:00 2001 From: ndolidze Date: Wed, 24 Jun 2026 14:36:57 +0400 Subject: [PATCH] Add custom clear clipboard timeout options --- package-lock.json | 2 +- src/action/index.jsx | 13 +- .../AppPreferencesContent/index.test.tsx | 35 ++++- .../content/AppPreferencesContent/index.tsx | 81 +++++++++- src/background/index.js | 56 ++++++- src/background/secureChannel.js | 26 ++++ src/hooks/useAutoLockPreferences.js | 2 + src/hooks/useAutoLockPreferences.test.js | 27 +++- src/hooks/useClipboardClearPreferences.js | 67 ++++++++ .../useClipboardClearPreferences.test.js | 147 ++++++++++++++++++ src/shared/commandDefinitions.js | 2 + src/shared/hooks/useCopyToClipboard.js | 10 +- src/shared/services/messageBridge.js | 13 +- src/tetherto-modules.d.ts | 13 +- 14 files changed, 468 insertions(+), 26 deletions(-) create mode 100644 src/hooks/useClipboardClearPreferences.js create mode 100644 src/hooks/useClipboardClearPreferences.test.js diff --git a/package-lock.json b/package-lock.json index 9e8ae45f..d73f1055 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6026,7 +6026,7 @@ "node_modules/@tetherto/pearpass-lib-constants": { "name": "pearpass-lib-constants", "version": "0.0.11", - "resolved": "git+ssh://git@github.com/tetherto/pearpass-lib-constants.git#c651dc76d92b7c66501db2e5c143802cd056af98", + "resolved": "git+ssh://git@github.com/tetherto/pearpass-lib-constants.git#8eda39c5c06d69b24d16306cd8de309b5c1fb221", "license": "Apache-2.0" }, "node_modules/@tetherto/pearpass-lib-ui-kit": { diff --git a/src/action/index.jsx b/src/action/index.jsx index 2fa3504e..fa3a2957 100644 --- a/src/action/index.jsx +++ b/src/action/index.jsx @@ -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' @@ -48,11 +49,13 @@ createRoot(document.getElementById('root')).render( - - - - - + + + + + + + diff --git a/src/action/pages/Settings/content/AppPreferencesContent/index.test.tsx b/src/action/pages/Settings/content/AppPreferencesContent/index.test.tsx index f4688980..19f92646 100644 --- a/src/action/pages/Settings/content/AppPreferencesContent/index.test.tsx +++ b/src/action/pages/Settings/content/AppPreferencesContent/index.test.tsx @@ -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) @@ -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 @@ -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] @@ -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() @@ -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() 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') @@ -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() @@ -286,4 +306,13 @@ describe('AppPreferencesContent', () => { expect(mockSetTimeoutMs).toHaveBeenCalledWith(60_000) }) + + it('opens the clipboard clear dropdown and calls setClipboardClearTimeoutMs on selection', () => { + render() + + fireEvent.click(screen.getByTestId('settings-clipboard-clear-select')) + fireEvent.click(screen.getByTestId('settings-clipboard-clear-option-never')) + + expect(mockSetClipboardClearTimeoutMs).toHaveBeenCalledWith(null) + }) }) diff --git a/src/action/pages/Settings/content/AppPreferencesContent/index.tsx b/src/action/pages/Settings/content/AppPreferencesContent/index.tsx index d9123209..70fd9a68 100644 --- a/src/action/pages/Settings/content/AppPreferencesContent/index.tsx +++ b/src/action/pages/Settings/content/AppPreferencesContent/index.tsx @@ -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, @@ -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 @@ -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', @@ -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() @@ -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 [ @@ -73,6 +90,7 @@ export const AppPreferencesContent = () => { ] const [isAutoLockDropdownOpen, setIsAutoLockDropdownOpen] = useState(false) + const [isClipboardDropdownOpen, setIsClipboardDropdownOpen] = useState(false) const [isReminderDisabled, setIsReminderDisabled] = useState(() => isPasswordChangeReminderDisabled() ) @@ -100,6 +118,15 @@ 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) ?? @@ -107,6 +134,18 @@ export const AppPreferencesContent = () => { [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) @@ -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 @@ -213,6 +260,38 @@ export const AppPreferencesContent = () => { description={t`Allow autofill and access on HTTP websites. When disabled, only secure HTTPS sites are supported`} /> +
+
+ {t`Clear Clipboard`} + + {t`Automatically clear clipboard after copying credentials`} + +
+ } + data-testid={TEST_IDS.clipboardClearSelect} + > + {selectedClipboardTimeoutOption?.label ?? t`Select a timeout`} + + } + > + {translatedClipboardTimeoutOptions.map((option) => ( + handleClipboardTimeoutSelect(option)} + /> + ))} + +
diff --git a/src/background/index.js b/src/background/index.js index 85448561..877dff64 100644 --- a/src/background/index.js +++ b/src/background/index.js @@ -1,5 +1,7 @@ import './nativeMessaging' +import { DEFAULT_CLIPBOARD_CLEAR_TIMEOUT } from '@tetherto/pearpass-lib-constants' + import { ensureClientKeypairUnlocked, commitPendingClientKeystore @@ -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 () => { @@ -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 { @@ -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, @@ -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 }) })() diff --git a/src/background/secureChannel.js b/src/background/secureChannel.js index 70ef84a7..13186c1b 100644 --- a/src/background/secureChannel.js +++ b/src/background/secureChannel.js @@ -763,6 +763,32 @@ export class SecureChannelClient { throw error } } + + async getClipboardClearTimeout() { + try { + return await nativeMessaging.sendRequest('getClipboardClearTimeout') + } catch (error) { + logger.error( + 'Failed to get clipboard clear timeout:', + error?.message || error + ) + throw error + } + } + + async setClipboardClearTimeout(clipboardClearTimeoutMs) { + try { + return await nativeMessaging.sendRequest('setClipboardClearTimeout', { + clipboardClearTimeoutMs + }) + } catch (error) { + logger.error( + 'Failed to set clipboard clear timeout:', + error?.message || error + ) + throw error + } + } } export const secureChannel = new SecureChannelClient() diff --git a/src/hooks/useAutoLockPreferences.js b/src/hooks/useAutoLockPreferences.js index c50b2fec..7013bc14 100644 --- a/src/hooks/useAutoLockPreferences.js +++ b/src/hooks/useAutoLockPreferences.js @@ -66,6 +66,7 @@ export const AutoLockProvider = ({ children }) => { }, []) const setAutoLockEnabled = useCallback((autoLockEnabled) => { + setIsAutoLockEnabledState(autoLockEnabled) chrome.runtime.sendMessage({ type: MESSAGE_TYPES.SET_AUTO_LOCK_ENABLED, autoLockEnabled @@ -73,6 +74,7 @@ export const AutoLockProvider = ({ children }) => { }, []) const setTimeoutMs = useCallback((ms) => { + setTimeoutMsState(ms) chrome.runtime.sendMessage({ type: MESSAGE_TYPES.SET_AUTO_LOCK_TIMEOUT, autoLockTimeoutMs: ms diff --git a/src/hooks/useAutoLockPreferences.test.js b/src/hooks/useAutoLockPreferences.test.js index 41b80124..cdbebb9d 100644 --- a/src/hooks/useAutoLockPreferences.test.js +++ b/src/hooks/useAutoLockPreferences.test.js @@ -74,23 +74,24 @@ describe('useAutoLockPreferences (extension)', () => { expect(result.current.timeoutMs).toBe(4567) }) - it('setAutoLockEnabled sends runtime message', () => { + it('setAutoLockEnabled updates state optimistically and sends runtime message', () => { getMock.mockImplementation((keys, cb) => cb({})) const { result } = renderHook(() => useAutoLockPreferences(), { wrapper: AutoLockProvider }) act(() => { - result.current.setAutoLockEnabled(true) + result.current.setAutoLockEnabled(false) }) + expect(result.current.isAutoLockEnabled).toBe(false) expect(sendMessageMock).toHaveBeenCalledWith({ type: MESSAGE_TYPES.SET_AUTO_LOCK_ENABLED, - autoLockEnabled: true + autoLockEnabled: false }) }) - it('setTimeoutMs sends runtime message', () => { + it('setTimeoutMs updates state optimistically and sends runtime message', () => { getMock.mockImplementation((keys, cb) => cb({})) const { result } = renderHook(() => useAutoLockPreferences(), { wrapper: AutoLockProvider @@ -100,9 +101,27 @@ describe('useAutoLockPreferences (extension)', () => { result.current.setTimeoutMs(9999) }) + expect(result.current.timeoutMs).toBe(9999) expect(sendMessageMock).toHaveBeenCalledWith({ type: MESSAGE_TYPES.SET_AUTO_LOCK_TIMEOUT, autoLockTimeoutMs: 9999 }) }) + + it('setTimeoutMs accepts null (Never)', () => { + getMock.mockImplementation((keys, cb) => cb({})) + const { result } = renderHook(() => useAutoLockPreferences(), { + wrapper: AutoLockProvider + }) + + act(() => { + result.current.setTimeoutMs(null) + }) + + expect(result.current.timeoutMs).toBeNull() + expect(sendMessageMock).toHaveBeenCalledWith({ + type: MESSAGE_TYPES.SET_AUTO_LOCK_TIMEOUT, + autoLockTimeoutMs: null + }) + }) }) diff --git a/src/hooks/useClipboardClearPreferences.js b/src/hooks/useClipboardClearPreferences.js new file mode 100644 index 00000000..44878fc0 --- /dev/null +++ b/src/hooks/useClipboardClearPreferences.js @@ -0,0 +1,67 @@ +import { + createContext, + createElement, + useCallback, + useContext, + useEffect, + useMemo, + useState +} from 'react' + +import { DEFAULT_CLIPBOARD_CLEAR_TIMEOUT } from '@tetherto/pearpass-lib-constants' + +import { MESSAGE_TYPES } from '../shared/services/messageBridge' + +export const ClipboardClearContext = createContext({ + clipboardClearTimeoutMs: DEFAULT_CLIPBOARD_CLEAR_TIMEOUT, + setClipboardClearTimeoutMs: () => {} +}) + +export const ClipboardClearProvider = ({ children }) => { + const [clipboardClearTimeoutMs, setClipboardClearTimeoutMsState] = useState( + DEFAULT_CLIPBOARD_CLEAR_TIMEOUT + ) + + useEffect(() => { + chrome.storage.local.get('clipboardClearTimeoutMs', (res) => { + if ( + typeof res.clipboardClearTimeoutMs === 'number' || + res.clipboardClearTimeoutMs === null + ) { + setClipboardClearTimeoutMsState(res.clipboardClearTimeoutMs) + } + }) + + const listener = (changes) => { + if ( + changes.clipboardClearTimeoutMs || + changes.clipboardClearTimeoutMs?.newValue === null + ) { + setClipboardClearTimeoutMsState( + changes.clipboardClearTimeoutMs.newValue + ) + } + } + + chrome.storage.onChanged.addListener(listener) + return () => chrome.storage.onChanged.removeListener(listener) + }, []) + + const setClipboardClearTimeoutMs = useCallback((ms) => { + setClipboardClearTimeoutMsState(ms) + chrome.runtime.sendMessage({ + type: MESSAGE_TYPES.SET_CLIPBOARD_CLEAR_TIMEOUT, + clipboardClearTimeoutMs: ms + }) + }, []) + + const value = useMemo( + () => ({ clipboardClearTimeoutMs, setClipboardClearTimeoutMs }), + [clipboardClearTimeoutMs, setClipboardClearTimeoutMs] + ) + + return createElement(ClipboardClearContext.Provider, { value }, children) +} + +export const useClipboardClearPreferences = () => + useContext(ClipboardClearContext) diff --git a/src/hooks/useClipboardClearPreferences.test.js b/src/hooks/useClipboardClearPreferences.test.js new file mode 100644 index 00000000..fc582906 --- /dev/null +++ b/src/hooks/useClipboardClearPreferences.test.js @@ -0,0 +1,147 @@ +import { renderHook, act } from '@testing-library/react' + +jest.mock('@tetherto/pearpass-lib-constants', () => ({ + DEFAULT_CLIPBOARD_CLEAR_TIMEOUT: 60_000 +})) + +import { + useClipboardClearPreferences, + ClipboardClearProvider +} from './useClipboardClearPreferences' +import { MESSAGE_TYPES } from '../shared/services/messageBridge' + +describe('useClipboardClearPreferences (extension)', () => { + let onChangedListener + const getMock = jest.fn() + const addListenerMock = jest.fn((listener) => { + onChangedListener = listener + }) + const removeListenerMock = jest.fn() + const sendMessageMock = jest.fn() + + beforeEach(() => { + onChangedListener = undefined + getMock.mockReset() + addListenerMock.mockClear() + removeListenerMock.mockClear() + sendMessageMock.mockReset() + + global.chrome = { + storage: { + local: { get: getMock }, + onChanged: { + addListener: addListenerMock, + removeListener: removeListenerMock + } + }, + runtime: { sendMessage: sendMessageMock } + } + }) + + it('initializes from chrome.storage.local and subscribes to changes', () => { + getMock.mockImplementation((key, cb) => { + cb({ clipboardClearTimeoutMs: 60_000 }) + }) + + const { result } = renderHook(() => useClipboardClearPreferences(), { + wrapper: ClipboardClearProvider + }) + + expect(getMock).toHaveBeenCalledWith( + 'clipboardClearTimeoutMs', + expect.any(Function) + ) + expect(addListenerMock).toHaveBeenCalled() + expect(result.current.clipboardClearTimeoutMs).toBe(60_000) + + act(() => { + onChangedListener({ clipboardClearTimeoutMs: { newValue: 300_000 } }) + }) + + expect(result.current.clipboardClearTimeoutMs).toBe(300_000) + }) + + it('keeps default when storage has no clipboardClearTimeoutMs', () => { + getMock.mockImplementation((key, cb) => cb({})) + + const { result } = renderHook(() => useClipboardClearPreferences(), { + wrapper: ClipboardClearProvider + }) + + expect(result.current.clipboardClearTimeoutMs).toBe(60_000) + }) + + it('accepts null (Never) from storage.get', () => { + getMock.mockImplementation((key, cb) => + cb({ clipboardClearTimeoutMs: null }) + ) + + const { result } = renderHook(() => useClipboardClearPreferences(), { + wrapper: ClipboardClearProvider + }) + + expect(result.current.clipboardClearTimeoutMs).toBeNull() + }) + + it('accepts null newValue from onChanged', () => { + getMock.mockImplementation((key, cb) => cb({})) + + const { result } = renderHook(() => useClipboardClearPreferences(), { + wrapper: ClipboardClearProvider + }) + + act(() => { + onChangedListener({ clipboardClearTimeoutMs: { newValue: null } }) + }) + + expect(result.current.clipboardClearTimeoutMs).toBeNull() + }) + + it('setClipboardClearTimeoutMs updates state optimistically and sends message', () => { + getMock.mockImplementation((key, cb) => cb({})) + + const { result } = renderHook(() => useClipboardClearPreferences(), { + wrapper: ClipboardClearProvider + }) + + act(() => { + result.current.setClipboardClearTimeoutMs(300_000) + }) + + expect(result.current.clipboardClearTimeoutMs).toBe(300_000) + expect(sendMessageMock).toHaveBeenCalledWith({ + type: MESSAGE_TYPES.SET_CLIPBOARD_CLEAR_TIMEOUT, + clipboardClearTimeoutMs: 300_000 + }) + }) + + it('setClipboardClearTimeoutMs sends null for Never', () => { + getMock.mockImplementation((key, cb) => cb({})) + + const { result } = renderHook(() => useClipboardClearPreferences(), { + wrapper: ClipboardClearProvider + }) + + act(() => { + result.current.setClipboardClearTimeoutMs(null) + }) + + expect(result.current.clipboardClearTimeoutMs).toBeNull() + expect(sendMessageMock).toHaveBeenCalledWith({ + type: MESSAGE_TYPES.SET_CLIPBOARD_CLEAR_TIMEOUT, + clipboardClearTimeoutMs: null + }) + }) + + it('removes the onChanged listener on unmount', () => { + getMock.mockImplementation((key, cb) => cb({})) + + const { unmount } = renderHook(() => useClipboardClearPreferences(), { + wrapper: ClipboardClearProvider + }) + + unmount() + + expect(removeListenerMock).toHaveBeenCalled() + }) +}) diff --git a/src/shared/commandDefinitions.js b/src/shared/commandDefinitions.js index d310a919..8b5b4ddb 100644 --- a/src/shared/commandDefinitions.js +++ b/src/shared/commandDefinitions.js @@ -79,6 +79,8 @@ export const COMMAND_DEFINITIONS = { setAutoLockTimeout: { params: ['autoLockTimeoutMs'] }, setAutoLockEnabled: { params: ['autoLockEnabled'] }, resetTimer: { params: [] }, + getClipboardClearTimeout: { params: [] }, + setClipboardClearTimeout: { params: ['clipboardClearTimeoutMs'] }, // Password commands initWithPassword: { params: ['password'] }, fetchFavicon: { params: ['url'] }, diff --git a/src/shared/hooks/useCopyToClipboard.js b/src/shared/hooks/useCopyToClipboard.js index f4cc90a1..a30ab867 100644 --- a/src/shared/hooks/useCopyToClipboard.js +++ b/src/shared/hooks/useCopyToClipboard.js @@ -1,7 +1,6 @@ import React, { useState, useRef } from 'react' import { t } from '@lingui/core/macro' -import { CLIPBOARD_CLEAR_TIMEOUT } from '@tetherto/pearpass-lib-constants' import { Check } from '@tetherto/pearpass-lib-ui-kit/icons' import { MESSAGES } from '../../background/constants' @@ -68,15 +67,10 @@ export const useCopyToClipboard = ({ onCopy } = {}) => { try { if (typeof chrome !== 'undefined') { - chrome?.runtime?.sendMessage?.({ - type: SCHEDULE_CLIPBOARD_CLEAR, - delayMs: CLIPBOARD_CLEAR_TIMEOUT - }) + chrome?.runtime?.sendMessage?.({ type: SCHEDULE_CLIPBOARD_CLEAR }) } } catch { - setTimeout(() => { - navigator?.clipboard?.writeText('') - }, CLIPBOARD_CLEAR_TIMEOUT) + // background not available — skip clipboard clearing } if (timeoutRef.current) { diff --git a/src/shared/services/messageBridge.js b/src/shared/services/messageBridge.js index e21dc258..54d56597 100644 --- a/src/shared/services/messageBridge.js +++ b/src/shared/services/messageBridge.js @@ -31,7 +31,8 @@ export const MESSAGE_TYPES = Object.freeze({ GET_AUTO_LOCK_SETTINGS: 'GET_AUTO_LOCK_SETTINGS', SET_AUTO_LOCK_ENABLED: 'SET_AUTO_LOCK_ENABLED', SET_AUTO_LOCK_TIMEOUT: 'SET_AUTO_LOCK_TIMEOUT', - RESET_TIMER: 'RESET_TIMER' + RESET_TIMER: 'RESET_TIMER', + SET_CLIPBOARD_CLEAR_TIMEOUT: 'SET_CLIPBOARD_CLEAR_TIMEOUT' }) /** @@ -272,6 +273,16 @@ export const secureChannelMessages = { async resetTimer() { return messageBridge.sendMessage(MESSAGE_TYPES.RESET_TIMER) + }, + + async setClipboardClearTimeout(clipboardClearTimeoutMs) { + return messageBridge.sendMessage( + MESSAGE_TYPES.SET_CLIPBOARD_CLEAR_TIMEOUT, + + { + clipboardClearTimeoutMs + } + ) } } diff --git a/src/tetherto-modules.d.ts b/src/tetherto-modules.d.ts index cf548cd3..2db992ef 100644 --- a/src/tetherto-modules.d.ts +++ b/src/tetherto-modules.d.ts @@ -12,7 +12,6 @@ declare module '@tetherto/pearpass-lib-constants' { export const AUTHENTICATOR_ENABLED: boolean export const PROTECTED_VAULT_ENABLED: boolean export const SAVE_CREDENTIALS_AFTER_LOGIN_ENABLED: boolean - export const CLIPBOARD_CLEAR_TIMEOUT: number export const LANGUAGES: Record export const MANIFEST_NAME: string export const MS_PER_SECOND: number @@ -345,7 +344,17 @@ declare module '@tetherto/pearpass-lib-constants' { export const PROTECTED_VAULT_ENABLED: boolean export const DELETE_VAULT_ENABLED: boolean export const SAVE_CREDENTIALS_AFTER_LOGIN_ENABLED: boolean - export const CLIPBOARD_CLEAR_TIMEOUT: number + export const BE_AUTO_LOCK_ENABLED: boolean + export const AUTO_LOCK_TIMEOUT_OPTIONS: Record< + string, + { label: string; value: number | null; deprecated?: boolean } + > + export const DEFAULT_AUTO_LOCK_TIMEOUT: number + export const CLIPBOARD_CLEAR_TIMEOUT_OPTIONS: Record< + string, + { label: string; value: number | null } + > + export const DEFAULT_CLIPBOARD_CLEAR_TIMEOUT: number export const LANGUAGES: Record export const MANIFEST_NAME: string export const MS_PER_SECOND: number