diff --git a/CLAUDE.md b/CLAUDE.md index dfac47c..02b969d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,9 +52,11 @@ main.tsx → AppProviders (QueryClientProvider > AuthProvider > RouterProvider) - No DELETE policy on `recovery_keys` (updatable but not removable) - Username availability: `check_username_availability()` RPC with IP-based rate limiting (10 req/2 min/IP) - Login salts: `get_login_salts()` RPC, pre-auth, rate-limited (5 req/2 min/IP) +- Recovery RPCs: `get_recovery_data(p_username)` pre-auth (5 req/2 min/IP), `recover_account(...)` pre-auth (3 req/15 min/IP), `save_recovery_data(...)` authenticated (bcrypt-hashes `recoveryAuthHash`) +- `recovery_keys` table has a `recovery_auth_hash` column (bcrypt hash of HKDF-derived proof-of-knowledge) ### Adapter Pattern -Backend abstracted behind interfaces: `IAuthAdapter`, `IRealtimeAdapter`. There is no `IApiAdapter` — the data layer uses direct Supabase functions instead. Current implementations: auth in `supabase-adapter.ts`, entry CRUD in `supabase-entries.ts`, field CRUD in `supabase-fields.ts`, key operations in `supabase-keys.ts`, recovery in `supabase-recovery.ts`, registration upload in `supabase-registration.ts`, realtime in `supabase-realtime.ts`. +Backend abstracted behind interfaces: `IAuthAdapter`, `IRealtimeAdapter`. There is no `IApiAdapter` — the data layer uses direct Supabase functions instead. Current implementations: auth in `supabase-adapter.ts`, entry CRUD in `supabase-entries.ts`, field CRUD in `supabase-fields.ts`, key operations in `supabase-keys.ts`, recovery RPCs in `supabase-recovery.ts`, registration upload in `supabase-registration.ts`, realtime in `supabase-realtime.ts`. ## Key Conventions @@ -185,6 +187,7 @@ See `docs/implementation-plan/README.md` for the full 36-step plan. - Step 28 (Multi-Device Session Handling) — complete - Step 29 (Change Password Flow + UI) — complete - Step 30 (Regenerate Seed Phrase) — complete +- Step 31 (Seed Phrase Recovery Flow + UI) — complete ### Implementation Notes @@ -192,7 +195,7 @@ Non-obvious decisions not visible from code alone: - **Auth store `isRestoringSession`**: defaults `true`; `reset()` does NOT touch it (logout doesn't re-trigger initialization) - **Auto-lock**: `useVaultTimeout` hook in ProtectedLayout resets a 15-minute inactivity timer on user activity (mousemove, keydown, mousedown, touchstart, scroll); calls `lockVault()` on expiry -- **VaultUnlockDialog**: uses a separate `vault-dialog-store` (in `features/vault/model/vault-dialog-store.ts`) so the dialog can be opened/closed independently of vault lock state. This lets the user dismiss the dialog without unlocking, and lets the sidebar/mobile nav trigger `openUnlockDialog()` directly +- **VaultUnlockDialog**: uses a separate `vault-dialog-store` (in `features/vault/model/vault-dialog-store.ts`) created via `createDialogStore` factory. The dialog can be opened/closed independently of vault lock state. This lets the user dismiss the dialog without unlocking, and lets the sidebar/mobile nav trigger `open()` directly - **`keyVault.lockVault()` vs `keyVault.clearVault()`**: `lockVault()` preserves the cached envelope (so re-unlock can skip network calls), while `clearVault()` (called on logout) zeros everything including the cache. `keyVault.unlockVault()` tries the cached envelope first and retries from server on `DecryptionError` (stale cache can happen if the password was changed in another session) - **Test file naming**: prefix with `-` in `src/app/routes/` to exclude from TanStack Router route tree generation - **Test setup**: shared setup (`src/test/setup.ts`) resets `useAuthStore` (with `isRestoringSession: false`), `useCryptoStore`, and `useLayoutStore` (including `sidebarWidth: 240`) in `afterEach`. Router mocking (`@tanstack/react-router`) is done per-file in each test that needs it, not centralized @@ -206,14 +209,17 @@ Non-obvious decisions not visible from code alone: - **Master key wrapping uses AAD constants**: All key wrapping is done directly with `encrypt`/`decrypt` from `aes-gcm.ts` using `{iv, aad}` options. `rewrapMasterKey` in `master-key.ts` uses `MASTER_KEY_PASSWORD_AAD`, recovery wrapping in `mnemonic.ts` uses `MASTER_KEY_RECOVERY_AAD`, field key wrapping uses `encodeAAD(fieldName, version)` from `crypto-utils.ts`. - **HKDF uses `deriveBits`, not `deriveKey`**: `hkdfExpand` in `hkdf.ts` returns raw `Uint8Array` bytes because the KEK bytes need to be imported as an AES-GCM CryptoKey via `importKey()` separately. `deriveKEK` and `deriveSigningKeySeed` are convenience wrappers. HKDF uses empty salt since the PRK is already random. - **BIP-39 mnemonic functions are async**: `generateMnemonic`, `validateMnemonic`, `mnemonicToSeed` in `mnemonic.ts` must be `async` despite the underlying `@scure/bip39` functions being synchronous, because the lazy-loading pattern (`await loadBip39()`) requires it. Same as how `argon2id.ts` wraps sync Argon2 in async. -- **`deriveRecoveryKEK` uses mnemonic string directly**: In `mnemonic.ts`, the mnemonic phrase is passed as the Argon2id "password" parameter, not the BIP-39 binary seed. The human-readable phrase is the input because it is what the user supplies and remembers; the binary seed is an internal derivation artifact. `mnemonicToSeed` is a utility function not used in the recovery KEK path. +- **`deriveRecoveryKEK` uses mnemonic string directly**: In `mnemonic.ts`, the mnemonic phrase is passed as the Argon2id "password" parameter, not the BIP-39 binary seed. The human-readable phrase is the input because it is what the user supplies and remembers; the binary seed is an internal derivation artifact. `mnemonicToSeed` is a utility function not used in the recovery KEK path. `wrapMasterKeyWithRecovery` and `unwrapMasterKeyWithRecovery` zero-fill the recovery KEK in a `finally` block and also derive `recoveryAuthHash` via `HKDF_INFO.RECOVERY_AUTH`. - **Crypto integration tests mock `deriveKey` re-consumption**: In `crypto-integration.test.ts` (in `shared/crypto/`), `unwrapMasterKeyWithRecovery` requires a fresh `deriveKey` mock even after `wrapMasterKeyWithRecovery` consumed one during setup. The `setupRegistration` helper uses `mockResolvedValueOnce` which is consumed, so the test must re-mock before calling unwrap. - **`deriveRegistrationKeys` is a pure crypto function**: in `features/auth/model/registration-crypto.ts`, it has no side effects (no auth, no DB, no store writes). The orchestration (signup + upload + store population) lives in `features/auth/model/auth-service.ts` `signUpUser`. Do not add side effects to this function. - **`signUpUser` error cleanup**: on any error after `deriveRegistrationKeys` succeeds, attempts `authAdapter.logout()` as best-effort cleanup (harmless if no session exists, since Supabase signOut with no session is a no-op). -- **Login salt fetch is pre-auth**: `get_login_salts(p_username)` is a SECURITY DEFINER RPC callable by anonymous users, rate-limited (5 req/2 min/IP). Salts must be fetched before auth to derive `authHash` for Supabase Auth, but the `master_keys` table is RLS-protected. After auth succeeds, `fetchMasterKeyEnvelope` and `fetchFieldKeys` fetch wrapped key material through standard RLS-protected queries. +- **`saveRecoveryData` uses RPC**: Registration and regeneration both call `saveRecoveryData()` in `supabase-recovery.ts`, which invokes the `save_recovery_data` SECURITY DEFINER function. This RPC bcrypt-hashes `recoveryAuthHash` before storage, ensuring the raw HKDF-derived value never appears in the DB. Direct table inserts into `recovery_keys` are not used anywhere. +- **Pre-auth RPCs**: `get_login_salts(p_username)` and `get_recovery_data(p_username)` are SECURITY DEFINER RPCs callable by anonymous users, rate-limited (5 req/2 min/IP). Salts must be fetched before auth to derive `authHash` for Supabase Auth, but the `master_keys` table is RLS-protected. After auth succeeds, `fetchMasterKeyEnvelope` and `fetchFieldKeys` fetch wrapped key material through standard RLS-protected queries. `recover_account` is also pre-auth but more strictly rate-limited (3 req/15 min/IP). - **Auth error codes fold username format into invalid credentials**: `AuthErrorCode.INVALID_USERNAME_FORMAT` doesn't exist — `supabase-keys.ts` throws `INVALID_CREDENTIALS` for invalid username format. This is deliberate: showing a different error for "wrong format" vs "wrong password" would leak whether a username exists. Missing key data is `ApiError(NOT_FOUND)`, not an auth error — `KEYS_NOT_FOUND` was removed from `AuthErrorCode` because "data not found" is a data-layer concern, not an auth concern. - **`AuthError` vs `ApiError` domain boundary**: `AuthError` (in `shared/auth/auth-errors.ts`) covers authentication errors (`INVALID_CREDENTIALS`, `USERNAME_TAKEN`, `NETWORK_ERROR`, `UNEXPECTED`). `ApiError` (in `shared/api/api-errors.ts`) covers data-layer errors (`NETWORK_ERROR`, `NOT_FOUND`, `UNEXPECTED`). `fetchLoginSalts` throws `AuthError` because it's a pre-auth RPC that's part of the login flow; all other data queries throw `ApiError`. Each domain has its own `wrapXxxError` that classifies raw errors using the shared `isNetworkError` helper. - **Network errors can bypass the adapter boundary**: `isNetworkError` (in `shared/lib/network-errors.ts`) is shared by both `wrapAuthError` and `wrapApiError`. Raw `TypeError('Failed to fetch')` from the browser can reach the UI without being wrapped by any adapter, so `getAuthErrorMessage` in `auth-error-messages.ts` also calls `isNetworkError` as a final fallback. - **Change password rollback**: `changeUserPassword` in `auth-service.ts` is a 4-step flow (derive → upload envelope → update auth → update store). If step 3 (Supabase Auth update) fails after step 2 (DB envelope upload) succeeds, it attempts to roll back the DB write with the old envelope values. If rollback also fails, it forces logout to prevent inconsistent state. - **Stale KEK detection**: Stale KEK from a password change on another device is detected in two places: (1) `use-realtime-sync.ts` `onKeyRotation` — when a key rotation event arrives, `syncFieldKeys` tries to unwrap with the current KEK; a `DecryptionError` means the KEK is stale, so `clearVault()` forces re-auth. (2) `key-vault.ts` `unlockVault` — if the cached envelope is stale, `clearVault` + retry from server. The save path (`useSaveField`) cannot produce a `DecryptionError` — `encryptField` only encrypts, and `getFieldKey` throws a generic `Error` when the vault is locked, not `DecryptionError`. +- **`recoveryAuthHash` for proof-of-knowledge**: `wrapMasterKeyWithRecovery` and `unwrapMasterKeyWithRecovery` in `mnemonic.ts` derive a `recoveryAuthHash` via `HKDF_INFO.RECOVERY_AUTH` from the recovery KEK, and zero-fill the KEK in a `finally` block. The server stores a bcrypt hash of this value (via `save_recovery_data` RPC — never a direct table insert), so the raw HKDF-derived value never appears in the DB. The `recover_account` RPC verifies this proof before atomically updating auth password, salts, and master key. +- **Account recovery is a two-step client flow**: `RecoveryFlow` class in `mnemonic-service.ts` holds state (`username`, `masterKey`, `recoveryAuthHash`) between `validateMnemonic()` and `setNewPassword()`. Master key is zero-filled in `clear()` (called on unmount, not in React state — to avoid devtools exposure). `recover_account` RPC is atomic on the server side; if the RPC succeeds but automatic login fails, a `RecoveryLoginError` is thrown and the user is redirected to `/login` with a success toast. diff --git a/docs/implementation-plan/08-phase-8-recovery.md b/docs/implementation-plan/08-phase-8-recovery.md index 3383f19..f29696a 100644 --- a/docs/implementation-plan/08-phase-8-recovery.md +++ b/docs/implementation-plan/08-phase-8-recovery.md @@ -63,40 +63,59 @@ --- -## Step 31 — Seed Phrase Recovery Flow + UI +## Step 31 — Seed Phrase Recovery Flow + UI ✅ **Goal:** Recover account using seed phrase when password is lost. **Code:** -- `src/app/routes/_public.recover.tsx` — replace placeholder with full recovery form: - - Username input + `MnemonicInput` (12-word input with BIP-39 validation) - - On submit: - 1. Fetch recovery salts via `get_recovery_salts(p_username)` RPC (pre-auth, rate-limited) - 2. Derive recovery KEK from mnemonic + recovery salt via `deriveRecoveryKEK` - 3. Unwrap master key with `unwrapMasterKeyWithRecovery` - 4. Derive full key hierarchy, unlock vault - 5. Prompt user to set a new password (required — old auth hash is invalid after recovery) - - Error states: invalid mnemonic (BIP-39 validation), wrong mnemonic (DecryptionError), network error +- `src/app/routes/_public.recover.tsx` — route renders `RecoverPage` component +- `src/features/auth/ui/RecoverPage.tsx` — two-step form (mnemonic → new password): + - Step 1: username input + `MnemonicInput`; calls `recoveryFlow.validateMnemonic()` + - Step 2: new password + confirm; calls `recoveryFlow.setNewPassword()` + - On success, navigates to `/dashboard`; on `RecoveryLoginError`, navigates to `/login` with toast + - Zero-fills recovery state on unmount via `recoveryFlow.clear()` - `src/features/auth/ui/MnemonicInput.tsx`: - - 12 individual word inputs with BIP-39 word validation - - Auto-advance to next word on space/tab; paste support (split pasted text into words) - - Highlight invalid words in red; disable submit until all 12 words are valid BIP-39 words + - Controlled component with `value`, `onChange`, `onValidityChange` props + - 12 individual word inputs with BIP-39 word validation (async via `getBip39Wordlist()`) + - Auto-advance on space/tab; paste support (multi-word paste splits into inputs) + - Invalid words highlighted with `border-destructive`; submit disabled until all valid - `src/features/auth/ui/VerifyMnemonicDialog.tsx`: - - From SecuritySection, let user verify their stored mnemonic can unwrap the master key - - Uses `MnemonicInput` → derive recovery KEK → `unwrapMasterKeyWithRecovery` with cached envelope - - Success: "Your recovery phrase is valid" / Failure: "Recovery phrase does not match" -- `src/features/auth/model/recovery-service.ts`: - - Orchestrates: fetch salts → derive KEK → unwrap master key → derive key hierarchy → set new password - - Reuses `deriveFullKeyHierarchy` and password-setting logic from `changeUserPassword` -- `src/shared/api/supabase-recovery.ts` — add `get_recovery_salts(p_username)` RPC call for pre-auth salt fetch -- Add i18n strings to `auth.json` for recovery flow + - From SecuritySection, verify mnemonic can unwrap the master key + - Uses `verifyMnemonic()` from `mnemonic-service.ts` → returns true/false + - Success toast / failure error message +- `src/features/auth/model/mnemonic-service.ts` — `RecoveryFlow` class: + - `validateMnemonic(username, mnemonic)` → fetches recovery data pre-auth, unwraps master key, stores state + - `setNewPassword(newPassword)` → re-wraps master key, calls `recoverAccount` RPC, logs in, unlocks vault + - `clear()` → zero-fills master key and clears state + - `RecoveryLoginError` thrown when recovery succeeds but auto-login fails + - `verifyMnemonic(mnemonic)` → standalone function for verifying mnemonic from SecuritySection +- `src/features/auth/model/recovery-schema.ts` — Zod schemas for both steps +- `src/features/auth/model/recovery-error-messages.ts` — error mapping for recovery + regenerate mnemonic flows +- `src/shared/api/supabase-recovery.ts` — three RPCs: + - `fetchRecoveryDataPreAuth(username)` — `get_recovery_data` RPC (pre-auth, rate-limited 5 req/2 min/IP) + - `recoverAccount(username, data)` — `recover_account` RPC (atomic: verify proof + update auth/salts/keys, rate-limited 3 req/15 min/IP) + - `saveRecoveryData` — changed from direct table insert to `save_recovery_data` RPC (bcrypt-hashes `recoveryAuthHash`) +- `supabase/migrations/00006_recovery_rpc.sql` — migration with all three RPCs +- Recovery uses `recoveryAuthHash` (HKDF of recovery KEK) as proof-of-knowledge: + - `wrapMasterKeyWithRecovery` and `unwrapMasterKeyWithRecovery` now return `recoveryAuthHash` + - Server stores bcrypt hash of `recoveryAuthHash`; `recover_account` verifies it before updating + - `RecoveryCredentials` type: `{newPasswordAuthHash, newKeySalt}` (no `mnemonic` field) +- `src/shared/ui/create-dialog-store.ts` — factory for simple open/close dialog stores +- `src/shared/auth/auth-dialogs-store.ts` — consolidated auth dialog stores using factory +- Dialog stores refactored: `change-password-dialog-store`, `regenerate-mnemonic-dialog-store`, `vault-dialog-store` → use `createDialogStore` with `isOpen/open/close` API +- Login page: "Forgot password?" link to `/recover` +- Add i18n strings to `auth.json` (recovery, verify mnemonic) and `settings.json` (verify seed phrase) **Tests:** -- Integration: register → write down mnemonic → recover with mnemonic → set new password → login with new password +- Integration: register → recover with mnemonic → set new password → login with new password - Component: MnemonicInput validates BIP-39 words, highlights invalid, supports paste - Unit: wrong mnemonic → `unwrapMasterKeyWithRecovery` throws `DecryptionError` -- Unit: recovery + new password → vault unlocks with new credentials +- Unit: `RecoveryFlow` class — validate mnemonic, set new password, clear - Component: VerifyMnemonicDialog shows success/failure based on mnemonic validity +- Component: RecoverPage — step transitions, error handling +- Unit: `recovery-schema` validation +- Unit: `recovery-error-messages` covers all error types +- Unit: `getBip39Wordlist()` caching, HKDF `RECOVERY_AUTH` branch --- diff --git a/docs/implementation-plan/README.md b/docs/implementation-plan/README.md index 89b4657..63fc2c0 100644 --- a/docs/implementation-plan/README.md +++ b/docs/implementation-plan/README.md @@ -68,7 +68,7 @@ This is the implementation plan for Cipher Note, an end-to-end encrypted note-ta - [x] Step 28 — Multi-Device Session Handling - [x] Step 29 — Change Password Flow + UI - [x] Step 30 — Seed Phrase Backup View -- [ ] Step 31 — Seed Phrase Recovery Flow + UI +- [x] Step 31 — Seed Phrase Recovery Flow + UI - [ ] Step 32 — Key Rotation + UI - [ ] Step 33 — Mobile Responsive Refinements - [ ] Step 34 — Loading States, Error Boundaries, Toast Notifications diff --git a/src/app/layouts/MobileNav.test.tsx b/src/app/layouts/MobileNav.test.tsx index 3ea57e6..0fda856 100644 --- a/src/app/layouts/MobileNav.test.tsx +++ b/src/app/layouts/MobileNav.test.tsx @@ -173,11 +173,11 @@ describe('MobileNav', () => { it('opens unlock dialog when unlock button is clicked while locked', async () => { useCryptoStore.setState({ isVaultLocked: true }) - useVaultDialogStore.setState({ isUnlockDialogOpen: false }) + useVaultDialogStore.setState({ isOpen: false }) const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: /unlock vault/i })) - expect(useVaultDialogStore.getState().isUnlockDialogOpen).toBe(true) + expect(useVaultDialogStore.getState().isOpen).toBe(true) expect(mockLockVault).not.toHaveBeenCalled() }) }) diff --git a/src/app/layouts/ProtectedLayout.tsx b/src/app/layouts/ProtectedLayout.tsx index 58479a0..ee4527a 100644 --- a/src/app/layouts/ProtectedLayout.tsx +++ b/src/app/layouts/ProtectedLayout.tsx @@ -11,6 +11,7 @@ import { VaultIndicator } from '@/features/vault/ui/VaultIndicator' import { VaultUnlockDialog } from '@/features/vault/ui/VaultUnlockDialog' import { ChangePasswordDialog } from '@/features/auth/ui/ChangePasswordDialog' import { RegenerateMnemonicDialog } from '@/features/auth/ui/RegenerateMnemonicDialog' +import { VerifyMnemonicDialog } from '@/features/auth/ui/VerifyMnemonicDialog' import { OfflineBanner } from '@/shared/ui/OfflineBanner' import { useLayoutStore } from './layout-store' import { useResizable } from '@/shared/lib/use-resizable' @@ -105,6 +106,7 @@ function AuthenticatedLayout() { + ) } diff --git a/src/app/layouts/Sidebar.test.tsx b/src/app/layouts/Sidebar.test.tsx index 42b76be..8156022 100644 --- a/src/app/layouts/Sidebar.test.tsx +++ b/src/app/layouts/Sidebar.test.tsx @@ -114,18 +114,18 @@ describe('Sidebar', () => { it('opens unlock dialog when unlock button is clicked while locked', async () => { useCryptoStore.setState({ isVaultLocked: true }) - useVaultDialogStore.setState({ isUnlockDialogOpen: false }) + useVaultDialogStore.setState({ isOpen: false }) const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: /unlock vault/i })) - expect(useVaultDialogStore.getState().isUnlockDialogOpen).toBe(true) + expect(useVaultDialogStore.getState().isOpen).toBe(true) expect(mockLockVault).not.toHaveBeenCalled() }) it('calls onClose when unlock button is clicked while locked', async () => { const onClose = vi.fn() useCryptoStore.setState({ isVaultLocked: true }) - useVaultDialogStore.setState({ isUnlockDialogOpen: false }) + useVaultDialogStore.setState({ isOpen: false }) const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: /unlock vault/i })) diff --git a/src/app/layouts/VaultLockButton.test.tsx b/src/app/layouts/VaultLockButton.test.tsx index d97ff92..e8b6377 100644 --- a/src/app/layouts/VaultLockButton.test.tsx +++ b/src/app/layouts/VaultLockButton.test.tsx @@ -22,7 +22,7 @@ vi.mock('@/shared/crypto/vault/crypto-store', () => ({ const mockOpenUnlockDialog = vi.fn() vi.mock('@/features/vault/model/vault-dialog-store', () => ({ - useVaultDialogStore: vi.fn((selector) => selector({ openUnlockDialog: mockOpenUnlockDialog })), + useVaultDialogStore: vi.fn((selector) => selector({ open: mockOpenUnlockDialog })), })) // Mock sync-status-store @@ -124,7 +124,7 @@ describe('VaultLockButton', () => { expect(mockLockVault).toHaveBeenCalledOnce() }) - it('calls openUnlockDialog when clicked while vault is locked', async () => { + it('calls open when clicked while vault is locked', async () => { mockIsVaultLocked.mockReturnValue(true) const user = userEvent.setup() render() diff --git a/src/app/layouts/VaultLockButton.tsx b/src/app/layouts/VaultLockButton.tsx index 871a17c..2ded7cd 100644 --- a/src/app/layouts/VaultLockButton.tsx +++ b/src/app/layouts/VaultLockButton.tsx @@ -18,7 +18,7 @@ interface VaultLockButtonProps { function VaultLockButton({ variant, onBeforeToggle, className }: VaultLockButtonProps) { const { t } = useTranslation('vault') const isVaultLocked = useCryptoStore((s) => s.isVaultLocked) - const openUnlockDialog = useVaultDialogStore((s) => s.openUnlockDialog) + const openUnlockDialog = useVaultDialogStore((s) => s.open) const [isLocking, setIsLocking] = useState(false) function handleVaultLock() { diff --git a/src/features/auth/ui/ChangePasswordDialog.test.tsx b/src/features/auth/ui/ChangePasswordDialog.test.tsx index 83f8320..a2a7d12 100644 --- a/src/features/auth/ui/ChangePasswordDialog.test.tsx +++ b/src/features/auth/ui/ChangePasswordDialog.test.tsx @@ -9,14 +9,14 @@ vi.mock('@/features/auth/model/auth-service', () => ({ import { ChangePasswordDialog } from './ChangePasswordDialog' import { changeUserPassword } from '@/features/auth/model/auth-service' -import { useChangePasswordDialogStore } from '@/shared/auth/change-password-dialog-store' +import { useChangePasswordDialogStore } from '@/shared/auth/auth-dialogs-store' const mockChangeUserPassword = vi.mocked(changeUserPassword) describe('ChangePasswordDialog', () => { beforeEach(() => { vi.clearAllMocks() - useChangePasswordDialogStore.setState({ isChangePasswordDialogOpen: true }) + useChangePasswordDialogStore.setState({ isOpen: true }) }) it('renders the dialog with all form fields when open', () => { @@ -29,7 +29,7 @@ describe('ChangePasswordDialog', () => { }) it('does not render form fields when closed', () => { - useChangePasswordDialogStore.setState({ isChangePasswordDialogOpen: false }) + useChangePasswordDialogStore.setState({ isOpen: false }) render() expect(screen.queryByLabelText('Current password')).not.toBeInTheDocument() @@ -80,7 +80,7 @@ describe('ChangePasswordDialog', () => { await user.click(submitButton) await vi.waitFor(() => { - expect(useChangePasswordDialogStore.getState().isChangePasswordDialogOpen).toBe(false) + expect(useChangePasswordDialogStore.getState().isOpen).toBe(false) }) }) @@ -124,7 +124,7 @@ describe('ChangePasswordDialog', () => { // Escape should not close the dialog await user.keyboard('{Escape}') - expect(useChangePasswordDialogStore.getState().isChangePasswordDialogOpen).toBe(true) + expect(useChangePasswordDialogStore.getState().isOpen).toBe(true) // Clean up: resolve the promise resolveChange() diff --git a/src/features/auth/ui/ChangePasswordDialog.tsx b/src/features/auth/ui/ChangePasswordDialog.tsx index 1d73f59..112b3a9 100644 --- a/src/features/auth/ui/ChangePasswordDialog.tsx +++ b/src/features/auth/ui/ChangePasswordDialog.tsx @@ -11,12 +11,12 @@ import { PasswordStrength } from '@/features/auth/ui/PasswordStrength' import { changePasswordSchema, type ChangePasswordFormData } from '@/features/auth/model/change-password-schema' import { changeUserPassword } from '@/features/auth/model/auth-service' import { getChangePasswordErrorMessage } from '@/features/auth/model/change-password-error-messages' -import { useChangePasswordDialogStore } from '@/shared/auth/change-password-dialog-store' +import { useChangePasswordDialogStore } from '@/shared/auth/auth-dialogs-store' function ChangePasswordDialog() { const { t } = useTranslation('auth') - const isChangePasswordDialogOpen = useChangePasswordDialogStore((s) => s.isChangePasswordDialogOpen) - const closeChangePasswordDialog = useChangePasswordDialogStore((s) => s.closeChangePasswordDialog) + const isOpen = useChangePasswordDialogStore((s) => s.isOpen) + const close = useChangePasswordDialogStore((s) => s.close) const cardRef = useRef(null) const methods = useForm({ @@ -42,7 +42,7 @@ function ChangePasswordDialog() { await changeUserPassword(data.currentPassword, data.newPassword) toast.success(t('changePassword.success')) reset() - closeChangePasswordDialog() + close() } catch (error) { toast.error(getChangePasswordErrorMessage(error, t)) } @@ -50,12 +50,12 @@ function ChangePasswordDialog() { return ( { if (!open) { reset() - closeChangePasswordDialog() + close() } }} > diff --git a/src/features/auth/ui/RegenerateMnemonicDialog.test.tsx b/src/features/auth/ui/RegenerateMnemonicDialog.test.tsx index 329f7fd..a2ac239 100644 --- a/src/features/auth/ui/RegenerateMnemonicDialog.test.tsx +++ b/src/features/auth/ui/RegenerateMnemonicDialog.test.tsx @@ -14,7 +14,7 @@ vi.mock('@/features/auth/model/mnemonic-service', () => ({ import { RegenerateMnemonicDialog } from './RegenerateMnemonicDialog' import { regenerateMnemonic } from '@/features/auth/model/mnemonic-service' -import { useRegenerateMnemonicDialogStore } from '@/shared/auth/regenerate-mnemonic-dialog-store' +import { useRegenerateMnemonicDialogStore } from '@/shared/auth/auth-dialogs-store' const mockRegenerateMnemonic = vi.mocked(regenerateMnemonic) @@ -23,7 +23,7 @@ const MNEMONIC = 'abandon ability able about above absent absorb abstract absurd describe('RegenerateMnemonicDialog', () => { beforeEach(() => { vi.clearAllMocks() - useRegenerateMnemonicDialogStore.setState({ isRegenerateMnemonicDialogOpen: true }) + useRegenerateMnemonicDialogStore.setState({ isOpen: true }) }) it('renders password confirm dialog when open and in password-confirm step', () => { @@ -34,7 +34,7 @@ describe('RegenerateMnemonicDialog', () => { }) it('does not render password dialog when closed', () => { - useRegenerateMnemonicDialogStore.setState({ isRegenerateMnemonicDialogOpen: false }) + useRegenerateMnemonicDialogStore.setState({ isOpen: false }) render() expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument() @@ -110,7 +110,7 @@ describe('RegenerateMnemonicDialog', () => { // Dialog should close and success toast should fire await vi.waitFor(() => { - expect(useRegenerateMnemonicDialogStore.getState().isRegenerateMnemonicDialogOpen).toBe(false) + expect(useRegenerateMnemonicDialogStore.getState().isOpen).toBe(false) }) }) @@ -123,7 +123,7 @@ describe('RegenerateMnemonicDialog', () => { // Click the close button on the PasswordConfirmDialog await user.click(screen.getByRole('button', { name: /close/i })) - expect(useRegenerateMnemonicDialogStore.getState().isRegenerateMnemonicDialogOpen).toBe(false) + expect(useRegenerateMnemonicDialogStore.getState().isOpen).toBe(false) }) it('shows network error when saveRecoveryData fails', async () => { diff --git a/src/features/auth/ui/RegenerateMnemonicDialog.tsx b/src/features/auth/ui/RegenerateMnemonicDialog.tsx index 3d2c677..90bf55c 100644 --- a/src/features/auth/ui/RegenerateMnemonicDialog.tsx +++ b/src/features/auth/ui/RegenerateMnemonicDialog.tsx @@ -4,15 +4,15 @@ import { toast } from 'sonner' import { PasswordConfirmDialog } from '@/shared/ui/PasswordConfirmDialog' import { MnemonicDialog } from '@/features/auth/ui/MnemonicDialog' -import { useRegenerateMnemonicDialogStore } from '@/shared/auth/regenerate-mnemonic-dialog-store' +import { useRegenerateMnemonicDialogStore } from '@/shared/auth/auth-dialogs-store' import { regenerateMnemonic } from '@/features/auth/model/mnemonic-service' import { getRegenerateMnemonicErrorMessage } from '@/features/auth/model/recovery-error-messages' function RegenerateMnemonicDialog() { const { t } = useTranslation('auth') const [mnemonic, setMnemonic] = useState(null) - const isPasswordDialogOpen = useRegenerateMnemonicDialogStore((s) => s.isRegenerateMnemonicDialogOpen) - const closeDialog = useRegenerateMnemonicDialogStore((s) => s.closeRegenerateMnemonicDialog) + const isPasswordDialogOpen = useRegenerateMnemonicDialogStore((s) => s.isOpen) + const closeDialog = useRegenerateMnemonicDialogStore((s) => s.close) const isMnemonicDialogOpen = mnemonic !== null async function handlePasswordConfirm(password: string) { diff --git a/src/features/auth/ui/VerifyMnemonicDialog.test.tsx b/src/features/auth/ui/VerifyMnemonicDialog.test.tsx new file mode 100644 index 0000000..dc15894 --- /dev/null +++ b/src/features/auth/ui/VerifyMnemonicDialog.test.tsx @@ -0,0 +1,227 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@/test/utils' +import userEvent from '@testing-library/user-event' + +// Mock mnemonic-service +vi.mock('@/features/auth/model/mnemonic-service', () => ({ + verifyMnemonic: vi.fn(), + RecoveryLoginError: class RecoveryLoginError extends Error { + constructor(cause?: Error) { + super('Recovery succeeded but automatic login failed', { cause }) + this.name = 'RecoveryLoginError' + } + }, +})) + +// Mock sonner toast +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +// Mock getBip39Wordlist for MnemonicInput validation +vi.mock('@/shared/crypto/keys/mnemonic', () => ({ + getBip39Wordlist: vi + .fn() + .mockResolvedValue( + new Set([ + 'abandon', + 'ability', + 'able', + 'about', + 'above', + 'absent', + 'absorb', + 'abstract', + 'absurd', + 'abuse', + 'access', + 'accident', + ]), + ), +})) + +import { VerifyMnemonicDialog } from './VerifyMnemonicDialog' +import { verifyMnemonic } from '@/features/auth/model/mnemonic-service' +import { useVerifyMnemonicDialogStore } from '@/shared/auth/auth-dialogs-store' +import { toast } from 'sonner' + +const mockVerifyMnemonic = vi.mocked(verifyMnemonic) + +const VALID_MNEMONIC = 'abandon ability able about above absent absorb abstract absurd abuse access accident' + +describe('VerifyMnemonicDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + useVerifyMnemonicDialogStore.setState({ isOpen: true }) + }) + + it('renders dialog when open', () => { + render() + + expect(screen.getByText('Verify Seed Phrase')).toBeInTheDocument() + expect(screen.getByText('Enter your seed phrase to verify it matches your recovery data.')).toBeInTheDocument() + }) + + it('does not render dialog content when closed', () => { + useVerifyMnemonicDialogStore.setState({ isOpen: false }) + render() + + expect(screen.queryByText('Verify Seed Phrase')).not.toBeInTheDocument() + }) + + it('shows success toast and closes dialog when mnemonic is correct', async () => { + const user = userEvent.setup() + mockVerifyMnemonic.mockResolvedValueOnce(true) + + render() + + // Paste a valid 12-word mnemonic + const firstInput = screen.getByPlaceholderText('1') + await user.click(firstInput) + await user.paste(VALID_MNEMONIC) + + // Wait for MnemonicInput validity check and submit button to be enabled + const submitButton = screen.getByRole('button', { name: /verify/i }) + await user.click(submitButton) + + await vi.waitFor(() => { + expect(toast.success).toHaveBeenCalledWith('Your recovery phrase is valid') + expect(useVerifyMnemonicDialogStore.getState().isOpen).toBe(false) + }) + }) + + it('shows failure error when mnemonic is wrong', async () => { + const user = userEvent.setup() + mockVerifyMnemonic.mockResolvedValueOnce(false) + + render() + + const firstInput = screen.getByPlaceholderText('1') + await user.click(firstInput) + await user.paste(VALID_MNEMONIC) + + const submitButton = screen.getByRole('button', { name: /verify/i }) + await user.click(submitButton) + + await vi.waitFor(() => { + expect(screen.getByText(/does not match/i)).toBeInTheDocument() + }) + }) + + it('shows network error when verifyMnemonic throws network error', async () => { + const user = userEvent.setup() + const { ApiError, ApiErrorCode } = await import('@/shared/api/api-errors') + mockVerifyMnemonic.mockRejectedValueOnce(new ApiError(ApiErrorCode.NETWORK_ERROR)) + + render() + + const firstInput = screen.getByPlaceholderText('1') + await user.click(firstInput) + await user.paste(VALID_MNEMONIC) + + const submitButton = screen.getByRole('button', { name: /verify/i }) + await user.click(submitButton) + + await vi.waitFor(() => { + expect(screen.getByText(/network error/i)).toBeInTheDocument() + }) + }) + + it('shows not found error when verifyMnemonic throws NOT_FOUND', async () => { + const user = userEvent.setup() + const { ApiError, ApiErrorCode } = await import('@/shared/api/api-errors') + mockVerifyMnemonic.mockRejectedValueOnce(new ApiError(ApiErrorCode.NOT_FOUND)) + + render() + + const firstInput = screen.getByPlaceholderText('1') + await user.click(firstInput) + await user.paste(VALID_MNEMONIC) + + const submitButton = screen.getByRole('button', { name: /verify/i }) + await user.click(submitButton) + + await vi.waitFor(() => { + expect(screen.getByText(/account not found/i)).toBeInTheDocument() + }) + }) + + it('shows unexpected error on unknown error', async () => { + const user = userEvent.setup() + mockVerifyMnemonic.mockRejectedValueOnce(new Error('Something unexpected')) + + render() + + const firstInput = screen.getByPlaceholderText('1') + await user.click(firstInput) + await user.paste(VALID_MNEMONIC) + + const submitButton = screen.getByRole('button', { name: /verify/i }) + await user.click(submitButton) + + await vi.waitFor(() => { + expect(screen.getByText(/unexpected error/i)).toBeInTheDocument() + }) + }) + + it('closes dialog on cancel button click', async () => { + const user = userEvent.setup() + render() + + const cancelButton = screen.getByRole('button', { name: /cancel/i }) + await user.click(cancelButton) + + expect(useVerifyMnemonicDialogStore.getState().isOpen).toBe(false) + }) + + it('blocks close during submission', async () => { + let resolveVerify!: (value: boolean) => void + mockVerifyMnemonic.mockReturnValue( + new Promise((resolve) => { + resolveVerify = resolve + }), + ) + + const user = userEvent.setup() + render() + + const firstInput = screen.getByPlaceholderText('1') + await user.click(firstInput) + await user.paste(VALID_MNEMONIC) + + const submitButton = screen.getByRole('button', { name: /verify/i }) + await user.click(submitButton) + + // Escape should not close the dialog during submission + await user.keyboard('{Escape}') + expect(useVerifyMnemonicDialogStore.getState().isOpen).toBe(true) + + // Clean up: resolve the promise + resolveVerify(true) + }) + + it('clears error when user types new input after failure', async () => { + const user = userEvent.setup() + mockVerifyMnemonic.mockResolvedValueOnce(false) + + render() + + const firstInput = screen.getByPlaceholderText('1') + await user.click(firstInput) + await user.paste(VALID_MNEMONIC) + + const submitButton = screen.getByRole('button', { name: /verify/i }) + await user.click(submitButton) + + // Wait for failure error to appear + await vi.waitFor(() => { + expect(screen.getByText(/does not match/i)).toBeInTheDocument() + }) + + // Type in the first input to clear the error + await user.type(firstInput, 'a') + + // Error should be cleared + expect(screen.queryByText(/does not match/i)).not.toBeInTheDocument() + }) +}) diff --git a/src/features/auth/ui/VerifyMnemonicDialog.tsx b/src/features/auth/ui/VerifyMnemonicDialog.tsx new file mode 100644 index 0000000..52ed72c --- /dev/null +++ b/src/features/auth/ui/VerifyMnemonicDialog.tsx @@ -0,0 +1,98 @@ +import { useState, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/shared/ui/dialog' +import { Button } from '@/shared/ui/button' +import { MnemonicInput } from '@/features/auth/ui/MnemonicInput' +import { useVerifyMnemonicDialogStore } from '@/shared/auth/auth-dialogs-store' +import { verifyMnemonic } from '@/features/auth/model/mnemonic-service' +import { getRecoveryErrorMessage } from '@/features/auth/model/recovery-error-messages' + +const EMPTY_WORDS = () => Array.from({ length: 12 }, () => '') + +function VerifyMnemonicDialog() { + const { t } = useTranslation('auth') + const { t: tc } = useTranslation('common') + const isOpen = useVerifyMnemonicDialogStore((s) => s.isOpen) + const closeDialog = useVerifyMnemonicDialogStore((s) => s.close) + const [words, setWords] = useState(EMPTY_WORDS) + const [isValid, setIsValid] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + + function handleClose() { + closeDialog() + setWords(EMPTY_WORDS()) + setIsValid(false) + setIsSubmitting(false) + setError(null) + } + + const handleChange = useCallback((newWords: string[]) => { + setWords(newWords) + setError(null) + }, []) + + const handleValidityChange = useCallback((valid: boolean) => { + setIsValid(valid) + }, []) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!isValid || isSubmitting) return + + setIsSubmitting(true) + setError(null) + + try { + const result = await verifyMnemonic(words.join(' ')) + if (result) { + toast.success(t('verifyMnemonic.success')) + handleClose() + } else { + setError(t('verifyMnemonic.failure')) + } + } catch (err) { + setError(getRecoveryErrorMessage(err, t)) + } finally { + setIsSubmitting(false) + } + } + + return ( + { + if (!open) handleClose() + }} + > + + + {t('verifyMnemonic.title')} + {t('verifyMnemonic.description')} + +
+ +
+ + +
+ +
+
+ ) +} + +export { VerifyMnemonicDialog } diff --git a/src/features/settings/ui/SecuritySection.test.tsx b/src/features/settings/ui/SecuritySection.test.tsx index c92cabf..9fe8ae0 100644 --- a/src/features/settings/ui/SecuritySection.test.tsx +++ b/src/features/settings/ui/SecuritySection.test.tsx @@ -2,14 +2,18 @@ import { describe, it, expect, beforeEach } from 'vitest' import { render, screen } from '@/test/utils' import userEvent from '@testing-library/user-event' -import { useChangePasswordDialogStore } from '@/shared/auth/change-password-dialog-store' -import { useRegenerateMnemonicDialogStore } from '@/shared/auth/regenerate-mnemonic-dialog-store' +import { + useChangePasswordDialogStore, + useRegenerateMnemonicDialogStore, + useVerifyMnemonicDialogStore, +} from '@/shared/auth/auth-dialogs-store' import { SecuritySection } from './SecuritySection' describe('SecuritySection', () => { beforeEach(() => { - useChangePasswordDialogStore.setState({ isChangePasswordDialogOpen: false }) - useRegenerateMnemonicDialogStore.setState({ isRegenerateMnemonicDialogOpen: false }) + useChangePasswordDialogStore.setState({ isOpen: false }) + useRegenerateMnemonicDialogStore.setState({ isOpen: false }) + useVerifyMnemonicDialogStore.setState({ isOpen: false }) }) it('renders section title and description', () => { @@ -25,10 +29,10 @@ describe('SecuritySection', () => { expect(screen.getByText('Key versions')).toBeInTheDocument() }) - it('renders two separator dividers between action items', () => { + it('renders three separator dividers between action items', () => { render() const separators = screen.getAllByRole('separator') - expect(separators).toHaveLength(2) + expect(separators).toHaveLength(3) }) it('opens change password dialog when clicking "Change password"', async () => { @@ -38,7 +42,7 @@ describe('SecuritySection', () => { const changePasswordButton = screen.getByRole('button', { name: /Change password/i }) await user.click(changePasswordButton) - expect(useChangePasswordDialogStore.getState().isChangePasswordDialogOpen).toBe(true) + expect(useChangePasswordDialogStore.getState().isOpen).toBe(true) }) it('opens change password dialog with Space key', async () => { @@ -49,7 +53,7 @@ describe('SecuritySection', () => { changePasswordButton.focus() await user.keyboard(' ') - expect(useChangePasswordDialogStore.getState().isChangePasswordDialogOpen).toBe(true) + expect(useChangePasswordDialogStore.getState().isOpen).toBe(true) }) it('opens regenerate mnemonic dialog when clicking "Regenerate seed phrase"', async () => { @@ -59,6 +63,16 @@ describe('SecuritySection', () => { const seedPhraseButton = screen.getByRole('button', { name: /Regenerate seed phrase/i }) await user.click(seedPhraseButton) - expect(useRegenerateMnemonicDialogStore.getState().isRegenerateMnemonicDialogOpen).toBe(true) + expect(useRegenerateMnemonicDialogStore.getState().isOpen).toBe(true) + }) + + it('opens verify mnemonic dialog when clicking "Verify seed phrase"', async () => { + const user = userEvent.setup() + render() + + const verifyButton = screen.getByRole('button', { name: /Verify seed phrase/i }) + await user.click(verifyButton) + + expect(useVerifyMnemonicDialogStore.getState().isOpen).toBe(true) }) }) diff --git a/src/features/settings/ui/SecuritySection.tsx b/src/features/settings/ui/SecuritySection.tsx index ca82185..0a7ce88 100644 --- a/src/features/settings/ui/SecuritySection.tsx +++ b/src/features/settings/ui/SecuritySection.tsx @@ -1,22 +1,30 @@ import { Fragment } from 'react' import { useTranslation } from 'react-i18next' -import { ChevronRight, KeyRound, ShieldCheck, Fingerprint, type LucideIcon } from 'lucide-react' +import { ChevronRight, KeyRound, ShieldCheck, ScanEye, Fingerprint, type LucideIcon } from 'lucide-react' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/shared/ui/card' import { Separator } from '@/shared/ui/separator' -import { useChangePasswordDialogStore } from '@/shared/auth/change-password-dialog-store' -import { useRegenerateMnemonicDialogStore } from '@/shared/auth/regenerate-mnemonic-dialog-store' +import { + useChangePasswordDialogStore, + useRegenerateMnemonicDialogStore, + useVerifyMnemonicDialogStore, +} from '@/shared/auth/auth-dialogs-store' const ITEMS: { icon: LucideIcon; labelKey: string; onClick?: () => void }[] = [ { icon: KeyRound, labelKey: 'security.changePassword', - onClick: () => useChangePasswordDialogStore.getState().openChangePasswordDialog(), + onClick: () => useChangePasswordDialogStore.getState().open(), }, { icon: ShieldCheck, labelKey: 'security.seedPhrase', - onClick: () => useRegenerateMnemonicDialogStore.getState().openRegenerateMnemonicDialog(), + onClick: () => useRegenerateMnemonicDialogStore.getState().open(), + }, + { + icon: ScanEye, + labelKey: 'security.verifySeedPhrase', + onClick: () => useVerifyMnemonicDialogStore.getState().open(), }, { icon: Fingerprint, labelKey: 'security.keyVersions' }, ] diff --git a/src/features/vault/model/vault-dialog-store.test.ts b/src/features/vault/model/vault-dialog-store.test.ts index 2f32863..e0fbe15 100644 --- a/src/features/vault/model/vault-dialog-store.test.ts +++ b/src/features/vault/model/vault-dialog-store.test.ts @@ -3,17 +3,17 @@ import { useVaultDialogStore } from './vault-dialog-store' describe('vault-dialog-store', () => { it('initializes with dialog closed', () => { - expect(useVaultDialogStore.getState().isUnlockDialogOpen).toBe(false) + expect(useVaultDialogStore.getState().isOpen).toBe(false) }) - it('openUnlockDialog sets isUnlockDialogOpen to true', () => { - useVaultDialogStore.getState().openUnlockDialog() - expect(useVaultDialogStore.getState().isUnlockDialogOpen).toBe(true) + it('open sets isOpen to true', () => { + useVaultDialogStore.getState().open() + expect(useVaultDialogStore.getState().isOpen).toBe(true) }) - it('closeUnlockDialog sets isUnlockDialogOpen to false', () => { - useVaultDialogStore.getState().openUnlockDialog() - useVaultDialogStore.getState().closeUnlockDialog() - expect(useVaultDialogStore.getState().isUnlockDialogOpen).toBe(false) + it('close sets isOpen to false', () => { + useVaultDialogStore.getState().open() + useVaultDialogStore.getState().close() + expect(useVaultDialogStore.getState().isOpen).toBe(false) }) }) diff --git a/src/features/vault/model/vault-dialog-store.ts b/src/features/vault/model/vault-dialog-store.ts index 5c0cf78..5417a52 100644 --- a/src/features/vault/model/vault-dialog-store.ts +++ b/src/features/vault/model/vault-dialog-store.ts @@ -1,21 +1,3 @@ -import { create } from 'zustand' -import { devtools } from 'zustand/middleware' +import { createDialogStore } from '@/shared/ui/create-dialog-store' -interface VaultDialogStore { - isUnlockDialogOpen: boolean - openUnlockDialog: () => void - closeUnlockDialog: () => void -} - -const useVaultDialogStore = create()( - devtools( - (set) => ({ - isUnlockDialogOpen: false, - openUnlockDialog: () => set({ isUnlockDialogOpen: true }, false, 'vaultDialog/open'), - closeUnlockDialog: () => set({ isUnlockDialogOpen: false }, false, 'vaultDialog/close'), - }), - { name: 'VaultDialogStore' }, - ), -) - -export { useVaultDialogStore } +export const useVaultDialogStore = createDialogStore('VaultDialogStore') diff --git a/src/features/vault/ui/LockedVaultCard.tsx b/src/features/vault/ui/LockedVaultCard.tsx index 4d99eaf..fd64cbc 100644 --- a/src/features/vault/ui/LockedVaultCard.tsx +++ b/src/features/vault/ui/LockedVaultCard.tsx @@ -6,7 +6,7 @@ import { Button } from '@/shared/ui/button' function LockedVaultCard() { const { t } = useTranslation('fields') - const openUnlockDialog = useVaultDialogStore((s) => s.openUnlockDialog) + const openUnlockDialog = useVaultDialogStore((s) => s.open) return (
diff --git a/src/features/vault/ui/VaultIndicator.test.tsx b/src/features/vault/ui/VaultIndicator.test.tsx index d86ccc3..792c95c 100644 --- a/src/features/vault/ui/VaultIndicator.test.tsx +++ b/src/features/vault/ui/VaultIndicator.test.tsx @@ -33,10 +33,10 @@ describe('VaultIndicator', () => { it('opens unlock dialog when clicked while locked', async () => { useCryptoStore.setState({ isVaultLocked: true }) - useVaultDialogStore.setState({ isUnlockDialogOpen: false }) + useVaultDialogStore.setState({ isOpen: false }) const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: /unlock vault/i })) - expect(useVaultDialogStore.getState().isUnlockDialogOpen).toBe(true) + expect(useVaultDialogStore.getState().isOpen).toBe(true) }) }) diff --git a/src/features/vault/ui/VaultIndicator.tsx b/src/features/vault/ui/VaultIndicator.tsx index d9d7b6f..cc9ba94 100644 --- a/src/features/vault/ui/VaultIndicator.tsx +++ b/src/features/vault/ui/VaultIndicator.tsx @@ -8,7 +8,7 @@ import { cn } from '@/shared/lib/utils' function VaultIndicator() { const { t } = useTranslation('vault') const isVaultLocked = useCryptoStore((s) => s.isVaultLocked) - const openUnlockDialog = useVaultDialogStore((s) => s.openUnlockDialog) + const openUnlockDialog = useVaultDialogStore((s) => s.open) if (isVaultLocked) { return ( diff --git a/src/features/vault/ui/VaultUnlockDialog.test.tsx b/src/features/vault/ui/VaultUnlockDialog.test.tsx index 81a9aa6..1eff66d 100644 --- a/src/features/vault/ui/VaultUnlockDialog.test.tsx +++ b/src/features/vault/ui/VaultUnlockDialog.test.tsx @@ -34,7 +34,7 @@ describe('VaultUnlockDialog', () => { vi.clearAllMocks() useCryptoStore.getState().clearVault() useCryptoStore.setState({ isVaultLocked: true }) - useVaultDialogStore.setState({ isUnlockDialogOpen: true }) + useVaultDialogStore.setState({ isOpen: true }) vi.mocked(useAuth).mockReturnValue({ isAuthenticated: true, user: mockUser, @@ -44,13 +44,13 @@ describe('VaultUnlockDialog', () => { }) }) - it('renders dialog when isUnlockDialogOpen is true', () => { + it('renders dialog when isOpen is true', () => { render() expect(screen.getByText('Vault Locked')).toBeInTheDocument() }) - it('does not render dialog content when isUnlockDialogOpen is false', () => { - useVaultDialogStore.setState({ isUnlockDialogOpen: false }) + it('does not render dialog content when isOpen is false', () => { + useVaultDialogStore.setState({ isOpen: false }) render() expect(screen.queryByText('Vault Locked')).not.toBeInTheDocument() }) diff --git a/src/features/vault/ui/VaultUnlockDialog.tsx b/src/features/vault/ui/VaultUnlockDialog.tsx index 3b91ed5..fe5d16f 100644 --- a/src/features/vault/ui/VaultUnlockDialog.tsx +++ b/src/features/vault/ui/VaultUnlockDialog.tsx @@ -9,13 +9,13 @@ import { PasswordConfirmDialog } from '@/shared/ui/PasswordConfirmDialog' function VaultUnlockDialog() { const { t } = useTranslation('vault') const { user } = useAuth() - const isUnlockDialogOpen = useVaultDialogStore((s) => s.isUnlockDialogOpen) - const closeUnlockDialog = useVaultDialogStore((s) => s.closeUnlockDialog) + const isOpen = useVaultDialogStore((s) => s.isOpen) + const close = useVaultDialogStore((s) => s.close) async function unlockVault(password: string) { if (!user) throw new Error('No authenticated user') await keyVault.unlockVault(user.id, password) - closeUnlockDialog() + close() } function mapError(error: unknown) { @@ -24,8 +24,8 @@ function VaultUnlockDialog() { return ( { - beforeEach(() => { - useChangePasswordDialogStore.setState({ isChangePasswordDialogOpen: false }) - }) - - it('initializes with dialog closed', () => { - expect(useChangePasswordDialogStore.getState().isChangePasswordDialogOpen).toBe(false) - }) - - it('openChangePasswordDialog sets isChangePasswordDialogOpen to true', () => { - useChangePasswordDialogStore.getState().openChangePasswordDialog() - expect(useChangePasswordDialogStore.getState().isChangePasswordDialogOpen).toBe(true) - }) - - it('closeChangePasswordDialog sets isChangePasswordDialogOpen to false', () => { - useChangePasswordDialogStore.getState().openChangePasswordDialog() - useChangePasswordDialogStore.getState().closeChangePasswordDialog() - expect(useChangePasswordDialogStore.getState().isChangePasswordDialogOpen).toBe(false) - }) -}) diff --git a/src/shared/auth/change-password-dialog-store.ts b/src/shared/auth/change-password-dialog-store.ts deleted file mode 100644 index e7d33c3..0000000 --- a/src/shared/auth/change-password-dialog-store.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { create } from 'zustand' -import { devtools } from 'zustand/middleware' - -interface ChangePasswordDialogStore { - isChangePasswordDialogOpen: boolean - openChangePasswordDialog: () => void - closeChangePasswordDialog: () => void -} - -const useChangePasswordDialogStore = create()( - devtools( - (set) => ({ - isChangePasswordDialogOpen: false, - openChangePasswordDialog: () => set({ isChangePasswordDialogOpen: true }, false, 'changePasswordDialog/open'), - closeChangePasswordDialog: () => set({ isChangePasswordDialogOpen: false }, false, 'changePasswordDialog/close'), - }), - { name: 'ChangePasswordDialogStore' }, - ), -) - -export { useChangePasswordDialogStore } diff --git a/src/shared/auth/regenerate-mnemonic-dialog-store.test.ts b/src/shared/auth/regenerate-mnemonic-dialog-store.test.ts deleted file mode 100644 index c860230..0000000 --- a/src/shared/auth/regenerate-mnemonic-dialog-store.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import { useRegenerateMnemonicDialogStore } from './regenerate-mnemonic-dialog-store' - -describe('regenerate-mnemonic-dialog-store', () => { - beforeEach(() => { - useRegenerateMnemonicDialogStore.setState({ isRegenerateMnemonicDialogOpen: false }) - }) - - it('initializes with dialog closed', () => { - expect(useRegenerateMnemonicDialogStore.getState().isRegenerateMnemonicDialogOpen).toBe(false) - }) - - it('openRegenerateMnemonicDialog sets isRegenerateMnemonicDialogOpen to true', () => { - useRegenerateMnemonicDialogStore.getState().openRegenerateMnemonicDialog() - expect(useRegenerateMnemonicDialogStore.getState().isRegenerateMnemonicDialogOpen).toBe(true) - }) - - it('closeRegenerateMnemonicDialog sets isRegenerateMnemonicDialogOpen to false', () => { - useRegenerateMnemonicDialogStore.getState().openRegenerateMnemonicDialog() - useRegenerateMnemonicDialogStore.getState().closeRegenerateMnemonicDialog() - expect(useRegenerateMnemonicDialogStore.getState().isRegenerateMnemonicDialogOpen).toBe(false) - }) -}) diff --git a/src/shared/auth/regenerate-mnemonic-dialog-store.ts b/src/shared/auth/regenerate-mnemonic-dialog-store.ts deleted file mode 100644 index 74a91ce..0000000 --- a/src/shared/auth/regenerate-mnemonic-dialog-store.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { create } from 'zustand' -import { devtools } from 'zustand/middleware' - -interface RegenerateMnemonicDialogStore { - isRegenerateMnemonicDialogOpen: boolean - openRegenerateMnemonicDialog: () => void - closeRegenerateMnemonicDialog: () => void -} - -const useRegenerateMnemonicDialogStore = create()( - devtools( - (set) => ({ - isRegenerateMnemonicDialogOpen: false, - openRegenerateMnemonicDialog: () => - set({ isRegenerateMnemonicDialogOpen: true }, false, 'regenerateMnemonicDialog/open'), - closeRegenerateMnemonicDialog: () => - set({ isRegenerateMnemonicDialogOpen: false }, false, 'regenerateMnemonicDialog/close'), - }), - { name: 'RegenerateMnemonicDialogStore' }, - ), -) - -export { useRegenerateMnemonicDialogStore } diff --git a/src/shared/i18n/locales/cs/settings.json b/src/shared/i18n/locales/cs/settings.json index dc38393..1c60367 100644 --- a/src/shared/i18n/locales/cs/settings.json +++ b/src/shared/i18n/locales/cs/settings.json @@ -5,6 +5,7 @@ "description": "Správa hesla a nastavení zabezpečení.", "changePassword": "Změnit heslo", "seedPhrase": "Znovu vygenerovat seed frázi", + "verifySeedPhrase": "Ověřit seed frázi", "keyVersions": "Verze klíčů" }, "preferences": { diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index 2f8db4c..436a043 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -5,6 +5,7 @@ "description": "Manage your password and security settings.", "changePassword": "Change password", "seedPhrase": "Regenerate seed phrase", + "verifySeedPhrase": "Verify seed phrase", "keyVersions": "Key versions" }, "preferences": { diff --git a/src/shared/ui/create-dialog-store.ts b/src/shared/ui/create-dialog-store.ts new file mode 100644 index 0000000..95f261c --- /dev/null +++ b/src/shared/ui/create-dialog-store.ts @@ -0,0 +1,25 @@ +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' + +interface DialogStore { + isOpen: boolean + open: () => void + close: () => void +} + +/** + * Creates a Zustand store for a simple open/close dialog. + * The store has `isOpen`, `open()`, and `close()`. No domain-specific state. + */ +export function createDialogStore(name: string) { + return create()( + devtools( + (set) => ({ + isOpen: false, + open: () => set({ isOpen: true }, false, `${name}/open`), + close: () => set({ isOpen: false }, false, `${name}/close`), + }), + { name }, + ), + ) +}