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
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Field Keys (one per field) → wrapped by KEK with AAD(fieldName, version)

### App Hierarchy
```
main.tsx → AppProviders (QueryClientProvider > AuthProvider > RouterProvider)
main.tsx → AppErrorBoundary → AppProviders (QueryClientProvider > AuthProvider > RouterProvider)
→ __root.tsx (ThemeProvider + Toaster)
→ _public (redirects to /dashboard if authed — guard in route beforeLoad)
→ /login, /register, /recover
Expand Down Expand Up @@ -190,11 +190,15 @@ See `docs/implementation-plan/README.md` for the full 36-step plan.
- Step 30 (Regenerate Seed Phrase) — complete
- Step 31 (Seed Phrase Recovery Flow + UI) — complete
- Step 32 (Key Rotation + UI) — complete
- Step 33 (Mobile Responsive Refinements) — complete
- Step 34 (Loading States, Error Boundaries, Toast Notifications) — complete

### Implementation Notes

Non-obvious decisions not visible from code alone:

- **Two error boundaries must stay separate**: `AppErrorBoundary` wraps `<AppProviders>` in `main.tsx` and renders a dependency-free fallback (no i18n, no theme, inline styles, hardcoded English). `RouteErrorBoundary` is the router's error component and uses i18n/theme/`mapErrorToMessage`. They cannot be merged because `AppErrorBoundary` must work when providers themselves are broken.
- **Field query error display avoids "empty but looks loaded" flash**: `useFieldEditor` returns the full `fieldQuery` (TanStack Query result) instead of picking off `error`/`refetch` separately. When a field errors and the user clicks retry, TanStack Query clears `error` and sets `isFetching: true`. Without checking `isFetching`, the UI would briefly show empty field editors that look correctly loaded. Checking `fieldQuery.isFetching` shows a spinner during refetch instead.
- **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`) 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
Expand Down
39 changes: 21 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,24 +84,27 @@ Dependency direction: `routes -> features -> shared`. No cross-feature imports.
│ │──▶ Tailwind CSS
└─────┬─────┘
┌───────────────────┐
│ AppProviders │
│ ┌──────────────┐ │
│ │ QueryClient │ │
│ │ ┌──────────┐ │ │
│ │ │ Auth │ │ │
│ │ │ ┌──────┐ │ │ │
│ │ │ │Router│ │ │ │
│ │ │ └──┬───┘ │ │ │
│ │ └────┬─────┘ │ │
│ └──────┼───────┘ │
└─────────┼─────────┘
┌────────────┐
│ __root │──▶ ThemeProvider + Toaster
└─────┬──────┘
┌───┴───┐
▼ ▼
┌───────────────────────┐
│ AppErrorBoundary │ ← catches provider/router crashes
│ ┌───────────────────┐ │
│ │ AppProviders │ │
│ │ ┌──────────────┐ │ │
│ │ │ QueryClient │ │ │
│ │ │ ┌──────────┐ │ │ │
│ │ │ │ Auth │ │ │ │
│ │ │ │ ┌──────┐ │ │ │ │
│ │ │ │ │Router│ │ │ │ │
│ │ │ │ └──┬───┘ │ │ │ │
│ │ │ └────┼─────┘ │ │ │
│ │ └──────┼───────┘ │ │
│ └─────────┼─────────┘ │
└───────────┼───────────┘
┌────────────┐
│ __root │────▶ ThemeProvider + Toaster
└─────┬──────┘
┌───┴───┐
▼ ▼
_public _authenticated
(guest) (logged in)
│ │
Expand Down
26 changes: 14 additions & 12 deletions docs/implementation-plan/09-phase-9-polish.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Phase 9: Polish

## Step 33 — Mobile Responsive Refinements
## Step 33 — Mobile Responsive Refinements

**Goal:** Ensure all pages work well on mobile viewports.

Expand All @@ -24,29 +24,31 @@

---

## Step 34 — Loading States, Error Boundaries, Toast Notifications
## Step 34 — Loading States, Error Boundaries, Toast Notifications

**Goal:** Professional UX for loading, errors, and notifications.

**Code:**
- Loading states:
- Skeleton loaders for field content while fetching
- Spinner component for loading states (field content, crypto operations)
- Spinner during Argon2id derivation (login, register, password change)
- Spinner during key wrapping/unwrapping operations
- "Saving..." indicator on field auto-save
- Error boundaries (enhance the root error boundary from Step 3):
- Crypto errors: "Decryption failed. Your data may be corrupted." with support link
- Network errors: "Connection lost. Changes will sync when reconnected."
- Inline save status indicator for field auto-save (saving/saved/error with retry)
- Error boundaries (two separate boundaries):
- App-level boundary above providers: dependency-free fallback (no i18n, no theme, inline styles, hardcoded English)
- Route-level boundary inside router: uses i18n, theme, and shared error-to-message mapper
- Shared `ErrorState` component for inline error display with retry and "Go home" actions
- Crypto errors: "Decryption failed. Your data may be corrupted.", "Corrupted data"
- Network errors: "Network error. Please try again."
- Route-level Suspense fallbacks already in place from Step 3
- Toast notifications:
- Success: "Saved", "Password changed", "Key rotated"
- Error: "Save failed — retrying", "Wrong password"
- Warning: "Vault locked due to inactivity", "Remote change detected"
- Notifications:
- Inline status indicators for save state (saving/saved/paused/error)
- Toast for vault lock due to inactivity and other one-time events
- Use shadcn `Toaster` component
- i18n strings for all error/success messages

**Tests:**
- Component test: skeleton loaders shown during loading
- Component test: spinners shown during loading
- Component test: error boundary catches and displays error
- Component test: toast notifications appear for success/error
- Integration: network error → error toast → retry → success toast
Expand Down
2 changes: 1 addition & 1 deletion docs/implementation-plan/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ This is the implementation plan for Cipher Note, an end-to-end encrypted note-ta
- [x] Step 31 — Seed Phrase Recovery Flow + UI
- [x] Step 32 — Key Rotation + UI
- [x] Step 33 — Mobile Responsive Refinements
- [ ] Step 34 — Loading States, Error Boundaries, Toast Notifications
- [x] Step 34 — Loading States, Error Boundaries, Toast Notifications
- [ ] Step 35 — Security Hardening
- [ ] Step 36 — E2E Tests (Playwright)

Expand Down
56 changes: 56 additions & 0 deletions src/app/AppErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AppErrorBoundary } from '@/app/AppErrorBoundary'

function Boom(): never {
throw new Error('kaboom')
}

describe('AppErrorBoundary', () => {
let errorSpy: ReturnType<typeof vi.spyOn>
let reloadSpy: ReturnType<typeof vi.fn>

beforeEach(() => {
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
reloadSpy = vi.fn()
Object.defineProperty(window, 'location', {
value: { reload: reloadSpy },
writable: true,
})
})

afterEach(() => {
errorSpy.mockRestore()
})

it('renders children when no error', () => {
render(
<AppErrorBoundary>
<span>ok content</span>
</AppErrorBoundary>,
)
expect(screen.getByText('ok content')).toBeInTheDocument()
})

it('renders fallback when a child throws', () => {
render(
<AppErrorBoundary>
<Boom />
</AppErrorBoundary>,
)
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
expect(screen.queryByText('ok content')).toBeNull()
expect(errorSpy).toHaveBeenCalled()
})

it('reloads the page when the reload button is clicked', async () => {
render(
<AppErrorBoundary>
<Boom />
</AppErrorBoundary>,
)
await userEvent.click(screen.getByRole('button', { name: 'Reload' }))
expect(reloadSpy).toHaveBeenCalledTimes(1)
})
})
72 changes: 72 additions & 0 deletions src/app/AppErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'

interface AppErrorBoundaryProps {
children: ReactNode
}

interface AppErrorBoundaryState {
error: Error | null
}

/**
* Top-level safety net above the provider tree. Catches render errors thrown
* by providers or anywhere below them that the TanStack Router boundary does
* not. Renders a minimal, dependency-free fallback (no i18n, no theme tokens)
* so it still works when providers/i18n themselves have crashed.
*/
class AppErrorBoundary extends Component<AppErrorBoundaryProps, AppErrorBoundaryState> {
state: AppErrorBoundaryState = { error: null }

static getDerivedStateFromError(error: unknown): AppErrorBoundaryState {
return { error: error instanceof Error ? error : new Error(String(error)) }
}

componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('Uncaught application error', error, info)
}

handleReload = (): void => {
window.location.reload()
}

render(): ReactNode {
if (this.state.error) {
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '1rem',
}}
>
<div style={{ textAlign: 'center', maxWidth: '28rem' }}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, marginBottom: '0.5rem' }}>Something went wrong</h1>
<p style={{ color: '#6b7280', marginBottom: '1rem' }}>
An unexpected error occurred. Reloading the page usually fixes it.
</p>
<button
type="button"
onClick={this.handleReload}
style={{
padding: '0.5rem 1rem',
borderRadius: '0.5rem',
border: '1px solid #d1d5db',
background: '#111827',
color: '#ffffff',
cursor: 'pointer',
}}
>
Reload
</button>
</div>
</div>
)
}

return this.props.children
}
}

export { AppErrorBoundary }
38 changes: 0 additions & 38 deletions src/app/ErrorBoundary.test.tsx

This file was deleted.

59 changes: 0 additions & 59 deletions src/app/ErrorBoundary.tsx

This file was deleted.

10 changes: 10 additions & 0 deletions src/app/NotFoundPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useTranslation } from 'react-i18next'
import { ErrorState } from '@/shared/ui/ErrorState'

function NotFoundPage() {
const { t } = useTranslation('common')

return <ErrorState className="mt-8" title={t('status.notFound')} description={t('common:errors.pageNotFound')} />
}

export { NotFoundPage }
Loading
Loading