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
1 change: 1 addition & 0 deletions .planning/STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 25 additions & 0 deletions .planning/quick/023-m2-tech-debt-store-logout-cleanup/023-PLAN.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
34 changes: 7 additions & 27 deletions apps/web/src/hooks/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();
Expand All @@ -49,7 +46,6 @@ export function useAuth() {
setLastAuthMethod,
setUserEmail,
setVaultKeypair,
logout: clearAuthState,
} = useAuthStore();

const [isLoggingIn, setIsLoggingIn] = useState(false);
Expand Down Expand Up @@ -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.
Expand Down
9 changes: 2 additions & 7 deletions apps/web/src/lib/api/client.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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;
})
Expand Down
38 changes: 38 additions & 0 deletions apps/web/src/lib/clear-user-stores.ts
Original file line number Diff line number Diff line change
@@ -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();
}
35 changes: 24 additions & 11 deletions apps/web/src/stores/quota.store.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,44 @@
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<void>;
removeUsage: (bytes: number) => void;
canUpload: (bytes: number) => boolean;
reset: () => void;
};

export const useQuotaStore = create<QuotaState>((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,
remainingBytes: quota.remainingBytes,
loading: false,
});
} catch {
if (requestVersion !== quotaSessionVersion) return;
set({ error: 'Failed to fetch quota', loading: false });
}
},
Expand All @@ -45,4 +53,9 @@ export const useQuotaStore = create<QuotaState>((set, get) => ({
const { remainingBytes } = get();
return bytes <= remainingBytes;
},

reset: () => {
quotaSessionVersion += 1;
set(initialState);
},
}));