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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/implementation-plan/06-phase-6-data.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Phase 6: Encrypted Data Layer
# Phase 6: Encrypted Data Layer

## Step 24 — Supabase API Adapter ✅

Expand Down
6 changes: 3 additions & 3 deletions docs/implementation-plan/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,9 @@ This is the implementation plan for Cipher Note, an end-to-end encrypted note-ta
- [x] Step 21 — Login Crypto Flow
- [x] Step 22 — Login UI + Vault Unlock
- [x] Step 23 — Non-Extractable Key Vault + Zustand Store Refactor
- [ ] Step 24 — Supabase API Adapter
- [ ] Step 25 — Encrypted Field CRUD
- [ ] Step 26 — Auto-Save + Sync Flow
- [x] Step 24 — Supabase API Adapter
- [x] Step 25 — Encrypted Field CRUD
- [x] Step 26 — Auto-Save + Sync Flow
- [ ] Step 27 — Supabase Realtime Adapter
- [ ] Step 28 — Multi-Device Session Handling
- [ ] Step 29 — Change Password Flow + UI
Expand Down
6 changes: 5 additions & 1 deletion src/app/layouts/ProtectedLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { MobileNav } from './MobileNav'
import { ResizeHandle } from '@/shared/ui/nav/ResizeHandle'
import { VaultIndicator } from '@/features/encryption/ui/VaultIndicator'
import { VaultUnlockDialog } from '@/features/encryption/ui/VaultUnlockDialog'
import { OfflineBanner } from '@/shared/ui/OfflineBanner'
import { useUiStore } from '@/features/settings/model/ui-store'
import { useResizable } from '@/shared/lib/use-resizable'
import { useVaultTimeout } from '@/features/encryption/model/use-vault-timeout'
Expand All @@ -35,7 +36,7 @@ function ProtectedLayout() {
<div className="text-foreground bg-background flex h-[calc(100vh-var(--banner-height))]">
{/* Desktop sidebar */}
<aside
className="bg-sidebar text-sidebar-foreground border-sidebar-border hidden flex-shrink-0 flex-col border-r md:flex"
className="bg-sidebar text-sidebar-foreground border-sidebar-border hidden shrink-0 flex-col border-r md:flex"
style={{ width: `${currentSidebarWidth}px` }}
>
<Sidebar onLogout={logoutUser} />
Expand Down Expand Up @@ -64,6 +65,9 @@ function ProtectedLayout() {
<VaultIndicator />
</header>

{/* Offline status banner */}
<OfflineBanner />

{/* Main content */}
<main className="flex-1 overflow-y-auto p-6 pb-20 md:pb-6">
<Outlet />
Expand Down
6 changes: 3 additions & 3 deletions src/app/layouts/PublicLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import { PublicHeader } from '@/shared/ui/nav/PublicHeader'
function PublicLayout() {
return (
<div className="bg-background text-foreground relative flex min-h-[calc(100vh-var(--banner-height))] flex-col overflow-hidden">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--color-primary)_0,_transparent_70%)] opacity-[0.08]" />
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_bottom,_var(--color-primary)_0,_transparent_50%)] opacity-[0.03]" />
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,var(--color-primary)_0,transparent_70%)] opacity-[0.08]" />
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_bottom,var(--color-primary)_0,transparent_50%)] opacity-[0.03]" />
<PublicHeader />
<main className="container mx-auto flex flex-1 items-center justify-center p-4">
<main className="container mx-auto flex justify-center px-4 py-8 md:flex-1 md:items-center">
<Outlet />
</main>
</div>
Expand Down
15 changes: 15 additions & 0 deletions src/app/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,18 @@
.animate-fade-in-up {
animation: fade-in-up 0.3s ease-out both;
}

@keyframes fade-out-up {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-8px);
}
}

.animate-fade-out-up {
animation: fade-out-up 0.2s ease-in both;
}
19 changes: 15 additions & 4 deletions src/features/encryption/model/crypto-error-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,24 @@ describe('getCryptoErrorMessage', () => {
expect(result).toBe('errors.networkError')
})

it('maps unknown error to errors.decryptFailed', () => {
it('maps network Error to errors.networkError', () => {
const result = getCryptoErrorMessage(new Error('A Network failure occurred'), t)
expect(result).toBe('errors.networkError')
})

it('maps Supabase plain-object with network message to errors.networkError', () => {
const supabaseError = { message: 'Failed to fetch', code: '', details: '', hint: '' }
const result = getCryptoErrorMessage(supabaseError, t)
expect(result).toBe('errors.networkError')
})

it('maps unknown error to errors.unexpectedError', () => {
const result = getCryptoErrorMessage(new Error('something unexpected'), t)
expect(result).toBe('errors.decryptFailed')
expect(result).toBe('errors.unexpectedError')
})

it('maps non-Error thrown value to errors.decryptFailed', () => {
it('maps non-Error thrown value to errors.unexpectedError', () => {
const result = getCryptoErrorMessage('string error', t)
expect(result).toBe('errors.decryptFailed')
expect(result).toBe('errors.unexpectedError')
})
})
2 changes: 1 addition & 1 deletion src/features/encryption/model/crypto-error-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ export function getCryptoErrorMessage(error: unknown, t: TFunction<'crypto'>): s

if (isNetworkError(error)) return t('errors.networkError')

return t('errors.decryptFailed')
return t('errors.unexpectedError')
}
2 changes: 1 addition & 1 deletion src/features/encryption/ui/VaultUnlockDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ describe('VaultUnlockDialog', () => {
await user.click(screen.getByRole('button', { name: /unlock/i }))

await waitFor(() => {
expect(screen.getByText('Decryption failed. Your data may be corrupted.')).toBeInTheDocument()
expect(screen.getByText('An unexpected error occurred. Please try again.')).toBeInTheDocument()
})
})

Expand Down
2 changes: 1 addition & 1 deletion src/features/fields/model/sync-status-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import type { FieldName } from '@/shared/types/entities/field.types'

export type SyncStatus = 'idle' | 'saving' | 'saved' | 'error'
export type SyncStatus = 'idle' | 'saving' | 'paused' | 'saved' | 'error'

interface SyncStatusState {
status: Record<FieldName, SyncStatus>
Expand Down
50 changes: 27 additions & 23 deletions src/features/fields/model/use-field-editor.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { QueryClient, QueryClientProvider, onlineManager } from '@tanstack/react-query'
import { createElement, type ReactNode } from 'react'
import { useCryptoStore } from '@/shared/crypto/crypto-store'
import { useSyncStatusStore } from '@/features/fields/model/sync-status-store'
Expand Down Expand Up @@ -62,6 +62,7 @@ describe('useFieldEditor', () => {
})

afterEach(() => {
onlineManager.setOnline(true)
queryClient.clear()
vi.useRealTimers()
})
Expand Down Expand Up @@ -190,8 +191,7 @@ describe('useFieldEditor', () => {
expect(mockSaveField).not.toHaveBeenCalled()
})

it('auto-retries save when browser comes back online', async () => {
mockSaveField.mockRejectedValueOnce(new Error('Network error'))
it('transitions to paused when offline, then saved when back online', async () => {
const { result } = renderHook(() => useFieldEditor('note'), {
wrapper: createWrapper(queryClient),
})
Expand All @@ -200,37 +200,41 @@ describe('useFieldEditor', () => {
expect(result.current.fieldValue).toBe('initial content')
})

// Go offline — mutations with networkMode: 'online' will pause
act(() => {
onlineManager.setOnline(false)
})

act(() => {
result.current.saveFieldValue('offline content')
})

// Wait for the debounce + failed mutation
// Wait for debounce + mutation pause → status 'paused'
await waitFor(
() => {
expect(result.current.fieldSyncStatus).toBe('error')
expect(result.current.fieldSyncStatus).toBe('paused')
},
{ timeout: 5000 },
)

// Reset mock so next call succeeds
mockSaveField.mockResolvedValue(undefined)
// saveField was NOT called while offline (mutation paused before calling mutationFn)
expect(mockSaveField).not.toHaveBeenCalled()

// Simulate browser coming back online
// Go back online — TanStack Query auto-resumes the paused mutation
act(() => {
window.dispatchEvent(new Event('online'))
onlineManager.setOnline(true)
})

// Wait for retry to succeed
// Wait for auto-resume to succeed
await waitFor(
() => {
expect(result.current.fieldSyncStatus).toBe('saved')
},
{ timeout: 5000 },
)

// First call failed, second call succeeded with latest fieldValue
expect(mockSaveField).toHaveBeenCalledTimes(2)
expect(mockSaveField).toHaveBeenLastCalledWith('user-123', 'note', 'offline content')
expect(mockSaveField).toHaveBeenCalledTimes(1)
expect(mockSaveField).toHaveBeenCalledWith('user-123', 'note', 'offline content')
})

it('does not auto-retry on online event when status is not error', async () => {
Expand All @@ -254,8 +258,7 @@ describe('useFieldEditor', () => {
expect(mockSaveField).not.toHaveBeenCalled()
})

it('does not retry on online event after error is resolved', async () => {
mockSaveField.mockRejectedValueOnce(new Error('Network error')).mockResolvedValue(undefined)
it('does not trigger extra save after paused mutation resumes', async () => {
const { result } = renderHook(() => useFieldEditor('note'), {
wrapper: createWrapper(queryClient),
})
Expand All @@ -264,24 +267,26 @@ describe('useFieldEditor', () => {
expect(result.current.fieldValue).toBe('initial content')
})

// Go offline, save → mutation pauses
act(() => {
onlineManager.setOnline(false)
})
act(() => {
result.current.saveFieldValue('offline content')
})

// Wait for debounce + failed mutation
await waitFor(
() => {
expect(result.current.fieldSyncStatus).toBe('error')
expect(result.current.fieldSyncStatus).toBe('paused')
},
{ timeout: 5000 },
)

// Simulate browser coming back online → retry succeeds
// Go back online — auto-resume succeeds
act(() => {
window.dispatchEvent(new Event('online'))
onlineManager.setOnline(true)
})

// Wait for retry to succeed
await waitFor(
() => {
expect(result.current.fieldSyncStatus).toBe('saved')
Expand All @@ -290,14 +295,13 @@ describe('useFieldEditor', () => {
)

// Fire another online event — should NOT trigger another save
// (handler checks status imperatively; status is now 'saved', not 'error')
act(() => {
window.dispatchEvent(new Event('online'))
})

await new Promise((resolve) => setTimeout(resolve, 100))
// Only 2 calls: one failed, one succeeded via retry
expect(mockSaveField).toHaveBeenCalledTimes(2)
// Only one save call (the auto-resumed one)
expect(mockSaveField).toHaveBeenCalledTimes(1)
})
})

Expand Down
24 changes: 12 additions & 12 deletions src/features/fields/model/use-field-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface UseFieldEditorResult {
saveFieldValue: (value: string) => void
fieldSyncStatus: SyncStatus
retrySave: () => void
isOfflineAwaitingData: boolean
}

/**
Expand Down Expand Up @@ -58,17 +59,15 @@ function useFieldEditor(fieldName: FieldName): UseFieldEditorResult {

const { debounceSave, retrySave } = useSaveScheduler(fieldName, setSyncStatus, saveMutation.mutate, isVaultLocked)

// Auto-retry when the browser regains connectivity - listener is always
// registered; the handler checks status imperatively to avoid add/remove churn.
useEffect(() => {
const handleOnline = () => {
if (useSyncStatusStore.getState().status[fieldName] === 'error') {
retrySave()
}
}
window.addEventListener('online', handleOnline)
return () => window.removeEventListener('online', handleOnline)
}, [fieldName, retrySave])
// Derive effective sync status from mutation pause state:
// When offline, TanStack Query pauses the mutation - reflect this in the UI
// by deriving 'paused' during render rather than syncing via effect.
const effectiveSyncStatus: SyncStatus =
saveMutation.isPaused && syncStatus === 'saving'
? 'paused'
: !saveMutation.isPaused && syncStatus === 'paused'
? 'saving'
: syncStatus

const saveFieldValue = useCallback(
(value: string) => {
Expand All @@ -80,8 +79,9 @@ function useFieldEditor(fieldName: FieldName): UseFieldEditorResult {

// FieldValue resolution: draft takes priority while editing, otherwise query data.
const fieldValue = isVaultLocked ? '' : (draft ?? fieldQuery.data ?? '')
const isOfflineAwaitingData = fieldQuery.isPaused && !fieldQuery.data

return { fieldValue, saveFieldValue, fieldSyncStatus: syncStatus, retrySave }
return { fieldValue, saveFieldValue, fieldSyncStatus: effectiveSyncStatus, retrySave, isOfflineAwaitingData }
}

export { useFieldEditor }
2 changes: 1 addition & 1 deletion src/features/fields/model/use-field-mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function useFieldMutation(fieldName: FieldName) {
const queryKey = ['field', fieldName] as const

return useMutation({
networkMode: 'offlineFirst', // run mutation even when offline, so errors surface immediately
networkMode: 'online', // pause mutations when offline; auto-resume when back online
mutationFn: (plaintext: string) => {
if (!userId) throw new Error('useFieldMutation requires an authenticated user')
return fieldService.saveField(userId, fieldName, plaintext)
Expand Down
3 changes: 2 additions & 1 deletion src/features/fields/ui/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,14 @@ import type { FieldName } from '@/shared/types/entities/field.types'
function FieldEditorWrapper({ fieldName, entranceIndex }: { fieldName: FieldName; entranceIndex: number }) {
const isVaultLocked = useCryptoStore((s) => s.isVaultLocked)
const openUnlockDialog = useVaultDialogStore((s) => s.openUnlockDialog)
const { fieldValue, saveFieldValue, fieldSyncStatus, retrySave } = useFieldEditor(fieldName)
const { fieldValue, saveFieldValue, fieldSyncStatus, retrySave, isOfflineAwaitingData } = useFieldEditor(fieldName)

return (
<FieldCard
fieldName={fieldName}
isLocked={isVaultLocked}
onUnlock={isVaultLocked ? openUnlockDialog : undefined}
isOfflineAwaitingData={isOfflineAwaitingData}
entranceIndex={entranceIndex}
statusIndicator={
<SaveIndicator status={fieldSyncStatus} onRetry={fieldSyncStatus === 'error' ? retrySave : undefined} />
Expand Down
Loading
Loading