diff --git a/CLAUDE.md b/CLAUDE.md index 062ec49..477c1c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -172,6 +172,7 @@ See `IMPLEMENTATION-PLAN.md` for the full 36-step plan. - Step 22 (Login UI + Vault Unlock) — complete - Step 23 (Non-Extractable Key Vault + Zustand Store Refactor) — complete - Step 24 (Supabase API Adapter) — complete +- Step 25 (Encrypted Field CRUD) — complete ### Implementation Notes diff --git a/docs/implementation-plan/06-phase-6-data.md b/docs/implementation-plan/06-phase-6-data.md index 8e7842f..cdd3fc8 100644 --- a/docs/implementation-plan/06-phase-6-data.md +++ b/docs/implementation-plan/06-phase-6-data.md @@ -28,29 +28,36 @@ --- -## Step 25 — Encrypted Field CRUD +## Step 25 — Encrypted Field CRUD ✅ **Goal:** Encrypt/decrypt all three field types (note, website, email) end-to-end. **Code:** - `src/features/fields/model/field-crypto.ts`: - - `encryptField(plaintext: string, fieldKey: Uint8Array): Promise` + - `encryptField(plaintext: string, fieldKey: CryptoKey, fieldName: FieldName): Promise` - Convert string to Uint8Array (TextEncoder) - Generate random IV - - Encrypt with AES-256-GCM using field key + - Encrypt with AES-256-GCM using non-extractable CryptoKey from KeyVault + - Bind ciphertext to field name + key version via AAD (prevents ciphertext swapping between fields) - Return `{ ciphertext: Uint8Array, iv: Uint8Array }` - - `decryptField(encryptedData: EncryptedFieldData, fieldKey: Uint8Array): Promise` - - Decrypt with AES-256-GCM + - `decryptField(encryptedData: EncryptedFieldData, fieldKey: CryptoKey, fieldName: FieldName): Promise` + - Decrypt with AES-256-GCM, reconstructing AAD from fieldName + version - Convert Uint8Array to string (TextDecoder) - Return plaintext string + - `toSaveFieldData(encryptedData)` — convert binary `EncryptedFieldData` to hex-string `SaveFieldData` for the API + - `toEncryptedFieldData(serverField)` — convert hex-string `ServerEncryptedField` from the API to binary `EncryptedFieldData` - `src/features/fields/model/field-service.ts`: - - `loadField(fieldName: string): Promise` — fetch from server, decrypt, return plaintext - - `saveField(fieldName: string, plaintext: string): Promise` — encrypt, save to server - - `loadAllFields(): Promise>` — load all three fields + - `FieldService` class (singleton `fieldService`) encapsulating auth + key vault access: + - `loadField(fieldName): Promise` — fetch from server, decrypt, return plaintext + - `saveField(fieldName, plaintext): Promise` — encrypt, save to server + - `loadAllFields(): Promise>` — load all three fields in parallel + - Gets user ID from auth store, gets CryptoKey from KeyVault (no separate hook needed) - Wire into TanStack Query hooks: - - `useField(fieldName)` — query + cache decrypted field content. **Must invalidate/purge this cache when vault is locked** (see Step 23). - - `useSaveField(fieldName)` — mutation for saving field content - - `useFieldKey(fieldName)` — get field key from crypto store (hex-decode from store before use) + - `useField(fieldName)` — query + cache decrypted field content, disabled while vault locked or field key not loaded + - `useSaveField(fieldName)` — mutation for saving field content, invalidates field query on success +- `src/shared/types/entities/field.types.ts`: + - `FieldName` type (`'note' | 'website' | 'email'`), `FIELD_NAMES` canonical list + - `EncryptedField` and `DecryptedField` interfaces for server/client representations **Tests:** - Unit: encrypt then decrypt returns original string diff --git a/src/app/layouts/PublicLayout.tsx b/src/app/layouts/PublicLayout.tsx index 157d6ca..18efec4 100644 --- a/src/app/layouts/PublicLayout.tsx +++ b/src/app/layouts/PublicLayout.tsx @@ -3,11 +3,11 @@ import { PublicHeader } from '@/shared/ui/nav/PublicHeader' function PublicLayout() { return ( -
+
-
+
diff --git a/src/features/auth/ui/.gitkeep b/src/features/auth/ui/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/features/encryption/ui/.gitkeep b/src/features/encryption/ui/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/features/fields/model/field-crypto.test.ts b/src/features/fields/model/field-crypto.test.ts new file mode 100644 index 0000000..45565e0 --- /dev/null +++ b/src/features/fields/model/field-crypto.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest' +import { importKey } from '@/shared/crypto/aes-gcm' +import { DecryptionError } from '@/shared/crypto/errors' +import { encryptField, decryptField, toSaveFieldData, toEncryptedFieldData } from '@/features/fields/model/field-crypto' +import { FIELD_NAMES } from '@/shared/types/entities/field.types' +import type { FieldName } from '@/shared/types/entities/field.types' + +const generateKey = async () => await importKey(crypto.getRandomValues(new Uint8Array(32))) + +describe('encryptField + decryptField', () => { + it('round-trips plaintext for all field names', async () => { + const key = await generateKey() + for (const fieldName of FIELD_NAMES) { + const plaintext = `Hello, ${fieldName}!` + const encrypted = await encryptField(plaintext, key, fieldName) + const decrypted = await decryptField(encrypted, key, fieldName) + expect(decrypted).toBe(plaintext) + } + }) + + it('round-trips an empty string', async () => { + const key = await generateKey() + const encrypted = await encryptField('', key, 'note') + const decrypted = await decryptField(encrypted, key, 'note') + expect(decrypted).toBe('') + }) + + it('round-trips a long string', async () => { + const key = await generateKey() + const plaintext = 'a'.repeat(10_000) + const encrypted = await encryptField(plaintext, key, 'note') + const decrypted = await decryptField(encrypted, key, 'note') + expect(decrypted).toBe(plaintext) + }) + + it('produces different ciphertexts for different IVs (randomness)', async () => { + const key = await generateKey() + const plaintext = 'same content' + const encrypted1 = await encryptField(plaintext, key, 'note') + const encrypted2 = await encryptField(plaintext, key, 'note') + // Random IV means ciphertext should differ each time + expect(encrypted1.ciphertext).not.toEqual(encrypted2.ciphertext) + }) + + it('produces different ciphertexts with different keys', async () => { + const key1 = await generateKey() + const key2 = await generateKey() + const plaintext = 'same content' + const encrypted1 = await encryptField(plaintext, key1, 'note') + const encrypted2 = await encryptField(plaintext, key2, 'note') + expect(encrypted1.ciphertext).not.toEqual(encrypted2.ciphertext) + }) + + it('throws DecryptionError with wrong key', async () => { + const key1 = await generateKey() + const key2 = await generateKey() + const encrypted = await encryptField('secret', key1, 'note') + await expect(decryptField(encrypted, key2, 'note')).rejects.toThrow(DecryptionError) + }) + + it('throws DecryptionError when field name mismatches (AAD binding)', async () => { + const key = await generateKey() + const encrypted = await encryptField('secret note', key, 'note') + // Attempt to decrypt as a different field — AAD won't match + await expect(decryptField(encrypted, key, 'website')).rejects.toThrow(DecryptionError) + }) +}) + +describe('toSaveFieldData + toEncryptedFieldData', () => { + it('round-trips through hex encoding/decoding', async () => { + const key = await generateKey() + const plaintext = 'test data for hex round-trip' + const encrypted = await encryptField(plaintext, key, 'note') + + // Internal binary → hex for API + const saveData = toSaveFieldData(encrypted) + expect(typeof saveData.encryptedBlob).toBe('string') + expect(typeof saveData.iv).toBe('string') + + // Hex from API → internal binary + const serverField = { + fieldName: 'note' as FieldName, + encryptedBlob: saveData.encryptedBlob, + iv: saveData.iv, + updatedAt: '2025-01-01T00:00:00Z', + } + const restored = toEncryptedFieldData(serverField) + expect(restored.ciphertext).toEqual(encrypted.ciphertext) + expect(restored.iv).toEqual(encrypted.iv) + + // Full round-trip: encrypt → toSaveFieldData → toEncryptedFieldData → decrypt + const decrypted = await decryptField(restored, key, 'note') + expect(decrypted).toBe(plaintext) + }) +}) diff --git a/src/features/fields/model/field-crypto.ts b/src/features/fields/model/field-crypto.ts new file mode 100644 index 0000000..553cdc2 --- /dev/null +++ b/src/features/fields/model/field-crypto.ts @@ -0,0 +1,56 @@ +import { decrypt, encrypt } from '@/shared/crypto/aes-gcm' +import { encodeAAD, generateIV, hexDecode, hexEncode } from '@/shared/crypto/crypto-utils' +import { FIELD_KEY_VERSION } from '@/shared/types/crypto.types' +import type { EncryptedFieldData } from '@/shared/types/crypto.types' +import type { FieldName } from '@/shared/types/entities/field.types' +import type { SaveFieldData, ServerEncryptedField } from '@/shared/types/api.types' + +/** + * Encrypt a plaintext field value using AES-256-GCM with the field's CryptoKey. + * + * AAD binds the ciphertext to the field name and key version, preventing ciphertext + * swapping between fields. A fresh random IV is generated each call. + */ +export async function encryptField( + plaintext: string, + fieldKey: CryptoKey, + fieldName: FieldName, +): Promise { + const plaintextBytes = new TextEncoder().encode(plaintext) as Uint8Array + const iv = generateIV() + const aad = encodeAAD(fieldName, FIELD_KEY_VERSION) + const ciphertext = await encrypt(plaintextBytes, fieldKey, { iv, aad }) + return { ciphertext, iv } +} + +/** + * Decrypt an encrypted field value back to a plaintext string. + * + * The AAD is reconstructed from fieldName + FIELD_KEY_VERSION, so the caller + * must pass the same field name used during encryption. + */ +export async function decryptField( + encryptedData: EncryptedFieldData, + fieldKey: CryptoKey, + fieldName: FieldName, +): Promise { + const aad = encodeAAD(fieldName, FIELD_KEY_VERSION) + const plaintextBytes = await decrypt(encryptedData.ciphertext, fieldKey, { iv: encryptedData.iv, aad }) + return new TextDecoder().decode(plaintextBytes) +} + +/** Convert internal binary EncryptedFieldData to hex-string SaveFieldData for the API. */ +export function toSaveFieldData(encryptedData: EncryptedFieldData): SaveFieldData { + return { + encryptedBlob: hexEncode(encryptedData.ciphertext), + iv: hexEncode(encryptedData.iv), + } +} + +/** Convert hex-string ServerEncryptedField from the API to binary EncryptedFieldData. */ +export function toEncryptedFieldData(serverField: ServerEncryptedField): EncryptedFieldData { + return { + ciphertext: hexDecode(serverField.encryptedBlob), + iv: hexDecode(serverField.iv), + } +} diff --git a/src/features/fields/model/field-service.test.ts b/src/features/fields/model/field-service.test.ts new file mode 100644 index 0000000..8e1e660 --- /dev/null +++ b/src/features/fields/model/field-service.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { importKey } from '@/shared/crypto/aes-gcm' +import { encryptField, toSaveFieldData } from '@/features/fields/model/field-crypto' +import type { FieldName } from '@/shared/types/entities/field.types' +import type { ServerEncryptedField, SaveFieldData } from '@/shared/types/api.types' + +// --- Hoisted mocks (must be declared before vi.mock factories that reference them) --- + +const { mockFetchField, mockSaveField, mockGetKey } = vi.hoisted(() => { + return { + mockFetchField: vi.fn<(...args: [string, string]) => Promise>(), + mockSaveField: vi.fn<(userId: string, fieldName: string, data: SaveFieldData) => Promise>(), + mockGetKey: vi.fn<(id: string) => CryptoKey | undefined>(), + } +}) + +vi.mock('@/shared/api/supabase-fields', () => ({ + fetchField: mockFetchField, + saveField: mockSaveField, +})) + +vi.mock('@/shared/crypto/key-vault', () => ({ + keyVault: { getKey: mockGetKey }, +})) + +// --- Import after mocks --- + +import { fieldService } from '@/features/fields/model/field-service' + +async function generateTestKey(): Promise { + return importKey(crypto.getRandomValues(new Uint8Array(32))) +} + +/** Encrypt plaintext and return a ServerEncryptedField mock with proper hex-encoded data. */ +async function encryptForServer( + plaintext: string, + fieldKey: CryptoKey, + fieldName: FieldName, +): Promise { + const encrypted = await encryptField(plaintext, fieldKey, fieldName) + const saveData = toSaveFieldData(encrypted) + return { + fieldName, + encryptedBlob: saveData.encryptedBlob, + iv: saveData.iv, + updatedAt: '2025-01-01T00:00:00Z', + } +} + +const TEST_USER_ID = 'user-123' + +describe('FieldService', () => { + let testKey: CryptoKey + + beforeEach(async () => { + vi.clearAllMocks() + testKey = await generateTestKey() + mockGetKey.mockReturnValue(testKey) + }) + + describe('loadField', () => { + it('throws when userId is empty', async () => { + await expect(fieldService.loadField('', 'note')).rejects.toThrow('userId is required') + }) + + it('returns null when server returns null (field never saved)', async () => { + mockFetchField.mockResolvedValue(null) + const result = await fieldService.loadField(TEST_USER_ID, 'note') + expect(result).toBeNull() + expect(mockFetchField).toHaveBeenCalledWith(TEST_USER_ID, 'note') + }) + + it('decrypts and returns plaintext when server returns field data', async () => { + const plaintext = 'Hello, world!' + const serverField = await encryptForServer(plaintext, testKey, 'note') + mockFetchField.mockResolvedValue(serverField) + + const result = await fieldService.loadField(TEST_USER_ID, 'note') + expect(result).toBe(plaintext) + }) + + it('throws when field key is not available (vault locked)', async () => { + mockGetKey.mockReturnValue(undefined) + await expect(fieldService.loadField(TEST_USER_ID, 'note')).rejects.toThrow('Field key not available for "note"') + }) + }) + + describe('saveField', () => { + it('throws when userId is empty', async () => { + await expect(fieldService.saveField('', 'note', 'test')).rejects.toThrow('userId is required') + }) + + it('encrypts plaintext and calls saveFieldToServer with hex-encoded data', async () => { + mockSaveField.mockResolvedValue(undefined) + + await fieldService.saveField(TEST_USER_ID, 'note', 'My secret note') + + expect(mockSaveField).toHaveBeenCalledWith( + TEST_USER_ID, + 'note', + expect.objectContaining({ + encryptedBlob: expect.any(String), + iv: expect.any(String), + }), + ) + const saveData = mockSaveField.mock.calls[0][2] + // Hex strings should contain only hex chars + expect(saveData.encryptedBlob).toMatch(/^[0-9a-f]+$/) + expect(saveData.iv).toMatch(/^[0-9a-f]+$/) + }) + + it('throws when field key is not available (vault locked)', async () => { + mockGetKey.mockReturnValue(undefined) + await expect(fieldService.saveField(TEST_USER_ID, 'note', 'test')).rejects.toThrow( + 'Field key not available for "note"', + ) + }) + }) + + describe('loadAllFields', () => { + it('throws when userId is empty', async () => { + await expect(fieldService.loadAllFields('')).rejects.toThrow('userId is required') + }) + + it('loads all three fields in parallel and returns null for unsaved fields', async () => { + mockFetchField.mockResolvedValue(null) + const result = await fieldService.loadAllFields(TEST_USER_ID) + expect(result).toEqual({ note: null, website: null, email: null }) + expect(mockFetchField).toHaveBeenCalledTimes(3) + }) + + it('loads all three fields with mixed data', async () => { + const noteKey = await generateTestKey() + const websiteKey = await generateTestKey() + const emailKey = await generateTestKey() + + mockGetKey.mockImplementation((id: string) => { + if (id === 'note') return noteKey + if (id === 'website') return websiteKey + if (id === 'email') return emailKey + return undefined + }) + + const noteServerField = await encryptForServer('My note', noteKey, 'note') + const websiteServerField = await encryptForServer('https://example.com', websiteKey, 'website') + + mockFetchField.mockImplementation(async (_userId: string, fieldName: string) => { + if (fieldName === 'note') return noteServerField + if (fieldName === 'website') return websiteServerField + return null // email never saved + }) + + const result = await fieldService.loadAllFields(TEST_USER_ID) + expect(result.note).toBe('My note') + expect(result.website).toBe('https://example.com') + expect(result.email).toBeNull() + }) + + it('returns null for a field that fails without killing other fields', async () => { + const noteKey = await generateTestKey() + + mockGetKey.mockImplementation((id: string) => { + if (id === 'note') return noteKey + return undefined // website and email keys unavailable + }) + + const noteServerField = await encryptForServer('My note', noteKey, 'note') + mockFetchField.mockImplementation(async (_userId: string, fieldName: string) => { + if (fieldName === 'note') return noteServerField + return null + }) + + const result = await fieldService.loadAllFields(TEST_USER_ID) + expect(result.note).toBe('My note') + // website and email return null because keys are unavailable (throws caught by allSettled) + expect(result.website).toBeNull() + expect(result.email).toBeNull() + }) + }) +}) diff --git a/src/features/fields/model/field-service.ts b/src/features/fields/model/field-service.ts new file mode 100644 index 0000000..ad08ca1 --- /dev/null +++ b/src/features/fields/model/field-service.ts @@ -0,0 +1,64 @@ +import { keyVault } from '@/shared/crypto/key-vault' +import { fetchField, saveField as saveFieldToServer } from '@/shared/api/supabase-fields' +import { FIELD_NAMES } from '@/shared/types/entities/field.types' +import type { FieldName } from '@/shared/types/entities/field.types' +import { encryptField, decryptField, toEncryptedFieldData, toSaveFieldData } from '@/features/fields/model/field-crypto' + +const USER_ID_REQUIRED = 'userId is required' + +class FieldService { + private getFieldKey(fieldName: FieldName): CryptoKey { + const key = keyVault.getKey(fieldName) + if (!key) throw new Error(`Field key not available for "${fieldName}" — vault may be locked`) + return key + } + + /** + * Load and decrypt a single field's content from the server. + * Returns null if the field has never been saved. + */ + async loadField(userId: string, fieldName: FieldName): Promise { + if (!userId) throw new Error(USER_ID_REQUIRED) + const fieldKey = this.getFieldKey(fieldName) + + const serverField = await fetchField(userId, fieldName) + if (!serverField) return null + + const encryptedData = toEncryptedFieldData(serverField) + return decryptField(encryptedData, fieldKey, fieldName) + } + + /** + * Encrypt and save a field's content to the server. + * Uses upsert — will create or update the field. + */ + async saveField(userId: string, fieldName: FieldName, plaintext: string): Promise { + if (!userId) throw new Error(USER_ID_REQUIRED) + const fieldKey = this.getFieldKey(fieldName) + + const encryptedData = await encryptField(plaintext, fieldKey, fieldName) + const saveData = toSaveFieldData(encryptedData) + await saveFieldToServer(userId, fieldName, saveData) + } + + /** + * Load and decrypt all three fields (note, website, email) in parallel. + * If a single field fails (e.g. network error), the others still succeed. + * Returns null for fields that failed or were never saved. + */ + async loadAllFields(userId: string): Promise> { + if (!userId) throw new Error(USER_ID_REQUIRED) + const results = await Promise.allSettled( + FIELD_NAMES.map(async (name) => [name, await this.loadField(userId, name)] as const), + ) + return Object.fromEntries( + results.map((result, i) => { + const name = FIELD_NAMES[i] + if (result.status === 'fulfilled') return [name, result.value[1]] as const + return [name, null] as const + }), + ) as Record + } +} + +export const fieldService = new FieldService() diff --git a/src/features/fields/model/use-field.test.ts b/src/features/fields/model/use-field.test.ts new file mode 100644 index 0000000..f3c39d5 --- /dev/null +++ b/src/features/fields/model/use-field.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createElement, type ReactNode } from 'react' +import { useCryptoStore } from '@/shared/crypto/crypto-store' + +// --- Hoisted mocks --- + +const { mockLoadField, mockUseAuth } = vi.hoisted(() => ({ + mockLoadField: vi.fn<(userId: string, fieldName: string) => Promise>(), + mockUseAuth: vi.fn<() => { user: { id: string; username: string } | null }>(), +})) + +vi.mock('@/features/fields/model/field-service', () => ({ + fieldService: { loadField: mockLoadField }, +})) + +vi.mock('@/shared/auth/auth-context', () => ({ + useAuth: mockUseAuth, +})) + +// --- Import after mocks --- + +import { useField } from '@/features/fields/model/use-field' + +const testQueryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, +}) + +function wrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client: testQueryClient }, children) +} + +afterEach(() => { + testQueryClient.clear() +}) + +describe('useField', () => { + beforeEach(() => { + vi.clearAllMocks() + // Default: authenticated user, vault unlocked with keys loaded + mockUseAuth.mockReturnValue({ user: { id: 'user-123', username: 'testuser' } }) + useCryptoStore.setState({ + loadedFieldKeys: { note: true, website: true, email: true }, + isVaultLocked: false, + lastActivity: Date.now(), + cachedEnvelope: null, + }) + mockLoadField.mockResolvedValue('test content') + }) + + it('fetches and returns decrypted field content when vault is unlocked', async () => { + mockLoadField.mockResolvedValue('My note content') + + const { result } = renderHook(() => useField('note'), { wrapper }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + expect(result.current.data).toBe('My note content') + expect(mockLoadField).toHaveBeenCalledWith('user-123', 'note') + }) + + it('returns null when field has never been saved', async () => { + mockLoadField.mockResolvedValue(null) + + const { result } = renderHook(() => useField('note'), { wrapper }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + expect(result.current.data).toBeNull() + }) + + it('is disabled when userId is empty (no authenticated user)', () => { + mockUseAuth.mockReturnValue({ user: null }) + + const { result } = renderHook(() => useField('note'), { wrapper }) + expect(result.current.fetchStatus).toBe('idle') + expect(mockLoadField).not.toHaveBeenCalled() + }) + + it('is disabled when vault is locked', () => { + useCryptoStore.setState({ isVaultLocked: true, loadedFieldKeys: {} }) + + const { result } = renderHook(() => useField('note'), { wrapper }) + expect(result.current.fetchStatus).toBe('idle') + expect(mockLoadField).not.toHaveBeenCalled() + }) + + it('is disabled when field key is not loaded', () => { + useCryptoStore.setState({ loadedFieldKeys: {} }) + + const { result } = renderHook(() => useField('note'), { wrapper }) + expect(result.current.fetchStatus).toBe('idle') + expect(mockLoadField).not.toHaveBeenCalled() + }) + + it('is enabled when vault is unlocked and field key is loaded', async () => { + mockLoadField.mockResolvedValue('hello') + + const { result } = renderHook(() => useField('note'), { wrapper }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + expect(result.current.data).toBe('hello') + }) + + it('sets error state immediately for DecryptionError (no retry)', async () => { + const { DecryptionError } = await import('@/shared/crypto/errors') + mockLoadField.mockRejectedValue(new DecryptionError('Decryption failed')) + + const { result } = renderHook(() => useField('note'), { wrapper }) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + expect(result.current.error).toBeInstanceOf(DecryptionError) + expect(mockLoadField).toHaveBeenCalledTimes(1) // no retry for crypto errors + }) + + it('retries on non-crypto errors before failing', async () => { + mockLoadField.mockRejectedValue(new Error('Network error')) + + const { result } = renderHook(() => useField('note'), { wrapper }) + + // Allow time for retries with backoff (1s + 2s = ~3s total) + await waitFor( + () => { + expect(result.current.isError).toBe(true) + }, + { timeout: 5000 }, + ) + // retry: (failureCount, error) => failureCount < 2 → 3 total attempts + expect(mockLoadField).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/features/fields/model/use-field.ts b/src/features/fields/model/use-field.ts new file mode 100644 index 0000000..4911b74 --- /dev/null +++ b/src/features/fields/model/use-field.ts @@ -0,0 +1,27 @@ +import { useQuery } from '@tanstack/react-query' +import { DecryptionError } from '@/shared/crypto/errors' +import { useCryptoStore } from '@/shared/crypto/crypto-store' +import { useAuth } from '@/shared/auth/auth-context' +import { fieldService } from '@/features/fields/model/field-service' +import type { FieldName } from '@/shared/types/entities/field.types' + +/** + * Load and decrypt a single field's content. + * + * The query is disabled while vault is locked or field key missing. On lock, + * crypto-store purges field queries so unlock triggers a fresh fetch. + */ +export function useField(fieldName: FieldName) { + const userId = useAuth().user?.id ?? '' + const enabled = useCryptoStore((s) => !s.isVaultLocked && s.loadedFieldKeys[fieldName] === true) && !!userId + + return useQuery({ + queryKey: ['field', fieldName], + queryFn: () => fieldService.loadField(userId, fieldName), + enabled, + retry: (failureCount, error) => { + if (error instanceof DecryptionError) return false + return failureCount < 2 + }, + }) +} diff --git a/src/features/fields/model/use-save-field.test.ts b/src/features/fields/model/use-save-field.test.ts new file mode 100644 index 0000000..0ac7696 --- /dev/null +++ b/src/features/fields/model/use-save-field.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createElement, type ReactNode } from 'react' + +// --- Hoisted mocks --- + +const { mockSaveField } = vi.hoisted(() => ({ + mockSaveField: vi.fn<(userId: string, fieldName: string, plaintext: string) => Promise>(), +})) + +const { mockUseAuth } = vi.hoisted(() => ({ + mockUseAuth: vi.fn<() => { user: { id: string; username: string } | null }>(), +})) + +vi.mock('@/features/fields/model/field-service', () => ({ + fieldService: { saveField: mockSaveField }, +})) + +vi.mock('@/shared/auth/auth-context', () => ({ + useAuth: mockUseAuth, +})) + +// --- Import after mocks --- + +import { useSaveField } from '@/features/fields/model/use-save-field' + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) +} + +function wrapper({ children }: { children: ReactNode }) { + const queryClient = createQueryClient() + return createElement(QueryClientProvider, { client: queryClient }, children) +} + +describe('useSaveField', () => { + beforeEach(() => { + vi.clearAllMocks() + mockSaveField.mockResolvedValue(undefined) + mockUseAuth.mockReturnValue({ user: { id: 'user-123', username: 'testuser' } }) + }) + + it('calls fieldService.saveField with userId, field name and plaintext on mutate', async () => { + const { result } = renderHook(() => useSaveField('note'), { wrapper }) + + result.current.mutate('My note content') + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + expect(mockSaveField).toHaveBeenCalledWith('user-123', 'note', 'My note content') + }) + + it('optimistically updates the field query cache on mutate', async () => { + const queryClient = createQueryClient() + // Pre-populate cache with existing data + queryClient.setQueryData(['field', 'note'], 'old content') + + const localWrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children) + + const { result } = renderHook(() => useSaveField('note'), { wrapper: localWrapper }) + + result.current.mutate('new content') + + // Optimistic update should be visible immediately + await waitFor(() => { + expect(queryClient.getQueryData(['field', 'note'])).toBe('new content') + }) + }) + + it('rolls back cache on error', async () => { + mockSaveField.mockRejectedValue(new Error('Save failed')) + + const queryClient = createQueryClient() + queryClient.setQueryData(['field', 'note'], 'original content') + + const localWrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children) + + const { result } = renderHook(() => useSaveField('note'), { wrapper: localWrapper }) + + result.current.mutate('new content') + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + // Cache should be rolled back + expect(queryClient.getQueryData(['field', 'note'])).toBe('original content') + }) + + it('invalidates the field query on settled', async () => { + const queryClient = createQueryClient() + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + const localWrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children) + + const { result } = renderHook(() => useSaveField('note'), { wrapper: localWrapper }) + + result.current.mutate('My note content') + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['field', 'note'] }) + }) + + it('sets error state when saveField throws', async () => { + mockSaveField.mockRejectedValue(new Error('Save failed')) + + const { result } = renderHook(() => useSaveField('note'), { wrapper }) + + result.current.mutate('test') + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + expect(result.current.error).toBeInstanceOf(Error) + expect(result.current.error?.message).toBe('Save failed') + }) + + it('throws when userId is empty (no authenticated user)', async () => { + mockUseAuth.mockReturnValue({ user: null }) + + const { result } = renderHook(() => useSaveField('note'), { wrapper }) + + result.current.mutate('test') + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + expect(result.current.error?.message).toBe('useSaveField requires an authenticated user') + }) +}) diff --git a/src/features/fields/model/use-save-field.ts b/src/features/fields/model/use-save-field.ts new file mode 100644 index 0000000..2024baa --- /dev/null +++ b/src/features/fields/model/use-save-field.ts @@ -0,0 +1,37 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useAuth } from '@/shared/auth/auth-context' +import { fieldService } from '@/features/fields/model/field-service' +import type { FieldName } from '@/shared/types/entities/field.types' + +/** + * Save (encrypt + upload) a field's content. + * + * Optimistically updates the cache so the user sees their change immediately, + * then confirms with the server. Rolls back on error. + */ +export function useSaveField(fieldName: FieldName) { + const queryClient = useQueryClient() + const userId = useAuth().user?.id ?? '' + const queryKey = ['field', fieldName] as const + + return useMutation({ + mutationFn: (plaintext: string) => { + if (!userId) throw new Error('useSaveField requires an authenticated user') + return fieldService.saveField(userId, fieldName, plaintext) + }, + onMutate: async (plaintext) => { + await queryClient.cancelQueries({ queryKey }) + const previousValue = queryClient.getQueryData(queryKey) + queryClient.setQueryData(queryKey, plaintext) + return { previousValue } + }, + onError: (_err, _plaintext, context) => { + if (context?.previousValue !== undefined) { + queryClient.setQueryData(queryKey, context.previousValue) + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey }) + }, + }) +} diff --git a/src/features/fields/ui/.gitkeep b/src/features/fields/ui/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/features/fields/ui/DashboardPage.tsx b/src/features/fields/ui/DashboardPage.tsx index 7400094..116c230 100644 --- a/src/features/fields/ui/DashboardPage.tsx +++ b/src/features/fields/ui/DashboardPage.tsx @@ -6,11 +6,10 @@ import { FieldCard } from '@/features/fields/ui/FieldCard' import { NoteField } from '@/features/fields/ui/NoteField' import { WebsiteField } from '@/features/fields/ui/WebsiteField' import { EmailField } from '@/features/fields/ui/EmailField' +import { FIELD_NAMES } from '@/shared/types/entities/field.types' import type { FieldName } from '@/shared/types/entities/field.types' import type { ReactNode } from 'react' -const FIELD_NAMES: FieldName[] = ['note', 'website', 'email'] - function getFieldEditor(fieldName: FieldName): ReactNode { switch (fieldName) { case 'note': diff --git a/src/features/landing/ui/HeroSection.tsx b/src/features/landing/ui/HeroSection.tsx index cf2a28d..6fee0c7 100644 --- a/src/features/landing/ui/HeroSection.tsx +++ b/src/features/landing/ui/HeroSection.tsx @@ -7,7 +7,7 @@ function HeroSection() { const { t } = useTranslation('landing') return ( -
+
diff --git a/src/features/landing/ui/LandingPage.tsx b/src/features/landing/ui/LandingPage.tsx index 50d8c1d..6e86834 100644 --- a/src/features/landing/ui/LandingPage.tsx +++ b/src/features/landing/ui/LandingPage.tsx @@ -10,7 +10,7 @@ function LandingPage() { const { t } = useTranslation('landing') return ( -
+
diff --git a/src/shared/api/supabase-fields.ts b/src/shared/api/supabase-fields.ts index 5be9e59..3d570bc 100644 --- a/src/shared/api/supabase-fields.ts +++ b/src/shared/api/supabase-fields.ts @@ -1,12 +1,13 @@ import { getSupabase } from '@/shared/api/supabase-client' import { wrapApiError } from '@/shared/api/api-errors' import type { ServerEncryptedField, SaveFieldData } from '@/shared/types/api.types' +import type { FieldName } from '@/shared/types/entities/field.types' /** * Fetch a single encrypted field for a user. * Returns null if the field does not exist. */ -export async function fetchField(userId: string, fieldName: string): Promise { +export async function fetchField(userId: string, fieldName: FieldName): Promise { const supabase = getSupabase() const { data, error } = await supabase .from('encrypted_fields') @@ -19,7 +20,7 @@ export async function fetchField(userId: string, fieldName: string): Promise { +export async function saveField(userId: string, fieldName: FieldName, data: SaveFieldData): Promise { const supabase = getSupabase() const { error } = await supabase.from('encrypted_fields').upsert( { diff --git a/src/shared/types/api.types.ts b/src/shared/types/api.types.ts index ecca713..c4be3d6 100644 --- a/src/shared/types/api.types.ts +++ b/src/shared/types/api.types.ts @@ -1,3 +1,5 @@ +import type { FieldName } from '@/shared/types/entities/field.types' + export interface ServerMasterKeyEnvelope { authSalt: string keySalt: string @@ -17,7 +19,7 @@ export interface ServerFieldKey { } export interface ServerEncryptedField { - fieldName: string + fieldName: FieldName encryptedBlob: string iv: string updatedAt: string diff --git a/src/shared/types/entities/field.types.ts b/src/shared/types/entities/field.types.ts index 5d7bba6..3eb109d 100644 --- a/src/shared/types/entities/field.types.ts +++ b/src/shared/types/entities/field.types.ts @@ -1,12 +1,15 @@ /** The three encrypted fields each user owns. */ export type FieldName = 'note' | 'website' | 'email' +/** Canonical list of all field names — single source of truth. */ +export const FIELD_NAMES = ['note', 'website', 'email'] as const + /** A field as stored on the server — ciphertext, IV, and metadata. */ export interface EncryptedField { fieldName: FieldName - /** Base64-encoded AES-GCM ciphertext. */ + /** Hex-encoded AES-GCM ciphertext. */ encryptedBlob: string - /** Base64-encoded initialization vector for AES-GCM. */ + /** Hex-encoded initialization vector for AES-GCM. */ iv: string updatedAt: string }