diff --git a/.planning/STATE.md b/.planning/STATE.md index 66419883ec..665a8e0ad5 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -300,6 +300,7 @@ Recent decisions affecting current work: | 020 | Fix shared items rendering | 2026-02-25 | 96b7591 | [020-fix-shared-items-rendering](./quick/020-fix-shared-items-rendering/) | | 021 | Account deletion (GDPR) | 2026-02-25 | 8ae01dd | [021-account-deletion-gdpr](./quick/021-account-deletion-gdpr/) | | 022 | Fix MFA status detection false positive | 2026-02-26 | ff850e0 | [022-fix-mfa-status-detection-false-positive](./quick/022-fix-mfa-status-detection-false-positive/) | +| 023 | M2 tech debt: store logout cleanup | 2026-03-04 | a8febeb | [023-m2-tech-debt-store-logout-cleanup](./quick/023-m2-tech-debt-store-logout-cleanup/) | ### Research Flags diff --git a/.planning/quick/023-m2-tech-debt-store-logout-cleanup/023-PLAN.md b/.planning/quick/023-m2-tech-debt-store-logout-cleanup/023-PLAN.md new file mode 100644 index 0000000000..7d4df21efa --- /dev/null +++ b/.planning/quick/023-m2-tech-debt-store-logout-cleanup/023-PLAN.md @@ -0,0 +1,25 @@ +# Quick Task 023: M2 Tech Debt — Store Logout Cleanup + +## Task + +Fix 2 tech debt items from M2 milestone audit: + +1. **Share store not cleared on logout** — `clearShares()` exists in `share.store.ts:93` but never called in `useAuth.ts` logout sequence +2. **Quota store not cleared on logout** — No `reset()` method exists; quota data persists across sessions + +## Changes + +### 1. Add `reset()` to quota store (`apps/web/src/stores/quota.store.ts`) + +- Add `reset: () => void` to `QuotaState` type +- Implement `reset()` that restores all fields to initial values (0 used, 500 MiB limit, no error) + +### 2. Wire both stores into logout (`apps/web/src/hooks/useAuth.ts`) + +- Import `useShareStore` and `useQuotaStore` +- Call `useShareStore.getState().clearShares()` in logout (both try and catch paths) +- Call `useQuotaStore.getState().reset()` in logout (both try and catch paths) + +## Verification + +- `pnpm --filter web build` passes diff --git a/.planning/quick/023-m2-tech-debt-store-logout-cleanup/023-SUMMARY.md b/.planning/quick/023-m2-tech-debt-store-logout-cleanup/023-SUMMARY.md new file mode 100644 index 0000000000..d79f8e7045 --- /dev/null +++ b/.planning/quick/023-m2-tech-debt-store-logout-cleanup/023-SUMMARY.md @@ -0,0 +1,15 @@ +# Quick Task 023: Summary + +## Changes + +1. **`apps/web/src/stores/quota.store.ts`** — Added `reset()` method to clear quota state on logout (resets to 0 used / 500 MiB limit / no error) +2. **`apps/web/src/hooks/useAuth.ts`** — Added `useShareStore.getState().clearShares()` and `useQuotaStore.getState().reset()` to both try and catch paths of the logout function + +## Verification + +- `pnpm --filter web build` passes (no type errors, no runtime issues) + +## Tech Debt Closed + +- Share store stale data across sessions: FIXED +- Quota store stale data across sessions: FIXED diff --git a/apps/web/src/hooks/useAuth.ts b/apps/web/src/hooks/useAuth.ts index ddcbc95968..9a0d2be578 100644 --- a/apps/web/src/hooks/useAuth.ts +++ b/apps/web/src/hooks/useAuth.ts @@ -6,10 +6,8 @@ import { authApi } from '../lib/api/auth'; import { vaultApi } from '../lib/api/vault'; import { useAuthStore } from '../stores/auth.store'; import { useVaultStore } from '../stores/vault.store'; -import { useFolderStore } from '../stores/folder.store'; -import { useSyncStore } from '../stores/sync.store'; import { useDeviceRegistryStore } from '../stores/device-registry.store'; -import { useMfaStore } from '../stores/mfa.store'; +import { clearAllUserStores } from '../lib/clear-user-stores'; import { initializeVault, encryptVaultKeys, @@ -24,7 +22,6 @@ import { initializeOrSyncRegistry } from '../services/device-registry.service'; import { initializeBin } from '../services/bin.service'; import { useBinStore } from '../stores/bin.store'; import { vaultControllerGetConfig } from '../api/vault/vault'; -import { clearFileSizeCache } from './useFileSize'; export function useAuth() { const navigate = useNavigate(); @@ -49,7 +46,6 @@ export function useAuth() { setLastAuthMethod, setUserEmail, setVaultKeypair, - logout: clearAuthState, } = useAuthStore(); const [isLoggingIn, setIsLoggingIn] = useState(false); @@ -446,36 +442,20 @@ export function useAuth() { // 2. Logout Core Kit (clears session from localStorage) await coreKitLogout(); - // 3. Clear local state - // [SECURITY: HIGH-02] Clear vault and folder stores - // BEFORE auth state to zero crypto keys from memory - useFolderStore.getState().clearFolders(); - useVaultStore.getState().clearVaultKeys(); - useSyncStore.getState().reset(); - useDeviceRegistryStore.getState().clearRegistry(); - useBinStore.getState().clearBin(); - useMfaStore.getState().reset(); - clearFileSizeCache(); - clearAuthState(); - - // 4. Navigate to login (pending auth cleared by clearAuthState -> store logout) + // 3. Clear all user-scoped stores (centralized helper) + clearAllUserStores(); + + // 4. Navigate to login navigate('/'); } catch (error) { console.error('[useAuth] Logout failed:', error); // Still clear state even if backend fails - useFolderStore.getState().clearFolders(); - useVaultStore.getState().clearVaultKeys(); - useSyncStore.getState().reset(); - useDeviceRegistryStore.getState().clearRegistry(); - useBinStore.getState().clearBin(); - useMfaStore.getState().reset(); - clearFileSizeCache(); - clearAuthState(); + clearAllUserStores(); navigate('/'); } finally { setIsLoggingOut(false); } - }, [accessToken, coreKitLogout, clearAuthState, navigate, isLoggingOut]); + }, [accessToken, coreKitLogout, navigate, isLoggingOut]); // Keep function refs up-to-date for use in session restoration effect. // Using refs prevents the effect from re-firing when function identities change. diff --git a/apps/web/src/lib/api/client.ts b/apps/web/src/lib/api/client.ts index 5dc44db896..8c67d09fdf 100644 --- a/apps/web/src/lib/api/client.ts +++ b/apps/web/src/lib/api/client.ts @@ -1,8 +1,6 @@ import axios from 'axios'; import { useAuthStore } from '../../stores/auth.store'; -import { useVaultStore } from '../../stores/vault.store'; -import { useFolderStore } from '../../stores/folder.store'; -import { useSyncStore } from '../../stores/sync.store'; +import { clearAllUserStores } from '../clear-user-stores'; // Shared refresh promise eliminates race condition where multiple concurrent // 401 responses each trigger their own POST /auth/refresh before the boolean @@ -56,10 +54,7 @@ apiClient.interceptors.response.use( }) .catch((refreshError) => { // [SECURITY: HIGH-03] Clear all stores including crypto keys on token refresh failure - useFolderStore.getState().clearFolders(); - useVaultStore.getState().clearVaultKeys(); - useSyncStore.getState().reset(); - useAuthStore.getState().logout(); + clearAllUserStores(); // Redirect to login will be handled by route guard throw refreshError; }) diff --git a/apps/web/src/lib/clear-user-stores.ts b/apps/web/src/lib/clear-user-stores.ts new file mode 100644 index 0000000000..78ab05636f --- /dev/null +++ b/apps/web/src/lib/clear-user-stores.ts @@ -0,0 +1,38 @@ +/** + * Centralized cleanup of all user-scoped Zustand stores. + * + * Called from: + * - useAuth logout (normal logout flow) + * - apiClient 401 interceptor (forced logout on token refresh failure) + * + * Order matters: clear stores holding crypto keys BEFORE clearing auth state, + * so sensitive key material is zeroed from memory first. [SECURITY: HIGH-02] + */ +import { useFolderStore } from '../stores/folder.store'; +import { useVaultStore } from '../stores/vault.store'; +import { useSyncStore } from '../stores/sync.store'; +import { useDeviceRegistryStore } from '../stores/device-registry.store'; +import { useBinStore } from '../stores/bin.store'; +import { useShareStore } from '../stores/share.store'; +import { useQuotaStore } from '../stores/quota.store'; +import { useMfaStore } from '../stores/mfa.store'; +import { useAuthStore } from '../stores/auth.store'; +import { clearFileSizeCache } from '../hooks/useFileSize'; + +export function clearAllUserStores(): void { + // 1. Clear stores with crypto key material first + useFolderStore.getState().clearFolders(); + useVaultStore.getState().clearVaultKeys(); + + // 2. Clear remaining user-scoped stores + useSyncStore.getState().reset(); + useDeviceRegistryStore.getState().clearRegistry(); + useBinStore.getState().clearBin(); + useShareStore.getState().clearShares(); + useQuotaStore.getState().reset(); + useMfaStore.getState().reset(); + clearFileSizeCache(); + + // 3. Clear auth state last (zeros keypair memory, sets isAuthenticated=false) + useAuthStore.getState().logout(); +} diff --git a/apps/web/src/stores/quota.store.ts b/apps/web/src/stores/quota.store.ts index a9483f6bef..3c2cd302bb 100644 --- a/apps/web/src/stores/quota.store.ts +++ b/apps/web/src/stores/quota.store.ts @@ -1,29 +1,36 @@ import { create } from 'zustand'; import { vaultApi, QuotaResponse } from '../lib/api/vault'; -type QuotaState = { - usedBytes: number; - limitBytes: number; - remainingBytes: number; - loading: boolean; - error: string | null; +const DEFAULT_LIMIT_BYTES = 500 * 1024 * 1024; // 500 MiB +const initialState = { + usedBytes: 0, + limitBytes: DEFAULT_LIMIT_BYTES, + remainingBytes: DEFAULT_LIMIT_BYTES, + loading: false, + error: null as string | null, +}; + +// Session version guard: incremented on reset() so in-flight fetchQuota() +// responses are discarded if they resolve after logout/reset. +let quotaSessionVersion = 0; + +type QuotaState = typeof initialState & { fetchQuota: () => Promise; removeUsage: (bytes: number) => void; canUpload: (bytes: number) => boolean; + reset: () => void; }; export const useQuotaStore = create((set, get) => ({ - usedBytes: 0, - limitBytes: 500 * 1024 * 1024, // 500 MiB - remainingBytes: 500 * 1024 * 1024, - loading: false, - error: null, + ...initialState, fetchQuota: async () => { + const requestVersion = quotaSessionVersion; set({ loading: true, error: null }); try { const quota: QuotaResponse = await vaultApi.getQuota(); + if (requestVersion !== quotaSessionVersion) return; set({ usedBytes: quota.usedBytes, limitBytes: quota.limitBytes, @@ -31,6 +38,7 @@ export const useQuotaStore = create((set, get) => ({ loading: false, }); } catch { + if (requestVersion !== quotaSessionVersion) return; set({ error: 'Failed to fetch quota', loading: false }); } }, @@ -45,4 +53,9 @@ export const useQuotaStore = create((set, get) => ({ const { remainingBytes } = get(); return bytes <= remainingBytes; }, + + reset: () => { + quotaSessionVersion += 1; + set(initialState); + }, }));