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
3 changes: 1 addition & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ Backend abstracted behind interfaces: `IAuthAdapter`, `IApiAdapter`, `IRealtimeA
- **Non-component files** (utilities, models, schemas, types, hooks, tests): kebab-case — e.g., `auth-store.ts`, `login-schema.ts`
- **Exceptions that stay kebab-case:**
- shadcn/ui primitives (generated by CLI as kebab-case): `button.tsx`, `card.tsx`, `dialog.tsx`, `input.tsx`, `label.tsx`, `sonner.tsx`
- Context/provider modules that export both a component and a hook: `auth-context.tsx`, `theme-provider.tsx`
- Context modules that export a context and a hook: `auth-context.tsx`; Provider components that export only a component: `auth-provider.tsx`, `theme-provider.tsx`
- Route files (TanStack Router convention): `__root.tsx`, `_public.login.tsx`, etc.

## Development Commands
Expand Down Expand Up @@ -186,7 +186,6 @@ Non-obvious decisions not visible from code alone:
- **Argon2id Web Worker**: `argon2id.ts` delegates all derivation to `argon2id.worker.ts` via `postMessage`. The worker lazy-loads `argon2-browser/dist/argon2-bundled.min.js` (not the default `argon2-browser` import — the default tries to load a `.wasm` file which Vite cannot handle; the bundled build embeds WASM as base64 in JS). Tests mock the Worker constructor; actual Argon2id computation is tested in E2E (Step 36).
- **FieldCard children pattern**: uses render function `() => ReactNode` so editors aren't mounted when vault is locked
- **FieldCard i18n keys**: `FIELD_I18N_KEYS` is a static record (not template literals) so i18next-parser can discover them
- **`useCurrentUser` hook**: wraps the auth store in `shared/auth/` so features can access user data without cross-feature imports. This is a deliberate exception to the "shared must not import from features" rule — the hook re-exports only what other features need, keeping the dependency surface narrow.
- **`Uint8Array<ArrayBuffer>` for Web Crypto**: TS 6.0 made `Uint8Array` generic; bare `Uint8Array` expands to `Uint8Array<ArrayBufferLike>` which doesn't satisfy `BufferSource`. All `crypto.subtle` function signatures must use `Uint8Array<ArrayBuffer>`.
- **`copyToUint8Array` only in aes-gcm.ts**: Web Crypto's `encrypt`, `decrypt`, and `exportKey` return `ArrayBuffer`, which can be neutered/transferred. `copyToUint8Array` wraps these calls and provides type narrowing to `Uint8Array<ArrayBuffer>`. Other crypto modules construct `Uint8Array` from scratch (e.g., `new Uint8Array(derivedBits)`) so they already own the buffer.
- **Master key wrapping uses AAD constants**: All key wrapping is done directly with `encrypt`/`decrypt` from `aes-gcm.ts` using `{iv, aad}` options. `changePassword` in `split-kdf.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`.
Expand Down
13 changes: 8 additions & 5 deletions docs/implementation-plan/00-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ cipher-note-react/
layouts/
PublicLayout.tsx # Centered card layout for auth pages
ProtectedLayout.tsx # Sidebar + header + main content
Sidebar.tsx # Responsive sidebar component
MobileNav.tsx # Bottom navigation for mobile
routes/
__root.tsx # Root route with providers + Suspense boundary
_public.tsx # Public layout route (GuestOnly)
Expand All @@ -92,6 +94,7 @@ cipher-note-react/
login-schema.ts # Zod validation
ui/
AuthLayout.tsx # Shared layout for auth pages
AuthProvider.tsx # Wires auth store into AuthContext (depends on features, so not in shared/)
MnemonicDialog.tsx # Seed phrase display
MnemonicInput.tsx # 12-word input with BIP-39 validation
PasswordStrength.tsx # Password strength indicator
Expand All @@ -113,7 +116,6 @@ cipher-note-react/
ConflictNotification.tsx
encryption/
model/
crypto-store.ts # Zustand: masterKey, KEK, fieldKeys (memory only, hex strings)
vault-timeout.ts # Auto-lock after inactivity
key-rotation.ts # Rotate individual field keys
multi-device.ts # Handle key changes from other sessions
Expand All @@ -139,8 +141,6 @@ cipher-note-react/
nav/
NavLink.tsx
LanguageSwitcher.tsx
MobileNav.tsx
Sidebar.tsx
ResizeHandle.tsx
form/
FormField.tsx
Expand All @@ -158,6 +158,9 @@ cipher-note-react/
split-kdf.ts # Split KDF (auth + key derivation from password)
mnemonic.ts # BIP-39 generate/validate/wrap/unwrap (lazy-loaded)
crypto-utils.ts # hexEncode, hexDecode, generateIV, generateSalt, generateKey, encodeAAD, zeroFill, copyToUint8Array
key-vault.ts # Module-scoped CryptoKey vault (non-extractable keys)
crypto-store.ts # Zustand: loadedFieldKeys, isVaultLocked (memory only, no devtools)
vault-dialog-store.ts # Zustand: vault unlock dialog state (open/close)
api/
api.types.ts # IApiAdapter interface
supabase-client.ts # Supabase client initialization only
Expand All @@ -170,7 +173,7 @@ cipher-note-react/
auth.types.ts # IAuthAdapter interface
auth-errors.ts # AuthError, AuthErrorCode, isAuthError, isNetworkError
supabase-adapter.ts # Supabase Auth implementation
auth-context.tsx # React context for auth adapter
auth-context.tsx # React context + useAuth hook (AuthProvider is in features/auth/)
username-utils.ts # toSupabaseEmail, fromSupabaseEmail
# future: custom-jwt-adapter.ts, opaque-adapter.ts
realtime/
Expand Down Expand Up @@ -229,5 +232,5 @@ Dependency rules: `routes` → `features` → `shared`. No cross-feature imports
- **Types in separate `.types.ts` files.** Keep type definitions separate from implementation. A consumer should import types without pulling in crypto dependencies.
- **Each test file mirrors its source.** `aes-gcm.ts` → `aes-gcm.test.ts` in the same directory. No separate `__tests__` folders — colocate tests with the code they test.
- **No barrel files (index.ts).** Import components directly by path: `import { Button } from '@/shared/ui/button'` not `import { Button } from '@/shared/ui'`. Barrel files defeat tree-shaking and cause the entire module graph to be analyzed even when only one export is needed. This applies to all directories — `shared/ui/`, `shared/crypto/`, `shared/auth/`, etc.
- **File naming convention.** Component files (`.tsx` exporting a React component) use PascalCase: `LoginPage.tsx`, `FormField.tsx`. Non-component files use kebab-case: `auth-store.ts`, `login-schema.ts`. Exceptions: shadcn/ui primitives stay kebab-case (`button.tsx`, `input.tsx`), context/provider modules that export both component and hook stay kebab-case (`auth-context.tsx`, `theme-provider.tsx`), and route files follow TanStack Router convention.
- **File naming convention.** Component files (`.tsx` exporting a React component) use PascalCase: `LoginPage.tsx`, `FormField.tsx`. Non-component files use kebab-case: `auth-store.ts`, `login-schema.ts`. Exceptions: shadcn/ui primitives stay kebab-case (`button.tsx`, `input.tsx`), context modules that export a context and hook stay kebab-case (`auth-context.tsx`), provider components that export only a component also stay kebab-case (`auth-provider.tsx`, `theme-provider.tsx`), and route files follow TanStack Router convention.
- **Lazy-load heavy crypto modules.** `argon2-browser` (WASM, ~200KB+) and `@scure/bip39` (2048-word dictionary) must be dynamically imported via `await import(...)` only when the user is actually authenticating or recovering. Never import them at the top level of a module that loads on app startup. **Important:** always import `argon2-browser/dist/argon2-bundled.min.js`, not `argon2-browser` — the default import tries to load a `.wasm` file which Vite cannot handle; the bundled build embeds WASM as base64 in JS.
2 changes: 1 addition & 1 deletion docs/implementation-plan/01-phase-1-foundation.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@
- Create `src/app/Providers.tsx` — QueryClientProvider + i18n provider
- Create Zustand stores:
- `src/features/auth/model/auth-store.ts` — session, user, isAuthenticated. **No devtools middleware** — auth tokens must not be exposed in browser DevTools.
- `src/features/encryption/model/crypto-store.ts` — masterKey, KEK, fieldKeys, isVaultLocked (memory only, no persist). **Use plain `Record<string, string>` (hex-encoded) for fieldKeys instead of `Map<string, Uint8Array>`** — Zustand uses `Object.is` for shallow comparison, which fails on Map mutations and Uint8Array references. Hex strings are comparable by value and trigger correct re-renders. **No devtools middleware** — crypto keys must not be exposed in browser DevTools.
- `src/shared/crypto/crypto-store.ts` — masterKey, KEK, fieldKeys, isVaultLocked (memory only, no persist). **Use plain `Record<string, string>` (hex-encoded) for fieldKeys instead of `Map<string, Uint8Array>`** — Zustand uses `Object.is` for shallow comparison, which fails on Map mutations and Uint8Array references. Hex strings are comparable by value and trigger correct re-renders. **No devtools middleware** — crypto keys must not be exposed in browser DevTools.
- `src/features/settings/model/ui-store.ts` — sidebarOpen, activeField. **Do NOT store `language` here** — `i18next` is the source of truth for language state. Only store UI state that i18next doesn't manage.
- Create adapter interfaces:
- `src/shared/auth/auth.types.ts` — `IAuthAdapter` interface: `login(username, authHash)`, `logout()`, `getSession()`, `signup(username, authHash)`, `recoverPassword()`
Expand Down
2 changes: 1 addition & 1 deletion docs/implementation-plan/02-phase-2-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
- `logout()` → calls `supabase.auth.signOut()`
- `getSession()` → calls `supabase.auth.getSession()`
- `recoverPassword()` → placeholder for seed phrase recovery
- Create `src/shared/auth/auth-context.tsx` — React context providing the auth adapter
- Create `src/shared/auth/auth-context.tsx` — React context + `useAuth` hook. `AuthProvider` component lives in `src/features/auth/ui/auth-provider.tsx` (it wires the auth store into the context, so it depends on features)
- Create `src/shared/api/supabase-client.ts` — Supabase client initialization from env vars (combined with API adapter)
- Create `src/shared/auth/username-utils.ts` — `toSupabaseEmail(username)`, `fromSupabaseEmail(email)`
- Configure Supabase Auth to accept 64-character hex strings as passwords (disable default password complexity rules since the "password" is already an Argon2id hash)
Expand Down
6 changes: 3 additions & 3 deletions docs/implementation-plan/03-phase-3-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
- Sidebar: app logo, nav links (Dashboard, Settings), user info, lock vault button, language switcher
- Header: page title, vault lock/unlock indicator
- `src/shared/ui/brand/AppLogo.tsx`
- `src/shared/ui/nav/Sidebar.tsx` — responsive sidebar component, shared between desktop aside and mobile Sheet overlay, with optional `onClose` prop for closing the Sheet on navigation
- `src/shared/ui/nav/MobileNav.tsx` — bottom navigation for mobile with vault toggle center button
- `src/app/layouts/Sidebar.tsx` — responsive sidebar component, shared between desktop aside and mobile Sheet overlay, with optional `onClose` prop for closing the Sheet on navigation
- `src/app/layouts/MobileNav.tsx` — bottom navigation for mobile with vault toggle center button
- `src/shared/ui/nav/ResizeHandle.tsx` — thin drag handle between sidebar and main content on desktop, 2×3 dot matrix grip indicator with hover/drag accent colors, hidden on mobile
- `src/shared/lib/use-resizable.ts` — custom hook managing drag resize logic: local state for smooth 60fps dragging, commits final width to Zustand store on release, pointer events for unified mouse+touch support
- `src/features/settings/model/ui-store.ts` — added `sidebarWidth: number` (default 240) and `setSidebarWidth` action, persisted to localStorage via `partialize`
Expand Down Expand Up @@ -78,7 +78,7 @@
- `src/features/settings/ui/PreferencesSection.tsx` — language switcher (full variant)
- `src/features/settings/ui/AccountSection.tsx` — account info + delete
- Enhance `LanguageSwitcher` with `variant` prop: `compact` (toggle button in sidebar/mobile) and `full` (button group showing language names, used in Preferences)
- Shared `useCurrentUser` hook in `src/shared/auth/` to access current user data without cross-feature imports from auth store
- `useAuth()` context hook in `src/shared/auth/auth-context.tsx` provides `user` — features access current user data without cross-feature imports from auth store
- Add i18n strings to `settings.json` (including `languageName.en/cs` for full variant labels)

**Tests:**
Expand Down
5 changes: 3 additions & 2 deletions src/app/Providers.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useState, useEffect } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { AuthProvider, useAuth } from '@/shared/auth/auth-context'
import { AuthProvider } from '@/features/auth/ui/auth-provider'
import { useAuth } from '@/shared/auth/auth-context'
import { RouterProvider } from '@tanstack/react-router'
import { createAppRouter } from './router'
import { restoreSession, subscribeToAuthChanges } from '@/app/flows/auth-flow'
import { setQueryClient } from '@/features/encryption/model/crypto-store'
import { setQueryClient } from '@/shared/crypto/crypto-store'
import { PageSkeleton } from '@/app/Pending'

const queryClient = new QueryClient({
Expand Down
6 changes: 3 additions & 3 deletions src/app/flows/auth-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ vi.mock('@/features/encryption/model/registration', () => ({
const { mockClearVault } = vi.hoisted(() => ({
mockClearVault: vi.fn<() => void>(),
}))
vi.mock('@/features/encryption/model/key-vault', () => ({
vi.mock('@/shared/crypto/key-vault', () => ({
keyVault: {
lockVault: vi.fn<() => void>(),
storeKey: vi.fn<() => void>(),
Expand Down Expand Up @@ -184,7 +184,7 @@ const cryptoStoreState = {
clearVault: mockClearVault,
}

vi.mock('@/features/encryption/model/crypto-store', () => ({
vi.mock('@/shared/crypto/crypto-store', () => ({
useCryptoStore: {
getState: vi.fn(() => cryptoStoreState),
setState: vi.fn(),
Expand All @@ -206,7 +206,7 @@ import { fetchLoginSalts, fetchMasterKeyEnvelope, fetchFieldKeys } from '@/share
import { useAuthStore } from '@/features/auth/model/auth-store'
import { AuthError, AuthErrorCode } from '@/shared/auth/auth-errors'
import type { AuthResult } from '@/shared/auth/auth.types'
import { keyVault } from '@/features/encryption/model/key-vault'
import { keyVault } from '@/shared/crypto/key-vault'
import { deriveAuthHash, derivePasswordKey, terminateWorker } from '@/shared/crypto/argon2id'
import { unwrapFieldKeys } from '@/shared/crypto/key-hierarchy'
import { deriveKEK } from '@/shared/crypto/hkdf'
Expand Down
4 changes: 2 additions & 2 deletions src/app/flows/auth-flow.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { deriveRegistrationKeys } from '@/features/encryption/model/registration'
import { useCryptoStore } from '@/features/encryption/model/crypto-store'
import { useCryptoStore } from '@/shared/crypto/crypto-store'
import { useAuthStore } from '@/features/auth/model/auth-store'
import { authAdapter } from '@/shared/auth/supabase-adapter'
import { uploadRegistrationData } from '@/shared/api/supabase-registration'
import { fetchLoginSalts, fetchMasterKeyEnvelope, fetchFieldKeys } from '@/shared/api/supabase-keys'
import { hexDecode, hexEncode, zeroFill } from '@/shared/crypto/crypto-utils'
import { decrypt, importKey } from '@/shared/crypto/aes-gcm'
import { keyVault } from '@/features/encryption/model/key-vault'
import { keyVault } from '@/shared/crypto/key-vault'
import type { CachedVaultEnvelope, ServerFieldKey } from '@/shared/types/api.types'
import { DecryptionError } from '@/shared/crypto/errors'
import { unwrapFieldKeys } from '@/shared/crypto/key-hierarchy'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import userEvent from '@testing-library/user-event'
import React from 'react'
import { render, screen } from '@/test/utils'
import { useCryptoStore } from '@/features/encryption/model/crypto-store'
import { useVaultDialogStore } from '@/features/encryption/model/vault-dialog-store'
import { useCryptoStore } from '@/shared/crypto/crypto-store'
import { useVaultDialogStore } from '@/shared/crypto/vault-dialog-store'

const { mockLockVault } = vi.hoisted(() => ({
mockLockVault: vi.fn(),
Expand All @@ -15,7 +15,7 @@ vi.mock('@tanstack/react-router', () => ({
useNavigate: () => vi.fn(),
}))

vi.mock('@/features/encryption/model/key-vault', () => ({
vi.mock('@/shared/crypto/key-vault', () => ({
keyVault: {
lockVault: mockLockVault,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { LayoutDashboard, Lock, Unlock, Settings } from 'lucide-react'

import { Button } from '@/shared/ui/button'
import { NavLink } from '@/shared/ui/nav/NavLink'
import { useCryptoStore } from '@/features/encryption/model/crypto-store'
import { useVaultDialogStore } from '@/features/encryption/model/vault-dialog-store'
import { keyVault } from '@/features/encryption/model/key-vault'
import { useCryptoStore } from '@/shared/crypto/crypto-store'
import { useVaultDialogStore } from '@/shared/crypto/vault-dialog-store'
import { keyVault } from '@/shared/crypto/key-vault'

function MobileNav() {
const { t } = useTranslation(['common', 'crypto'])
Expand Down
2 changes: 1 addition & 1 deletion src/app/layouts/ProtectedLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import React from 'react'
import { render, screen } from '@/test/utils'
import { useAuthStore } from '@/features/auth/model/auth-store'
import { useUiStore } from '@/features/settings/model/ui-store'
import { useCryptoStore } from '@/features/encryption/model/crypto-store'
import { useCryptoStore } from '@/shared/crypto/crypto-store'

vi.mock('@tanstack/react-router', () => ({
Link: ({ children, ...props }: Record<string, unknown> & { children?: React.ReactNode }) =>
Expand Down
4 changes: 2 additions & 2 deletions src/app/layouts/ProtectedLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { Outlet } from '@tanstack/react-router'

import { Button } from '@/shared/ui/button'
import { Sheet, SheetTrigger, SheetContent, SheetTitle } from '@/shared/ui/sheet'
import { Sidebar } from '@/shared/ui/nav/Sidebar'
import { MobileNav } from '@/shared/ui/nav/MobileNav'
import { Sidebar } from './Sidebar'
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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import userEvent from '@testing-library/user-event'
import React from 'react'
import { render, screen } from '@/test/utils'
import { useAuthStore } from '@/features/auth/model/auth-store'
import { useCryptoStore } from '@/features/encryption/model/crypto-store'
import { useVaultDialogStore } from '@/features/encryption/model/vault-dialog-store'
import { useCryptoStore } from '@/shared/crypto/crypto-store'
import { useVaultDialogStore } from '@/shared/crypto/vault-dialog-store'

const { mockNavigate, mockLockVault } = vi.hoisted(() => ({
mockNavigate: vi.fn(),
Expand All @@ -17,7 +17,7 @@ vi.mock('@tanstack/react-router', () => ({
useNavigate: () => mockNavigate,
}))

vi.mock('@/features/encryption/model/key-vault', () => ({
vi.mock('@/shared/crypto/key-vault', () => ({
keyVault: {
lockVault: mockLockVault,
},
Expand Down
6 changes: 3 additions & 3 deletions src/shared/ui/nav/Sidebar.tsx → src/app/layouts/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import { NavLink } from '@/shared/ui/nav/NavLink'
import { Separator } from '@/shared/ui/separator'
import { AppLogo } from '@/shared/ui/brand/AppLogo'
import { useAuthStore } from '@/features/auth/model/auth-store'
import { useCryptoStore } from '@/features/encryption/model/crypto-store'
import { useVaultDialogStore } from '@/features/encryption/model/vault-dialog-store'
import { keyVault } from '@/features/encryption/model/key-vault'
import { useCryptoStore } from '@/shared/crypto/crypto-store'
import { useVaultDialogStore } from '@/shared/crypto/vault-dialog-store'
import { keyVault } from '@/shared/crypto/key-vault'

interface SidebarProps {
onClose?: () => void
Expand Down
Loading
Loading