fix(security): keep form drafts per user and delete them on logout
Drafts were stored in localStorage by form only, so on a shared phone the next person was offered the previous user's unfinished entry (colony observations, treatment and supplier details). Drafts are now keyed by the signed-in user id and every draft is removed on logout, like the offline cache already was. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
031e47eaad
commit
d4b1addb7d
4 files changed
+64
-5
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useEffect, useState, ReactNode } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useState, ReactNode } from 'react';
|
||||||
|
import { clearAllFormDrafts } from '@/lib/forms/draft-storage';
|
||||||
import {
|
import {
|
||||||
getCurrentUserRequest,
|
getCurrentUserRequest,
|
||||||
isMfaRequired,
|
isMfaRequired,
|
||||||
@@ -91,6 +92,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
await logoutRequest();
|
await logoutRequest();
|
||||||
storeAccessToken(null);
|
storeAccessToken(null);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
// Unfinished form entries belong to the person who typed them.
|
||||||
|
clearAllFormDrafts();
|
||||||
// The service worker cache now holds real business data (colonies,
|
// The service worker cache now holds real business data (colonies,
|
||||||
// tasks, etc.) for offline viewing - on a shared/handed-off device
|
// tasks, etc.) for offline viewing - on a shared/handed-off device
|
||||||
// that must not survive into the next person's session.
|
// 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 {
|
export function useAuth(): AuthContextValue {
|
||||||
const context = useContext(AuthContext);
|
const context = useContext(AuthContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
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;
|
const SAVE_DELAY_MS = 600;
|
||||||
// Drafts older than this are stale (the situation at the hive has changed).
|
// Drafts older than this are stale (the situation at the hive has changed).
|
||||||
const MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000;
|
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
|
* sting, a dead battery) does not cost the typed-in entry. Saves only once
|
||||||
* something was changed (`dirty`), never sends anything anywhere.
|
* something was changed (`dirty`), never sends anything anywhere.
|
||||||
*/
|
*/
|
||||||
export function useFormDraft<T>(key: string, values: T, dirty: boolean): FormDraft<T> {
|
export function useFormDraft<T>(formKey: string, values: T, dirty: boolean): FormDraft<T> {
|
||||||
|
// 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<FormDraft<T>["pending"]>(null);
|
const [pending, setPending] = useState<FormDraft<T>["pending"]>(null);
|
||||||
const offered = useRef(false);
|
// Re-checked when the key changes (e.g. once the signed-in user is known).
|
||||||
|
const offeredKey = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (offered.current) return;
|
if (offeredKey.current === key) return;
|
||||||
offered.current = true;
|
offeredKey.current = key;
|
||||||
const stored = read<T>(key);
|
const stored = read<T>(key);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
// Read after mount: localStorage does not exist during server render.
|
// Read after mount: localStorage does not exist during server render.
|
||||||
|
|||||||
Reference in new issue
Block a user