From a908ed894bd775ba1518b470bbced8f17f4fe044 Mon Sep 17 00:00:00 2001 From: ParkerES Date: Sun, 22 Feb 2026 03:48:58 -0500 Subject: [PATCH] feat(storage): implement user-scoped data storage Isolate user data (notes, captures, briefings, etc.) per logged-in Hub account: - Add UserSessionManager to track login/logout and emit session change events - Add UserDataResolver to compute user-scoped paths (users/{userId}/) - Add UserDataMigrator to copy existing data on first login - Add ReinitializableService interface for user-scoped services - Update 10 services to implement reinitialize/clearState pattern: notes, dashboard, briefing, assistant-history, alerts, ideas, planner, fitness, milestones, changelog - Wire service-registry to reinitialize services on session change - Update auth handlers to trigger session changes on login/logout/restore - Add useSessionEvents hook to clear React Query cache on session change - Update ARCHITECTURE.md with user-scoped storage documentation Global data (settings, hub-config, oauth-tokens) remains at root. User data lives in {appData}/adc/users/{userId}/. Closes user data isolation issue where different Hub accounts would see each other's local data. --- ai-docs/ARCHITECTURE.md | 39 + docs/features/user-scoped-storage/plan.md | 897 ++++++++++++++++++ docs/tracker.json | 12 +- src/main/bootstrap/service-registry.ts | 98 +- src/main/ipc/handlers/auth-handlers.ts | 26 +- src/main/ipc/index.ts | 21 +- src/main/services/alerts/alert-store.ts | 79 +- src/main/services/assistant/history-store.ts | 30 +- src/main/services/auth/index.ts | 5 + .../services/auth/user-session-manager.ts | 58 ++ .../services/briefing/briefing-service.ts | 33 +- .../services/changelog/changelog-service.ts | 22 +- .../services/dashboard/dashboard-service.ts | 19 +- src/main/services/data-management/index.ts | 3 + .../reinitializable-service.ts | 27 + .../data-management/user-data-migrator.ts | 74 ++ .../data-management/user-data-resolver.ts | 48 + src/main/services/fitness/fitness-service.ts | 48 +- src/main/services/ideas/ideas-service.ts | 25 +- .../services/milestones/milestones-service.ts | 22 +- src/main/services/notes/notes-service.ts | 19 +- src/main/services/planner/planner-service.ts | 151 +-- .../app/components/route-skeletons.tsx | 12 +- .../features/auth/components/AuthGuard.tsx | 5 +- .../features/auth/hooks/useSessionEvents.ts | 30 + src/renderer/features/auth/index.ts | 1 + src/shared/ipc/auth/contract.ts | 10 + src/shared/ipc/auth/index.ts | 2 +- src/shared/ipc/index.ts | 9 +- tests/e2e/helpers/auth.ts | 65 +- .../ipc-handlers/auth-handlers.test.ts | 49 +- 31 files changed, 1745 insertions(+), 194 deletions(-) create mode 100644 docs/features/user-scoped-storage/plan.md create mode 100644 src/main/services/auth/index.ts create mode 100644 src/main/services/auth/user-session-manager.ts create mode 100644 src/main/services/data-management/reinitializable-service.ts create mode 100644 src/main/services/data-management/user-data-migrator.ts create mode 100644 src/main/services/data-management/user-data-resolver.ts create mode 100644 src/renderer/features/auth/hooks/useSessionEvents.ts diff --git a/ai-docs/ARCHITECTURE.md b/ai-docs/ARCHITECTURE.md index 247b491..b3714df 100644 --- a/ai-docs/ARCHITECTURE.md +++ b/ai-docs/ARCHITECTURE.md @@ -103,12 +103,51 @@ This replaces the previous monolithic `index.ts` where all initialization lived ## Data Persistence +### User-Scoped vs Global Data + +Data is separated into **user-scoped** (per-Hub-account) and **global** (device-level): + +``` +{appData}/adc/ +├── settings.json # Global — device preferences +├── hub-config.json # Global — needed before login +├── oauth-tokens.json # Global — device OAuth tokens +├── error-log.json # Global — diagnostics +└── users/ + └── {userId}/ # User-scoped directory + ├── notes.json + ├── captures.json + ├── briefings.json + ├── assistant-history.json + ├── alerts.json + ├── ideas.json + ├── milestones.json + ├── changelog.json + ├── planner/ # Daily plans + └── fitness/ # Workouts, measurements, goals +``` + +**Session lifecycle:** +- On login: `UserSessionManager.setSession()` → services reinitialize with user-scoped paths +- On logout: `UserSessionManager.clearSession()` → services clear state and reset to global paths +- First login: `UserDataMigrator` copies existing global data to user folder + +Key modules: +- `src/main/services/auth/user-session-manager.ts` — Tracks logged-in user, emits session change events +- `src/main/services/data-management/user-data-resolver.ts` — Computes user-scoped paths +- `src/main/services/data-management/user-data-migrator.ts` — Migrates data on first login +- `src/main/services/data-management/reinitializable-service.ts` — Interface for user-scoped services + +### Data Locations + | Data | Storage | Location | |------|---------|----------| | Projects | JSON file | `{appData}/adc/projects.json` | | Settings | JSON file | `{appData}/adc/settings.json` | | Tasks | File directories | `{projectPath}/.adc/specs/{taskId}/` | | Task specs | JSON files | `requirements.json`, `implementation_plan.json`, `task_metadata.json` | +| Notes | JSON file | `{appData}/adc/users/{userId}/notes.json` | +| Captures | JSON file | `{appData}/adc/users/{userId}/captures.json` | | Terminals | In-memory only | PTY processes managed by TerminalService | | Agents | In-memory only | PTY processes managed by AgentService | diff --git a/docs/features/user-scoped-storage/plan.md b/docs/features/user-scoped-storage/plan.md new file mode 100644 index 0000000..c450c8b --- /dev/null +++ b/docs/features/user-scoped-storage/plan.md @@ -0,0 +1,897 @@ +# User-Scoped Data Storage Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Isolate user data (notes, captures, briefings, etc.) per logged-in user so different Hub accounts don't share local data. + +**Architecture:** Create a `users//` subdirectory structure under userData. Services that store user-specific data receive a user-scoped path after login. Global settings (hub-config, oauth-tokens, app settings) remain at the root. On login, services reinitialize with user-scoped paths. On logout, in-memory caches clear and services reset to pre-login state. + +**Tech Stack:** Electron (app.getPath), Node.js fs, TypeScript, existing service factory pattern + +--- + +## File Categories + +### User-Scoped Files (move to `users//`) + +- `notes.json` - Personal notes +- `captures.json` - Quick capture entries +- `briefings.json` - Daily briefing summaries +- `assistant-history.json` - Assistant command history +- `assistant-watches.json` - Proactive notification triggers +- `alerts.json` - User alerts +- `ideas.json` - Ideas board +- `milestones.json` - Project milestones +- `changelog.json` - Change records +- `planner/` - Daily plans directory +- `fitness/` - Workouts, measurements, goals + +### Global Files (remain at root) + +- `settings.json` - App preferences (theme, hotkeys, API keys) +- `hub-config.json` - Hub connection (needed before login) +- `oauth-tokens.json` - OAuth tokens +- `oauth-providers.json` - OAuth client credentials +- `voice-config.json` - Voice input configuration +- `email-config.json` - SMTP configuration +- `error-log.json` - Error diagnostics +- `hub-sync-queue.json` - Offline sync queue +- `worktrees.json` - Git worktree mapping + +--- + +## Task 1: Create User Data Path Resolver + +**Files:** + +- Create: `src/main/services/data-management/user-data-resolver.ts` +- Modify: `src/main/services/data-management/index.ts` + +**Step 1: Create the user data resolver module** + +```typescript +// src/main/services/data-management/user-data-resolver.ts +/** + * User Data Path Resolver + * + * Provides user-scoped data directory paths. User data is stored in + * `/users//` to isolate data between Hub accounts. + */ + +import { existsSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +export interface UserDataResolver { + /** Get the data directory for a specific user. Creates if needed. */ + getUserDataDir(userId: string): string; + /** Get the global data directory (for non-user-scoped data). */ + getGlobalDataDir(): string; + /** Check if a user data directory exists. */ + userDataExists(userId: string): boolean; +} + +export function createUserDataResolver(baseDataDir: string): UserDataResolver { + const usersDir = join(baseDataDir, 'users'); + + function getUserDataDir(userId: string): string { + if (!userId) { + throw new Error('userId is required for user-scoped data'); + } + const userDir = join(usersDir, userId); + if (!existsSync(userDir)) { + mkdirSync(userDir, { recursive: true }); + } + return userDir; + } + + function getGlobalDataDir(): string { + return baseDataDir; + } + + function userDataExists(userId: string): boolean { + if (!userId) return false; + return existsSync(join(usersDir, userId)); + } + + return { + getUserDataDir, + getGlobalDataDir, + userDataExists, + }; +} +``` + +**Step 2: Export from barrel** + +Add to `src/main/services/data-management/index.ts`: + +```typescript +export { createUserDataResolver, type UserDataResolver } from './user-data-resolver'; +``` + +**Step 3: Commit** + +```bash +git add src/main/services/data-management/user-data-resolver.ts src/main/services/data-management/index.ts +git commit -m "feat(storage): add user data path resolver" +``` + +--- + +## Task 2: Create User Session Manager + +**Files:** + +- Create: `src/main/services/auth/user-session-manager.ts` +- Create: `src/main/services/auth/index.ts` + +**Step 1: Create user session manager** + +```typescript +// src/main/services/auth/user-session-manager.ts +/** + * User Session Manager + * + * Tracks the currently logged-in user and emits events on login/logout. + * Services subscribe to these events to reinitialize with user-scoped paths. + */ + +import type { IpcRouter } from '@main/ipc/router'; + +export interface UserSession { + userId: string; + email: string; +} + +export interface UserSessionManager { + /** Get current session, or null if not logged in. */ + getCurrentSession(): UserSession | null; + /** Called when user logs in successfully. */ + setSession(session: UserSession): void; + /** Called when user logs out. */ + clearSession(): void; + /** Subscribe to session changes. Returns unsubscribe function. */ + onSessionChange(callback: (session: UserSession | null) => void): () => void; +} + +export function createUserSessionManager(router: IpcRouter): UserSessionManager { + let currentSession: UserSession | null = null; + const listeners = new Set<(session: UserSession | null) => void>(); + + function notifyListeners(): void { + for (const listener of listeners) { + listener(currentSession); + } + } + + return { + getCurrentSession() { + return currentSession; + }, + + setSession(session) { + currentSession = session; + router.emit('event:user.sessionChanged', { userId: session.userId, email: session.email }); + notifyListeners(); + }, + + clearSession() { + currentSession = null; + router.emit('event:user.sessionChanged', { userId: null, email: null }); + notifyListeners(); + }, + + onSessionChange(callback) { + listeners.add(callback); + return () => listeners.delete(callback); + }, + }; +} +``` + +**Step 2: Create barrel export** + +```typescript +// src/main/services/auth/index.ts +export { + createUserSessionManager, + type UserSessionManager, + type UserSession, +} from './user-session-manager'; +``` + +**Step 3: Add IPC event contract** + +Add to `src/shared/ipc/auth/contract.ts` in the events section: + +```typescript +'event:user.sessionChanged': { + payload: z.object({ + userId: z.string().nullable(), + email: z.string().nullable(), + }), +}, +``` + +**Step 4: Commit** + +```bash +git add src/main/services/auth/ src/shared/ipc/auth/contract.ts +git commit -m "feat(auth): add user session manager with session change events" +``` + +--- + +## Task 3: Create Reinitializable Service Pattern + +**Files:** + +- Create: `src/main/services/data-management/reinitializable-service.ts` +- Modify: `src/main/services/data-management/index.ts` + +**Step 1: Create reinitializable service interface** + +```typescript +// src/main/services/data-management/reinitializable-service.ts +/** + * Reinitializable Service Pattern + * + * Services that store user-scoped data implement this interface. + * They reinitialize when the user changes (login/logout). + */ + +export interface ReinitializableService { + /** Reinitialize the service with a new data directory. */ + reinitialize(dataDir: string): void; + /** Clear all in-memory state (called on logout). */ + clearState(): void; +} + +/** + * Type guard to check if a service is reinitializable. + */ +export function isReinitializable(service: unknown): service is ReinitializableService { + return ( + typeof service === 'object' && + service !== null && + 'reinitialize' in service && + 'clearState' in service && + typeof (service as ReinitializableService).reinitialize === 'function' && + typeof (service as ReinitializableService).clearState === 'function' + ); +} +``` + +**Step 2: Export from barrel** + +Add to `src/main/services/data-management/index.ts`: + +```typescript +export { type ReinitializableService, isReinitializable } from './reinitializable-service'; +``` + +**Step 3: Commit** + +```bash +git add src/main/services/data-management/ +git commit -m "feat(storage): add reinitializable service pattern" +``` + +--- + +## Task 4: Update Notes Service for User-Scoping + +**Files:** + +- Modify: `src/main/services/notes/notes-service.ts` + +**Step 1: Add reinitializable interface to notes service** + +Update the `NotesService` interface to extend `ReinitializableService`: + +```typescript +import type { ReinitializableService } from '@main/services/data-management'; + +export interface NotesService extends ReinitializableService { + // ... existing methods +} +``` + +**Step 2: Add state management variables** + +Add near the top of the factory function: + +```typescript +let currentFilePath = join(deps.dataDir, NOTES_FILE); +let notesCache: Note[] | null = null; +``` + +**Step 3: Update all file operations to use `currentFilePath`** + +Replace hardcoded `filePath` references with `currentFilePath`. + +**Step 4: Implement reinitialize and clearState** + +Add to the returned object: + +```typescript +reinitialize(dataDir: string) { + currentFilePath = join(dataDir, NOTES_FILE); + notesCache = null; // Force reload from new path +}, + +clearState() { + notesCache = null; +}, +``` + +**Step 5: Commit** + +```bash +git add src/main/services/notes/notes-service.ts +git commit -m "feat(notes): make notes service reinitializable for user-scoping" +``` + +--- + +## Task 5: Update Captures/Dashboard Service for User-Scoping + +**Files:** + +- Modify: `src/main/services/dashboard/dashboard-service.ts` + +**Step 1: Add reinitializable interface** + +Same pattern as Task 4 - add `ReinitializableService` interface, add state variables, update file operations, implement `reinitialize` and `clearState`. + +**Step 2: Commit** + +```bash +git add src/main/services/dashboard/dashboard-service.ts +git commit -m "feat(dashboard): make dashboard service reinitializable for user-scoping" +``` + +--- + +## Task 6: Update Briefing Service for User-Scoping + +**Files:** + +- Modify: `src/main/services/briefing/briefing-service.ts` + +**Step 1: Add reinitializable interface** + +Same pattern as Task 4. + +**Step 2: Commit** + +```bash +git add src/main/services/briefing/briefing-service.ts +git commit -m "feat(briefing): make briefing service reinitializable for user-scoping" +``` + +--- + +## Task 7: Update Assistant History Service for User-Scoping + +**Files:** + +- Modify: `src/main/services/assistant/history-store.ts` + +**Step 1: Add reinitializable interface** + +Same pattern as Task 4. + +**Step 2: Commit** + +```bash +git add src/main/services/assistant/history-store.ts +git commit -m "feat(assistant): make history store reinitializable for user-scoping" +``` + +--- + +## Task 8: Update Alerts Service for User-Scoping + +**Files:** + +- Modify: `src/main/services/alerts/alert-store.ts` + +**Step 1: Add reinitializable interface** + +Same pattern as Task 4. + +**Step 2: Commit** + +```bash +git add src/main/services/alerts/alert-store.ts +git commit -m "feat(alerts): make alert store reinitializable for user-scoping" +``` + +--- + +## Task 9: Update Ideas Service for User-Scoping + +**Files:** + +- Modify: `src/main/services/ideas/ideas-service.ts` + +**Step 1: Add reinitializable interface** + +Same pattern as Task 4. + +**Step 2: Commit** + +```bash +git add src/main/services/ideas/ideas-service.ts +git commit -m "feat(ideas): make ideas service reinitializable for user-scoping" +``` + +--- + +## Task 10: Update Planner Service for User-Scoping + +**Files:** + +- Modify: `src/main/services/planner/planner-service.ts` + +**Step 1: Add reinitializable interface** + +This service uses a directory (`planner/`) instead of a single file. Update the `plannerDir` variable to be mutable and reinitializable. + +**Step 2: Commit** + +```bash +git add src/main/services/planner/planner-service.ts +git commit -m "feat(planner): make planner service reinitializable for user-scoping" +``` + +--- + +## Task 11: Update Fitness Service for User-Scoping + +**Files:** + +- Modify: `src/main/services/fitness/fitness-service.ts` + +**Step 1: Add reinitializable interface** + +This service uses a directory (`fitness/`). Same pattern as planner. + +**Step 2: Commit** + +```bash +git add src/main/services/fitness/fitness-service.ts +git commit -m "feat(fitness): make fitness service reinitializable for user-scoping" +``` + +--- + +## Task 12: Update Milestones Service for User-Scoping + +**Files:** + +- Modify: `src/main/services/milestones/milestones-service.ts` + +**Step 1: Add reinitializable interface** + +Same pattern as Task 4. + +**Step 2: Commit** + +```bash +git add src/main/services/milestones/milestones-service.ts +git commit -m "feat(milestones): make milestones service reinitializable for user-scoping" +``` + +--- + +## Task 13: Update Changelog Service for User-Scoping + +**Files:** + +- Modify: `src/main/services/changelog/changelog-service.ts` + +**Step 1: Add reinitializable interface** + +Same pattern as Task 4. + +**Step 2: Commit** + +```bash +git add src/main/services/changelog/changelog-service.ts +git commit -m "feat(changelog): make changelog service reinitializable for user-scoping" +``` + +--- + +## Task 14: Wire Up Service Registry with User Session + +**Files:** + +- Modify: `src/main/bootstrap/service-registry.ts` + +**Step 1: Import new modules** + +```typescript +import { createUserDataResolver, isReinitializable } from '@main/services/data-management'; +import { createUserSessionManager } from '@main/services/auth'; +``` + +**Step 2: Create user session manager and data resolver** + +After `const dataDir = app.getPath('userData');`: + +```typescript +const userDataResolver = createUserDataResolver(dataDir); +const userSessionManager = createUserSessionManager(router); +``` + +**Step 3: Collect user-scoped services** + +After all services are created, collect the reinitializable ones: + +```typescript +const userScopedServices = [ + notesService, + dashboardService, + briefingService, + assistantHistoryStore, + alertStore, + ideasService, + plannerService, + fitnessService, + milestonesService, + changelogService, +].filter(isReinitializable); +``` + +**Step 4: Subscribe to session changes** + +```typescript +userSessionManager.onSessionChange((session) => { + if (session) { + // User logged in - reinitialize with user-scoped paths + const userDataDir = userDataResolver.getUserDataDir(session.userId); + for (const service of userScopedServices) { + service.reinitialize(userDataDir); + } + } else { + // User logged out - clear state and reset to global dir + for (const service of userScopedServices) { + service.clearState(); + service.reinitialize(dataDir); // Reset to global (pre-login state) + } + } +}); +``` + +**Step 5: Export userSessionManager** + +Add to the returned services object: + +```typescript +return { + // ... existing + userSessionManager, +}; +``` + +**Step 6: Commit** + +```bash +git add src/main/bootstrap/service-registry.ts +git commit -m "feat(bootstrap): wire user session to service reinitialization" +``` + +--- + +## Task 15: Update Auth Handlers to Trigger Session Changes + +**Files:** + +- Modify: `src/main/ipc/handlers/auth-handlers.ts` + +**Step 1: Accept userSessionManager in dependencies** + +Update the handler factory to accept `userSessionManager`. + +**Step 2: Call setSession on successful login** + +After successful login in `auth.login` handler: + +```typescript +deps.userSessionManager.setSession({ + userId: result.user.id, + email: result.user.email, +}); +``` + +**Step 3: Call clearSession on logout** + +In `auth.logout` handler: + +```typescript +deps.userSessionManager.clearSession(); +``` + +**Step 4: Call setSession on successful session restore** + +In `auth.restore` handler when restore succeeds: + +```typescript +deps.userSessionManager.setSession({ + userId: result.user.id, + email: result.user.email, +}); +``` + +**Step 5: Commit** + +```bash +git add src/main/ipc/handlers/auth-handlers.ts +git commit -m "feat(auth): trigger session changes on login/logout/restore" +``` + +--- + +## Task 16: Update Handler Registration + +**Files:** + +- Modify: `src/main/bootstrap/ipc-wiring.ts` + +**Step 1: Pass userSessionManager to auth handlers** + +Update the call to `registerAuthHandlers` to include `userSessionManager` in deps. + +**Step 2: Commit** + +```bash +git add src/main/bootstrap/ipc-wiring.ts +git commit -m "feat(ipc): pass userSessionManager to auth handlers" +``` + +--- + +## Task 17: Add Data Migration for Existing Users + +**Files:** + +- Create: `src/main/services/data-management/user-data-migrator.ts` +- Modify: `src/main/services/data-management/index.ts` + +**Step 1: Create migrator** + +```typescript +// src/main/services/data-management/user-data-migrator.ts +/** + * User Data Migrator + * + * Migrates existing global data files to user-scoped directories + * on first login by a user. + */ + +import { existsSync, copyFileSync, mkdirSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const USER_SCOPED_FILES = [ + 'notes.json', + 'captures.json', + 'briefings.json', + 'assistant-history.json', + 'assistant-watches.json', + 'alerts.json', + 'ideas.json', + 'milestones.json', + 'changelog.json', +]; + +const USER_SCOPED_DIRS = ['planner', 'fitness']; + +export interface UserDataMigrator { + /** Migrate global data to user directory if user dir is empty. */ + migrateIfNeeded(globalDir: string, userDir: string): void; +} + +export function createUserDataMigrator(): UserDataMigrator { + function copyDirRecursive(src: string, dest: string): void { + if (!existsSync(dest)) { + mkdirSync(dest, { recursive: true }); + } + for (const entry of readdirSync(src)) { + const srcPath = join(src, entry); + const destPath = join(dest, entry); + if (statSync(srcPath).isDirectory()) { + copyDirRecursive(srcPath, destPath); + } else { + copyFileSync(srcPath, destPath); + } + } + } + + return { + migrateIfNeeded(globalDir, userDir) { + // Check if user dir already has data + const hasExistingData = USER_SCOPED_FILES.some((f) => existsSync(join(userDir, f))); + if (hasExistingData) { + return; // Already migrated or has data + } + + // Migrate files + for (const file of USER_SCOPED_FILES) { + const src = join(globalDir, file); + const dest = join(userDir, file); + if (existsSync(src) && !existsSync(dest)) { + copyFileSync(src, dest); + } + } + + // Migrate directories + for (const dir of USER_SCOPED_DIRS) { + const src = join(globalDir, dir); + const dest = join(userDir, dir); + if (existsSync(src) && !existsSync(dest)) { + copyDirRecursive(src, dest); + } + } + }, + }; +} +``` + +**Step 2: Export from barrel** + +**Step 3: Call migrator in service-registry before reinitializing services** + +In the `onSessionChange` callback: + +```typescript +if (session) { + const userDataDir = userDataResolver.getUserDataDir(session.userId); + userDataMigrator.migrateIfNeeded(dataDir, userDataDir); + // ... reinitialize services +} +``` + +**Step 4: Commit** + +```bash +git add src/main/services/data-management/ +git commit -m "feat(storage): add user data migrator for first-login migration" +``` + +--- + +## Task 18: Update Renderer to Handle Session Change Events + +**Files:** + +- Modify: `src/renderer/features/auth/store.ts` + +**Step 1: Clear React Query cache on logout** + +The auth store's `clearAuth` should also signal that all user data queries need to be invalidated. This is already handled in `useLogout` hook which calls `queryClient.clear()`. + +**Step 2: Listen for session change events** + +Add a hook in the auth feature that listens for `event:user.sessionChanged` and invalidates all queries: + +```typescript +// In useAuthEvents.ts or similar +useIpcEvent('event:user.sessionChanged', () => { + queryClient.clear(); +}); +``` + +**Step 3: Commit** + +```bash +git add src/renderer/features/auth/ +git commit -m "feat(auth): clear query cache on session change" +``` + +--- + +## Task 19: Run Verification Suite + +**Step 1: Run lint** + +```bash +npm run lint +``` + +Expected: 0 errors + +**Step 2: Run typecheck** + +```bash +npm run typecheck +``` + +Expected: 0 errors + +**Step 3: Run tests** + +```bash +npm run test +``` + +Expected: All tests pass + +**Step 4: Run build** + +```bash +npm run build +``` + +Expected: Build succeeds + +**Step 5: Manual test** + +1. Start dev server: `npm run dev` +2. Log out (if logged in) +3. Log in with test account +4. Verify data is empty (or migrated) +5. Create a note +6. Log out +7. Log in with different account (if available) or check file system +8. Verify note is not visible +9. Check `userData/users//notes.json` exists + +**Step 6: Commit all remaining changes** + +```bash +git add . +git commit -m "feat(storage): complete user-scoped data storage implementation" +``` + +--- + +## Task 20: Update Documentation + +**Files:** + +- Modify: `ai-docs/ARCHITECTURE.md` +- Modify: `ai-docs/DATA-FLOW.md` +- Modify: `ai-docs/FEATURES-INDEX.md` + +**Step 1: Document user-scoped storage in ARCHITECTURE.md** + +Add section explaining the user-scoped storage pattern. + +**Step 2: Update DATA-FLOW.md** + +Add diagram showing data flow on login/logout. + +**Step 3: Update FEATURES-INDEX.md** + +Add new services to the inventory. + +**Step 4: Commit** + +```bash +git add ai-docs/ +git commit -m "docs: document user-scoped data storage architecture" +``` + +--- + +## Summary + +This plan implements user-scoped data storage in 20 tasks: + +1. **Tasks 1-3:** Create infrastructure (resolver, session manager, reinitializable pattern) +2. **Tasks 4-13:** Update 10 services to be reinitializable +3. **Tasks 14-16:** Wire everything together in bootstrap +4. **Task 17:** Add migration for existing data +5. **Task 18:** Update renderer to handle session changes +6. **Task 19:** Verification +7. **Task 20:** Documentation + +**Parallel execution opportunities:** + +- Tasks 4-13 (service updates) can run in parallel - each is independent +- Tasks 1-3 must complete before 4-13 +- Tasks 14-16 must complete after 4-13 +- Task 17-18 can run in parallel after 16 diff --git a/docs/tracker.json b/docs/tracker.json index cb13e8a..c8ac2c0 100644 --- a/docs/tracker.json +++ b/docs/tracker.json @@ -1,7 +1,17 @@ { "version": 2, - "lastUpdated": "2026-02-21", + "lastUpdated": "2026-02-22", "plans": { + "user-scoped-storage": { + "title": "User-Scoped Data Storage", + "status": "IN_PROGRESS", + "planFile": "docs/features/user-scoped-storage/plan.md", + "created": "2026-02-22", + "statusChangedAt": "2026-02-22", + "branch": "feature/user-scoped-storage", + "pr": null, + "tags": ["storage", "auth", "data-isolation", "multi-user"] + }, "future-roadmap": { "title": "Future Roadmap", "status": "TRACKING", diff --git a/src/main/bootstrap/service-registry.ts b/src/main/bootstrap/service-registry.ts index 33d4c59..2ba1f5f 100644 --- a/src/main/bootstrap/service-registry.ts +++ b/src/main/bootstrap/service-registry.ts @@ -31,12 +31,19 @@ import { createAssistantService } from '../services/assistant/assistant-service' import { createCrossDeviceQuery } from '../services/assistant/cross-device-query'; import { createWatchEvaluator } from '../services/assistant/watch-evaluator'; import { createWatchStore } from '../services/assistant/watch-store'; +import { createUserSessionManager } from '../services/auth'; import { createBriefingService } from '../services/briefing/briefing-service'; import { createSuggestionEngine } from '../services/briefing/suggestion-engine'; import { createCalendarService } from '../services/calendar/calendar-service'; import { createChangelogService } from '../services/changelog/changelog-service'; import { createClaudeClient } from '../services/claude'; import { createDashboardService } from '../services/dashboard/dashboard-service'; +import { + createUserDataMigrator, + createUserDataResolver, + isReinitializable, + type ReinitializableService, +} from '../services/data-management'; import { createCleanupService } from '../services/data-management/cleanup-service'; import { createCrashRecovery } from '../services/data-management/crash-recovery'; import { createStorageInspector } from '../services/data-management/storage-inspector'; @@ -79,7 +86,11 @@ import { createQaTrigger } from '../services/qa/qa-trigger'; import { createScreenCaptureService } from '../services/screen/screen-capture-service'; import { createSettingsService } from '../services/settings/settings-service'; import { createSpotifyService } from '../services/spotify/spotify-service'; -import { createGithubImporter, createTaskDecomposer, createTaskRepository } from '../services/tasks'; +import { + createGithubImporter, + createTaskDecomposer, + createTaskRepository, +} from '../services/tasks'; import { createTerminalService } from '../services/terminal/terminal-service'; import { createTimeParserService } from '../services/time-parser/time-parser-service'; import { createTrackerService } from '../services/tracker/tracker-service'; @@ -90,6 +101,7 @@ import { createQuickInputWindow } from '../tray/quick-input'; import type { OAuthConfig } from '../auth/types'; import type { Services } from '../ipc'; +import type { UserSessionManager } from '../services/auth'; import type { HubApiClient } from '../services/hub/hub-api-client'; import type { TaskRepository } from '../services/tasks/types'; @@ -119,6 +131,7 @@ export interface ServiceRegistryResult { taskRepository: TaskRepository; heartbeatIntervalId: ReturnType | null; registeredDeviceId: string | null; + userSessionManager: UserSessionManager; } /** @@ -131,11 +144,22 @@ export function createServiceRegistry( const router = new IpcRouter(getMainWindow); const dataDir = app.getPath('userData'); + // ─── User session management ───────────────────────────────── + const userDataResolver = createUserDataResolver(dataDir); + const userDataMigrator = createUserDataMigrator(); + const userSessionManager = createUserSessionManager(router); + // ─── Error collector + health registry (created early) ────── const errorCollector = createErrorCollector(dataDir, { - onError: (entry) => { router.emit('event:app.error', entry); }, - onCapacityAlert: (count, message) => { router.emit('event:app.capacityAlert', { count, message }); }, - onDataRecovery: (store, message) => { router.emit('event:app.dataRecovery', { store, message }); }, + onError: (entry) => { + router.emit('event:app.error', entry); + }, + onCapacityAlert: (count, message) => { + router.emit('event:app.capacityAlert', { count, message }); + }, + onDataRecovery: (store, message) => { + router.emit('event:app.dataRecovery', { store, message }); + }, }); const healthRegistry = createHealthRegistry({ onUnhealthy: (serviceName, missedCount) => { @@ -151,7 +175,9 @@ export function createServiceRegistry( const msg = error instanceof Error ? error.message : String(error); appLogger.warn(`[Bootstrap] Non-critical service "${name}" failed to init: ${msg}`); errorCollector.report({ - severity: 'warning', tier: 'app', category: 'service', + severity: 'warning', + tier: 'app', + category: 'service', message: `Service initialization failed: ${name} - ${msg}`, }); return null; @@ -220,7 +246,7 @@ export function createServiceRegistry( const notesService = createNotesService({ dataDir, router }); const dashboardService = createDashboardService({ dataDir, router }); const dockerService = createDockerService(); - const plannerService = createPlannerService(router); + const plannerService = createPlannerService({ dataDir, router }); const alertService = createAlertService(router); alertService.startChecking(); @@ -251,12 +277,8 @@ export function createServiceRegistry( const milestonesService = initNonCritical('milestones', () => createMilestonesService({ dataDir, router }), ); - const ideasService = initNonCritical('ideas', () => - createIdeasService({ dataDir, router }), - ); - const changelogService = initNonCritical('changelog', () => - createChangelogService({ dataDir }), - ); + const ideasService = initNonCritical('ideas', () => createIdeasService({ dataDir, router })); + const changelogService = initNonCritical('changelog', () => createChangelogService({ dataDir })); const fitnessService = initNonCritical('fitness', () => createFitnessService({ dataDir, router }), ); @@ -322,9 +344,7 @@ export function createServiceRegistry( // ─── External API services ─────────────────────────────────── const githubCliClient = createGitHubCliClient(); const githubService = createGitHubService({ client: githubCliClient, router }); - const spotifyService = initNonCritical('spotify', () => - createSpotifyService({ oauthManager }), - ); + const spotifyService = initNonCritical('spotify', () => createSpotifyService({ oauthManager })); const calendarService = initNonCritical('calendar', () => createCalendarService({ oauthManager }), ); @@ -362,9 +382,7 @@ export function createServiceRegistry( // ─── Misc services ─────────────────────────────────────────── const voiceService = initNonCritical('voice', () => createVoiceService()); - const screenCaptureService = initNonCritical('screenCapture', () => - createScreenCaptureService(), - ); + const screenCaptureService = initNonCritical('screenCapture', () => createScreenCaptureService()); const appUpdateService = createAppUpdateService(router); // ─── Hotkey + quick input ──────────────────────────────────── @@ -417,7 +435,12 @@ export function createServiceRegistry( agentWatchdog.start(); // QA auto-trigger — starts QA when tasks enter review - const qaTrigger = createQaTrigger({ qaRunner, orchestrator: agentOrchestrator, taskRepository, router }); + const qaTrigger = createQaTrigger({ + qaRunner, + orchestrator: agentOrchestrator, + taskRepository, + router, + }); // Health registry enrollment — register background services for monitoring healthRegistry.register('hubHeartbeat', 60_000); @@ -540,11 +563,47 @@ export function createServiceRegistry( codebaseAnalyzer, setupPipeline, trackerService, + userSessionManager, dataDir, providers, tokenStore, }; + // ─── User session change handling ──────────────────────────── + // Collect all user-scoped services that can be reinitialized + // Filter to only include services that implement ReinitializableService + const candidateServices: unknown[] = [ + notesService, + dashboardService, + briefingService, + // alertService uses alert-store internally but doesn't expose reinitialize yet + alertService, + ideasService, + plannerService, + fitnessService, + milestonesService, + changelogService, + ]; + const userScopedServices: ReinitializableService[] = candidateServices.filter(isReinitializable); + + // Subscribe to session changes to reinitialize services with user-scoped paths + userSessionManager.onSessionChange((session) => { + if (session) { + // User logged in - migrate existing data then reinitialize with user-scoped paths + const userDataDir = userDataResolver.getUserDataDir(session.userId); + userDataMigrator.migrateIfNeeded(dataDir, userDataDir); + for (const service of userScopedServices) { + service.reinitialize(userDataDir); + } + } else { + // User logged out - clear state and reset to global dir + for (const service of userScopedServices) { + service.clearState(); + service.reinitialize(dataDir); // Reset to global (pre-login state) + } + } + }); + return { router, services, @@ -570,5 +629,6 @@ export function createServiceRegistry( crashRecovery, heartbeatIntervalId, registeredDeviceId, + userSessionManager, }; } diff --git a/src/main/ipc/handlers/auth-handlers.ts b/src/main/ipc/handlers/auth-handlers.ts index ef19b0d..7185a1d 100644 --- a/src/main/ipc/handlers/auth-handlers.ts +++ b/src/main/ipc/handlers/auth-handlers.ts @@ -6,12 +6,14 @@ * to match the IPC contract schemas (UserSchema, AuthTokensSchema). */ +import type { UserSessionManager } from '@main/services/auth'; import type { HubAuthService } from '@main/services/hub/hub-auth-service'; import type { IpcRouter } from '../router'; export interface AuthHandlerDependencies { hubAuthService: HubAuthService; + userSessionManager: UserSessionManager; } /** @@ -24,11 +26,8 @@ function expiresAtToExpiresIn(expiresAt: string): number { return Math.max(0, Math.round((expiresMs - nowMs) / 1000)); } -export function registerAuthHandlers( - router: IpcRouter, - deps: AuthHandlerDependencies, -): void { - const { hubAuthService } = deps; +export function registerAuthHandlers(router: IpcRouter, deps: AuthHandlerDependencies): void { + const { hubAuthService, userSessionManager } = deps; router.handle('auth.login', async ({ email, password }) => { const result = await hubAuthService.login({ email, password }); @@ -38,6 +37,13 @@ export function registerAuthHandlers( } const { user, accessToken, refreshToken, expiresAt } = result.data; + + // Set user session for user-scoped storage + userSessionManager.setSession({ + userId: user.id, + email: user.email, + }); + return { user: { id: user.id, @@ -100,6 +106,10 @@ export function registerAuthHandlers( router.handle('auth.logout', async () => { await hubAuthService.logout(); + + // Clear user session for user-scoped storage + userSessionManager.clearSession(); + return { success: true }; }); @@ -132,6 +142,12 @@ export function registerAuthHandlers( return { restored: false as const }; } + // Set user session for user-scoped storage on successful restore + userSessionManager.setSession({ + userId: result.user.id, + email: result.user.email, + }); + return { restored: true as const, user: { diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 0b0abd7..713466e 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -61,6 +61,7 @@ import type { AgentOrchestrator } from '../services/agent-orchestrator/types'; import type { AlertService } from '../services/alerts/alert-service'; import type { AppUpdateService } from '../services/app/app-update-service'; import type { AssistantService } from '../services/assistant/assistant-service'; +import type { UserSessionManager } from '../services/auth'; import type { BriefingService } from '../services/briefing/briefing-service'; import type { CalendarService } from '../services/calendar/calendar-service'; import type { ChangelogService } from '../services/changelog/changelog-service'; @@ -155,13 +156,19 @@ export interface Services { codebaseAnalyzer: CodebaseAnalyzerService; setupPipeline: SetupPipelineService; trackerService: TrackerService; + userSessionManager: UserSessionManager; dataDir: string; providers: Map; tokenStore: TokenStore; } export function registerAllHandlers(router: IpcRouter, services: Services): void { - registerProjectHandlers(router, services.projectService, services.codebaseAnalyzer, services.setupPipeline); + registerProjectHandlers( + router, + services.projectService, + services.codebaseAnalyzer, + services.setupPipeline, + ); registerTaskHandlers( router, services.taskRepository, @@ -174,7 +181,10 @@ export function registerAllHandlers(router: IpcRouter, services: Services): void providers: services.providers, }); registerAlertHandlers(router, services.alertService); - registerAuthHandlers(router, { hubAuthService: services.hubAuthService }); + registerAuthHandlers(router, { + hubAuthService: services.hubAuthService, + userSessionManager: services.userSessionManager, + }); registerAppHandlers(router, { tokenStore: services.tokenStore, providers: services.providers, @@ -231,7 +241,12 @@ export function registerAllHandlers(router: IpcRouter, services: Services): void registerWorkspaceHandlers(router, services.hubApiClient); registerDeviceHandlers(router, services.deviceService); registerAgentOrchestratorHandlers(router, services.agentOrchestrator, services.taskRepository); - registerQaHandlers(router, services.qaRunner, services.agentOrchestrator, services.taskRepository); + registerQaHandlers( + router, + services.qaRunner, + services.agentOrchestrator, + services.taskRepository, + ); registerDashboardHandlers(router, services.dashboardService); registerDockerHandlers(router, services.dockerService); registerSecurityHandlers(router, services.settingsService); diff --git a/src/main/services/alerts/alert-store.ts b/src/main/services/alerts/alert-store.ts index 02b4a73..c269982 100644 --- a/src/main/services/alerts/alert-store.ts +++ b/src/main/services/alerts/alert-store.ts @@ -3,38 +3,81 @@ */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { app } from 'electron'; import type { Alert } from '@shared/types'; +import type { ReinitializableService } from '@main/services/data-management'; + +const ALERTS_FILE = 'alerts.json'; + interface AlertStoreData { alerts: Alert[]; } -function getFilePath(): string { - return join(app.getPath('userData'), 'alerts.json'); +export interface AlertStore extends ReinitializableService { + loadAlerts: () => Alert[]; + saveAlerts: (alerts: Alert[]) => void; } -export function loadAlerts(): Alert[] { - const filePath = getFilePath(); - if (existsSync(filePath)) { - try { - const raw = readFileSync(filePath, 'utf-8'); - const data = JSON.parse(raw) as unknown as AlertStoreData; - return Array.isArray(data.alerts) ? data.alerts : []; - } catch { - return []; +export function createAlertStore(deps: { dataDir: string }): AlertStore { + let currentFilePath = join(deps.dataDir, ALERTS_FILE); + let alertsCache: Alert[] | null = null; + + function loadFromDisk(): Alert[] { + if (existsSync(currentFilePath)) { + try { + const raw = readFileSync(currentFilePath, 'utf-8'); + const data = JSON.parse(raw) as unknown as AlertStoreData; + return Array.isArray(data.alerts) ? data.alerts : []; + } catch { + return []; + } } + return []; } - return []; + + return { + loadAlerts(): Alert[] { + alertsCache ??= loadFromDisk(); + return alertsCache; + }, + + saveAlerts(alerts: Alert[]): void { + const dir = dirname(currentFilePath); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const data: AlertStoreData = { alerts }; + writeFileSync(currentFilePath, JSON.stringify(data, null, 2), 'utf-8'); + alertsCache = alerts; + }, + + reinitialize(dataDir: string): void { + currentFilePath = join(dataDir, ALERTS_FILE); + alertsCache = null; // Force reload from new path + }, + + clearState(): void { + alertsCache = null; + }, + }; +} + +// Legacy exports for backward compatibility +// TODO: Migrate callers to use createAlertStore factory +const legacyDataDir = app.getPath('userData'); +let legacyStore: AlertStore | null = null; + +function getLegacyStore(): AlertStore { + legacyStore ??= createAlertStore({ dataDir: legacyDataDir }); + return legacyStore; +} + +export function loadAlerts(): Alert[] { + return getLegacyStore().loadAlerts(); } export function saveAlerts(alerts: Alert[]): void { - const filePath = getFilePath(); - const dir = join(filePath, '..'); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const data: AlertStoreData = { alerts }; - writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); + getLegacyStore().saveAlerts(alerts); } diff --git a/src/main/services/assistant/history-store.ts b/src/main/services/assistant/history-store.ts index 90983a6..d528fbd 100644 --- a/src/main/services/assistant/history-store.ts +++ b/src/main/services/assistant/history-store.ts @@ -14,10 +14,12 @@ import { app } from 'electron'; import type { CommandHistoryEntry } from '@shared/types'; import { serviceLogger } from '@main/lib/logger'; +import type { ReinitializableService } from '@main/services/data-management'; const MAX_HISTORY_ENTRIES = 1000; +const HISTORY_FILE = 'assistant-history.json'; -export interface HistoryStore { +export interface HistoryStore extends ReinitializableService { /** Get the most recent entries, newest first. */ getEntries: (limit?: number) => CommandHistoryEntry[]; /** Add a new entry to the history. */ @@ -26,12 +28,7 @@ export interface HistoryStore { clear: () => void; } -function getHistoryFilePath(): string { - return join(app.getPath('userData'), 'assistant-history.json'); -} - -function loadHistory(): CommandHistoryEntry[] { - const filePath = getHistoryFilePath(); +function loadHistoryFromPath(filePath: string): CommandHistoryEntry[] { if (!existsSync(filePath)) { return []; } @@ -49,8 +46,7 @@ function loadHistory(): CommandHistoryEntry[] { } } -function saveHistory(entries: CommandHistoryEntry[]): void { - const filePath = getHistoryFilePath(); +function saveHistoryToPath(filePath: string, entries: CommandHistoryEntry[]): void { const dir = join(filePath, '..'); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); @@ -59,7 +55,8 @@ function saveHistory(entries: CommandHistoryEntry[]): void { } export function createHistoryStore(): HistoryStore { - let entries: CommandHistoryEntry[] = loadHistory(); + let currentFilePath = join(app.getPath('userData'), HISTORY_FILE); + let entries: CommandHistoryEntry[] = loadHistoryFromPath(currentFilePath); return { getEntries(limit) { @@ -81,12 +78,21 @@ export function createHistoryStore(): HistoryStore { entries = entries.slice(0, MAX_HISTORY_ENTRIES); } - saveHistory(entries); + saveHistoryToPath(currentFilePath, entries); }, clear() { entries = []; - saveHistory(entries); + saveHistoryToPath(currentFilePath, entries); + }, + + reinitialize(dataDir: string) { + currentFilePath = join(dataDir, HISTORY_FILE); + entries = loadHistoryFromPath(currentFilePath); + }, + + clearState() { + entries = []; }, }; } diff --git a/src/main/services/auth/index.ts b/src/main/services/auth/index.ts new file mode 100644 index 0000000..392af0d --- /dev/null +++ b/src/main/services/auth/index.ts @@ -0,0 +1,5 @@ +export { + createUserSessionManager, + type UserSessionManager, + type UserSession, +} from './user-session-manager'; diff --git a/src/main/services/auth/user-session-manager.ts b/src/main/services/auth/user-session-manager.ts new file mode 100644 index 0000000..3c23aec --- /dev/null +++ b/src/main/services/auth/user-session-manager.ts @@ -0,0 +1,58 @@ +/** + * User Session Manager + * + * Tracks the currently logged-in user and emits events on login/logout. + * Services subscribe to these events to reinitialize with user-scoped paths. + */ + +import type { IpcRouter } from '@main/ipc/router'; + +export interface UserSession { + userId: string; + email: string; +} + +export interface UserSessionManager { + /** Get current session, or null if not logged in. */ + getCurrentSession: () => UserSession | null; + /** Called when user logs in successfully. */ + setSession: (session: UserSession) => void; + /** Called when user logs out. */ + clearSession: () => void; + /** Subscribe to session changes. Returns unsubscribe function. */ + onSessionChange: (callback: (session: UserSession | null) => void) => () => void; +} + +export function createUserSessionManager(router: IpcRouter): UserSessionManager { + let currentSession: UserSession | null = null; + const listeners = new Set<(session: UserSession | null) => void>(); + + function notifyListeners(): void { + for (const listener of listeners) { + listener(currentSession); + } + } + + return { + getCurrentSession() { + return currentSession; + }, + + setSession(session) { + currentSession = session; + router.emit('event:user.sessionChanged', { userId: session.userId, email: session.email }); + notifyListeners(); + }, + + clearSession() { + currentSession = null; + router.emit('event:user.sessionChanged', { userId: null, email: null }); + notifyListeners(); + }, + + onSessionChange(callback) { + listeners.add(callback); + return () => listeners.delete(callback); + }, + }; +} diff --git a/src/main/services/briefing/briefing-service.ts b/src/main/services/briefing/briefing-service.ts index 3b743e0..048b18f 100644 --- a/src/main/services/briefing/briefing-service.ts +++ b/src/main/services/briefing/briefing-service.ts @@ -14,6 +14,8 @@ import { app } from 'electron'; import type { BriefingConfig, DailyBriefing, Suggestion } from '@shared/types'; +import type { ReinitializableService } from '@main/services/data-management'; + import { createBriefingCache } from './briefing-cache'; import { createBriefingConfigManager } from './briefing-config'; import { createBriefingGenerator } from './briefing-generator'; @@ -31,7 +33,7 @@ const CONFIG_FILE = 'briefing-config.json'; const BRIEFING_READY_EVENT = 'event:briefing.ready' as const; /** Briefing service interface */ -export interface BriefingService { +export interface BriefingService extends ReinitializableService { /** Get the current daily briefing (cached for the day) */ getDailyBriefing: () => DailyBriefing | null; /** Generate a new daily briefing */ @@ -65,15 +67,15 @@ export interface BriefingServiceDeps { export function createBriefingService(deps: BriefingServiceDeps): BriefingService { const { router, suggestionEngine } = deps; - const dataDir = app.getPath('userData'); + let currentDataDir = app.getPath('userData'); // Ensure data directory exists - if (!existsSync(dataDir)) { - mkdirSync(dataDir, { recursive: true }); + if (!existsSync(currentDataDir)) { + mkdirSync(currentDataDir, { recursive: true }); } - const configManager = createBriefingConfigManager(join(dataDir, CONFIG_FILE)); - const cache = createBriefingCache(join(dataDir, BRIEFING_FILE)); + let configManager = createBriefingConfigManager(join(currentDataDir, CONFIG_FILE)); + let cache = createBriefingCache(join(currentDataDir, BRIEFING_FILE)); const generator = createBriefingGenerator({ projectService: deps.projectService, taskService: deps.taskService, @@ -85,6 +87,8 @@ export function createBriefingService(deps: BriefingServiceDeps): BriefingServic let schedulerInterval: ReturnType | null = null; let lastScheduledDate = ''; + // Reserved for future caching - currently cleared on reinit/clearState + let _cachedBriefing: DailyBriefing | null = null; function getTodayDate(): string { return new Date().toISOString().split('T')[0] ?? ''; @@ -145,5 +149,22 @@ export function createBriefingService(deps: BriefingServiceDeps): BriefingServic schedulerInterval = null; } }, + + reinitialize(dataDir: string) { + // Ensure new data directory exists + if (!existsSync(dataDir)) { + mkdirSync(dataDir, { recursive: true }); + } + currentDataDir = dataDir; + configManager = createBriefingConfigManager(join(dataDir, CONFIG_FILE)); + cache = createBriefingCache(join(dataDir, BRIEFING_FILE)); + _cachedBriefing = null; + lastScheduledDate = ''; + }, + + clearState() { + _cachedBriefing = null; + lastScheduledDate = ''; + }, }; } diff --git a/src/main/services/changelog/changelog-service.ts b/src/main/services/changelog/changelog-service.ts index c62887e..213efc2 100644 --- a/src/main/services/changelog/changelog-service.ts +++ b/src/main/services/changelog/changelog-service.ts @@ -10,9 +10,11 @@ import { join } from 'node:path'; import type { ChangeCategory, ChangelogEntry } from '@shared/types'; +import type { ReinitializableService } from '@main/services/data-management'; + import { generateChangelogEntry } from './changelog-generator'; -export interface ChangelogService { +export interface ChangelogService extends ReinitializableService { listEntries: () => ChangelogEntry[]; addEntry: (data: { version: string; @@ -113,11 +115,13 @@ function saveFile(filePath: string, data: ChangelogFile): void { } export function createChangelogService(deps: { dataDir: string }): ChangelogService { - const filePath = join(deps.dataDir, 'changelog.json'); - const store = loadFile(filePath); + // Mutable file path for user-scoping + let currentFilePath = join(deps.dataDir, 'changelog.json'); + // In-memory cache + let store = loadFile(currentFilePath); function persist(): void { - saveFile(filePath, store); + saveFile(currentFilePath, store); } return { @@ -140,5 +144,15 @@ export function createChangelogService(deps: { dataDir: string }): ChangelogServ async generateFromGit(repoPath, version, fromTag) { return await generateChangelogEntry(repoPath, version, fromTag); }, + + reinitialize(dataDir: string) { + currentFilePath = join(dataDir, 'changelog.json'); + // Reload data from new path + store = loadFile(currentFilePath); + }, + + clearState() { + store = { entries: [...DEFAULT_ENTRIES] }; + }, }; } diff --git a/src/main/services/dashboard/dashboard-service.ts b/src/main/services/dashboard/dashboard-service.ts index 6aef4be..be7abed 100644 --- a/src/main/services/dashboard/dashboard-service.ts +++ b/src/main/services/dashboard/dashboard-service.ts @@ -9,6 +9,8 @@ import { randomUUID } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import type { ReinitializableService } from '@main/services/data-management'; + import type { IpcRouter } from '../../ipc/router'; export interface Capture { @@ -17,7 +19,7 @@ export interface Capture { createdAt: string; } -export interface DashboardService { +export interface DashboardService extends ReinitializableService { listCaptures: () => Capture[]; createCapture: (text: string) => Capture; deleteCapture: (id: string) => { success: boolean }; @@ -52,11 +54,11 @@ export function createDashboardService(deps: { dataDir: string; router: IpcRouter; }): DashboardService { - const filePath = join(deps.dataDir, 'captures.json'); - const store = loadCapturesFile(filePath); + let currentFilePath = join(deps.dataDir, 'captures.json'); + let store = loadCapturesFile(currentFilePath); function persist(): void { - saveCapturesFile(filePath, store); + saveCapturesFile(currentFilePath, store); } function emitChanged(captureId: string): void { @@ -91,5 +93,14 @@ export function createDashboardService(deps: { emitChanged(id); return { success: true }; }, + + reinitialize(dataDir: string) { + currentFilePath = join(dataDir, 'captures.json'); + store = loadCapturesFile(currentFilePath); + }, + + clearState() { + store = { captures: [] }; + }, }; } diff --git a/src/main/services/data-management/index.ts b/src/main/services/data-management/index.ts index 34eec72..4c725c6 100644 --- a/src/main/services/data-management/index.ts +++ b/src/main/services/data-management/index.ts @@ -7,3 +7,6 @@ export type { StorageInspector } from './storage-inspector'; export { exportData, importData } from './data-export'; export { DATA_STORE_REGISTRY } from './store-registry'; export { STORE_CLEANUP_FUNCTIONS } from './store-cleaners'; +export { createUserDataResolver, type UserDataResolver } from './user-data-resolver'; +export { type ReinitializableService, isReinitializable } from './reinitializable-service'; +export { createUserDataMigrator, type UserDataMigrator } from './user-data-migrator'; diff --git a/src/main/services/data-management/reinitializable-service.ts b/src/main/services/data-management/reinitializable-service.ts new file mode 100644 index 0000000..5b25523 --- /dev/null +++ b/src/main/services/data-management/reinitializable-service.ts @@ -0,0 +1,27 @@ +/** + * Reinitializable Service Pattern + * + * Services that store user-scoped data implement this interface. + * They reinitialize when the user changes (login/logout). + */ + +export interface ReinitializableService { + /** Reinitialize the service with a new data directory. */ + reinitialize: (dataDir: string) => void; + /** Clear all in-memory state (called on logout). */ + clearState: () => void; +} + +/** + * Type guard to check if a service is reinitializable. + */ +export function isReinitializable(service: unknown): service is ReinitializableService { + return ( + typeof service === 'object' && + service !== null && + 'reinitialize' in service && + 'clearState' in service && + typeof (service as ReinitializableService).reinitialize === 'function' && + typeof (service as ReinitializableService).clearState === 'function' + ); +} diff --git a/src/main/services/data-management/user-data-migrator.ts b/src/main/services/data-management/user-data-migrator.ts new file mode 100644 index 0000000..83bfdc9 --- /dev/null +++ b/src/main/services/data-management/user-data-migrator.ts @@ -0,0 +1,74 @@ +// src/main/services/data-management/user-data-migrator.ts +/** + * User Data Migrator + * + * Migrates existing global data files to user-scoped directories + * on first login by a user. + */ + +import { existsSync, copyFileSync, mkdirSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const USER_SCOPED_FILES = [ + 'notes.json', + 'captures.json', + 'briefings.json', + 'assistant-history.json', + 'assistant-watches.json', + 'alerts.json', + 'ideas.json', + 'milestones.json', + 'changelog.json', +]; + +const USER_SCOPED_DIRS = ['planner', 'fitness']; + +export interface UserDataMigrator { + /** Migrate global data to user directory if user dir is empty. */ + migrateIfNeeded: (globalDir: string, userDir: string) => void; +} + +export function createUserDataMigrator(): UserDataMigrator { + function copyDirRecursive(src: string, dest: string): void { + if (!existsSync(dest)) { + mkdirSync(dest, { recursive: true }); + } + for (const entry of readdirSync(src)) { + const srcPath = join(src, entry); + const destPath = join(dest, entry); + if (statSync(srcPath).isDirectory()) { + copyDirRecursive(srcPath, destPath); + } else { + copyFileSync(srcPath, destPath); + } + } + } + + return { + migrateIfNeeded(globalDir, userDir) { + // Check if user dir already has data + const hasExistingData = USER_SCOPED_FILES.some((f) => existsSync(join(userDir, f))); + if (hasExistingData) { + return; // Already migrated or has data + } + + // Migrate files + for (const file of USER_SCOPED_FILES) { + const src = join(globalDir, file); + const dest = join(userDir, file); + if (existsSync(src) && !existsSync(dest)) { + copyFileSync(src, dest); + } + } + + // Migrate directories + for (const dir of USER_SCOPED_DIRS) { + const src = join(globalDir, dir); + const dest = join(userDir, dir); + if (existsSync(src) && !existsSync(dest)) { + copyDirRecursive(src, dest); + } + } + }, + }; +} diff --git a/src/main/services/data-management/user-data-resolver.ts b/src/main/services/data-management/user-data-resolver.ts new file mode 100644 index 0000000..decc4c4 --- /dev/null +++ b/src/main/services/data-management/user-data-resolver.ts @@ -0,0 +1,48 @@ +/** + * User Data Path Resolver + * + * Provides user-scoped data directory paths. User data is stored in + * `/users//` to isolate data between Hub accounts. + */ + +import { existsSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +export interface UserDataResolver { + /** Get the data directory for a specific user. Creates if needed. */ + getUserDataDir: (userId: string) => string; + /** Get the global data directory (for non-user-scoped data). */ + getGlobalDataDir: () => string; + /** Check if a user data directory exists. */ + userDataExists: (userId: string) => boolean; +} + +export function createUserDataResolver(baseDataDir: string): UserDataResolver { + const usersDir = join(baseDataDir, 'users'); + + function getUserDataDir(userId: string): string { + if (!userId) { + throw new Error('userId is required for user-scoped data'); + } + const userDir = join(usersDir, userId); + if (!existsSync(userDir)) { + mkdirSync(userDir, { recursive: true }); + } + return userDir; + } + + function getGlobalDataDir(): string { + return baseDataDir; + } + + function userDataExists(userId: string): boolean { + if (!userId) return false; + return existsSync(join(usersDir, userId)); + } + + return { + getUserDataDir, + getGlobalDataDir, + userDataExists, + }; +} diff --git a/src/main/services/fitness/fitness-service.ts b/src/main/services/fitness/fitness-service.ts index db59da7..f418c9a 100644 --- a/src/main/services/fitness/fitness-service.ts +++ b/src/main/services/fitness/fitness-service.ts @@ -20,13 +20,15 @@ import type { WorkoutType, } from '@shared/types'; +import type { ReinitializableService } from '@main/services/data-management'; + import { calculateStats } from './stats-calculator'; import type { IpcRouter } from '../../ipc/router'; // ── Interface ──────────────────────────────────────────────── -export interface FitnessService { +export interface FitnessService extends ReinitializableService { logWorkout: (data: { date: string; type: WorkoutType; @@ -91,25 +93,35 @@ function saveJson(filePath: string, data: StoreData): void { // ── Factory ────────────────────────────────────────────────── export function createFitnessService(deps: { dataDir: string; router: IpcRouter }): FitnessService { - const fitnessDir = join(deps.dataDir, 'fitness'); - const workoutsPath = join(fitnessDir, 'workouts.json'); - const measurementsPath = join(fitnessDir, 'measurements.json'); - const goalsPath = join(fitnessDir, 'goals.json'); + // Mutable directory path for user-scoping + let fitnessDir = join(deps.dataDir, 'fitness'); + + // Helper to get current file paths + function getWorkoutsPath(): string { + return join(fitnessDir, 'workouts.json'); + } + function getMeasurementsPath(): string { + return join(fitnessDir, 'measurements.json'); + } + function getGoalsPath(): string { + return join(fitnessDir, 'goals.json'); + } - const workouts = loadJson(workoutsPath); - const measurements = loadJson(measurementsPath); - const goals = loadJson(goalsPath); + // In-memory caches + let workouts = loadJson(getWorkoutsPath()); + let measurements = loadJson(getMeasurementsPath()); + let goals = loadJson(getGoalsPath()); function persistWorkouts(): void { - saveJson(workoutsPath, workouts); + saveJson(getWorkoutsPath(), workouts); } function persistMeasurements(): void { - saveJson(measurementsPath, measurements); + saveJson(getMeasurementsPath(), measurements); } function persistGoals(): void { - saveJson(goalsPath, goals); + saveJson(getGoalsPath(), goals); } return { @@ -235,5 +247,19 @@ export function createFitnessService(deps: { dataDir: string; router: IpcRouter deps.router.emit('event:fitness.goalChanged', { goalId: id }); return { success: true }; }, + + reinitialize(dataDir: string) { + fitnessDir = join(dataDir, 'fitness'); + // Reload data from new directory + workouts = loadJson(getWorkoutsPath()); + measurements = loadJson(getMeasurementsPath()); + goals = loadJson(getGoalsPath()); + }, + + clearState() { + workouts = { items: [] }; + measurements = { items: [] }; + goals = { items: [] }; + }, }; } diff --git a/src/main/services/ideas/ideas-service.ts b/src/main/services/ideas/ideas-service.ts index 132782d..a304cb7 100644 --- a/src/main/services/ideas/ideas-service.ts +++ b/src/main/services/ideas/ideas-service.ts @@ -7,13 +7,17 @@ import { randomUUID } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import type { Idea, IdeaCategory, IdeaStatus } from '@shared/types'; +import type { ReinitializableService } from '@main/services/data-management'; + import type { IpcRouter } from '../../ipc/router'; -export interface IdeasService { +const IDEAS_FILE = 'ideas.json'; + +export interface IdeasService extends ReinitializableService { listIdeas: (filters: { projectId?: string; status?: IdeaStatus; @@ -60,17 +64,17 @@ function loadFile(filePath: string): IdeasFile { } function saveFile(filePath: string, data: IdeasFile): void { - const dir = join(filePath, '..'); + const dir = dirname(filePath); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); } export function createIdeasService(deps: { dataDir: string; router: IpcRouter }): IdeasService { - const filePath = join(deps.dataDir, 'ideas.json'); - const store = loadFile(filePath); + let currentFilePath = join(deps.dataDir, IDEAS_FILE); + let store = loadFile(currentFilePath); function persist(): void { - saveFile(filePath, store); + saveFile(currentFilePath, store); } function emitChanged(ideaId: string): void { @@ -163,5 +167,14 @@ export function createIdeasService(deps: { dataDir: string; router: IpcRouter }) emitChanged(updated.id); return updated; }, + + reinitialize(dataDir: string): void { + currentFilePath = join(dataDir, IDEAS_FILE); + store = loadFile(currentFilePath); + }, + + clearState(): void { + store = { ideas: [] }; + }, }; } diff --git a/src/main/services/milestones/milestones-service.ts b/src/main/services/milestones/milestones-service.ts index 2c9192d..f07d42a 100644 --- a/src/main/services/milestones/milestones-service.ts +++ b/src/main/services/milestones/milestones-service.ts @@ -11,9 +11,11 @@ import { join } from 'node:path'; import type { Milestone, MilestoneStatus } from '@shared/types'; +import type { ReinitializableService } from '@main/services/data-management'; + import type { IpcRouter } from '../../ipc/router'; -export interface MilestonesService { +export interface MilestonesService extends ReinitializableService { listMilestones: (filters: { projectId?: string }) => Milestone[]; createMilestone: (data: { title: string; @@ -72,11 +74,13 @@ export function createMilestonesService(deps: { dataDir: string; router: IpcRouter; }): MilestonesService { - const filePath = join(deps.dataDir, 'milestones.json'); - const store = loadFile(filePath); + // Mutable file path for user-scoping + let currentFilePath = join(deps.dataDir, 'milestones.json'); + // In-memory cache + let store = loadFile(currentFilePath); function persist(): void { - saveFile(filePath, store); + saveFile(currentFilePath, store); } function emitChanged(milestoneId: string): void { @@ -164,5 +168,15 @@ export function createMilestonesService(deps: { emitChanged(milestoneId); return updated; }, + + reinitialize(dataDir: string) { + currentFilePath = join(dataDir, 'milestones.json'); + // Reload data from new path + store = loadFile(currentFilePath); + }, + + clearState() { + store = { milestones: [] }; + }, }; } diff --git a/src/main/services/notes/notes-service.ts b/src/main/services/notes/notes-service.ts index 20f8139..77d5d05 100644 --- a/src/main/services/notes/notes-service.ts +++ b/src/main/services/notes/notes-service.ts @@ -11,9 +11,11 @@ import { join } from 'node:path'; import type { Note } from '@shared/types'; +import type { ReinitializableService } from '@main/services/data-management'; + import type { IpcRouter } from '../../ipc/router'; -export interface NotesService { +export interface NotesService extends ReinitializableService { listNotes: (filters: { projectId?: string; tag?: string }) => Note[]; createNote: (data: { title: string; @@ -56,11 +58,11 @@ function saveNotesFile(filePath: string, data: NotesFile): void { } export function createNotesService(deps: { dataDir: string; router: IpcRouter }): NotesService { - const filePath = join(deps.dataDir, 'notes.json'); - const store = loadNotesFile(filePath); + let currentFilePath = join(deps.dataDir, 'notes.json'); + let store = loadNotesFile(currentFilePath); function persist(): void { - saveNotesFile(filePath, store); + saveNotesFile(currentFilePath, store); } function emitChanged(noteId: string): void { @@ -142,5 +144,14 @@ export function createNotesService(deps: { dataDir: string; router: IpcRouter }) (n) => n.title.toLowerCase().includes(lower) || n.content.toLowerCase().includes(lower), ); }, + + reinitialize(dataDir: string) { + currentFilePath = join(dataDir, 'notes.json'); + store = loadNotesFile(currentFilePath); + }, + + clearState() { + store = { notes: [] }; + }, }; } diff --git a/src/main/services/planner/planner-service.ts b/src/main/services/planner/planner-service.ts index 62f98ba..272814b 100644 --- a/src/main/services/planner/planner-service.ts +++ b/src/main/services/planner/planner-service.ts @@ -9,8 +9,6 @@ import { randomUUID } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { app } from 'electron'; - import type { DailyPlan, ScheduledTask, @@ -19,9 +17,13 @@ import type { WeeklyReviewSummary, } from '@shared/types'; +import type { ReinitializableService } from '@main/services/data-management'; + import type { IpcRouter } from '../../ipc/router'; -export interface PlannerService { +const PLANNER_DIR_NAME = 'planner'; + +export interface PlannerService extends ReinitializableService { getDay: (date: string) => DailyPlan; updateDay: ( date: string, @@ -43,21 +45,6 @@ export interface PlannerService { updateWeeklyReflection: (startDate: string, reflection: string) => WeeklyReview; } -function getPlannerDir(): string { - return join(app.getPath('userData'), 'planner'); -} - -function getPlanFilePath(date: string): string { - return join(getPlannerDir(), `${date}.json`); -} - -function ensurePlannerDir(): void { - const dir = getPlannerDir(); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } -} - function makeEmptyPlan(date: string): DailyPlan { return { date, @@ -67,25 +54,6 @@ function makeEmptyPlan(date: string): DailyPlan { }; } -function loadPlan(date: string): DailyPlan { - const filePath = getPlanFilePath(date); - if (existsSync(filePath)) { - try { - const raw = readFileSync(filePath, 'utf-8'); - return JSON.parse(raw) as DailyPlan; - } catch { - return makeEmptyPlan(date); - } - } - return makeEmptyPlan(date); -} - -function savePlan(plan: DailyPlan): void { - ensurePlannerDir(); - const filePath = getPlanFilePath(plan.date); - writeFileSync(filePath, JSON.stringify(plan, null, 2), 'utf-8'); -} - /** * Get Monday of the week containing the given date (ISO string YYYY-MM-DD) */ @@ -176,42 +144,80 @@ function generateSummary(days: DailyPlan[]): WeeklyReviewSummary { }; } -/** - * Get file path for weekly review reflection - */ -function getWeeklyReviewFilePath(mondayStr: string): string { - return join(getPlannerDir(), `week-${mondayStr}.json`); -} +export function createPlannerService(deps: { dataDir: string; router: IpcRouter }): PlannerService { + let plannerDir = join(deps.dataDir, PLANNER_DIR_NAME); -/** - * Load weekly review reflection (if exists) - */ -function loadWeeklyReflection(mondayStr: string): string | undefined { - const filePath = getWeeklyReviewFilePath(mondayStr); - if (existsSync(filePath)) { - try { - const raw = readFileSync(filePath, 'utf-8'); - const data = JSON.parse(raw) as { reflection?: string }; - return data.reflection; - } catch { - return undefined; + // In-memory cache for loaded plans (keyed by date) + let plansCache = new Map(); + + function getPlanFilePath(date: string): string { + return join(plannerDir, `${date}.json`); + } + + function ensurePlannerDir(): void { + if (!existsSync(plannerDir)) { + mkdirSync(plannerDir, { recursive: true }); } } - return undefined; -} -/** - * Save weekly review reflection - */ -function saveWeeklyReflection(mondayStr: string, reflection: string): void { - ensurePlannerDir(); - const filePath = getWeeklyReviewFilePath(mondayStr); - writeFileSync(filePath, JSON.stringify({ reflection }, null, 2), 'utf-8'); -} + function loadPlan(date: string): DailyPlan { + // Check cache first + const cached = plansCache.get(date); + if (cached) { + return cached; + } + + const filePath = getPlanFilePath(date); + if (existsSync(filePath)) { + try { + const raw = readFileSync(filePath, 'utf-8'); + const plan = JSON.parse(raw) as DailyPlan; + plansCache.set(date, plan); + return plan; + } catch { + const plan = makeEmptyPlan(date); + plansCache.set(date, plan); + return plan; + } + } + const plan = makeEmptyPlan(date); + plansCache.set(date, plan); + return plan; + } + + function savePlan(plan: DailyPlan): void { + ensurePlannerDir(); + const filePath = getPlanFilePath(plan.date); + writeFileSync(filePath, JSON.stringify(plan, null, 2), 'utf-8'); + plansCache.set(plan.date, plan); + } + + function getWeeklyReviewFilePath(mondayStr: string): string { + return join(plannerDir, `week-${mondayStr}.json`); + } + + function loadWeeklyReflection(mondayStr: string): string | undefined { + const filePath = getWeeklyReviewFilePath(mondayStr); + if (existsSync(filePath)) { + try { + const raw = readFileSync(filePath, 'utf-8'); + const data = JSON.parse(raw) as { reflection?: string }; + return data.reflection; + } catch { + return undefined; + } + } + return undefined; + } + + function saveWeeklyReflection(mondayStr: string, reflection: string): void { + ensurePlannerDir(); + const filePath = getWeeklyReviewFilePath(mondayStr); + writeFileSync(filePath, JSON.stringify({ reflection }, null, 2), 'utf-8'); + } -export function createPlannerService(router: IpcRouter): PlannerService { function emitDayChanged(date: string): void { - router.emit('event:planner.dayChanged', { date }); + deps.router.emit('event:planner.dayChanged', { date }); } return { @@ -300,8 +306,17 @@ export function createPlannerService(router: IpcRouter): PlannerService { updateWeeklyReflection(startDate, reflection) { const monday = getWeekMonday(startDate); saveWeeklyReflection(monday, reflection); - router.emit('event:planner.dayChanged', { date: monday }); + deps.router.emit('event:planner.dayChanged', { date: monday }); return this.getWeek(startDate); }, + + reinitialize(dataDir: string): void { + plannerDir = join(dataDir, PLANNER_DIR_NAME); + plansCache = new Map(); // Clear cache to force reload from new path + }, + + clearState(): void { + plansCache = new Map(); + }, }; } diff --git a/src/renderer/app/components/route-skeletons.tsx b/src/renderer/app/components/route-skeletons.tsx index 4d98501..ff8dded 100644 --- a/src/renderer/app/components/route-skeletons.tsx +++ b/src/renderer/app/components/route-skeletons.tsx @@ -17,13 +17,13 @@ export function DashboardSkeleton() { {/* Stat cards row */}
- - - + + +
{/* Main content area */} - + ); } @@ -90,8 +90,8 @@ export function GenericPageSkeleton() { {/* Content placeholder */} - - + + ); } diff --git a/src/renderer/features/auth/components/AuthGuard.tsx b/src/renderer/features/auth/components/AuthGuard.tsx index 440326c..5432706 100644 --- a/src/renderer/features/auth/components/AuthGuard.tsx +++ b/src/renderer/features/auth/components/AuthGuard.tsx @@ -17,6 +17,7 @@ import { ThemeHydrator } from '@renderer/shared/stores'; import { Spinner } from '@ui'; import { useAuthInit } from '../hooks/useAuthEvents'; +import { useSessionEvents } from '../hooks/useSessionEvents'; import { useTokenRefresh } from '../hooks/useTokenRefresh'; import { useAuthStore } from '../store'; @@ -27,6 +28,8 @@ export function AuthGuard() { // Restore session via auth.restore IPC on app startup useAuthInit(); + // Clear query cache on session changes (login/logout/switch user) + useSessionEvents(); // Proactively refresh tokens before expiry useTokenRefresh(); @@ -41,7 +44,7 @@ export function AuthGuard() { // Show loading spinner during initial auth check if (isInitializing) { return ( -
+
diff --git a/src/renderer/features/auth/hooks/useSessionEvents.ts b/src/renderer/features/auth/hooks/useSessionEvents.ts new file mode 100644 index 0000000..a3ef4df --- /dev/null +++ b/src/renderer/features/auth/hooks/useSessionEvents.ts @@ -0,0 +1,30 @@ +/** + * Session change IPC event listeners -> query cache invalidation + * + * Listens for user session changes (login/logout) from the main process + * and clears the React Query cache to ensure stale user data is not + * displayed after switching accounts. + * + * This is critical for user-scoped storage: when the main process + * reinitializes services with a new user's data directory, the renderer + * must also clear its cached queries to fetch fresh data. + */ + +import { useQueryClient } from '@tanstack/react-query'; + +import { useIpcEvent } from '@renderer/shared/hooks'; + +/** + * Clears all cached queries when the user session changes. + * + * Call this from AuthGuard to ensure it runs for all authenticated routes. + */ +export function useSessionEvents(): void { + const queryClient = useQueryClient(); + + useIpcEvent('event:user.sessionChanged', () => { + // Clear the entire query cache when session changes. + // This ensures no stale data from the previous user is displayed. + queryClient.clear(); + }); +} diff --git a/src/renderer/features/auth/index.ts b/src/renderer/features/auth/index.ts index 70c74c8..3c907f9 100644 --- a/src/renderer/features/auth/index.ts +++ b/src/renderer/features/auth/index.ts @@ -13,6 +13,7 @@ export { export { authKeys } from './api/queryKeys'; export { useAuthStore } from './store'; export { useAuthInit } from './hooks/useAuthEvents'; +export { useSessionEvents } from './hooks/useSessionEvents'; export { useTokenRefresh } from './hooks/useTokenRefresh'; export { AuthGuard } from './components/AuthGuard'; export { LoginPage } from './components/LoginPage'; diff --git a/src/shared/ipc/auth/contract.ts b/src/shared/ipc/auth/contract.ts index 7317199..e2b8451 100644 --- a/src/shared/ipc/auth/contract.ts +++ b/src/shared/ipc/auth/contract.ts @@ -45,3 +45,13 @@ export const authInvoke = { output: RestoreOutputSchema, }, } as const; + +/** Event channels for auth operations */ +export const authEvents = { + 'event:user.sessionChanged': { + payload: z.object({ + userId: z.string().nullable(), + email: z.string().nullable(), + }), + }, +} as const; diff --git a/src/shared/ipc/auth/index.ts b/src/shared/ipc/auth/index.ts index 6e9a37d..5bd1611 100644 --- a/src/shared/ipc/auth/index.ts +++ b/src/shared/ipc/auth/index.ts @@ -16,4 +16,4 @@ export { UserSchema, } from './schemas'; -export { authInvoke } from './contract'; +export { authEvents, authInvoke } from './contract'; diff --git a/src/shared/ipc/index.ts b/src/shared/ipc/index.ts index c0b495f..27a1d90 100644 --- a/src/shared/ipc/index.ts +++ b/src/shared/ipc/index.ts @@ -11,7 +11,7 @@ import { orchestratorEvents, orchestratorInvoke } from './agents'; import { appEvents, appInvoke } from './app'; import { assistantEvents, assistantInvoke } from './assistant'; -import { authInvoke } from './auth'; +import { authEvents, authInvoke } from './auth'; import { briefingEvents, briefingInvoke } from './briefing'; import { claudeEvents, claudeInvoke } from './claude'; import { dashboardEvents, dashboardInvoke } from './dashboard'; @@ -138,6 +138,7 @@ export const ipcEventContract = { ...qaEvents, ...dashboardEvents, ...dataManagementEvents, + ...authEvents, } as const; // ─── Type Utilities ────────────────────────────────────────── @@ -148,11 +149,7 @@ export type { EventChannel, EventPayload, InvokeChannel, InvokeInput, InvokeOutp // Explicit named re-exports to avoid ambiguity from mega-domains // that aggregate schemas from multiple sub-domains. -export { - AgentPhaseSchema, - AgentSessionStatusSchema, - OrchestratorSessionSchema, -} from './agents'; +export { AgentPhaseSchema, AgentSessionStatusSchema, OrchestratorSessionSchema } from './agents'; export { AssistantActionSchema, diff --git a/tests/e2e/helpers/auth.ts b/tests/e2e/helpers/auth.ts index d0e1001..af7720a 100644 --- a/tests/e2e/helpers/auth.ts +++ b/tests/e2e/helpers/auth.ts @@ -7,11 +7,28 @@ import type { Page } from 'playwright'; +/** + * Wait for Hub connection to be established by polling. + * + * The app auto-connects to Hub on startup, but this is async. + * We poll until the login form is ready and no "Hub URL not configured" error appears. + * Uses a simple delay-based approach since the login page doesn't have a Hub status indicator. + */ +async function waitForHubConnection(page: Page): Promise { + // Wait for the login form to be ready + await page.getByPlaceholder('you@example.com').waitFor({ state: 'visible', timeout: 10_000 }); + + // Give Hub time to auto-connect (happens async on app startup) + // The Hub config exists but connection takes a moment to establish + await page.waitForTimeout(3000); +} + /** * Log in using the test account credentials from environment variables. * * Fills the login form (email + password), clicks "Sign In", * and waits for redirect to the dashboard route. + * Includes retry logic for cases where Hub connection isn't ready. * * @throws If TEST_EMAIL or TEST_PASSWORD environment variables are missing. */ @@ -26,18 +43,48 @@ export async function loginWithTestAccount(page: Page): Promise { ); } - // Fill the login form - await page.getByPlaceholder('you@example.com').fill(email); - await page.getByPlaceholder('Enter your password').fill(password); + // Wait for Hub to be connected before attempting login + await waitForHubConnection(page); - // Click the Sign In button - await page.getByRole('button', { name: 'Sign In' }).click(); + // Retry login up to 3 times (Hub connection may still be establishing) + const maxAttempts = 3; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + // Fill the login form + await page.getByPlaceholder('you@example.com').fill(email); + await page.getByPlaceholder('Enter your password').fill(password); - // Wait for redirect to the dashboard - await page.waitForURL(/\/dashboard/, { timeout: 15_000 }); + // Click the Sign In button + await page.getByRole('button', { name: 'Sign In' }).click(); - // Verify the sidebar is visible — confirms auth succeeded and layout loaded - await page.locator('aside').first().waitFor({ state: 'visible', timeout: 10_000 }); + // Check if we got a Hub connection error + const hubError = page.locator('text=Hub URL not configured'); + const dashboardUrl = page.waitForURL(/\/dashboard/, { timeout: 10_000 }); + + // Race between error appearing and successful navigation + const result = await Promise.race([ + hubError.waitFor({ state: 'visible', timeout: 5_000 }).then(() => 'hub_error' as const), + dashboardUrl.then(() => 'success' as const), + ]).catch(() => 'timeout' as const); + + if (result === 'success') { + // Login succeeded, verify sidebar is visible + await page.locator('aside').first().waitFor({ state: 'visible', timeout: 10_000 }); + return; + } + + if (result === 'hub_error' && attempt < maxAttempts) { + // Hub not ready yet, wait and retry + await page.waitForTimeout(2000); + continue; + } + + // Final attempt or timeout — let it fail naturally + if (attempt === maxAttempts) { + // One final wait for dashboard redirect + await page.waitForURL(/\/dashboard/, { timeout: 15_000 }); + await page.locator('aside').first().waitFor({ state: 'visible', timeout: 10_000 }); + } + } } /** diff --git a/tests/integration/ipc-handlers/auth-handlers.test.ts b/tests/integration/ipc-handlers/auth-handlers.test.ts index b74d4ad..0dc79d7 100644 --- a/tests/integration/ipc-handlers/auth-handlers.test.ts +++ b/tests/integration/ipc-handlers/auth-handlers.test.ts @@ -9,8 +9,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ipcInvokeContract, type InvokeChannel } from '@shared/ipc-contract'; +import type { UserSessionManager } from '@main/services/auth'; import type { IpcRouter } from '@main/ipc/router'; -import type { HubAuthResult, HubAuthService, RestoreResult } from '@main/services/hub/hub-auth-service'; +import type { + HubAuthResult, + HubAuthService, + RestoreResult, +} from '@main/services/hub/hub-auth-service'; import type { AuthRefreshResponse, AuthResponse, User } from '@shared/types/hub-protocol'; // ─── Mock Factory ────────────────────────────────────────────── @@ -52,12 +57,24 @@ function createMockHubAuthService(): HubAuthService { }; } +function createMockUserSessionManager(): UserSessionManager { + return { + getCurrentSession: vi.fn().mockReturnValue(null), + setSession: vi.fn(), + clearSession: vi.fn(), + onSessionChange: vi.fn().mockReturnValue(() => {}), + }; +} + // ─── Test Router Implementation ──────────────────────────────── function createTestRouter(): { router: IpcRouter; handlers: Map Promise>; - invoke: (channel: string, input: unknown) => Promise<{ success: boolean; data?: unknown; error?: string }>; + invoke: ( + channel: string, + input: unknown, + ) => Promise<{ success: boolean; data?: unknown; error?: string }>; } { const handlers = new Map Promise>(); @@ -97,17 +114,19 @@ function createTestRouter(): { describe('Auth IPC Handlers', () => { let hubAuthService: HubAuthService; + let userSessionManager: UserSessionManager; let router: IpcRouter; let invoke: ReturnType['invoke']; beforeEach(async () => { hubAuthService = createMockHubAuthService(); + userSessionManager = createMockUserSessionManager(); const testRouter = createTestRouter(); ({ router, invoke } = testRouter); const { registerAuthHandlers } = await import('@main/ipc/handlers/auth-handlers'); - registerAuthHandlers(router, { hubAuthService }); + registerAuthHandlers(router, { hubAuthService, userSessionManager }); }); afterEach(() => { @@ -130,7 +149,10 @@ describe('Auth IPC Handlers', () => { }); expect(result.success).toBe(true); - const data = result.data as { user: Record; tokens: Record }; + const data = result.data as { + user: Record; + tokens: Record; + }; expect(data.user.id).toBe('user-1'); expect(data.user.email).toBe('test@example.com'); expect(data.user.displayName).toBe('Test User'); @@ -141,6 +163,11 @@ describe('Auth IPC Handlers', () => { email: 'test@example.com', password: 'password123', }); + // Verify user session is set for user-scoped storage + expect(userSessionManager.setSession).toHaveBeenCalledWith({ + userId: 'user-1', + email: 'test@example.com', + }); }); it('transforms avatarUrl null correctly', async () => { @@ -229,7 +256,10 @@ describe('Auth IPC Handlers', () => { }); expect(result.success).toBe(true); - const data = result.data as { user: Record; tokens: Record }; + const data = result.data as { + user: Record; + tokens: Record; + }; expect(data.user.id).toBe('user-1'); expect(data.tokens.accessToken).toBe('access-token-123'); expect(hubAuthService.register).toHaveBeenCalledWith({ @@ -378,7 +408,7 @@ describe('Auth IPC Handlers', () => { // ─── auth.logout ────────────────────────────────────────────── describe('auth.logout', () => { - it('returns success on logout', async () => { + it('returns success on logout and clears user session', async () => { vi.mocked(hubAuthService.logout).mockResolvedValue({ ok: true, data: { success: true }, @@ -389,6 +419,8 @@ describe('Auth IPC Handlers', () => { expect(result.success).toBe(true); expect(result.data).toEqual({ success: true }); expect(hubAuthService.logout).toHaveBeenCalled(); + // Verify user session is cleared for user-scoped storage + expect(userSessionManager.clearSession).toHaveBeenCalled(); }); it('propagates logout errors', async () => { @@ -492,6 +524,11 @@ describe('Auth IPC Handlers', () => { expect(data.tokens.refreshToken).toBe('restored-refresh-token'); expect(typeof data.tokens.expiresIn).toBe('number'); expect(hubAuthService.restoreSession).toHaveBeenCalled(); + // Verify user session is set for user-scoped storage on restore + expect(userSessionManager.setSession).toHaveBeenCalledWith({ + userId: 'user-1', + email: 'test@example.com', + }); }); it('returns restored: false when no session to restore', async () => {