diff --git a/src/features/vault/model/use-vault-timeout.test.ts b/src/features/vault/model/use-vault-timeout.test.ts
index 7046056..47a09bc 100644
--- a/src/features/vault/model/use-vault-timeout.test.ts
+++ b/src/features/vault/model/use-vault-timeout.test.ts
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { act } from 'react'
import { useCryptoStore } from '@/shared/crypto/vault/crypto-store'
-import { DEFAULT_VAULT_TIMEOUT_MS } from './use-vault-timeout'
+import { useVaultSettingsStore, DEFAULT_VAULT_TIMEOUT_MS } from '@/shared/stores/vault-settings-store'
const toastWarning = vi.hoisted(() => vi.fn<(msg: string, options?: unknown) => string | number>())
@@ -24,12 +24,14 @@ describe('useVaultTimeout', () => {
beforeEach(() => {
vi.useFakeTimers()
useCryptoStore.setState({ isVaultLocked: true })
+ useVaultSettingsStore.setState({ vaultTimeoutMs: DEFAULT_VAULT_TIMEOUT_MS, lockOnTabHidden: false })
vi.clearAllMocks()
})
afterEach(() => {
vi.useRealTimers()
useCryptoStore.setState({ isVaultLocked: true })
+ useVaultSettingsStore.setState({ vaultTimeoutMs: DEFAULT_VAULT_TIMEOUT_MS, lockOnTabHidden: false })
})
it('does not start timer when vault is locked', () => {
@@ -135,10 +137,11 @@ describe('useVaultTimeout', () => {
expect(keyVault.lockVault).not.toHaveBeenCalled()
})
- it('supports custom timeout', () => {
+ it('supports custom timeout via store', () => {
const customTimeout = 5000
useCryptoStore.setState({ isVaultLocked: false })
- renderHook(() => useVaultTimeout(customTimeout))
+ useVaultSettingsStore.setState({ vaultTimeoutMs: customTimeout })
+ renderHook(() => useVaultTimeout())
vi.advanceTimersByTime(customTimeout)
diff --git a/src/features/vault/model/use-vault-timeout.ts b/src/features/vault/model/use-vault-timeout.ts
index de5fa63..bf2f70b 100644
--- a/src/features/vault/model/use-vault-timeout.ts
+++ b/src/features/vault/model/use-vault-timeout.ts
@@ -4,13 +4,13 @@ import { toast } from 'sonner'
import { useCryptoStore } from '@/shared/crypto/vault/crypto-store'
import { keyVault } from '@/shared/crypto/vault/key-vault'
-
-export const DEFAULT_VAULT_TIMEOUT_MS = 15 * 60 * 1000
+import { useVaultSettingsStore } from '@/shared/stores/vault-settings-store'
const ACTIVITY_EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll'] as const
-export function useVaultTimeout(timeoutMs: number = DEFAULT_VAULT_TIMEOUT_MS): void {
+export function useVaultTimeout(): void {
const isVaultLocked = useCryptoStore((s) => s.isVaultLocked)
+ const vaultTimeoutMs = useVaultSettingsStore((s) => s.vaultTimeoutMs)
const { t } = useTranslation('vault')
const timeoutRef = useRef
| null>(null)
@@ -30,7 +30,7 @@ export function useVaultTimeout(timeoutMs: number = DEFAULT_VAULT_TIMEOUT_MS): v
timeoutRef.current = setTimeout(() => {
keyVault.lockVault()
toast.warning(t('inactivityLocked'))
- }, timeoutMs)
+ }, vaultTimeoutMs)
}
resetTimeout()
@@ -50,5 +50,5 @@ export function useVaultTimeout(timeoutMs: number = DEFAULT_VAULT_TIMEOUT_MS): v
document.removeEventListener(event, handler)
}
}
- }, [isVaultLocked, timeoutMs, t])
+ }, [isVaultLocked, vaultTimeoutMs, t])
}
diff --git a/src/features/vault/model/use-vault-visibility-lock.test.ts b/src/features/vault/model/use-vault-visibility-lock.test.ts
new file mode 100644
index 0000000..b35ede6
--- /dev/null
+++ b/src/features/vault/model/use-vault-visibility-lock.test.ts
@@ -0,0 +1,97 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+
+import { useCryptoStore } from '@/shared/crypto/vault/crypto-store'
+import { useVaultSettingsStore } from '@/shared/stores/vault-settings-store'
+
+const toastWarning = vi.hoisted(() => vi.fn<(msg: string, options?: unknown) => string | number>())
+
+vi.mock('sonner', () => ({
+ toast: { warning: toastWarning },
+}))
+
+vi.mock('@/shared/crypto/vault/key-vault', () => ({
+ keyVault: {
+ lockVault: vi.fn<() => void>(),
+ },
+}))
+
+import { keyVault } from '@/shared/crypto/vault/key-vault'
+import { useVaultVisibilityLock } from './use-vault-visibility-lock'
+import { renderHook } from '@/test/utils'
+
+function setVisibilityState(state: 'visible' | 'hidden') {
+ Object.defineProperty(document, 'visibilityState', { value: state, configurable: true })
+}
+
+describe('useVaultVisibilityLock', () => {
+ beforeEach(() => {
+ useCryptoStore.setState({ isVaultLocked: true })
+ useVaultSettingsStore.setState({ lockOnTabHidden: false })
+ setVisibilityState('visible')
+ vi.clearAllMocks()
+ })
+
+ afterEach(() => {
+ useCryptoStore.setState({ isVaultLocked: true })
+ useVaultSettingsStore.setState({ lockOnTabHidden: false })
+ setVisibilityState('visible')
+ })
+
+ it('does nothing when disabled', () => {
+ useCryptoStore.setState({ isVaultLocked: false })
+ renderHook(() => useVaultVisibilityLock())
+
+ setVisibilityState('hidden')
+ document.dispatchEvent(new Event('visibilitychange'))
+
+ expect(keyVault.lockVault).not.toHaveBeenCalled()
+ })
+
+ it('locks the vault when document becomes hidden and vault is unlocked', () => {
+ useCryptoStore.setState({ isVaultLocked: false })
+ useVaultSettingsStore.setState({ lockOnTabHidden: true })
+ renderHook(() => useVaultVisibilityLock())
+
+ setVisibilityState('hidden')
+ document.dispatchEvent(new Event('visibilitychange'))
+
+ expect(keyVault.lockVault).toHaveBeenCalledTimes(1)
+ expect(toastWarning).toHaveBeenCalledTimes(1)
+ expect(toastWarning.mock.calls[0][0]).toBe('Vault locked due to tab switch.')
+ })
+
+ it('does not lock when the vault is already locked', () => {
+ useCryptoStore.setState({ isVaultLocked: true })
+ useVaultSettingsStore.setState({ lockOnTabHidden: true })
+ renderHook(() => useVaultVisibilityLock())
+
+ setVisibilityState('hidden')
+ document.dispatchEvent(new Event('visibilitychange'))
+
+ expect(keyVault.lockVault).not.toHaveBeenCalled()
+ })
+
+ it('does not lock when document becomes visible', () => {
+ useCryptoStore.setState({ isVaultLocked: false })
+ useVaultSettingsStore.setState({ lockOnTabHidden: true })
+ renderHook(() => useVaultVisibilityLock())
+
+ setVisibilityState('visible')
+ document.dispatchEvent(new Event('visibilitychange'))
+
+ expect(keyVault.lockVault).not.toHaveBeenCalled()
+ })
+
+ it('removes the listener on unmount', () => {
+ useCryptoStore.setState({ isVaultLocked: false })
+ useVaultSettingsStore.setState({ lockOnTabHidden: true })
+ const { unmount } = renderHook(() => useVaultVisibilityLock())
+
+ unmount()
+
+ setVisibilityState('hidden')
+ document.dispatchEvent(new Event('visibilitychange'))
+
+ expect(keyVault.lockVault).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/features/vault/model/use-vault-visibility-lock.ts b/src/features/vault/model/use-vault-visibility-lock.ts
new file mode 100644
index 0000000..1b11f22
--- /dev/null
+++ b/src/features/vault/model/use-vault-visibility-lock.ts
@@ -0,0 +1,31 @@
+import { useEffect } from 'react'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+
+import { useCryptoStore } from '@/shared/crypto/vault/crypto-store'
+import { keyVault } from '@/shared/crypto/vault/key-vault'
+import { useVaultSettingsStore } from '@/shared/stores/vault-settings-store'
+
+/**
+ * Locks the vault when the document becomes hidden (tab switch, minimize,
+ * screen lock) if the user has enabled the setting. No-op when the vault is
+ * already locked, so it never fires redundantly with the inactivity timer.
+ */
+export function useVaultVisibilityLock(): void {
+ const enabled = useVaultSettingsStore((s) => s.lockOnTabHidden)
+ const { t } = useTranslation('vault')
+
+ useEffect(() => {
+ if (!enabled) return
+
+ function handleVisibilityChange() {
+ if (document.visibilityState === 'hidden' && !useCryptoStore.getState().isVaultLocked) {
+ keyVault.lockVault()
+ toast.warning(t('tabSwitchLocked'))
+ }
+ }
+
+ document.addEventListener('visibilitychange', handleVisibilityChange)
+ return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
+ }, [enabled, t])
+}
diff --git a/src/features/vault/model/vault-dialog-store.ts b/src/features/vault/model/vault-dialog-store.ts
index 5417a52..7adb556 100644
--- a/src/features/vault/model/vault-dialog-store.ts
+++ b/src/features/vault/model/vault-dialog-store.ts
@@ -1,3 +1,3 @@
-import { createDialogStore } from '@/shared/ui/create-dialog-store'
+import { createDialogStore } from '@/shared/stores/create-dialog-store'
export const useVaultDialogStore = createDialogStore('VaultDialogStore')
diff --git a/src/shared/crypto/core/argon2id.test.ts b/src/shared/crypto/core/argon2id.test.ts
index 30ccfbd..6964556 100644
--- a/src/shared/crypto/core/argon2id.test.ts
+++ b/src/shared/crypto/core/argon2id.test.ts
@@ -314,6 +314,25 @@ describe('argon2id.worker — onmessage handler', () => {
expect(response.hash.byteLength).toBe(32)
})
+ it('zero-fills the worker-side hash after posting the result', async () => {
+ await import('@/shared/crypto/core/argon2id.worker')
+
+ const handler = self.onmessage as (event: MessageEvent) => Promise
+ const salt = new Uint8Array(16).fill(1)
+
+ await handler(
+ new MessageEvent('message', {
+ data: { type: 'derive', id: 42, password: 'pw', salt, params: DEFAULT_ARGON2_PARAMS },
+ }),
+ )
+
+ // postMessage structured-clones the buffer, so zeroing the worker-side
+ // copy does not affect the message already delivered. The recorded
+ // response.hash is the same reference the worker zero-filled.
+ const response = postMessageSpy.mock.calls[0][0]
+ expect(Array.from(response.hash as Uint8Array)).toEqual(new Array(32).fill(0))
+ })
+
it('sends error back when derivation fails', async () => {
mockArgon2Module.hash.mockRejectedValue(new Error('WASM load failed'))
diff --git a/src/shared/crypto/core/argon2id.worker.ts b/src/shared/crypto/core/argon2id.worker.ts
index 6ea45a6..e236bb3 100644
--- a/src/shared/crypto/core/argon2id.worker.ts
+++ b/src/shared/crypto/core/argon2id.worker.ts
@@ -1,3 +1,4 @@
+import { zeroFill } from '@/shared/crypto/core/crypto-utils'
import type { Argon2Params } from '@/shared/types/crypto.types'
import type { Argon2DeriveRequest, Argon2DeriveResult, Argon2DeriveError } from '@/shared/types/argon2-worker.types'
@@ -53,6 +54,9 @@ self.onmessage = async (event: MessageEvent) => {
const hash = await computeArgon2id(password, salt, params)
const response: Argon2DeriveResult = { type: 'result', id, hash }
self.postMessage(response)
+ // postMessage structured-clones the buffer, so zeroing the worker-side
+ // copy does not affect the message already delivered to the main thread.
+ zeroFill(hash)
} catch (err) {
const response: Argon2DeriveError = {
type: 'error',
diff --git a/src/shared/crypto/keys/field-keys.test.ts b/src/shared/crypto/keys/field-keys.test.ts
index 84e2f29..3e3292f 100644
--- a/src/shared/crypto/keys/field-keys.test.ts
+++ b/src/shared/crypto/keys/field-keys.test.ts
@@ -179,6 +179,32 @@ describe('field-keys', () => {
expect(unwrapped.size).toBe(0)
})
+ it('zero-fills each unwrapped raw field key after importing it', async () => {
+ const { kek } = await setupKEK()
+ const { wrappedFieldKeys } = await generateAllFieldKeys(kek)
+ const serverFieldKeys: ServerFieldKey[] = wrappedFieldKeys.map((w) => ({
+ fieldName: w.fieldName,
+ version: w.version,
+ wrappedFieldKey: hexEncode(w.wrappedFieldKey),
+ fieldKeyIV: hexEncode(w.fieldKeyIV),
+ }))
+
+ const zeroFillSpy = vi.spyOn(cryptoUtils, 'zeroFill')
+ try {
+ await unwrapFieldKeys(serverFieldKeys, kek)
+
+ // One zeroFill per field (4 fields), each on a 32-byte raw key buffer.
+ const calls = zeroFillSpy.mock.calls.map(([buf]) => buf as Uint8Array)
+ expect(calls).toHaveLength(4)
+ for (const buf of calls) {
+ expect(buf.length).toBe(32)
+ expect(Array.from(buf).every((b) => b === 0)).toBe(true)
+ }
+ } finally {
+ zeroFillSpy.mockRestore()
+ }
+ })
+
it('keeps the last key when two versions are present for a field (atomic-swap guard)', async () => {
// After an atomic rotation the server holds exactly one version per
// field. If a multi-version state were ever reintroduced, unwrapFieldKeys
diff --git a/src/shared/crypto/keys/field-keys.ts b/src/shared/crypto/keys/field-keys.ts
index 1346723..0a6aac2 100644
--- a/src/shared/crypto/keys/field-keys.ts
+++ b/src/shared/crypto/keys/field-keys.ts
@@ -76,8 +76,12 @@ export async function unwrapFieldKeys(fieldKeys: ServerFieldKey[], kek: CryptoKe
const aad = encodeAAD(fieldName, version)
const iv = hexDecode(fieldKeyIV)
const unwrappedKey = await decrypt(hexDecode(wrappedFieldKey), kek, { iv, aad })
- const key = await importKey(unwrappedKey)
- return [fieldName, key] as [string, CryptoKey]
+ try {
+ const key = await importKey(unwrappedKey)
+ return [fieldName, key] as [string, CryptoKey]
+ } finally {
+ zeroFill(unwrappedKey)
+ }
}),
)
diff --git a/src/shared/crypto/keys/mnemonic.test.ts b/src/shared/crypto/keys/mnemonic.test.ts
index 9f0bce1..3408da3 100644
--- a/src/shared/crypto/keys/mnemonic.test.ts
+++ b/src/shared/crypto/keys/mnemonic.test.ts
@@ -88,12 +88,25 @@ describe('mnemonic', () => {
it('truncates the 64-byte seed to 32 bytes', async () => {
const fullSeed = new Uint8Array(64).fill(0xab)
+ const expected = fullSeed.slice(0, 32)
const { mnemonicToSeedSync } = await import('@scure/bip39')
vi.mocked(mnemonicToSeedSync).mockReturnValueOnce(fullSeed)
const seed = await mnemonicToSeed(MOCK_VALID_MNEMONIC)
- expect(seed).toEqual(fullSeed.slice(0, 32))
+ expect(seed).toEqual(expected)
+ })
+
+ it('zero-fills the 64-byte full seed after taking the 32-byte slice', async () => {
+ const fullSeed = new Uint8Array(64).fill(0xab)
+ const { mnemonicToSeedSync } = await import('@scure/bip39')
+ vi.mocked(mnemonicToSeedSync).mockReturnValueOnce(fullSeed)
+
+ await mnemonicToSeed(MOCK_VALID_MNEMONIC)
+
+ // The slice is a separate buffer (returned to the caller); the source
+ // 64-byte array is zeroed in the finally block.
+ expect(Array.from(fullSeed)).toEqual(new Array(64).fill(0))
})
it('throws MnemonicError for an invalid mnemonic', async () => {
diff --git a/src/shared/crypto/keys/mnemonic.ts b/src/shared/crypto/keys/mnemonic.ts
index 9247cd3..0fbe75b 100644
--- a/src/shared/crypto/keys/mnemonic.ts
+++ b/src/shared/crypto/keys/mnemonic.ts
@@ -77,7 +77,11 @@ export async function mnemonicToSeed(mnemonic: string): Promise
+ try {
+ return fullSeed.slice(0, CRYPTO_KEY_LENGTH) as Uint8Array
+ } finally {
+ zeroFill(fullSeed)
+ }
}
/**
diff --git a/src/shared/i18n/locales/cs/settings.json b/src/shared/i18n/locales/cs/settings.json
index 9e47169..3ce0770 100644
--- a/src/shared/i18n/locales/cs/settings.json
+++ b/src/shared/i18n/locales/cs/settings.json
@@ -2,10 +2,13 @@
"title": "Nastavení",
"security": {
"title": "Zabezpečení",
- "description": "Správa šifrovacích klíčů a seed fráze.",
+ "description": "Správa trezoru, šifrovacích klíčů a seed fráze.",
"seedPhrase": "Znovu vygenerovat seed frázi",
"verifySeedPhrase": "Ověřit seed frázi",
- "keyManagement": "Správa klíčů"
+ "keyManagement": "Správa klíčů",
+ "autoLock": "Automatické uzamčení trezoru",
+ "lockOnTabHidden": "Uzamknout při přepnutí karty",
+ "minutesShort": "{{count}} min"
},
"preferences": {
"title": "Předvolby",
diff --git a/src/shared/i18n/locales/cs/vault.json b/src/shared/i18n/locales/cs/vault.json
index 6c1b71c..e19b5a1 100644
--- a/src/shared/i18n/locales/cs/vault.json
+++ b/src/shared/i18n/locales/cs/vault.json
@@ -24,5 +24,6 @@
"staleVault": "Dešifrování selhalo. Odemkněte trezor znovu a zkuste to znovu.",
"locked": "Trezor je uzamčen."
},
- "inactivityLocked": "Trezor byl uzamčen kvůli nečinnosti."
+ "inactivityLocked": "Trezor byl uzamčen kvůli nečinnosti.",
+ "tabSwitchLocked": "Trezor byl uzamčen kvůli přepnutí karty."
}
diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json
index 433ba0c..759dfba 100644
--- a/src/shared/i18n/locales/en/settings.json
+++ b/src/shared/i18n/locales/en/settings.json
@@ -2,10 +2,13 @@
"title": "Settings",
"security": {
"title": "Security",
- "description": "Manage your encryption keys and seed phrase.",
+ "description": "Manage vault, encryption keys, and seed phrase.",
"seedPhrase": "Regenerate seed phrase",
"verifySeedPhrase": "Verify seed phrase",
- "keyManagement": "Key management"
+ "keyManagement": "Key management",
+ "autoLock": "Auto-lock vault",
+ "lockOnTabHidden": "Lock when switching tabs",
+ "minutesShort": "{{count}} min"
},
"preferences": {
"title": "Preferences",
diff --git a/src/shared/i18n/locales/en/vault.json b/src/shared/i18n/locales/en/vault.json
index a1a910a..4d283e3 100644
--- a/src/shared/i18n/locales/en/vault.json
+++ b/src/shared/i18n/locales/en/vault.json
@@ -24,5 +24,6 @@
"staleVault": "Failed to decrypt. Re-unlock the vault and try again.",
"locked": "Vault is locked."
},
- "inactivityLocked": "Vault locked due to inactivity."
+ "inactivityLocked": "Vault locked due to inactivity.",
+ "tabSwitchLocked": "Vault locked due to tab switch."
}
diff --git a/src/shared/ui/create-dialog-store.ts b/src/shared/stores/create-dialog-store.ts
similarity index 100%
rename from src/shared/ui/create-dialog-store.ts
rename to src/shared/stores/create-dialog-store.ts
diff --git a/src/shared/auth/auth-dialogs-store.ts b/src/shared/stores/dialogs-store.ts
similarity index 95%
rename from src/shared/auth/auth-dialogs-store.ts
rename to src/shared/stores/dialogs-store.ts
index 11159bc..9a57be8 100644
--- a/src/shared/auth/auth-dialogs-store.ts
+++ b/src/shared/stores/dialogs-store.ts
@@ -1,4 +1,4 @@
-import { createDialogStore, createDialogStoreWithPayload } from '@/shared/ui/create-dialog-store'
+import { createDialogStore, createDialogStoreWithPayload } from '@/shared/stores/create-dialog-store'
import type { FieldName } from '@/shared/types/entities/field.types'
export const useChangePasswordDialogStore = createDialogStore('ChangePasswordDialogStore')
diff --git a/src/shared/stores/vault-settings-store.test.ts b/src/shared/stores/vault-settings-store.test.ts
new file mode 100644
index 0000000..10ea0e7
--- /dev/null
+++ b/src/shared/stores/vault-settings-store.test.ts
@@ -0,0 +1,34 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+
+import { useVaultSettingsStore, DEFAULT_VAULT_TIMEOUT_MS } from './vault-settings-store'
+
+describe('vault-settings-store', () => {
+ beforeEach(() => {
+ useVaultSettingsStore.setState({
+ vaultTimeoutMs: DEFAULT_VAULT_TIMEOUT_MS,
+ lockOnTabHidden: false,
+ })
+ })
+
+ it('initializes with default values', () => {
+ const state = useVaultSettingsStore.getState()
+ expect(state.vaultTimeoutMs).toBe(DEFAULT_VAULT_TIMEOUT_MS)
+ expect(state.lockOnTabHidden).toBe(false)
+ })
+
+ it('setVaultTimeoutMs updates vaultTimeoutMs', () => {
+ useVaultSettingsStore.getState().setVaultTimeoutMs(5 * 60 * 1000)
+ expect(useVaultSettingsStore.getState().vaultTimeoutMs).toBe(5 * 60 * 1000)
+
+ useVaultSettingsStore.getState().setVaultTimeoutMs(60 * 60 * 1000)
+ expect(useVaultSettingsStore.getState().vaultTimeoutMs).toBe(60 * 60 * 1000)
+ })
+
+ it('setLockOnTabHidden updates lockOnTabHidden', () => {
+ useVaultSettingsStore.getState().setLockOnTabHidden(true)
+ expect(useVaultSettingsStore.getState().lockOnTabHidden).toBe(true)
+
+ useVaultSettingsStore.getState().setLockOnTabHidden(false)
+ expect(useVaultSettingsStore.getState().lockOnTabHidden).toBe(false)
+ })
+})
diff --git a/src/shared/stores/vault-settings-store.ts b/src/shared/stores/vault-settings-store.ts
new file mode 100644
index 0000000..2fe2325
--- /dev/null
+++ b/src/shared/stores/vault-settings-store.ts
@@ -0,0 +1,40 @@
+import { create } from 'zustand'
+import { devtools, persist } from 'zustand/middleware'
+
+/** Default inactivity auto-lock timeout (15 minutes). */
+const DEFAULT_VAULT_TIMEOUT_MS = 15 * 60 * 1000
+
+interface VaultSettingsState {
+ vaultTimeoutMs: number
+ lockOnTabHidden: boolean
+}
+
+interface VaultSettingsActions {
+ setVaultTimeoutMs: (ms: number) => void
+ setLockOnTabHidden: (enabled: boolean) => void
+}
+
+const useVaultSettingsStore = create()(
+ devtools(
+ persist(
+ (set) => ({
+ vaultTimeoutMs: DEFAULT_VAULT_TIMEOUT_MS,
+ lockOnTabHidden: false,
+ setVaultTimeoutMs: (ms) => set({ vaultTimeoutMs: ms }, false, 'vaultSettings/setVaultTimeoutMs'),
+ setLockOnTabHidden: (enabled) => set({ lockOnTabHidden: enabled }, false, 'vaultSettings/setLockOnTabHidden'),
+ }),
+ {
+ name: 'cipher-note-vault-settings',
+ version: 0,
+ partialize: (state) => ({
+ vaultTimeoutMs: state.vaultTimeoutMs,
+ lockOnTabHidden: state.lockOnTabHidden,
+ }),
+ },
+ ),
+ { name: 'VaultSettingsStore' },
+ ),
+)
+
+export { useVaultSettingsStore, DEFAULT_VAULT_TIMEOUT_MS }
+export type { VaultSettingsState, VaultSettingsActions }
diff --git a/src/shared/ui/SegmentedControl.tsx b/src/shared/ui/SegmentedControl.tsx
index 304d59a..187c791 100644
--- a/src/shared/ui/SegmentedControl.tsx
+++ b/src/shared/ui/SegmentedControl.tsx
@@ -30,7 +30,7 @@ function SegmentedControl({ items, value, onChange, ariaLabel }: SegmentedContro
role="tab"
aria-pressed={value === item.value}
>
- {item.icon}
+ {item.icon ? item.icon : {item.value.toUpperCase()}}
{item.label}
))}
diff --git a/src/shared/ui/nav/LanguageSwitcher.test.tsx b/src/shared/ui/nav/LanguageSwitcher.test.tsx
index 48c47ac..249cf4e 100644
--- a/src/shared/ui/nav/LanguageSwitcher.test.tsx
+++ b/src/shared/ui/nav/LanguageSwitcher.test.tsx
@@ -17,8 +17,8 @@ describe('LanguageSwitcher', () => {
it('renders full variant with language names', () => {
render()
- expect(screen.getByRole('tab', { name: 'English' })).toBeInTheDocument()
- expect(screen.getByRole('tab', { name: 'Čeština' })).toBeInTheDocument()
+ expect(screen.getByRole('tab', { name: /English/ })).toBeInTheDocument()
+ expect(screen.getByRole('tab', { name: /Čeština/ })).toBeInTheDocument()
})
it('defaults to compact variant', () => {
@@ -36,14 +36,14 @@ describe('LanguageSwitcher', () => {
it('switches language when clicked in full variant', async () => {
const user = userEvent.setup()
render()
- await user.click(screen.getByRole('tab', { name: 'Čeština' }))
+ await user.click(screen.getByRole('tab', { name: /Čeština/ }))
expect(i18next.language.startsWith('cs')).toBe(true)
})
it('marks active language with aria-pressed in full variant', () => {
render()
- const enButton = screen.getByRole('tab', { name: 'English' })
- const csButton = screen.getByRole('tab', { name: 'Čeština' })
+ const enButton = screen.getByRole('tab', { name: /English/ })
+ const csButton = screen.getByRole('tab', { name: /Čeština/ })
expect(enButton).toHaveAttribute('aria-pressed', 'true')
expect(csButton).toHaveAttribute('aria-pressed', 'false')
})
diff --git a/src/shared/ui/select.tsx b/src/shared/ui/select.tsx
new file mode 100644
index 0000000..6289531
--- /dev/null
+++ b/src/shared/ui/select.tsx
@@ -0,0 +1,165 @@
+import * as React from 'react'
+import { Select as SelectPrimitive } from '@base-ui/react/select'
+
+import { cn } from '@/shared/lib/utils'
+import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from 'lucide-react'
+
+const Select = SelectPrimitive.Root
+
+function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
+ return
+}
+
+function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
+ return (
+
+ )
+}
+
+function SelectTrigger({
+ className,
+ size = 'default',
+ children,
+ ...props
+}: SelectPrimitive.Trigger.Props & {
+ size?: 'sm' | 'default'
+}) {
+ return (
+
+ {children}
+ } />
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ side = 'bottom',
+ sideOffset = 4,
+ align = 'center',
+ alignOffset = 0,
+ alignItemWithTrigger = true,
+ ...props
+}: SelectPrimitive.Popup.Props &
+ Pick) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) {
+ return (
+
+ )
+}
+
+function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
+ return (
+
+
+ {children}
+
+ }
+ >
+
+
+
+ )
+}
+
+function SelectSeparator({ className, ...props }: SelectPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({ className, ...props }: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({ className, ...props }: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/src/test/setup.ts b/src/test/setup.ts
index 1090436..64944af 100644
--- a/src/test/setup.ts
+++ b/src/test/setup.ts
@@ -18,6 +18,7 @@ import vaultCs from '@/shared/i18n/locales/cs/vault.json'
import { useAuthStore } from '@/features/auth/model/auth-store'
import { useCryptoStore } from '@/shared/crypto/vault/crypto-store'
import { useLayoutStore } from '@/app/layouts/layout-store'
+import { useVaultSettingsStore, DEFAULT_VAULT_TIMEOUT_MS } from '@/shared/stores/vault-settings-store'
import { useSyncStatusStore } from '@/features/fields/model/sync-status-store'
afterEach(() => {
@@ -38,6 +39,10 @@ afterEach(() => {
activeField: null,
sidebarWidth: 240,
})
+ useVaultSettingsStore.setState({
+ vaultTimeoutMs: DEFAULT_VAULT_TIMEOUT_MS,
+ lockOnTabHidden: false,
+ })
useSyncStatusStore.getState().resetAll()
})