114 lines
5.2 KiB
TypeScript
114 lines
5.2 KiB
TypeScript
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { authApi } from "@/api/auth";
|
|
import { navigateTopLevel } from "@/lib/browserNavigation";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
import { useCloudInstance } from "./useCloudInstance";
|
|
|
|
const CLOUD_SIGN_OUT_PATH = "/cloud/logout";
|
|
|
|
/**
|
|
* Query-key roots that describe the *instance* rather than the account that was
|
|
* signed in, and so are allowed to outlive a sign-out.
|
|
*
|
|
* `health` is the only one. It carries deployment mode, bootstrap state and
|
|
* Cloud metadata — nothing account-scoped — and `useCloudInstance` observes it
|
|
* with `enabled: false`, deliberately leaving the fetch to CloudAccessGate.
|
|
* Dropping the entry would strand every such observer on `null` until the gate
|
|
* happened to refetch, flipping Cloud instances into their self-hosted
|
|
* rendering mid-sign-out. It is refreshed in place instead.
|
|
*
|
|
* Everything *not* listed here is treated as account-scoped and cleared. That
|
|
* direction is the point: a query key added later is account-scoped unless
|
|
* someone deliberately says otherwise, so forgetting this file fails closed.
|
|
*/
|
|
const INSTANCE_SCOPED_QUERY_ROOTS: readonly unknown[] = [queryKeys.health[0]];
|
|
|
|
export function isAccountScopedQueryKey(queryKey: readonly unknown[]): boolean {
|
|
return !INSTANCE_SCOPED_QUERY_ROOTS.includes(queryKey[0]);
|
|
}
|
|
|
|
interface UseSignOutOptions {
|
|
onSignedOut?: () => void;
|
|
}
|
|
|
|
/**
|
|
* Owns the app-wide sign-out decision.
|
|
*
|
|
* Cloud-managed tenants must enter the harness-owned logout sequence without
|
|
* first clearing the tenant session; that path is a top-level navigation, so
|
|
* the document reload it triggers builds a new QueryClient and there is nothing
|
|
* left here to clear. Authenticated self-hosted instances keep the local API
|
|
* flow and drop the account-scoped caches afterward.
|
|
*/
|
|
export function useSignOut({ onSignedOut }: UseSignOutOptions = {}) {
|
|
const cloud = useCloudInstance();
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async () => {
|
|
if (cloud) {
|
|
onSignedOut?.();
|
|
navigateTopLevel(CLOUD_SIGN_OUT_PATH);
|
|
return "cloud" as const;
|
|
}
|
|
|
|
await authApi.signOut();
|
|
return "self-hosted" as const;
|
|
},
|
|
onSuccess: async (target) => {
|
|
if (target === "cloud") return;
|
|
|
|
onSignedOut?.();
|
|
|
|
// Drop every account-scoped cache entry, rather than invalidating a
|
|
// couple of them. `invalidateQueries` only marks an entry stale and goes
|
|
// on serving the old value until a refetch succeeds, and several of these
|
|
// queries set `retry: false`, so a single failed request is enough to
|
|
// leave the previous account's data readable for the whole of the *next*
|
|
// account's session.
|
|
//
|
|
// The company list is no longer the example: it is keyed by account, so
|
|
// the next account reads a different entry regardless of what happens
|
|
// here. Everything else still is — the board-access gate, per-company
|
|
// details and stats, and every account-scoped key added since — which is
|
|
// why this sweeps by predicate rather than naming the keys it knows.
|
|
//
|
|
// `resetQueries` rather than `removeQueries`: removal empties the cache
|
|
// but does not notify the observers already subscribed to those entries,
|
|
// so a mounted `useQuery` keeps returning its last result until some
|
|
// unrelated re-render happens to rebuild the query. That is not a corner
|
|
// case here — CompanyProvider sits above the router and stays mounted
|
|
// across the whole sign-out/sign-in cycle. Reset notifies them, so the
|
|
// old data is gone from the cache *and* from everything reading it.
|
|
//
|
|
// Measured against query-core 5.101.4 rather than reasoned about:
|
|
// removal produced 0 notifications and left the observer holding the
|
|
// signed-out account's session; reset produced 3 and null. The
|
|
// consequence of the former is not just stale reads — the redirect in
|
|
// CloudAccessGate fires on the session going empty, and it never runs.
|
|
//
|
|
// The opposite advice holds for a *local* reset of a key an observer is
|
|
// still mounted against. Reset rewinds the update counters
|
|
// `isFetchedAfterMount` derives from while that observer keeps its
|
|
// bind-time baseline, so the flag can never read true again and anything
|
|
// gated on it withholds forever. `AppsConnect` is the consumer to check
|
|
// against: it gates its render on that flag for two queries.
|
|
//
|
|
// Sign-out is not that case. It resets the session too, so `AppsConnect`
|
|
// — which sits behind a route — unmounts on the redirect and remounts
|
|
// with a fresh baseline.
|
|
//
|
|
// Not awaited: the refetches this kicks off are expected to 401 now that
|
|
// the session is gone, and the sign-out button should not sit pending
|
|
// while they fail.
|
|
void queryClient.resetQueries({
|
|
predicate: (query) => isAccountScopedQueryKey(query.queryKey),
|
|
});
|
|
|
|
// Instance-scoped, so refreshed in place rather than dropped — see
|
|
// INSTANCE_SCOPED_QUERY_ROOTS.
|
|
await queryClient.invalidateQueries({ queryKey: queryKeys.health });
|
|
},
|
|
});
|
|
}
|