feat: platform backbone and field capture complete (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed: - webhooks with delivery table, retries and stable delivery id - calendar handlers; a finished task removes its Outlook event - tasks, automations, inspections, varroa, treatments, feedings, harvests, colonies, wintering, stock and bottling record domain events in the same transaction; legacy post-commit calls removed - row locks through one helper (lockRowForUpdate, lockRowsForShare, lockRowsForUpdate) - job framework with JobRun, cli exit codes, cleanup, task templates, daily evaluation, hourly app-backup and the rekey job - system API and page "Verwaltung > System" with retry - @nestjs/schedule removed, guard tests against in-process schedulers Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed: - inspection photo offline without metadata - bulk capture for treatments, feedings and varroa (backend + frontend) - offline stand mode with service worker, offline pages and packs - offline session, cold start and logout on shared devices Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md. Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema drift; frontend lint, typecheck, 913 tests, build. Not rolled out. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c01a38835a
commit
cdd30cf3ee
288 files changed
+39165
-2853
No files matched your search
@@ -7,7 +7,7 @@ import {
|
||||
UPLOAD_TIMEOUT_MS,
|
||||
} from "@/lib/http/fetch-with-timeout";
|
||||
import { expectTimeoutAt, stubHangingFetch } from "@/lib/http/deadline-test-helper";
|
||||
import { setAccessToken } from "./token-store";
|
||||
import { getAccessToken, setAccessToken, setRefreshedTokenHandler } from "./token-store";
|
||||
import { ApiError, apiFetch, apiFetchBlob, apiFetchFormData } from "./api-client";
|
||||
|
||||
vi.mock("../config/runtime-config", () => ({
|
||||
@@ -102,3 +102,67 @@ describe("api-client", () => {
|
||||
expect(error.body).toEqual({ message: "Nein", why: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("api-client token refresh", () => {
|
||||
beforeEach(() => {
|
||||
setAccessToken(null);
|
||||
reportRequestOutcome("reached-server");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setRefreshedTokenHandler(null);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function stubServer(refreshBody: unknown) {
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
if (url.endsWith("/auth/refresh")) return jsonResponse(200, refreshBody);
|
||||
const retried = fetchMock.mock.calls.filter(([called]) => called === url).length > 1;
|
||||
return retried ? jsonResponse(200, { ok: true }) : jsonResponse(401, {});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
it("retries with the new token once the handler accepts it", async () => {
|
||||
const handler = vi.fn().mockResolvedValue(true);
|
||||
setRefreshedTokenHandler(handler);
|
||||
stubServer({ accessToken: "fresh" });
|
||||
|
||||
await expect(apiFetch("/things")).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(handler).toHaveBeenCalledWith("fresh");
|
||||
});
|
||||
|
||||
it("does not retry - and keeps no token - when the handler rejects it (another account)", async () => {
|
||||
setRefreshedTokenHandler(vi.fn().mockResolvedValue(false));
|
||||
const fetchMock = stubServer({ accessToken: "other-users-token" });
|
||||
|
||||
await expect(apiFetch("/things")).rejects.toMatchObject({ status: 401 });
|
||||
|
||||
expect(getAccessToken()).toBeNull();
|
||||
expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/things"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("takes the token as it is without a handler", async () => {
|
||||
stubServer({ accessToken: "fresh" });
|
||||
|
||||
await expect(apiFetch("/things")).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(getAccessToken()).toBe("fresh");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["blobs", () => apiFetchBlob("/export", "x.csv")],
|
||||
["form data", () => apiFetchFormData("/upload", new FormData())],
|
||||
])("also asks the handler before retrying %s", async (_name, call) => {
|
||||
const handler = vi.fn().mockResolvedValue(false);
|
||||
setRefreshedTokenHandler(handler);
|
||||
stubServer({ accessToken: "other" });
|
||||
|
||||
await expect(call()).rejects.toBeInstanceOf(ApiError);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith("other");
|
||||
expect(getAccessToken()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getAccessToken, setAccessToken } from './token-store';
|
||||
import { acceptRefreshedToken, getAccessToken } from './token-store';
|
||||
import { refreshRequest } from './api';
|
||||
import { getApiUrl } from '../config/runtime-config';
|
||||
import {
|
||||
@@ -63,8 +63,7 @@ export async function apiFetchBlob(path: string, fallbackFilename: string): Prom
|
||||
|
||||
if (response.status === 401) {
|
||||
const refreshed = await refreshRequest();
|
||||
if (refreshed) {
|
||||
setAccessToken(refreshed.accessToken);
|
||||
if (refreshed && (await acceptRefreshedToken(refreshed.accessToken))) {
|
||||
response = await rawFetch(path, {}, DOWNLOAD_TIMEOUT_MS);
|
||||
}
|
||||
}
|
||||
@@ -114,8 +113,7 @@ export async function apiFetchFormData<T>(
|
||||
|
||||
if (response.status === 401) {
|
||||
const refreshed = await refreshRequest();
|
||||
if (refreshed) {
|
||||
setAccessToken(refreshed.accessToken);
|
||||
if (refreshed && (await acceptRefreshedToken(refreshed.accessToken))) {
|
||||
response = await rawFetchFormData(path, formData, options);
|
||||
}
|
||||
}
|
||||
@@ -132,8 +130,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
||||
|
||||
if (response.status === 401) {
|
||||
const refreshed = await refreshRequest();
|
||||
if (refreshed) {
|
||||
setAccessToken(refreshed.accessToken);
|
||||
if (refreshed && (await acceptRefreshedToken(refreshed.accessToken))) {
|
||||
response = await rawFetch(path, init);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
NON_IDEMPOTENT_WRITE_TIMEOUT_MS,
|
||||
isNetworkError,
|
||||
} from "@/lib/http/fetch-with-timeout";
|
||||
import { expectTimeoutAt, stubHangingFetch } from "@/lib/http/deadline-test-helper";
|
||||
import {
|
||||
@@ -42,3 +43,41 @@ describe("auth api timeouts", () => {
|
||||
await expectTimeoutAt(() => getCurrentUserRequest("tok"), DEFAULT_TIMEOUT_MS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshRequest outcome", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function answerWith(status: number, body: unknown = {}) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status })),
|
||||
);
|
||||
}
|
||||
|
||||
it("returns the access token when the server accepts the session", async () => {
|
||||
answerWith(200, { accessToken: "token-1" });
|
||||
|
||||
await expect(refreshRequest()).resolves.toEqual({ accessToken: "token-1" });
|
||||
});
|
||||
|
||||
it.each([401, 403])("says the session is over for %i", async (status) => {
|
||||
answerWith(status);
|
||||
|
||||
await expect(refreshRequest()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it.each([429, 500, 502, 503, 504])(
|
||||
"does not end the session for %i - the server was just unavailable",
|
||||
async (status) => {
|
||||
answerWith(status);
|
||||
|
||||
const failure = await refreshRequest().catch((error: unknown) => error);
|
||||
|
||||
expect(failure).toBeInstanceOf(Error);
|
||||
// Treated like a lost connection by everything that retries or queues.
|
||||
expect(isNetworkError(failure)).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -69,9 +69,15 @@ async function performRefresh(): Promise<AccessTokenResponse | null> {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) {
|
||||
// Only 401/403 say the session is over. Anything else (429, 5xx from the
|
||||
// server or a proxy) is a server that cannot answer right now: a TypeError,
|
||||
// so it counts as a lost connection and never as an ended session.
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new TypeError(`Refresh unavailable (HTTP ${response.status}).`);
|
||||
}
|
||||
return response.json() as Promise<AccessTokenResponse>;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { toast } from "sonner";
|
||||
import { getDb, queueInspection, summarizeQueue } from "@/lib/offline/db";
|
||||
import { reportRequestOutcome } from "@/lib/offline/connectivity";
|
||||
import { cancelSync, resumeSync, syncPendingEntries } from "@/lib/offline/sync";
|
||||
import {
|
||||
getCurrentUserRequest,
|
||||
loginRequest,
|
||||
logoutRequest,
|
||||
refreshRequest,
|
||||
verifyMfaRequest,
|
||||
} from "./api";
|
||||
import { AuthProvider, useAuth, type AuthUser } from "./auth-context";
|
||||
import {
|
||||
isLogoutPending,
|
||||
loadIdentity,
|
||||
loadLastUserId,
|
||||
markLogoutPending,
|
||||
saveIdentity,
|
||||
saveLastUserId,
|
||||
} from "./offline-identity";
|
||||
import { removeOtherUsersData, restoreOfflineShell, wipeDeviceData } from "./session-cleanup";
|
||||
import { acceptRefreshedToken, getAccessToken, setAccessToken } from "./token-store";
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
|
||||
vi.mock("./api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./api")>()),
|
||||
refreshRequest: vi.fn(),
|
||||
getCurrentUserRequest: vi.fn(),
|
||||
loginRequest: vi.fn(),
|
||||
logoutRequest: vi.fn(),
|
||||
verifyMfaRequest: vi.fn(),
|
||||
ssoCompleteRequest: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/offline/sync", () => ({
|
||||
cancelSync: vi.fn().mockResolvedValue(undefined),
|
||||
resumeSync: vi.fn(),
|
||||
syncPendingEntries: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/photos/store", () => ({ finishHandOvers: vi.fn().mockResolvedValue(0) }));
|
||||
vi.mock("./session-cleanup", () => ({
|
||||
wipeDeviceData: vi.fn().mockResolvedValue(true),
|
||||
removeOtherUsersData: vi.fn().mockResolvedValue(undefined),
|
||||
restoreOfflineShell: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const ALICE: AuthUser = {
|
||||
id: "user-alice",
|
||||
email: "[email protected]",
|
||||
name: "Alice",
|
||||
roleKey: "admin",
|
||||
roleName: "Admin",
|
||||
permissions: ["colonies.read"],
|
||||
mfaEnabled: false,
|
||||
hasAvatar: false,
|
||||
};
|
||||
const BOB: AuthUser = { ...ALICE, id: "user-bob", email: "[email protected]", name: "Bob" };
|
||||
const ACCESS_TOKEN = "secret-access-token-4711";
|
||||
const INSPECTION = { colonyId: "colony-1", date: "2026-10-14T00:00:00.000Z" };
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const refreshMock = vi.mocked(refreshRequest);
|
||||
const profileMock = vi.mocked(getCurrentUserRequest);
|
||||
const loginMock = vi.mocked(loginRequest);
|
||||
const logoutRequestMock = vi.mocked(logoutRequest);
|
||||
const cancelSyncMock = vi.mocked(cancelSync);
|
||||
const wipeMock = vi.mocked(wipeDeviceData);
|
||||
|
||||
let latest: ReturnType<typeof useAuth>;
|
||||
let queryClient: QueryClient;
|
||||
|
||||
function Probe() {
|
||||
const auth = useAuth();
|
||||
// After every committed render, so `latest` is what the screen shows.
|
||||
useEffect(() => {
|
||||
latest = auth;
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
async function renderAuth(): Promise<void> {
|
||||
queryClient = new QueryClient();
|
||||
vi.spyOn(queryClient, "clear");
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<Probe />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await waitFor(() => expect(latest.isLoading).toBe(false));
|
||||
}
|
||||
|
||||
const NETWORK_FAILURE = new TypeError("Failed to fetch");
|
||||
|
||||
function serverKnowsAlice(): void {
|
||||
refreshMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
}
|
||||
|
||||
async function emptyDevice(): Promise<void> {
|
||||
const db = await getDb();
|
||||
await Promise.all([...db.objectStoreNames].map((name) => db.clear(name)));
|
||||
window.localStorage.clear();
|
||||
}
|
||||
|
||||
describe("AuthProvider", () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
cancelSyncMock.mockResolvedValue(undefined);
|
||||
wipeMock.mockResolvedValue(true);
|
||||
logoutRequestMock.mockResolvedValue(undefined);
|
||||
setAccessToken(null);
|
||||
reportRequestOutcome("reached-server");
|
||||
await emptyDevice();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
reportRequestOutcome("reached-server");
|
||||
});
|
||||
|
||||
describe("cold start", () => {
|
||||
it("signs in with a fresh refresh and keeps who it is for offline", async () => {
|
||||
serverKnowsAlice();
|
||||
|
||||
await renderAuth();
|
||||
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
expect(latest.sessionMode).toBe("online");
|
||||
expect(getAccessToken()).toBe(ACCESS_TOKEN);
|
||||
expect(await loadIdentity()).toEqual(ALICE);
|
||||
expect(await loadLastUserId()).toBe(ALICE.id);
|
||||
// Sync stays closed until the offline band opens it for this user.
|
||||
expect(cancelSyncMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cold start offline with a cached identity shows the app in offline mode", async () => {
|
||||
await saveIdentity(ALICE);
|
||||
reportRequestOutcome("network-error");
|
||||
refreshMock.mockRejectedValue(NETWORK_FAILURE);
|
||||
|
||||
await renderAuth();
|
||||
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
expect(latest.sessionMode).toBe("offline");
|
||||
expect(getAccessToken()).toBeNull();
|
||||
expect(cancelSyncMock).toHaveBeenCalled();
|
||||
expect(resumeSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cold start offline without or with an expired identity goes to login", async () => {
|
||||
reportRequestOutcome("network-error");
|
||||
refreshMock.mockRejectedValue(NETWORK_FAILURE);
|
||||
|
||||
await renderAuth();
|
||||
expect(latest.user).toBeNull();
|
||||
|
||||
await saveIdentity(ALICE, Date.now() - 8 * DAY_MS);
|
||||
cleanup();
|
||||
await renderAuth();
|
||||
expect(latest.user).toBeNull();
|
||||
expect(await loadIdentity()).toBeNull();
|
||||
});
|
||||
|
||||
it("cold start with a revoked session goes to login, drops the identity and keeps the queue", async () => {
|
||||
await saveIdentity(ALICE);
|
||||
await queueInspection(INSPECTION, "key-1", ALICE.id);
|
||||
refreshMock.mockResolvedValue(null);
|
||||
|
||||
await renderAuth();
|
||||
|
||||
expect(latest.user).toBeNull();
|
||||
expect(await loadIdentity()).toBeNull();
|
||||
expect(await summarizeQueue(ALICE.id)).toMatchObject({ own: 1 });
|
||||
});
|
||||
|
||||
it("a pending logout revokes the session before any refresh", async () => {
|
||||
markLogoutPending();
|
||||
serverKnowsAlice();
|
||||
|
||||
await renderAuth();
|
||||
|
||||
expect(logoutRequestMock).toHaveBeenCalledTimes(1);
|
||||
expect(refreshMock).not.toHaveBeenCalled();
|
||||
expect(isLogoutPending()).toBe(false);
|
||||
expect(latest.user).toBeNull();
|
||||
expect(getAccessToken()).toBeNull();
|
||||
});
|
||||
|
||||
it("stays signed out and keeps the pending logout while the network is still down", async () => {
|
||||
markLogoutPending();
|
||||
logoutRequestMock.mockRejectedValue(NETWORK_FAILURE);
|
||||
await saveIdentity(ALICE);
|
||||
|
||||
await renderAuth();
|
||||
|
||||
expect(refreshMock).not.toHaveBeenCalled();
|
||||
expect(isLogoutPending()).toBe(true);
|
||||
expect(latest.user).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("offline session", () => {
|
||||
async function startOfflineAsAlice(): Promise<void> {
|
||||
await saveIdentity(ALICE);
|
||||
await saveLastUserId(ALICE.id);
|
||||
reportRequestOutcome("network-error");
|
||||
refreshMock.mockRejectedValueOnce(NETWORK_FAILURE);
|
||||
await renderAuth();
|
||||
expect(latest.sessionMode).toBe("offline");
|
||||
}
|
||||
|
||||
it("back online with a 401 refresh sends the user to login and keeps the queue", async () => {
|
||||
await queueInspection(INSPECTION, "key-1", ALICE.id);
|
||||
await queueInspection(INSPECTION, "key-2", ALICE.id);
|
||||
await startOfflineAsAlice();
|
||||
refreshMock.mockResolvedValue(null);
|
||||
cancelSyncMock.mockClear();
|
||||
|
||||
act(() => reportRequestOutcome("reached-server"));
|
||||
|
||||
await waitFor(() => expect(latest.user).toBeNull());
|
||||
expect(cancelSyncMock).toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
"Sitzung abgelaufen – bitte neu anmelden. 2 Einträge warten und werden danach gesendet.",
|
||||
expect.anything(),
|
||||
);
|
||||
expect(await loadIdentity()).toBeNull();
|
||||
expect(await summarizeQueue(ALICE.id)).toMatchObject({ own: 2 });
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
expect(getAccessToken()).toBeNull();
|
||||
});
|
||||
|
||||
it("back online with a refresh the server could not answer stays offline and keeps the session", async () => {
|
||||
await startOfflineAsAlice();
|
||||
refreshMock.mockRejectedValue(new TypeError("Refresh unavailable (HTTP 503)."));
|
||||
|
||||
act(() => reportRequestOutcome("reached-server"));
|
||||
await waitFor(() => expect(refreshMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
expect(latest.sessionMode).toBe("offline");
|
||||
expect(await loadIdentity()).toEqual(ALICE);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("back online with a valid session gets a token and switches to online", async () => {
|
||||
await startOfflineAsAlice();
|
||||
serverKnowsAlice();
|
||||
|
||||
act(() => reportRequestOutcome("reached-server"));
|
||||
|
||||
await waitFor(() => expect(latest.sessionMode).toBe("online"));
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
expect(getAccessToken()).toBe(ACCESS_TOKEN);
|
||||
expect(wipeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stays offline when the refresh cannot reach the server yet", async () => {
|
||||
await startOfflineAsAlice();
|
||||
refreshMock.mockRejectedValue(NETWORK_FAILURE);
|
||||
|
||||
act(() => reportRequestOutcome("reached-server"));
|
||||
await waitFor(() => expect(refreshMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(latest.sessionMode).toBe("offline");
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
});
|
||||
|
||||
it("another account behind the refresh cookie is a user switch: the device is wiped", async () => {
|
||||
await queueInspection(INSPECTION, "key-1", ALICE.id);
|
||||
await startOfflineAsAlice();
|
||||
refreshMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(BOB);
|
||||
|
||||
act(() => reportRequestOutcome("reached-server"));
|
||||
|
||||
await waitFor(() => expect(latest.user).toEqual(BOB));
|
||||
expect(wipeMock).toHaveBeenCalledWith({ keepDraftsOf: BOB.id, discardOwnerless: true });
|
||||
expect(queryClient.clear).toHaveBeenCalled();
|
||||
expect(await summarizeQueue(ALICE.id)).toMatchObject({ own: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("refresh outcomes that are not a verdict", () => {
|
||||
it("cold start with a refresh the server could not answer (503) opens the offline session", async () => {
|
||||
await saveIdentity(ALICE);
|
||||
refreshMock.mockRejectedValue(new TypeError("Refresh unavailable (HTTP 503)."));
|
||||
|
||||
await renderAuth();
|
||||
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
expect(latest.sessionMode).toBe("offline");
|
||||
expect(await loadIdentity()).toEqual(ALICE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a silent refresh in the api client", () => {
|
||||
beforeEach(async () => {
|
||||
serverKnowsAlice();
|
||||
await renderAuth();
|
||||
cancelSyncMock.mockClear();
|
||||
setAccessToken(ACCESS_TOKEN);
|
||||
});
|
||||
|
||||
it("keeps the signed-in account going when the refreshed token is theirs", async () => {
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
|
||||
await expect(acceptRefreshedToken("fresh-token")).resolves.toBe(true);
|
||||
|
||||
expect(getAccessToken()).toBe("fresh-token");
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
});
|
||||
|
||||
it("ends the session when another account token comes out (second tab signed in as someone else)", async () => {
|
||||
await queueInspection(INSPECTION, "key-1", ALICE.id);
|
||||
profileMock.mockResolvedValue(BOB);
|
||||
|
||||
await act(async () => {
|
||||
await expect(acceptRefreshedToken("bobs-token")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
expect(getAccessToken()).toBeNull();
|
||||
expect(cancelSyncMock).toHaveBeenCalled();
|
||||
expect(latest.user).toBeNull();
|
||||
expect(await loadIdentity()).toBeNull();
|
||||
expect(wipeMock).toHaveBeenCalledWith({ keepDraftsOf: null, discardOwnerless: true });
|
||||
expect(queryClient.clear).toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining("anderes Konto"));
|
||||
// The first account entries are never sent under the second.
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
expect(await summarizeQueue(BOB.id)).toMatchObject({ own: 0, foreign: 1 });
|
||||
});
|
||||
|
||||
it("does not revive a session nobody is signed in to", async () => {
|
||||
await act(async () => {
|
||||
await latest.logout();
|
||||
});
|
||||
|
||||
await expect(acceptRefreshedToken("stray-token")).resolves.toBe(false);
|
||||
|
||||
expect(getAccessToken()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a sign-in or reconnect that is overtaken", () => {
|
||||
function deferred<T>() {
|
||||
let resolve: (value: T) => void = () => undefined;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
it("does not take a token into use when the person logs out during an in-flight reconnect", async () => {
|
||||
await saveIdentity(ALICE);
|
||||
reportRequestOutcome("network-error");
|
||||
refreshMock.mockRejectedValueOnce(NETWORK_FAILURE);
|
||||
await renderAuth();
|
||||
const refresh = deferred<{ accessToken: string } | null>();
|
||||
refreshMock.mockReturnValue(refresh.promise);
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
act(() => reportRequestOutcome("reached-server"));
|
||||
await waitFor(() => expect(refreshMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
await act(async () => {
|
||||
await latest.logout();
|
||||
});
|
||||
await act(async () => {
|
||||
refresh.resolve({ accessToken: ACCESS_TOKEN });
|
||||
});
|
||||
|
||||
expect(latest.user).toBeNull();
|
||||
expect(getAccessToken()).toBeNull();
|
||||
expect(await loadIdentity()).toBeNull();
|
||||
});
|
||||
|
||||
it("does not take a token into use when the person logs out during an in-flight sign-in", async () => {
|
||||
refreshMock.mockResolvedValue(null);
|
||||
await renderAuth();
|
||||
const profile = deferred<AuthUser>();
|
||||
profileMock.mockReturnValue(profile.promise);
|
||||
loginMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
|
||||
let signingIn: Promise<unknown> = Promise.resolve();
|
||||
act(() => {
|
||||
signingIn = latest.login("[email protected]", "pw");
|
||||
});
|
||||
await waitFor(() => expect(profileMock).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
await latest.logout();
|
||||
});
|
||||
await act(async () => {
|
||||
profile.resolve(ALICE);
|
||||
await signingIn;
|
||||
});
|
||||
|
||||
expect(latest.user).toBeNull();
|
||||
expect(getAccessToken()).toBeNull();
|
||||
expect(await loadIdentity()).toBeNull();
|
||||
});
|
||||
|
||||
it("prepares the device before the new token is in use", async () => {
|
||||
refreshMock.mockResolvedValue(null);
|
||||
await renderAuth();
|
||||
await saveLastUserId(ALICE.id);
|
||||
loginMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(BOB);
|
||||
let tokenDuringWipe: string | null = "unset";
|
||||
wipeMock.mockImplementation(async () => {
|
||||
tokenDuringWipe = getAccessToken();
|
||||
return true;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await latest.login("[email protected]", "pw");
|
||||
});
|
||||
|
||||
expect(tokenDuringWipe).toBeNull();
|
||||
expect(getAccessToken()).toBe(ACCESS_TOKEN);
|
||||
});
|
||||
|
||||
it("refills the service worker after a sign-in", async () => {
|
||||
refreshMock.mockResolvedValue(null);
|
||||
await renderAuth();
|
||||
await saveLastUserId(ALICE.id);
|
||||
loginMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
|
||||
await act(async () => {
|
||||
await latest.login("[email protected]", "pw");
|
||||
});
|
||||
|
||||
expect(restoreOfflineShell).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("login", () => {
|
||||
beforeEach(async () => {
|
||||
refreshMock.mockResolvedValue(null);
|
||||
await renderAuth();
|
||||
cancelSyncMock.mockClear();
|
||||
});
|
||||
|
||||
it("closes sync before the token changes", async () => {
|
||||
loginMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
let tokenWhenSyncWasClosed: string | null = "unset";
|
||||
cancelSyncMock.mockImplementation(async () => {
|
||||
tokenWhenSyncWasClosed = getAccessToken();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await latest.login("[email protected]", "pw");
|
||||
});
|
||||
|
||||
expect(tokenWhenSyncWasClosed).toBeNull();
|
||||
expect(getAccessToken()).toBe(ACCESS_TOKEN);
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
expect(resumeSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logging in as another user wipes the previous user's packs and caches but keeps their queue unsent", async () => {
|
||||
await saveLastUserId(ALICE.id);
|
||||
await queueInspection(INSPECTION, "key-1", ALICE.id);
|
||||
loginMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(BOB);
|
||||
|
||||
await act(async () => {
|
||||
await latest.login("[email protected]", "pw");
|
||||
});
|
||||
|
||||
expect(wipeMock).toHaveBeenCalledWith({ keepDraftsOf: BOB.id, discardOwnerless: true });
|
||||
expect(queryClient.clear).toHaveBeenCalled();
|
||||
expect(await loadLastUserId()).toBe(BOB.id);
|
||||
expect(await summarizeQueue(ALICE.id)).toMatchObject({ own: 1 });
|
||||
expect(await summarizeQueue(BOB.id)).toMatchObject({ own: 0, foreign: 1 });
|
||||
expect(syncPendingEntries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("the same user logging in again keeps their data and only drops what others left unsaved", async () => {
|
||||
await saveLastUserId(ALICE.id);
|
||||
loginMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
|
||||
await act(async () => {
|
||||
await latest.login("[email protected]", "pw");
|
||||
});
|
||||
|
||||
expect(wipeMock).not.toHaveBeenCalled();
|
||||
expect(removeOtherUsersData).toHaveBeenCalledWith(ALICE.id);
|
||||
});
|
||||
|
||||
it("refuses to sign in while an earlier logout could not be sent", async () => {
|
||||
markLogoutPending();
|
||||
logoutRequestMock.mockRejectedValue(NETWORK_FAILURE);
|
||||
|
||||
await act(async () => {
|
||||
await expect(latest.login("[email protected]", "pw")).rejects.toBeInstanceOf(TypeError);
|
||||
});
|
||||
|
||||
expect(loginMock).not.toHaveBeenCalled();
|
||||
expect(isLogoutPending()).toBe(true);
|
||||
});
|
||||
|
||||
it("revokes an earlier pending logout first, then signs in", async () => {
|
||||
markLogoutPending();
|
||||
loginMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
|
||||
await act(async () => {
|
||||
await latest.login("[email protected]", "pw");
|
||||
});
|
||||
|
||||
expect(logoutRequestMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
loginMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(isLogoutPending()).toBe(false);
|
||||
});
|
||||
|
||||
it("closes sync before an MFA login changes the token as well", async () => {
|
||||
vi.mocked(verifyMfaRequest).mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
|
||||
await act(async () => {
|
||||
await latest.verifyMfa("mfa-token", "123456");
|
||||
});
|
||||
|
||||
expect(cancelSyncMock).toHaveBeenCalled();
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
});
|
||||
|
||||
it("never writes the access token to localStorage or IndexedDB", async () => {
|
||||
const setItem = vi.spyOn(Storage.prototype, "setItem");
|
||||
const idbWrites = [
|
||||
vi.spyOn(IDBObjectStore.prototype, "put"),
|
||||
vi.spyOn(IDBObjectStore.prototype, "add"),
|
||||
];
|
||||
loginMock.mockResolvedValue({ accessToken: ACCESS_TOKEN });
|
||||
profileMock.mockResolvedValue(ALICE);
|
||||
|
||||
await act(async () => {
|
||||
await latest.login("[email protected]", "pw");
|
||||
});
|
||||
await act(async () => {
|
||||
await latest.logout();
|
||||
});
|
||||
|
||||
const written = [...setItem.mock.calls, ...idbWrites.flatMap((spy) => spy.mock.calls)];
|
||||
expect(written.length).toBeGreaterThan(0);
|
||||
expect(JSON.stringify(written)).not.toContain(ACCESS_TOKEN);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logout", () => {
|
||||
beforeEach(async () => {
|
||||
serverKnowsAlice();
|
||||
await renderAuth();
|
||||
cancelSyncMock.mockClear();
|
||||
});
|
||||
|
||||
it("closes sync, revokes the session, then clears the token and the device", async () => {
|
||||
await act(async () => {
|
||||
await latest.logout();
|
||||
});
|
||||
|
||||
expect(cancelSyncMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
logoutRequestMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(getAccessToken()).toBeNull();
|
||||
expect(latest.user).toBeNull();
|
||||
expect(await loadIdentity()).toBeNull();
|
||||
expect(await loadLastUserId()).toBeNull();
|
||||
expect(wipeMock).toHaveBeenCalledWith({ keepDraftsOf: null, discardQueueOf: undefined });
|
||||
expect(queryClient.clear).toHaveBeenCalled();
|
||||
expect(isLogoutPending()).toBe(false);
|
||||
// Sync stays closed: the next token opens it again.
|
||||
expect(resumeSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes the account's own queue only when asked to", async () => {
|
||||
await act(async () => {
|
||||
await latest.logout({ discardQueue: true });
|
||||
});
|
||||
|
||||
expect(wipeMock).toHaveBeenCalledWith({ keepDraftsOf: null, discardQueueOf: ALICE.id });
|
||||
});
|
||||
|
||||
it("logout without network marks the logout as pending and still signs out", async () => {
|
||||
logoutRequestMock.mockRejectedValue(NETWORK_FAILURE);
|
||||
|
||||
await act(async () => {
|
||||
await latest.logout();
|
||||
});
|
||||
|
||||
expect(isLogoutPending()).toBe(true);
|
||||
expect(latest.user).toBeNull();
|
||||
expect(getAccessToken()).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the layout mounted for the old user until the user is gone", async () => {
|
||||
let isLoadingDuringLogout = false;
|
||||
logoutRequestMock.mockImplementation(async () => {
|
||||
isLoadingDuringLogout = latest.isLoading;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await latest.logout();
|
||||
});
|
||||
|
||||
expect(isLoadingDuringLogout).toBe(false);
|
||||
expect(latest.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("tells the person when the device could not be cleaned completely", async () => {
|
||||
wipeMock.mockResolvedValue(false);
|
||||
|
||||
await act(async () => {
|
||||
await latest.logout();
|
||||
});
|
||||
|
||||
expect(latest.user).toBeNull();
|
||||
expect(toast.error).toHaveBeenCalled();
|
||||
// The next sign-in of anyone cleans again.
|
||||
expect(await loadLastUserId()).toBe(ALICE.id);
|
||||
});
|
||||
|
||||
it("reopens sync when the logout itself fails before the user is gone", async () => {
|
||||
cancelSyncMock.mockRejectedValue(new Error("boom"));
|
||||
|
||||
await act(async () => {
|
||||
await expect(latest.logout()).rejects.toThrow("boom");
|
||||
});
|
||||
|
||||
expect(resumeSync).toHaveBeenCalled();
|
||||
expect(latest.user).toEqual(ALICE);
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshUser keeps the cached identity current without closing sync", async () => {
|
||||
serverKnowsAlice();
|
||||
await renderAuth();
|
||||
cancelSyncMock.mockClear();
|
||||
profileMock.mockResolvedValue({ ...ALICE, name: "Alice Neu" });
|
||||
|
||||
await act(async () => {
|
||||
await latest.refreshUser();
|
||||
});
|
||||
|
||||
expect(latest.user?.name).toBe("Alice Neu");
|
||||
expect((await loadIdentity())?.name).toBe("Alice Neu");
|
||||
expect(cancelSyncMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState, ReactNode } from 'react';
|
||||
import { clearAllFormDrafts } from '@/lib/forms/draft-storage';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { isNetworkError } from '@/lib/http/fetch-with-timeout';
|
||||
import { useConnectivity } from '@/lib/offline/connectivity';
|
||||
import { summarizeQueue } from '@/lib/offline/db';
|
||||
import { sessionExpiredWithEntries } from '@/lib/offline/queue-texts';
|
||||
import { cancelSync, resumeSync } from '@/lib/offline/sync';
|
||||
import { finishHandOvers } from '@/lib/photos/store';
|
||||
import {
|
||||
getCurrentUserRequest,
|
||||
isMfaRequired,
|
||||
@@ -11,7 +26,23 @@ import {
|
||||
ssoCompleteRequest,
|
||||
verifyMfaRequest,
|
||||
} from './api';
|
||||
import { getAccessToken, setAccessToken as storeAccessToken } from './token-store';
|
||||
import {
|
||||
clearIdentity,
|
||||
clearLastUserId,
|
||||
clearLogoutPending,
|
||||
isLogoutPending,
|
||||
loadIdentity,
|
||||
loadLastUserId,
|
||||
markLogoutPending,
|
||||
saveIdentity,
|
||||
saveLastUserId,
|
||||
} from './offline-identity';
|
||||
import { removeOtherUsersData, restoreOfflineShell, wipeDeviceData } from './session-cleanup';
|
||||
import {
|
||||
getAccessToken,
|
||||
setAccessToken as storeAccessToken,
|
||||
setRefreshedTokenHandler,
|
||||
} from './token-store';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
@@ -26,104 +57,459 @@ export interface AuthUser {
|
||||
|
||||
export type LoginOutcome = { mfaRequired: false } | { mfaRequired: true; mfaToken: string };
|
||||
|
||||
/**
|
||||
* online: a refresh succeeded and an access token is held in memory.
|
||||
* offline: opened from the cached identity - no token, so nothing can be sent
|
||||
* to the server until a refresh succeeds again.
|
||||
*/
|
||||
export type SessionMode = 'online' | 'offline';
|
||||
|
||||
export interface LogoutOptions {
|
||||
/** Also delete the unsent entries of this account (the person confirmed it). */
|
||||
discardQueue?: boolean;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
isLoading: boolean;
|
||||
sessionMode: SessionMode;
|
||||
login: (email: string, password: string) => Promise<LoginOutcome>;
|
||||
verifyMfa: (mfaToken: string, code: string) => Promise<void>;
|
||||
completeSso: () => Promise<LoginOutcome>;
|
||||
logout: () => Promise<void>;
|
||||
logout: (options?: LogoutOptions) => Promise<void>;
|
||||
hasPermission: (permission: string) => boolean;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
user: AuthUser | null;
|
||||
mode: SessionMode;
|
||||
}
|
||||
|
||||
const SIGNED_OUT: Session = { user: null, mode: 'online' };
|
||||
// While offline: how often to look for the server again.
|
||||
const RECONNECT_INTERVAL_MS = 30_000;
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
async function applyAccessToken(token: string): Promise<AuthUser | null> {
|
||||
storeAccessToken(token);
|
||||
/**
|
||||
* Loads the profile that belongs to a new access token. The token itself is
|
||||
* only taken into use (storeAccessToken) once the device is prepared for that
|
||||
* user. Sync is closed first: nothing queued may be sent under the old token
|
||||
* meanwhile. The user is null when the profile cannot be loaded; a network
|
||||
* failure is thrown.
|
||||
*/
|
||||
async function loadProfile(token: string): Promise<AuthUser | null> {
|
||||
await cancelSync();
|
||||
try {
|
||||
return await getCurrentUserRequest(token);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Revokes a session whose logout could not reach the server earlier. */
|
||||
async function settlePendingLogout(): Promise<boolean> {
|
||||
if (!isLogoutPending()) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
await logoutRequest();
|
||||
clearLogoutPending();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openOfflineSession(): Promise<Session> {
|
||||
storeAccessToken(null);
|
||||
const identity = await loadIdentity().catch(() => null);
|
||||
return identity ? { user: identity, mode: 'offline' } : SIGNED_OUT;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const connectivity = useConnectivity();
|
||||
const [session, setSession] = useState<Session>(SIGNED_OUT);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const sessionRef = useRef<Session>(SIGNED_OUT);
|
||||
// A logout's cleaning of the device runs on after the user is gone; nobody
|
||||
// signs in before it is done.
|
||||
const cleaningRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const isReconnectingRef = useRef(false);
|
||||
// Raised whenever the session changes or ends. A sign-in or reconnect that
|
||||
// is still on its way checks it after every wait and gives up when it moved.
|
||||
const epochRef = useRef(0);
|
||||
|
||||
const commit = useCallback((next: Session) => {
|
||||
epochRef.current += 1;
|
||||
sessionRef.current = next;
|
||||
setSession(next);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Makes `user` the person this device works for. Another account than the
|
||||
* last one is a user switch: the previous person's packs, caches, drafts
|
||||
* and in-memory data are removed (their queue stays, and is never sent
|
||||
* under this account).
|
||||
*/
|
||||
const prepareDevice = useCallback(
|
||||
async (user: AuthUser): Promise<boolean> => {
|
||||
const lastUserId = await loadLastUserId().then(
|
||||
(id) => ({ id, known: true }),
|
||||
() => ({ id: null, known: false }),
|
||||
);
|
||||
const isSwitch = !lastUserId.known || (lastUserId.id !== null && lastUserId.id !== user.id);
|
||||
if (isSwitch) {
|
||||
// Owner-less legacy entries would be sent under this account.
|
||||
await wipeDeviceData({ keepDraftsOf: user.id, discardOwnerless: true });
|
||||
queryClient.clear();
|
||||
} else {
|
||||
await removeOtherUsersData(user.id).catch(() => undefined);
|
||||
}
|
||||
return isSwitch;
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
/** Only once the session is certain: an aborted sign-in leaves no identity behind. */
|
||||
const takeIntoUse = useCallback(
|
||||
async (token: string, user: AuthUser, isSwitch: boolean) => {
|
||||
storeAccessToken(token);
|
||||
await Promise.allSettled([saveLastUserId(user.id), saveIdentity(user)]);
|
||||
commit({ user, mode: 'online' });
|
||||
if (isSwitch) {
|
||||
void restoreOfflineShell();
|
||||
}
|
||||
},
|
||||
[commit],
|
||||
);
|
||||
|
||||
const signInWithToken = useCallback(
|
||||
async (token: string) => {
|
||||
const epoch = epochRef.current;
|
||||
await cleaningRef.current;
|
||||
const user = await loadProfile(token);
|
||||
if (epochRef.current !== epoch) {
|
||||
return;
|
||||
}
|
||||
if (!user) {
|
||||
commit(SIGNED_OUT);
|
||||
return;
|
||||
}
|
||||
await prepareDevice(user);
|
||||
if (epochRef.current !== epoch) {
|
||||
return;
|
||||
}
|
||||
// A sign-in always refills the worker: the wipe of the last logout may
|
||||
// have emptied it, and the backend address never passes it again.
|
||||
await takeIntoUse(token, user, true);
|
||||
},
|
||||
[commit, prepareDevice, takeIntoUse],
|
||||
);
|
||||
|
||||
/** The session is over on the server: back to login, the queue stays. */
|
||||
const expireSession = useCallback(async () => {
|
||||
epochRef.current += 1;
|
||||
await cancelSync();
|
||||
storeAccessToken(null);
|
||||
const userId = sessionRef.current.user?.id;
|
||||
const waiting = userId
|
||||
? await summarizeQueue(userId).then(
|
||||
(summary) => summary.own + summary.failed,
|
||||
() => 0,
|
||||
)
|
||||
: 0;
|
||||
await clearIdentity().catch(() => undefined);
|
||||
queryClient.clear();
|
||||
toast.error(sessionExpiredWithEntries(waiting), { duration: 10_000 });
|
||||
commit(SIGNED_OUT);
|
||||
}, [commit, queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshRequest()
|
||||
.then(async (result) => {
|
||||
if (!result) {
|
||||
return;
|
||||
let isCurrent = true;
|
||||
|
||||
async function start(): Promise<Session> {
|
||||
// Nothing is sent until a token is set and the offline band reopens sync.
|
||||
await cancelSync();
|
||||
if (isLogoutPending()) {
|
||||
await settlePendingLogout();
|
||||
return SIGNED_OUT;
|
||||
}
|
||||
let refreshed;
|
||||
try {
|
||||
refreshed = await refreshRequest();
|
||||
} catch {
|
||||
return openOfflineSession();
|
||||
}
|
||||
if (!refreshed) {
|
||||
await clearIdentity().catch(() => undefined);
|
||||
return SIGNED_OUT;
|
||||
}
|
||||
try {
|
||||
const user = await loadProfile(refreshed.accessToken);
|
||||
if (!user) {
|
||||
return SIGNED_OUT;
|
||||
}
|
||||
setUser(await applyAccessToken(result.accessToken));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string): Promise<LoginOutcome> => {
|
||||
const result = await loginRequest(email, password);
|
||||
if (isMfaRequired(result)) {
|
||||
return { mfaRequired: true, mfaToken: result.mfaToken };
|
||||
const isSwitch = await prepareDevice(user);
|
||||
storeAccessToken(refreshed.accessToken);
|
||||
await Promise.allSettled([saveLastUserId(user.id), saveIdentity(user)]);
|
||||
if (isSwitch) {
|
||||
void restoreOfflineShell();
|
||||
}
|
||||
return { user, mode: 'online' };
|
||||
} catch {
|
||||
return openOfflineSession();
|
||||
}
|
||||
}
|
||||
setUser(await applyAccessToken(result.accessToken));
|
||||
return { mfaRequired: false };
|
||||
|
||||
start()
|
||||
.catch(() => SIGNED_OUT)
|
||||
.then((next) => {
|
||||
if (isCurrent) {
|
||||
commit(next);
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [commit, prepareDevice]);
|
||||
|
||||
// Offline session: look for the server again, and take a token as soon as
|
||||
// the refresh works. A refresh the server refuses ends the session.
|
||||
const reconnect = useCallback(async () => {
|
||||
if (isReconnectingRef.current || sessionRef.current.mode !== 'offline') {
|
||||
return;
|
||||
}
|
||||
isReconnectingRef.current = true;
|
||||
const epoch = epochRef.current;
|
||||
try {
|
||||
const refreshed = await refreshRequest().catch(() => undefined);
|
||||
// No answer, or the person signed out / switched while waiting.
|
||||
if (refreshed === undefined || epochRef.current !== epoch) {
|
||||
return;
|
||||
}
|
||||
if (!refreshed) {
|
||||
await expireSession();
|
||||
return;
|
||||
}
|
||||
const user = await loadProfile(refreshed.accessToken);
|
||||
if (epochRef.current !== epoch) {
|
||||
return;
|
||||
}
|
||||
if (!user) {
|
||||
await expireSession();
|
||||
return;
|
||||
}
|
||||
const isSwitch = await prepareDevice(user);
|
||||
if (epochRef.current !== epoch) {
|
||||
return;
|
||||
}
|
||||
await takeIntoUse(refreshed.accessToken, user, isSwitch);
|
||||
} catch {
|
||||
// The server went away again: still an offline session.
|
||||
} finally {
|
||||
isReconnectingRef.current = false;
|
||||
}
|
||||
}, [expireSession, prepareDevice, takeIntoUse]);
|
||||
|
||||
const isOfflineSession = session.mode === 'offline' && session.user !== null;
|
||||
useEffect(() => {
|
||||
if (!isOfflineSession) {
|
||||
return;
|
||||
}
|
||||
// Not in the effect body itself: a refresh result sets state.
|
||||
const first = connectivity === 'online' ? setTimeout(() => void reconnect(), 0) : undefined;
|
||||
const handleOnline = () => void reconnect();
|
||||
window.addEventListener('online', handleOnline);
|
||||
const timer = setInterval(() => {
|
||||
if (navigator.onLine !== false) {
|
||||
void reconnect();
|
||||
}
|
||||
}, RECONNECT_INTERVAL_MS);
|
||||
return () => {
|
||||
clearTimeout(first);
|
||||
window.removeEventListener('online', handleOnline);
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [isOfflineSession, connectivity, reconnect]);
|
||||
|
||||
// A silent refresh in the api client (after a 401) may hand out a token of
|
||||
// another account: the refresh cookie is shared by all tabs. Only the
|
||||
// account this tab shows may continue; anyone else ends this session here.
|
||||
useEffect(() => {
|
||||
setRefreshedTokenHandler(async (token) => {
|
||||
const current = sessionRef.current.user;
|
||||
if (!current) {
|
||||
// Nobody is signed in: a refresh must not revive a session.
|
||||
return false;
|
||||
}
|
||||
const epoch = epochRef.current;
|
||||
const profile = await getCurrentUserRequest(token).catch((error: unknown) => {
|
||||
if (isNetworkError(error)) {
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (epochRef.current !== epoch || !profile) {
|
||||
return false;
|
||||
}
|
||||
if (profile.id === current.id) {
|
||||
storeAccessToken(token);
|
||||
if (sessionRef.current.mode === 'offline') {
|
||||
commit({ user: profile, mode: 'online' });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
epochRef.current += 1;
|
||||
await cancelSync();
|
||||
storeAccessToken(null);
|
||||
await clearIdentity().catch(() => undefined);
|
||||
queryClient.clear();
|
||||
commit(SIGNED_OUT);
|
||||
toast.error('Ein anderes Konto wurde angemeldet – bitte neu anmelden.');
|
||||
const isClean = await wipeDeviceData({ keepDraftsOf: null, discardOwnerless: true });
|
||||
if (!isClean) {
|
||||
toast.error('Nicht alle Daten konnten von diesem Gerät gelöscht werden.');
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return () => setRefreshedTokenHandler(null);
|
||||
}, [commit, queryClient]);
|
||||
|
||||
// A logout the server never heard of is revoked at the next network contact.
|
||||
useEffect(() => {
|
||||
const handleOnline = () => void settlePendingLogout();
|
||||
window.addEventListener('online', handleOnline);
|
||||
return () => window.removeEventListener('online', handleOnline);
|
||||
}, []);
|
||||
|
||||
const verifyMfa = useCallback(async (mfaToken: string, code: string) => {
|
||||
const result = await verifyMfaRequest(mfaToken, code);
|
||||
setUser(await applyAccessToken(result.accessToken));
|
||||
/**
|
||||
* A new sign-in starts by revoking an unsent logout: with the old cookie
|
||||
* still alive, the pending logout would otherwise end the new session.
|
||||
*/
|
||||
const beginSignIn = useCallback(async () => {
|
||||
await cleaningRef.current;
|
||||
if (!(await settlePendingLogout())) {
|
||||
throw new TypeError('A pending logout has not reached the server yet.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = useCallback(
|
||||
async (email: string, password: string): Promise<LoginOutcome> => {
|
||||
await beginSignIn();
|
||||
const result = await loginRequest(email, password);
|
||||
if (isMfaRequired(result)) {
|
||||
return { mfaRequired: true, mfaToken: result.mfaToken };
|
||||
}
|
||||
await signInWithToken(result.accessToken);
|
||||
return { mfaRequired: false };
|
||||
},
|
||||
[beginSignIn, signInWithToken],
|
||||
);
|
||||
|
||||
const verifyMfa = useCallback(
|
||||
async (mfaToken: string, code: string) => {
|
||||
const result = await verifyMfaRequest(mfaToken, code);
|
||||
await signInWithToken(result.accessToken);
|
||||
},
|
||||
[signInWithToken],
|
||||
);
|
||||
|
||||
const completeSso = useCallback(async (): Promise<LoginOutcome> => {
|
||||
await beginSignIn();
|
||||
const result = await ssoCompleteRequest();
|
||||
if (isMfaRequired(result)) {
|
||||
return { mfaRequired: true, mfaToken: result.mfaToken };
|
||||
}
|
||||
setUser(await applyAccessToken(result.accessToken));
|
||||
await signInWithToken(result.accessToken);
|
||||
return { mfaRequired: false };
|
||||
}, []);
|
||||
}, [beginSignIn, signInWithToken]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
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.
|
||||
if ("caches" in window) {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((key) => caches.delete(key)));
|
||||
}
|
||||
}, []);
|
||||
const logout = useCallback(
|
||||
async ({ discardQueue = false }: LogoutOptions = {}) => {
|
||||
const current = sessionRef.current.user;
|
||||
let isSignedOut = false;
|
||||
epochRef.current += 1;
|
||||
try {
|
||||
// Nothing may be sent under a session that is being ended.
|
||||
await cancelSync();
|
||||
if (current) {
|
||||
// Photos of an online save that is not handed over yet would be
|
||||
// deleted with the drafts below.
|
||||
await finishHandOvers(current.id).catch(() => undefined);
|
||||
}
|
||||
try {
|
||||
await logoutRequest();
|
||||
clearLogoutPending();
|
||||
} catch {
|
||||
// No network: the cookie lives on. Revoked at the next contact.
|
||||
markLogoutPending();
|
||||
}
|
||||
storeAccessToken(null);
|
||||
await clearIdentity().catch(() => undefined);
|
||||
queryClient.clear();
|
||||
// The user is gone before the device is cleaned: nothing of theirs
|
||||
// stays on screen while the caches are deleted.
|
||||
commit(SIGNED_OUT);
|
||||
isSignedOut = true;
|
||||
const cleaning = wipeDeviceData({
|
||||
keepDraftsOf: null,
|
||||
discardQueueOf: discardQueue && current ? current.id : undefined,
|
||||
});
|
||||
cleaningRef.current = cleaning;
|
||||
const isClean = await cleaning;
|
||||
if (isClean) {
|
||||
await clearLastUserId().catch(() => undefined);
|
||||
} else {
|
||||
toast.error('Nicht alle Daten konnten von diesem Gerät gelöscht werden.');
|
||||
}
|
||||
} finally {
|
||||
if (!isSignedOut) {
|
||||
// The logout was not carried out: the signed-in user keeps syncing.
|
||||
resumeSync();
|
||||
}
|
||||
}
|
||||
},
|
||||
[commit, queryClient],
|
||||
);
|
||||
|
||||
const hasPermission = useCallback(
|
||||
(permission: string) => user?.permissions.includes(permission) ?? false,
|
||||
[user],
|
||||
(permission: string) => session.user?.permissions.includes(permission) ?? false,
|
||||
[session.user],
|
||||
);
|
||||
|
||||
// Re-fetches the profile with the access token already held in memory -
|
||||
// no need to rotate the refresh-token cookie just to reflect a self-edit
|
||||
// (name/email/avatar/MFA change) in the cached user.
|
||||
// (name/email/avatar/MFA change) in the cached user. The token stays the
|
||||
// same, so sync stays open.
|
||||
const refreshUser = useCallback(async () => {
|
||||
const token = getAccessToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
setUser(await applyAccessToken(token));
|
||||
}, []);
|
||||
try {
|
||||
const user = await getCurrentUserRequest(token);
|
||||
commit({ user, mode: sessionRef.current.mode });
|
||||
await saveIdentity(user).catch(() => undefined);
|
||||
} catch (error) {
|
||||
if (!isNetworkError(error)) {
|
||||
commit(SIGNED_OUT);
|
||||
}
|
||||
}
|
||||
}, [commit]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
user: session.user,
|
||||
isLoading,
|
||||
sessionMode: session.mode,
|
||||
login,
|
||||
verifyMfa,
|
||||
completeSso,
|
||||
@@ -142,6 +528,11 @@ export function useCurrentUserId(): string | null {
|
||||
return useContext(AuthContext)?.user?.id ?? null;
|
||||
}
|
||||
|
||||
/** online, unless this is an offline session - usable outside an AuthProvider (tests). */
|
||||
export function useSessionMode(): SessionMode {
|
||||
return useContext(AuthContext)?.sessionMode ?? 'online';
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type DbModule = typeof import("@/lib/offline/db");
|
||||
type LegacyModule = typeof import("./legacy-queue");
|
||||
|
||||
let db: DbModule;
|
||||
let legacy: LegacyModule;
|
||||
|
||||
const INSPECTION = { colonyId: "colony-1", date: "2026-10-01T00:00:00.000Z" };
|
||||
const TREATMENT = { colonyId: "colony-1", date: "2026-10-01T00:00:00.000Z", product: "Oxuvar" };
|
||||
|
||||
/** An entry queued before owners existed (DB v2): it has no ownerUserId. */
|
||||
async function queueOwnerless(): Promise<void> {
|
||||
const connection = await db.getDb();
|
||||
await connection.add("pending-inspections", {
|
||||
input: INSPECTION,
|
||||
queuedAt: "2026-10-01T08:00:00.000Z",
|
||||
} as never);
|
||||
}
|
||||
|
||||
describe("owner-less queue entries", () => {
|
||||
beforeEach(async () => {
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
vi.resetModules();
|
||||
db = await import("@/lib/offline/db");
|
||||
legacy = await import("./legacy-queue");
|
||||
});
|
||||
|
||||
it("deletes only the owner-less entries", async () => {
|
||||
await queueOwnerless();
|
||||
await db.queueTreatment(TREATMENT, "key-legacy-sibling", "user-1");
|
||||
await db.queueInspection(INSPECTION, "key-own", "user-1");
|
||||
await db.queueInspection(INSPECTION, "key-foreign", "user-2");
|
||||
|
||||
expect(await db.summarizeQueue("user-1")).toMatchObject({ own: 3 });
|
||||
|
||||
await legacy.clearOwnerlessEntries();
|
||||
|
||||
// Own and foreign entries stay; the owner-less one is gone.
|
||||
expect(await db.summarizeQueue("user-1")).toEqual({ own: 2, failed: 0, foreign: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { getDb, type QueueStoreName } from "@/lib/offline/db";
|
||||
|
||||
/*
|
||||
* Entries queued before owners existed (DB v2) have no ownerUserId, so
|
||||
* clearQueueOf(userId) never reaches them - yet they are sent under whoever
|
||||
* signs in next. The logout dialog counts them with the person's own entries
|
||||
* (summarizeQueue treats them as own) and a discarding logout deletes them,
|
||||
* so they cannot end up in the next person's account.
|
||||
*/
|
||||
|
||||
type QueuedEntry = { id: number; ownerUserId?: string };
|
||||
|
||||
/** Every store that holds queued entries (all pending-* stores except photos). */
|
||||
async function queueStores(): Promise<QueueStoreName[]> {
|
||||
const db = await getDb();
|
||||
return [...db.objectStoreNames].filter(
|
||||
(name): name is QueueStoreName => name.startsWith("pending-") && name !== "pending-photos",
|
||||
);
|
||||
}
|
||||
|
||||
async function ownerlessEntries(store: QueueStoreName): Promise<QueuedEntry[]> {
|
||||
const db = await getDb();
|
||||
const entries = (await db.getAll(store)) as QueuedEntry[];
|
||||
return entries.filter((entry) => entry.ownerUserId === undefined);
|
||||
}
|
||||
|
||||
export async function clearOwnerlessEntries(): Promise<void> {
|
||||
const db = await getDb();
|
||||
for (const store of await queueStores()) {
|
||||
for (const entry of await ownerlessEntries(store)) {
|
||||
await db.delete(store, entry.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { IDBFactory } from "fake-indexeddb";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AuthUser } from "./auth-context";
|
||||
|
||||
type IdentityModule = typeof import("./offline-identity");
|
||||
type DbModule = typeof import("@/lib/offline/db");
|
||||
|
||||
let identity: IdentityModule;
|
||||
let db: DbModule;
|
||||
|
||||
const USER: AuthUser = {
|
||||
id: "user-1",
|
||||
email: "[email protected]",
|
||||
name: "Test Imker",
|
||||
roleKey: "admin",
|
||||
roleName: "Admin",
|
||||
permissions: ["colonies.read"],
|
||||
mfaEnabled: true,
|
||||
hasAvatar: false,
|
||||
};
|
||||
|
||||
// Compile-time list: a new AuthUser field must be decided on here.
|
||||
const AUTH_USER_KEYS: Record<keyof AuthUser, true> = {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
roleKey: true,
|
||||
roleName: true,
|
||||
permissions: true,
|
||||
mfaEnabled: true,
|
||||
hasAvatar: true,
|
||||
};
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const NOW = Date.parse("2026-10-14T08:00:00.000Z");
|
||||
|
||||
describe("offline identity", () => {
|
||||
beforeEach(async () => {
|
||||
globalThis.indexedDB = new IDBFactory();
|
||||
window.localStorage.clear();
|
||||
vi.resetModules();
|
||||
db = await import("@/lib/offline/db");
|
||||
identity = await import("./offline-identity");
|
||||
});
|
||||
|
||||
it("stores exactly the user fields, never a token", async () => {
|
||||
const withToken = { ...USER, accessToken: "secret-token", refreshToken: "secret-refresh" };
|
||||
|
||||
await identity.saveIdentity(withToken as AuthUser, NOW);
|
||||
|
||||
const stored = (await (await db.getDb()).get("meta", "identity")) as {
|
||||
user: Record<string, unknown>;
|
||||
cachedAt: number;
|
||||
};
|
||||
expect(Object.keys(stored.user).sort()).toEqual(Object.keys(AUTH_USER_KEYS).sort());
|
||||
expect(JSON.stringify(stored)).not.toContain("secret");
|
||||
expect(stored.cachedAt).toBe(NOW);
|
||||
expect(await identity.loadIdentity(NOW)).toEqual(USER);
|
||||
});
|
||||
|
||||
it("expires after 7 days", async () => {
|
||||
await identity.saveIdentity(USER, NOW);
|
||||
|
||||
expect(await identity.loadIdentity(NOW + 7 * DAY_MS)).toEqual(USER);
|
||||
expect(await identity.loadIdentity(NOW + 7 * DAY_MS + 1)).toBeNull();
|
||||
// Gone for good, not only hidden.
|
||||
expect(await (await db.getDb()).get("meta", "identity")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns null without an identity and after it was cleared", async () => {
|
||||
expect(await identity.loadIdentity(NOW)).toBeNull();
|
||||
await identity.saveIdentity(USER, NOW);
|
||||
await identity.clearIdentity();
|
||||
|
||||
expect(await identity.loadIdentity(NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it("discards a stored value that is not a complete user", async () => {
|
||||
await (await db.getDb()).put("meta", { user: { id: "user-1" }, cachedAt: NOW }, "identity");
|
||||
|
||||
expect(await identity.loadIdentity(NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it("remembers which user was signed in last, apart from the identity", async () => {
|
||||
expect(await identity.loadLastUserId()).toBeNull();
|
||||
await identity.saveLastUserId("user-1");
|
||||
await identity.clearIdentity();
|
||||
|
||||
expect(await identity.loadLastUserId()).toBe("user-1");
|
||||
|
||||
await identity.clearLastUserId();
|
||||
expect(await identity.loadLastUserId()).toBeNull();
|
||||
});
|
||||
|
||||
it("notes a pending logout on this device until it is cleared", () => {
|
||||
expect(identity.isLogoutPending()).toBe(false);
|
||||
|
||||
identity.markLogoutPending();
|
||||
expect(identity.isLogoutPending()).toBe(true);
|
||||
expect(window.localStorage.getItem("apicore:logout-pending")).not.toBeNull();
|
||||
|
||||
identity.clearLogoutPending();
|
||||
expect(identity.isLogoutPending()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { getDb } from "@/lib/offline/db";
|
||||
import type { AuthUser } from "./auth-context";
|
||||
|
||||
/*
|
||||
* Who was signed in, so the app can open without network (offline session).
|
||||
* Only the profile fields live here - no access token, no refresh token: they
|
||||
* stay in memory and in the httpOnly cookie. An offline session therefore can
|
||||
* show cached data and queue input, but it cannot talk to the server until a
|
||||
* refresh succeeds again.
|
||||
*/
|
||||
|
||||
/** As long as the refresh session itself lives (SESSION_MAX_AGE of the backend). */
|
||||
export const OFFLINE_IDENTITY_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const IDENTITY_KEY = "identity";
|
||||
const LAST_USER_KEY = "lastUserId";
|
||||
const LOGOUT_PENDING_KEY = "apicore:logout-pending";
|
||||
|
||||
interface StoredIdentity {
|
||||
user: AuthUser;
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
/** Copies the known fields only - whatever else the object carries is dropped. */
|
||||
function pickUser(user: AuthUser): AuthUser {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
roleKey: user.roleKey,
|
||||
roleName: user.roleName,
|
||||
permissions: [...user.permissions],
|
||||
mfaEnabled: user.mfaEnabled,
|
||||
hasAvatar: user.hasAvatar,
|
||||
};
|
||||
}
|
||||
|
||||
function isUser(value: unknown): value is AuthUser {
|
||||
const user = value as Partial<AuthUser> | null;
|
||||
return (
|
||||
typeof user?.id === "string" &&
|
||||
typeof user.email === "string" &&
|
||||
typeof user.name === "string" &&
|
||||
typeof user.roleKey === "string" &&
|
||||
typeof user.roleName === "string" &&
|
||||
Array.isArray(user.permissions) &&
|
||||
user.permissions.every((permission) => typeof permission === "string") &&
|
||||
typeof user.mfaEnabled === "boolean" &&
|
||||
typeof user.hasAvatar === "boolean"
|
||||
);
|
||||
}
|
||||
|
||||
function isStoredIdentity(value: unknown): value is StoredIdentity {
|
||||
const stored = value as Partial<StoredIdentity> | null;
|
||||
return typeof stored?.cachedAt === "number" && isUser(stored.user);
|
||||
}
|
||||
|
||||
export async function saveIdentity(user: AuthUser, now: number = Date.now()): Promise<void> {
|
||||
const db = await getDb();
|
||||
const stored: StoredIdentity = { user: pickUser(user), cachedAt: now };
|
||||
await db.put("meta", stored, IDENTITY_KEY);
|
||||
}
|
||||
|
||||
/** The cached user, or null when there is none, it is incomplete or older than 7 days. */
|
||||
export async function loadIdentity(now: number = Date.now()): Promise<AuthUser | null> {
|
||||
const db = await getDb();
|
||||
const stored = await db.get("meta", IDENTITY_KEY);
|
||||
if (isStoredIdentity(stored) && now - stored.cachedAt <= OFFLINE_IDENTITY_MAX_AGE_MS) {
|
||||
return pickUser(stored.user);
|
||||
}
|
||||
if (stored !== undefined) {
|
||||
await db.delete("meta", IDENTITY_KEY);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function clearIdentity(): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete("meta", IDENTITY_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user who was signed in last - kept when the session just expires, so
|
||||
* the next sign-in of another account is recognised as a user switch.
|
||||
*/
|
||||
export async function loadLastUserId(): Promise<string | null> {
|
||||
const db = await getDb();
|
||||
const stored = await db.get("meta", LAST_USER_KEY);
|
||||
return typeof stored === "string" ? stored : null;
|
||||
}
|
||||
|
||||
export async function saveLastUserId(userId: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.put("meta", userId, LAST_USER_KEY);
|
||||
}
|
||||
|
||||
export async function clearLastUserId(): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete("meta", LAST_USER_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* A logout without network could not revoke the session on the server. This
|
||||
* note (no secret in it) makes the next start or sign-in do that first.
|
||||
* localStorage on purpose: it must be readable synchronously and survive a
|
||||
* failing IndexedDB.
|
||||
*/
|
||||
export function markLogoutPending(): void {
|
||||
try {
|
||||
window.localStorage.setItem(LOGOUT_PENDING_KEY, "1");
|
||||
} catch {
|
||||
// Storage unavailable: the cookie then expires on its own.
|
||||
}
|
||||
}
|
||||
|
||||
export function isLogoutPending(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(LOGOUT_PENDING_KEY) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearLogoutPending(): void {
|
||||
try {
|
||||
window.localStorage.removeItem(LOGOUT_PENDING_KEY);
|
||||
} catch {
|
||||
// Storage unavailable - nothing stored either.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { deleteAllPacks } from "@/lib/apiaries/offline-pack";
|
||||
import { clearAllFormDrafts } from "@/lib/forms/draft-storage";
|
||||
import { clearQueueOf } from "@/lib/offline/db";
|
||||
import { forgetHandOversExcept } from "@/lib/photos/handover";
|
||||
import { discardDraftPhotosExcept } from "@/lib/photos/store";
|
||||
import { clearAllRounds } from "@/lib/rounds/round-state";
|
||||
import { clearOwnerlessEntries } from "./legacy-queue";
|
||||
import {
|
||||
removeOtherUsersData,
|
||||
restoreOfflineShell,
|
||||
wipeCacheStorage,
|
||||
wipeDeviceData,
|
||||
} from "./session-cleanup";
|
||||
|
||||
vi.mock("@/lib/apiaries/offline-pack", () => ({ deleteAllPacks: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock("@/lib/forms/draft-storage", () => ({ clearAllFormDrafts: vi.fn() }));
|
||||
vi.mock("@/lib/offline/db", () => ({ clearQueueOf: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock("@/lib/photos/handover", () => ({ forgetHandOversExcept: vi.fn() }));
|
||||
vi.mock("@/lib/photos/store", () => ({
|
||||
discardDraftPhotosExcept: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rounds/round-state", () => ({ clearAllRounds: vi.fn() }));
|
||||
vi.mock("./legacy-queue", () => ({ clearOwnerlessEntries: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
let cacheKeys: string[];
|
||||
let deleteCache: ReturnType<typeof vi.fn>;
|
||||
let postMessage: ReturnType<typeof vi.fn>;
|
||||
|
||||
function stubServiceWorker(replyWith: "done" | "silence"): void {
|
||||
postMessage = vi.fn((_message: unknown, [port]: MessagePort[]) => {
|
||||
if (replyWith === "done") {
|
||||
port.postMessage({ type: "WIPE_CACHES_DONE" });
|
||||
}
|
||||
});
|
||||
vi.stubGlobal("navigator", {
|
||||
...navigator,
|
||||
serviceWorker: { getRegistration: async () => ({ active: { postMessage } }) },
|
||||
});
|
||||
}
|
||||
|
||||
describe("session cleanup", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cacheKeys = ["apicore-shell-v4", "apicore-data-v2"];
|
||||
deleteCache = vi.fn().mockResolvedValue(true);
|
||||
vi.stubGlobal("caches", { keys: async () => cacheKeys, delete: deleteCache });
|
||||
stubServiceWorker("done");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("wipeCacheStorage", () => {
|
||||
it("tells the service worker first, then deletes every cache", async () => {
|
||||
await wipeCacheStorage();
|
||||
|
||||
expect(postMessage).toHaveBeenCalledWith({ type: "WIPE_CACHES" }, expect.any(Array));
|
||||
expect(deleteCache).toHaveBeenCalledWith("apicore-shell-v4");
|
||||
expect(deleteCache).toHaveBeenCalledWith("apicore-data-v2");
|
||||
expect(postMessage.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
deleteCache.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("does not wait for a service worker that never answers", async () => {
|
||||
vi.useFakeTimers();
|
||||
stubServiceWorker("silence");
|
||||
|
||||
const wiped = wipeCacheStorage();
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await wiped;
|
||||
|
||||
expect(deleteCache).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("still deletes the caches without a service worker", async () => {
|
||||
vi.stubGlobal("navigator", { ...navigator, serviceWorker: undefined });
|
||||
Reflect.deleteProperty(navigator, "serviceWorker");
|
||||
|
||||
await wipeCacheStorage();
|
||||
|
||||
expect(deleteCache).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wipeDeviceData", () => {
|
||||
it("removes what the person left on the device and keeps their queue by default", async () => {
|
||||
const ok = await wipeDeviceData({ keepDraftsOf: null });
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(clearAllFormDrafts).toHaveBeenCalled();
|
||||
expect(clearAllRounds).toHaveBeenCalled();
|
||||
expect(deleteAllPacks).toHaveBeenCalled();
|
||||
expect(discardDraftPhotosExcept).toHaveBeenCalledWith(null);
|
||||
expect(forgetHandOversExcept).toHaveBeenCalledWith(null);
|
||||
expect(deleteCache).toHaveBeenCalledTimes(2);
|
||||
expect(clearQueueOf).not.toHaveBeenCalled();
|
||||
expect(clearOwnerlessEntries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("also deletes the queue of the given account and every owner-less entry on request", async () => {
|
||||
await wipeDeviceData({ keepDraftsOf: null, discardQueueOf: "user-1" });
|
||||
|
||||
expect(clearQueueOf).toHaveBeenCalledWith("user-1");
|
||||
expect(clearOwnerlessEntries).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes owner-less legacy entries on a user switch without touching anyone's queue", async () => {
|
||||
await wipeDeviceData({ keepDraftsOf: "user-2", discardOwnerless: true });
|
||||
|
||||
expect(clearOwnerlessEntries).toHaveBeenCalled();
|
||||
expect(clearQueueOf).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps going when one step fails and reports it", async () => {
|
||||
vi.mocked(deleteAllPacks).mockRejectedValueOnce(new Error("IndexedDB blocked"));
|
||||
|
||||
const ok = await wipeDeviceData({ keepDraftsOf: null });
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(discardDraftPhotosExcept).toHaveBeenCalled();
|
||||
expect(deleteCache).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeOtherUsersData", () => {
|
||||
it("drops the unsaved photos and hand-overs of every other account", async () => {
|
||||
await removeOtherUsersData("user-2");
|
||||
|
||||
expect(discardDraftPhotosExcept).toHaveBeenCalledWith("user-2");
|
||||
expect(forgetHandOversExcept).toHaveBeenCalledWith("user-2");
|
||||
expect(deleteAllPacks).not.toHaveBeenCalled();
|
||||
expect(deleteCache).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreOfflineShell", () => {
|
||||
it("lets the service worker see the runtime config again, then warms the shell", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response("{}"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const warm = vi.fn();
|
||||
vi.stubGlobal("navigator", {
|
||||
...navigator,
|
||||
serviceWorker: { getRegistration: async () => ({ active: { postMessage: warm } }) },
|
||||
});
|
||||
|
||||
await restoreOfflineShell();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/runtime-config", expect.anything());
|
||||
expect(warm).toHaveBeenCalledWith({ type: "WARM_SHELL" });
|
||||
expect(fetchMock.mock.invocationCallOrder[0]).toBeLessThan(warm.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it("never throws, whatever is missing", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("offline")));
|
||||
Reflect.deleteProperty(navigator, "serviceWorker");
|
||||
|
||||
await expect(restoreOfflineShell()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { deleteAllPacks } from "@/lib/apiaries/offline-pack";
|
||||
import { clearAllFormDrafts } from "@/lib/forms/draft-storage";
|
||||
import { clearQueueOf } from "@/lib/offline/db";
|
||||
import { forgetHandOversExcept } from "@/lib/photos/handover";
|
||||
import { discardDraftPhotosExcept } from "@/lib/photos/store";
|
||||
import { clearAllRounds } from "@/lib/rounds/round-state";
|
||||
import { clearOwnerlessEntries } from "./legacy-queue";
|
||||
|
||||
/*
|
||||
* What a person leaves on a shared phone besides their session: unfinished
|
||||
* forms, a round, offline packs, unsaved photos, hand-over notes and the
|
||||
* service worker's copies of business data. Signing out and switching users
|
||||
* remove it; the queue of unsent entries is only deleted when asked for.
|
||||
*/
|
||||
|
||||
// The worker answers within milliseconds; a hung one must not block a logout.
|
||||
const SW_WIPE_TIMEOUT_MS = 3_000;
|
||||
|
||||
/** Asks the active service worker to wipe its caches and waits for its answer. */
|
||||
async function wipeServiceWorkerCaches(): Promise<void> {
|
||||
if (typeof navigator === "undefined" || !navigator.serviceWorker) {
|
||||
return;
|
||||
}
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
const worker = registration?.active;
|
||||
if (!worker) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
const channel = new MessageChannel();
|
||||
const timer = setTimeout(resolve, SW_WIPE_TIMEOUT_MS);
|
||||
channel.port1.onmessage = () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
worker.postMessage({ type: "WIPE_CACHES" }, [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes everything in Cache Storage. The worker goes first: it stops every
|
||||
* running request from writing into the cache afterwards (WIPE_CACHES).
|
||||
*/
|
||||
export async function wipeCacheStorage(): Promise<void> {
|
||||
await wipeServiceWorkerCaches().catch(() => undefined);
|
||||
if (typeof caches === "undefined") {
|
||||
return;
|
||||
}
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((key) => caches.delete(key)));
|
||||
}
|
||||
|
||||
interface WipeOptions {
|
||||
/** Signed out: null. Switched to this user: their own unsaved photos stay. */
|
||||
keepDraftsOf: string | null;
|
||||
/** Also delete this account's queue (and the owner-less legacy entries). */
|
||||
discardQueueOf?: string;
|
||||
/**
|
||||
* Also delete the entries that belong to no account (queued before owners
|
||||
* existed): they are sent under whoever signs in, so a user switch must
|
||||
* not leave them for the new account.
|
||||
*/
|
||||
discardOwnerless?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the previous person's data from this device. Every step is tried
|
||||
* even when another failed; the result says whether all of them worked.
|
||||
*/
|
||||
export async function wipeDeviceData({
|
||||
keepDraftsOf,
|
||||
discardQueueOf,
|
||||
discardOwnerless = false,
|
||||
}: WipeOptions): Promise<boolean> {
|
||||
const steps: Array<() => void | Promise<void>> = [
|
||||
clearAllFormDrafts,
|
||||
clearAllRounds,
|
||||
deleteAllPacks,
|
||||
() => discardDraftPhotosExcept(keepDraftsOf),
|
||||
() => forgetHandOversExcept(keepDraftsOf),
|
||||
wipeCacheStorage,
|
||||
];
|
||||
if (discardQueueOf !== undefined) {
|
||||
steps.push(() => clearQueueOf(discardQueueOf));
|
||||
}
|
||||
if (discardQueueOf !== undefined || discardOwnerless) {
|
||||
steps.push(clearOwnerlessEntries);
|
||||
}
|
||||
const results = await Promise.allSettled(steps.map(async (step) => step()));
|
||||
return results.every((result) => result.status === "fulfilled");
|
||||
}
|
||||
|
||||
/**
|
||||
* The same account signs in again: nothing of theirs is removed, but what
|
||||
* other accounts left unsaved (photos, hand-overs) is not carried over.
|
||||
*/
|
||||
export async function removeOtherUsersData(userId: string): Promise<void> {
|
||||
await discardDraftPhotosExcept(userId);
|
||||
forgetHandOversExcept(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* After the caches were wiped in this page's lifetime the service worker is
|
||||
* blind: the runtime config (where the backend is) never passes it again, and
|
||||
* the shell is gone. Fetching the config once teaches it the backend; then it
|
||||
* refills the shell. Best effort, never throws.
|
||||
*/
|
||||
export async function restoreOfflineShell(): Promise<void> {
|
||||
try {
|
||||
await fetch("/api/runtime-config", { cache: "no-store" });
|
||||
if (typeof navigator === "undefined" || !navigator.serviceWorker) {
|
||||
return;
|
||||
}
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
registration?.active?.postMessage({ type: "WARM_SHELL" });
|
||||
} catch {
|
||||
// Offline or no worker: the next online visit refills it.
|
||||
}
|
||||
}
|
||||
@@ -7,3 +7,25 @@ export function getAccessToken(): string | null {
|
||||
export function setAccessToken(token: string | null): void {
|
||||
currentAccessToken = token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether an access token that came out of a silent refresh (a 401 in
|
||||
* the api client) may be used. The refresh cookie is shared by every tab, so
|
||||
* it can belong to another account than the one this tab shows.
|
||||
*/
|
||||
export type RefreshedTokenHandler = (token: string) => Promise<boolean>;
|
||||
|
||||
let refreshedTokenHandler: RefreshedTokenHandler | null = null;
|
||||
|
||||
export function setRefreshedTokenHandler(handler: RefreshedTokenHandler | null): void {
|
||||
refreshedTokenHandler = handler;
|
||||
}
|
||||
|
||||
/** True when the token was taken into use and the failed request may be retried. */
|
||||
export async function acceptRefreshedToken(token: string): Promise<boolean> {
|
||||
if (!refreshedTokenHandler) {
|
||||
setAccessToken(token);
|
||||
return true;
|
||||
}
|
||||
return refreshedTokenHandler(token);
|
||||
}
|
||||
Reference in new issue
Block a user