diff --git a/CLAUDE.md b/CLAUDE.md index 3c70956..062ec49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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` for Web Crypto**: TS 6.0 made `Uint8Array` generic; bare `Uint8Array` expands to `Uint8Array` which doesn't satisfy `BufferSource`. All `crypto.subtle` function signatures must use `Uint8Array`. - **`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`. 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`. diff --git a/docs/implementation-plan/00-overview.md b/docs/implementation-plan/00-overview.md index 7a29582..44a0ea6 100644 --- a/docs/implementation-plan/00-overview.md +++ b/docs/implementation-plan/00-overview.md @@ -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) @@ -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 @@ -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 @@ -139,8 +141,6 @@ cipher-note-react/ nav/ NavLink.tsx LanguageSwitcher.tsx - MobileNav.tsx - Sidebar.tsx ResizeHandle.tsx form/ FormField.tsx @@ -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 @@ -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/ @@ -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. diff --git a/docs/implementation-plan/01-phase-1-foundation.md b/docs/implementation-plan/01-phase-1-foundation.md index f16ddc6..cb07ea4 100644 --- a/docs/implementation-plan/01-phase-1-foundation.md +++ b/docs/implementation-plan/01-phase-1-foundation.md @@ -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` (hex-encoded) for fieldKeys instead of `Map`** — 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` (hex-encoded) for fieldKeys instead of `Map`** — 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()` diff --git a/docs/implementation-plan/02-phase-2-auth.md b/docs/implementation-plan/02-phase-2-auth.md index 5689c94..c949d23 100644 --- a/docs/implementation-plan/02-phase-2-auth.md +++ b/docs/implementation-plan/02-phase-2-auth.md @@ -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) diff --git a/docs/implementation-plan/03-phase-3-dashboard.md b/docs/implementation-plan/03-phase-3-dashboard.md index aa1eddb..c4be5b5 100644 --- a/docs/implementation-plan/03-phase-3-dashboard.md +++ b/docs/implementation-plan/03-phase-3-dashboard.md @@ -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` @@ -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:** diff --git a/src/app/Providers.tsx b/src/app/Providers.tsx index 9de22c7..90c39fe 100644 --- a/src/app/Providers.tsx +++ b/src/app/Providers.tsx @@ -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({ diff --git a/src/app/flows/auth-flow.test.ts b/src/app/flows/auth-flow.test.ts index a704745..80b7aaf 100644 --- a/src/app/flows/auth-flow.test.ts +++ b/src/app/flows/auth-flow.test.ts @@ -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>(), @@ -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(), @@ -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' diff --git a/src/app/flows/auth-flow.ts b/src/app/flows/auth-flow.ts index b26a693..0519f04 100644 --- a/src/app/flows/auth-flow.ts +++ b/src/app/flows/auth-flow.ts @@ -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' diff --git a/src/shared/ui/nav/MobileNav.test.tsx b/src/app/layouts/MobileNav.test.tsx similarity index 91% rename from src/shared/ui/nav/MobileNav.test.tsx rename to src/app/layouts/MobileNav.test.tsx index 6230182..4786e36 100644 --- a/src/shared/ui/nav/MobileNav.test.tsx +++ b/src/app/layouts/MobileNav.test.tsx @@ -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(), @@ -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, }, diff --git a/src/shared/ui/nav/MobileNav.tsx b/src/app/layouts/MobileNav.tsx similarity index 89% rename from src/shared/ui/nav/MobileNav.tsx rename to src/app/layouts/MobileNav.tsx index c5ad9a8..04f975c 100644 --- a/src/shared/ui/nav/MobileNav.tsx +++ b/src/app/layouts/MobileNav.tsx @@ -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']) diff --git a/src/app/layouts/ProtectedLayout.test.tsx b/src/app/layouts/ProtectedLayout.test.tsx index b045de2..feb8547 100644 --- a/src/app/layouts/ProtectedLayout.test.tsx +++ b/src/app/layouts/ProtectedLayout.test.tsx @@ -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 & { children?: React.ReactNode }) => diff --git a/src/app/layouts/ProtectedLayout.tsx b/src/app/layouts/ProtectedLayout.tsx index 14a1cbf..a95be2c 100644 --- a/src/app/layouts/ProtectedLayout.tsx +++ b/src/app/layouts/ProtectedLayout.tsx @@ -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' diff --git a/src/shared/ui/nav/Sidebar.test.tsx b/src/app/layouts/Sidebar.test.tsx similarity index 94% rename from src/shared/ui/nav/Sidebar.test.tsx rename to src/app/layouts/Sidebar.test.tsx index b33e160..442fb92 100644 --- a/src/shared/ui/nav/Sidebar.test.tsx +++ b/src/app/layouts/Sidebar.test.tsx @@ -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(), @@ -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, }, diff --git a/src/shared/ui/nav/Sidebar.tsx b/src/app/layouts/Sidebar.tsx similarity index 93% rename from src/shared/ui/nav/Sidebar.tsx rename to src/app/layouts/Sidebar.tsx index e26caf3..a4eb5dd 100644 --- a/src/shared/ui/nav/Sidebar.tsx +++ b/src/app/layouts/Sidebar.tsx @@ -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 diff --git a/src/shared/auth/auth-context.test.tsx b/src/features/auth/ui/auth-provider.test.tsx similarity index 95% rename from src/shared/auth/auth-context.test.tsx rename to src/features/auth/ui/auth-provider.test.tsx index 426c285..e0a7690 100644 --- a/src/shared/auth/auth-context.test.tsx +++ b/src/features/auth/ui/auth-provider.test.tsx @@ -1,8 +1,9 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { renderHook, act } from '@testing-library/react' -import { AuthProvider, useAuth } from './auth-context' -import { useAuthStore } from '@/features/auth/model/auth-store' import type { ReactNode } from 'react' +import { AuthProvider } from './auth-provider' +import { useAuth } from '@/shared/auth/auth-context' +import { useAuthStore } from '@/features/auth/model/auth-store' vi.mock('@/shared/api/supabase-client', () => ({ getSupabase: () => ({ @@ -22,7 +23,7 @@ function wrapper({ children }: { children: ReactNode }) { return {children} } -describe('auth-context', () => { +describe('auth-provider', () => { beforeEach(() => { useAuthStore.setState({ user: null, diff --git a/src/features/auth/ui/auth-provider.tsx b/src/features/auth/ui/auth-provider.tsx new file mode 100644 index 0000000..6e93079 --- /dev/null +++ b/src/features/auth/ui/auth-provider.tsx @@ -0,0 +1,21 @@ +import { useMemo, type ReactNode } from 'react' +import { useAuthStore, isAuthenticated as isAuthenticatedGetter } from '@/features/auth/model/auth-store' +import { authAdapter } from '@/shared/auth/supabase-adapter' +import { AuthContext } from '@/shared/auth/auth-context' + +function AuthProvider({ children }: { children: ReactNode }) { + const user = useAuthStore((s) => s.user) + const isAuthenticated = useAuthStore(isAuthenticatedGetter) + const isLoading = useAuthStore((s) => s.isLoading) + const isRestoringSession = useAuthStore((s) => s.isRestoringSession) + + const value = useMemo( + () => ({ isAuthenticated, user, isLoading, isRestoringSession, adapter: authAdapter }), + // authAdapter is a module-level singleton — stable reference, no need in deps + [isAuthenticated, user, isLoading, isRestoringSession], + ) + + return {children} +} + +export { AuthProvider } diff --git a/src/features/encryption/model/vault-timeout.test.ts b/src/features/encryption/model/vault-timeout.test.ts index 23f9e05..402bd22 100644 --- a/src/features/encryption/model/vault-timeout.test.ts +++ b/src/features/encryption/model/vault-timeout.test.ts @@ -1,16 +1,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { act } from 'react' -import { useCryptoStore } from '@/features/encryption/model/crypto-store' +import { useCryptoStore } from '@/shared/crypto/crypto-store' import { DEFAULT_VAULT_TIMEOUT_MS } from './vault-timeout' -vi.mock('@/features/encryption/model/key-vault', () => ({ +vi.mock('@/shared/crypto/key-vault', () => ({ keyVault: { lockVault: vi.fn<() => void>(), }, })) -import { keyVault } from '@/features/encryption/model/key-vault' +import { keyVault } from '@/shared/crypto/key-vault' import { useVaultTimeout } from './vault-timeout' import { renderHook } from '@/test/utils' diff --git a/src/features/encryption/model/vault-timeout.ts b/src/features/encryption/model/vault-timeout.ts index 0dfdea8..e6e33da 100644 --- a/src/features/encryption/model/vault-timeout.ts +++ b/src/features/encryption/model/vault-timeout.ts @@ -1,7 +1,7 @@ import { useEffect, useRef } from 'react' -import { useCryptoStore } from '@/features/encryption/model/crypto-store' -import { keyVault } from '@/features/encryption/model/key-vault' +import { useCryptoStore } from '@/shared/crypto/crypto-store' +import { keyVault } from '@/shared/crypto/key-vault' export const DEFAULT_VAULT_TIMEOUT_MS = 15 * 60 * 1000 diff --git a/src/features/encryption/ui/VaultIndicator.test.tsx b/src/features/encryption/ui/VaultIndicator.test.tsx index 19203be..a581f1f 100644 --- a/src/features/encryption/ui/VaultIndicator.test.tsx +++ b/src/features/encryption/ui/VaultIndicator.test.tsx @@ -1,8 +1,8 @@ import { describe, it, expect } from 'vitest' import userEvent from '@testing-library/user-event' 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' import { VaultIndicator } from './VaultIndicator' describe('VaultIndicator', () => { diff --git a/src/features/encryption/ui/VaultIndicator.tsx b/src/features/encryption/ui/VaultIndicator.tsx index a702be6..339e8e3 100644 --- a/src/features/encryption/ui/VaultIndicator.tsx +++ b/src/features/encryption/ui/VaultIndicator.tsx @@ -1,8 +1,8 @@ import { useTranslation } from 'react-i18next' import { Lock, Unlock } from 'lucide-react' -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' import { cn } from '@/shared/lib/utils' function VaultIndicator() { diff --git a/src/features/encryption/ui/VaultUnlockDialog.test.tsx b/src/features/encryption/ui/VaultUnlockDialog.test.tsx index 35a5095..56212fb 100644 --- a/src/features/encryption/ui/VaultUnlockDialog.test.tsx +++ b/src/features/encryption/ui/VaultUnlockDialog.test.tsx @@ -1,8 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, waitFor } from '@/test/utils' import userEvent from '@testing-library/user-event' -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' import { DecryptionError } from '@/shared/crypto/errors' const { mockUnlockVault } = vi.hoisted(() => ({ diff --git a/src/features/encryption/ui/VaultUnlockDialog.tsx b/src/features/encryption/ui/VaultUnlockDialog.tsx index 0080aba..067d290 100644 --- a/src/features/encryption/ui/VaultUnlockDialog.tsx +++ b/src/features/encryption/ui/VaultUnlockDialog.tsx @@ -5,8 +5,8 @@ import { useForm } from 'react-hook-form' import { standardSchemaResolver } from '@hookform/resolvers/standard-schema' import { z } from 'zod' -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' import { unlockVault } from '@/app/flows/auth-flow' import { getCryptoErrorMessage } from '@/features/encryption/model/crypto-error-messages' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/shared/ui/dialog' diff --git a/src/features/fields/ui/DashboardPage.test.tsx b/src/features/fields/ui/DashboardPage.test.tsx index bdb3578..5d584ad 100644 --- a/src/features/fields/ui/DashboardPage.test.tsx +++ b/src/features/fields/ui/DashboardPage.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { render, screen } from '@/test/utils' -import { useCryptoStore } from '@/features/encryption/model/crypto-store' +import { useCryptoStore } from '@/shared/crypto/crypto-store' import { DashboardPage } from './DashboardPage' diff --git a/src/features/fields/ui/DashboardPage.tsx b/src/features/fields/ui/DashboardPage.tsx index 1291148..7400094 100644 --- a/src/features/fields/ui/DashboardPage.tsx +++ b/src/features/fields/ui/DashboardPage.tsx @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next' -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' import { FieldCard } from '@/features/fields/ui/FieldCard' import { NoteField } from '@/features/fields/ui/NoteField' import { WebsiteField } from '@/features/fields/ui/WebsiteField' diff --git a/src/features/settings/ui/AccountSection.tsx b/src/features/settings/ui/AccountSection.tsx index 3d1e618..af6f81f 100644 --- a/src/features/settings/ui/AccountSection.tsx +++ b/src/features/settings/ui/AccountSection.tsx @@ -1,14 +1,14 @@ import { useTranslation } from 'react-i18next' import { Trash2 } from 'lucide-react' -import { useCurrentUser } from '@/shared/auth/use-current-user' +import { useAuth } from '@/shared/auth/auth-context' import { Button } from '@/shared/ui/button' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/shared/ui/card' import { Separator } from '@/shared/ui/separator' function AccountSection() { const { t } = useTranslation('settings') - const username = useCurrentUser()?.username + const username = useAuth().user?.username return ( diff --git a/src/shared/auth/auth-context.tsx b/src/shared/auth/auth-context.tsx index b35b651..769f940 100644 --- a/src/shared/auth/auth-context.tsx +++ b/src/shared/auth/auth-context.tsx @@ -1,6 +1,4 @@ -import { createContext, useContext, useMemo, type ReactNode } from 'react' -import { useAuthStore, isAuthenticated as isAuthenticatedGetter } from '@/features/auth/model/auth-store' -import { authAdapter } from '@/shared/auth/supabase-adapter' +import { createContext, useContext } from 'react' import type { IAuthAdapter } from '@/shared/auth/auth.types' export interface AuthContext { @@ -11,22 +9,7 @@ export interface AuthContext { adapter: IAuthAdapter } -const AuthContext = createContext(null) - -function AuthProvider({ children }: { children: ReactNode }) { - const user = useAuthStore((s) => s.user) - const isAuthenticated = useAuthStore(isAuthenticatedGetter) - const isLoading = useAuthStore((s) => s.isLoading) - const isRestoringSession = useAuthStore((s) => s.isRestoringSession) - - const value = useMemo( - () => ({ isAuthenticated, user, isLoading, isRestoringSession, adapter: authAdapter }), - // authAdapter is a module-level singleton — stable reference, no need in deps - [isAuthenticated, user, isLoading, isRestoringSession], - ) - - return {children} -} +export const AuthContext = createContext(null) function useAuth(): AuthContext { const context = useContext(AuthContext) @@ -36,4 +19,4 @@ function useAuth(): AuthContext { return context } -export { AuthProvider, useAuth } +export { useAuth } diff --git a/src/shared/auth/use-current-user.ts b/src/shared/auth/use-current-user.ts deleted file mode 100644 index eb088e8..0000000 --- a/src/shared/auth/use-current-user.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { useAuthStore } from '@/features/auth/model/auth-store' - -function useCurrentUser() { - return useAuthStore((s) => s.user) -} - -export { useCurrentUser } diff --git a/src/features/encryption/model/crypto-store.test.ts b/src/shared/crypto/crypto-store.test.ts similarity index 100% rename from src/features/encryption/model/crypto-store.test.ts rename to src/shared/crypto/crypto-store.test.ts diff --git a/src/features/encryption/model/crypto-store.ts b/src/shared/crypto/crypto-store.ts similarity index 100% rename from src/features/encryption/model/crypto-store.ts rename to src/shared/crypto/crypto-store.ts diff --git a/src/features/encryption/model/key-vault.test.ts b/src/shared/crypto/key-vault.test.ts similarity index 97% rename from src/features/encryption/model/key-vault.test.ts rename to src/shared/crypto/key-vault.test.ts index 6f81989..74ac663 100644 --- a/src/features/encryption/model/key-vault.test.ts +++ b/src/shared/crypto/key-vault.test.ts @@ -17,7 +17,7 @@ const createStoreState = () => ({ updateActivity: vi.fn(), }) -vi.mock('@/features/encryption/model/crypto-store', () => ({ +vi.mock('@/shared/crypto/crypto-store', () => ({ useCryptoStore: { getState: vi.fn(() => createStoreState()), }, @@ -28,7 +28,7 @@ vi.mock('@/shared/crypto/argon2id', () => ({ terminateWorker: vi.fn(), })) -import { keyVault } from '@/features/encryption/model/key-vault' +import { keyVault } from '@/shared/crypto/key-vault' describe('key-vault', () => { beforeEach(() => { diff --git a/src/features/encryption/model/key-vault.ts b/src/shared/crypto/key-vault.ts similarity index 94% rename from src/features/encryption/model/key-vault.ts rename to src/shared/crypto/key-vault.ts index ff5da9a..3ffe693 100644 --- a/src/features/encryption/model/key-vault.ts +++ b/src/shared/crypto/key-vault.ts @@ -1,4 +1,4 @@ -import { useCryptoStore } from '@/features/encryption/model/crypto-store' +import { useCryptoStore } from '@/shared/crypto/crypto-store' /** * Module-scoped crypto key vault. diff --git a/src/features/encryption/model/vault-dialog-store.test.ts b/src/shared/crypto/vault-dialog-store.test.ts similarity index 100% rename from src/features/encryption/model/vault-dialog-store.test.ts rename to src/shared/crypto/vault-dialog-store.test.ts diff --git a/src/features/encryption/model/vault-dialog-store.ts b/src/shared/crypto/vault-dialog-store.ts similarity index 100% rename from src/features/encryption/model/vault-dialog-store.ts rename to src/shared/crypto/vault-dialog-store.ts diff --git a/src/test/setup.ts b/src/test/setup.ts index 447997f..318dd36 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -14,7 +14,7 @@ import fieldsCs from '@/shared/i18n/locales/cs/fields.json' import settingsCs from '@/shared/i18n/locales/cs/settings.json' import cryptoCs from '@/shared/i18n/locales/cs/crypto.json' import { useAuthStore } from '@/features/auth/model/auth-store' -import { useCryptoStore } from '@/features/encryption/model/crypto-store' +import { useCryptoStore } from '@/shared/crypto/crypto-store' import { useUiStore } from '@/features/settings/model/ui-store' afterEach(() => { diff --git a/src/test/utils.tsx b/src/test/utils.tsx index 3da5954..cc65934 100644 --- a/src/test/utils.tsx +++ b/src/test/utils.tsx @@ -4,7 +4,7 @@ import { Suspense } from 'react' import { afterEach } from 'vitest' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { ThemeProvider } from '@/shared/lib/theme-provider' -import { AuthProvider } from '@/shared/auth/auth-context' +import { AuthProvider } from '@/features/auth/ui/auth-provider' const testQueryClient = new QueryClient({ defaultOptions: {