diff --git a/ApiCore-Frontend/src/lib/auth/auth-context.tsx b/ApiCore-Frontend/src/lib/auth/auth-context.tsx index 6565a04..675d869 100644 --- a/ApiCore-Frontend/src/lib/auth/auth-context.tsx +++ b/ApiCore-Frontend/src/lib/auth/auth-context.tsx @@ -1,6 +1,7 @@ 'use client'; import { createContext, useCallback, useContext, useEffect, useState, ReactNode } from 'react'; +import { clearAllFormDrafts } from '@/lib/forms/draft-storage'; import { getCurrentUserRequest, isMfaRequired, @@ -91,6 +92,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { await logoutRequest(); storeAccessToken(null); setUser(null); + // Unfinished form entries belong to the person who typed them. + clearAllFormDrafts(); // The service worker cache now holds real business data (colonies, // tasks, etc.) for offline viewing - on a shared/handed-off device // that must not survive into the next person's session. @@ -134,6 +137,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { ); } +/** The signed-in user's id, or null - usable outside an AuthProvider (tests). */ +export function useCurrentUserId(): string | null { + return useContext(AuthContext)?.user?.id ?? null; +} + export function useAuth(): AuthContextValue { const context = useContext(AuthContext); if (!context) { diff --git a/ApiCore-Frontend/src/lib/forms/draft-storage.spec.ts b/ApiCore-Frontend/src/lib/forms/draft-storage.spec.ts new file mode 100644 index 0000000..f636845 --- /dev/null +++ b/ApiCore-Frontend/src/lib/forms/draft-storage.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { DRAFT_PREFIX, clearAllFormDrafts } from "./draft-storage"; +import { useFormDraft } from "./use-form-draft"; + +describe("form drafts", () => { + it("removes every draft on logout and nothing else", () => { + window.localStorage.setItem(`${DRAFT_PREFIX}user-1:inspection-new`, "{}"); + window.localStorage.setItem("apicore:motion", "subtle"); + + clearAllFormDrafts(); + + expect(window.localStorage.getItem(`${DRAFT_PREFIX}user-1:inspection-new`)).toBeNull(); + expect(window.localStorage.getItem("apicore:motion")).toBe("subtle"); + }); + + it("does not offer another user's draft", () => { + window.localStorage.setItem( + `${DRAFT_PREFIX}someone-else:inspection-new`, + JSON.stringify({ savedAt: Date.now(), values: { notes: "fremd" } }), + ); + + const { result } = renderHook(() => useFormDraft("inspection-new", {}, false)); + + expect(result.current.pending).toBeNull(); + }); +}); diff --git a/ApiCore-Frontend/src/lib/forms/draft-storage.ts b/ApiCore-Frontend/src/lib/forms/draft-storage.ts new file mode 100644 index 0000000..1914495 --- /dev/null +++ b/ApiCore-Frontend/src/lib/forms/draft-storage.ts @@ -0,0 +1,18 @@ +export const DRAFT_PREFIX = "apicore:draft:"; + +/** + * Removes every draft on this device - called on logout, so the next + * person on a shared phone never sees someone else's unfinished entry. + */ +export function clearAllFormDrafts(): void { + try { + for (let index = window.localStorage.length - 1; index >= 0; index--) { + const storageKey = window.localStorage.key(index); + if (storageKey?.startsWith(DRAFT_PREFIX)) { + window.localStorage.removeItem(storageKey); + } + } + } catch { + // Storage unavailable - nothing stored either. + } +} diff --git a/ApiCore-Frontend/src/lib/forms/use-form-draft.ts b/ApiCore-Frontend/src/lib/forms/use-form-draft.ts index 148b1b7..c2e5b19 100644 --- a/ApiCore-Frontend/src/lib/forms/use-form-draft.ts +++ b/ApiCore-Frontend/src/lib/forms/use-form-draft.ts @@ -1,8 +1,9 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useCurrentUserId } from "@/lib/auth/auth-context"; -const PREFIX = "apicore:draft:"; +import { DRAFT_PREFIX as PREFIX } from "./draft-storage"; const SAVE_DELAY_MS = 600; // Drafts older than this are stale (the situation at the hive has changed). const MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000; @@ -49,13 +50,18 @@ function remove(key: string): void { * sting, a dead battery) does not cost the typed-in entry. Saves only once * something was changed (`dirty`), never sends anything anywhere. */ -export function useFormDraft(key: string, values: T, dirty: boolean): FormDraft { +export function useFormDraft(formKey: string, values: T, dirty: boolean): FormDraft { + // Drafts are per signed-in user: on a shared device nobody is offered + // someone else's entry. + const userId = useCurrentUserId(); + const key = `${userId ?? "anonymous"}:${formKey}`; const [pending, setPending] = useState["pending"]>(null); - const offered = useRef(false); + // Re-checked when the key changes (e.g. once the signed-in user is known). + const offeredKey = useRef(null); useEffect(() => { - if (offered.current) return; - offered.current = true; + if (offeredKey.current === key) return; + offeredKey.current = key; const stored = read(key); if (stored) { // Read after mount: localStorage does not exist during server render.