diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-02 11:31:42 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-02 11:31:42 +0900 |
| commit | 13a3d16ffc88845d7bc65fb0778da9aaff53b653 (patch) | |
| tree | f05ab5d06b3a6608951f8ba47ff57e7f4443d371 /src | |
| parent | d9b78a9fa440d84c6cd0c1f2a6ebb43df895ccdf (diff) | |
| download | kioku-13a3d16ffc88845d7bc65fb0778da9aaff53b653.tar.gz kioku-13a3d16ffc88845d7bc65fb0778da9aaff53b653.tar.zst kioku-13a3d16ffc88845d7bc65fb0778da9aaff53b653.zip | |
feat(auth): clear local IndexedDB and sync state on explicit logout
Wipe Dexie databases (main + CRDT sync state) and reset the sync queue
when the user explicitly logs out so the next account on the same device
starts from a clean local store. Session expiry deliberately keeps local
data intact so a returning user finds their offline work waiting.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/client/atoms/auth.ts | 9 | ||||
| -rw-r--r-- | src/client/db/clear.test.ts | 102 | ||||
| -rw-r--r-- | src/client/db/clear.ts | 25 | ||||
| -rw-r--r-- | src/client/pages/HomePage.test.tsx | 4 |
4 files changed, 137 insertions, 3 deletions
diff --git a/src/client/atoms/auth.ts b/src/client/atoms/auth.ts index 9ee69cf..1ddfd53 100644 --- a/src/client/atoms/auth.ts +++ b/src/client/atoms/auth.ts @@ -3,6 +3,7 @@ import { atomWithStorage } from "jotai/utils"; import { useEffect } from "react"; import { useLocation } from "wouter"; import { apiClient, type User } from "../api/client"; +import { clearAllLocalData } from "../db/clear"; // userAtom is the single source of truth for auth state. Persisted to // localStorage so that the authenticated user survives page reloads alongside @@ -27,8 +28,12 @@ export const loginAtom = atom( }, ); -// Action atom - logout -export const logoutAtom = atom(null, (_get, set) => { +// Action atom - logout. Wipes locally persisted user-scoped data so the next +// user (or a fresh re-login) starts from a clean IndexedDB. Session expiry +// (handled in useAuthInit) intentionally keeps local data so the user finds +// their offline work waiting after re-authenticating. +export const logoutAtom = atom(null, async (_get, set) => { + await clearAllLocalData(); apiClient.logout(); set(userAtom, null); }); diff --git a/src/client/db/clear.test.ts b/src/client/db/clear.test.ts new file mode 100644 index 0000000..1033b5f --- /dev/null +++ b/src/client/db/clear.test.ts @@ -0,0 +1,102 @@ +/** + * @vitest-environment jsdom + */ +import "fake-indexeddb/auto"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { crdtSyncStateManager } from "../sync/crdt/sync-state"; +import { CrdtEntityType } from "../sync/crdt/types"; +import { syncQueue } from "../sync/queue"; +import { clearAllLocalData } from "./clear"; +import { CardState, db } from "./index"; + +describe("clearAllLocalData", () => { + beforeEach(async () => { + await db.delete(); + await db.open(); + await syncQueue.reset(); + await crdtSyncStateManager.clearAll(); + }); + + afterEach(async () => { + await db.delete(); + await db.open(); + await syncQueue.reset(); + await crdtSyncStateManager.clearAll(); + }); + + it("clears all main IndexedDB tables", async () => { + await db.decks.add({ + id: "deck-1", + userId: "user-1", + name: "Deck", + description: null, + defaultNoteTypeId: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + syncVersion: 0, + _synced: false, + }); + await db.cards.add({ + id: "card-1", + deckId: "deck-1", + noteId: "note-1", + isReversed: false, + front: "Q", + back: "A", + state: CardState.New, + due: new Date(), + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + syncVersion: 0, + _synced: false, + }); + + await clearAllLocalData(); + await db.open(); + + expect(await db.decks.toArray()).toHaveLength(0); + expect(await db.cards.toArray()).toHaveLength(0); + }); + + it("resets the sync queue state", async () => { + await syncQueue.startSync(); + await syncQueue.completeSync(42); + + const before = await syncQueue.getState(); + expect(before.lastSyncVersion).toBe(42); + + await clearAllLocalData(); + await db.open(); + + const after = await syncQueue.getState(); + expect(after.lastSyncVersion).toBe(0); + expect(after.lastSyncAt).toBeNull(); + expect(after.lastError).toBeNull(); + }); + + it("clears the CRDT sync state", async () => { + await crdtSyncStateManager.setDocumentBinary( + CrdtEntityType.Note, + "note-1", + new Uint8Array([1, 2, 3]), + 1, + ); + expect(await crdtSyncStateManager.getTotalDocumentCount()).toBeGreaterThan( + 0, + ); + + await clearAllLocalData(); + await db.open(); + + expect(await crdtSyncStateManager.getTotalDocumentCount()).toBe(0); + }); +}); diff --git a/src/client/db/clear.ts b/src/client/db/clear.ts new file mode 100644 index 0000000..589e5a5 --- /dev/null +++ b/src/client/db/clear.ts @@ -0,0 +1,25 @@ +import { crdtSyncStateManager } from "../sync/crdt/sync-state"; +import { syncQueue } from "../sync/queue"; +import { db } from "./index"; + +/** + * Clears all locally persisted user-scoped data: the main IndexedDB tables, + * the sync queue state, and the CRDT sync state. Used at explicit logout to + * prevent the next user from seeing the previous user's offline data. + * + * Each step is isolated so that a partial failure (e.g. one tab still has the + * Dexie connection open) does not stop the rest from running. + */ +export async function clearAllLocalData(): Promise<void> { + const results = await Promise.allSettled([ + syncQueue.reset(), + crdtSyncStateManager.clearAll(), + db.delete(), + ]); + + for (const result of results) { + if (result.status === "rejected") { + console.error("Failed to clear local data:", result.reason); + } + } +} diff --git a/src/client/pages/HomePage.test.tsx b/src/client/pages/HomePage.test.tsx index 3b053f0..7628a75 100644 --- a/src/client/pages/HomePage.test.tsx +++ b/src/client/pages/HomePage.test.tsx @@ -239,7 +239,9 @@ describe("HomePage", () => { await user.click(screen.getByRole("button", { name: "Logout" })); - expect(apiClient.logout).toHaveBeenCalled(); + await waitFor(() => { + expect(apiClient.logout).toHaveBeenCalled(); + }); }); it("does not show description if deck has none", () => { |
