// OrgGate — gates the entire /app/* subtree on having a workspace // selected, both client- and server-side. // // The user can land without a current workspace in several ways: // - brand-new signup → orgs.length === 0 // - removed from every org they were in // - they explicitly cleared currentOrganization // - new login, persisted `currentOrganization` was wiped on logout // - they have orgs but never round-tripped a /organization/switch // // Resolution rules: // - 0 orgs: send to /select-org to create or accept an invite. // - 1 org with no current selection: pick it server-side and stay. // (Auto-pick is safe here because the choice is forced.) // - 2+ orgs with no current selection: send to /select-org to pick. // - current org no longer in membership list: send to /select-org. // - current org still a member but never synced to the server session // this load: re-POST the switch so the DB session row matches the // locally-persisted pointer (see below). // // Why the re-sync matters: the backend resolves org context from the // *server session* (sessions.current_organization_id), loaded fresh // from Postgres on every request — NOT from the JWT. A fresh login // starts with current_organization_id = NULL. The zustand // `currentOrganization` is persisted across logins, so on the next // login it's already populated and `stillMember` is true — which used // to early-return and skip the switch entirely. The UI then showed an // org as selected while the session row stayed NULL, so user-scoped // tabs (emails) worked but org-scoped tabs (contacts, campaigns) 4xx'd // with "no organization selected". We now POST the switch once per // mount to guarantee the session row matches the local pointer. import { useEffect, useRef } from "react"; import { useNavigate } from "@tanstack/react-router"; import useOrganizations from "@/lib/api/hooks/app/organizations/useOrganizations"; import useSwitchOrganization from "@/lib/api/hooks/app/organizations/useSwitchOrganization"; import { useAppStore } from "@/stores"; import { isOrgSynced } from "@/lib/boot"; export function OrgGate() { const navigate = useNavigate(); const orgs = useOrganizations(); const setOrganizations = useAppStore((s) => s.setOrganizations); const setCurrentOrganization = useAppStore((s) => s.setCurrentOrganization); const currentOrg = useAppStore((s) => s.currentOrganization); // Destructure `mutate` — react-query returns a new wrapper object // each render, but the bound mutate fn is stable. Putting the // whole mutation object in the effect deps below caused the effect // to fire on every render → repeated navigate() calls into // /select-org → "Too many calls to Location or History APIs" and a // setState loop from setOrganizations re-firing each time. // "sync" mode: this is session reconciliation, NOT a workspace change, so // it must INVALIDATE (refetch in place) rather than removeQueries — the // latter orphans the dashboard's in-flight bootstrap queries and hangs the // first paint after login. See useSwitchOrganization. const { mutate: switchOrgMutate } = useSwitchOrganization({ mode: "sync" }); // Guard against the single-org auto-pick re-firing while the // mutation is in flight (effect re-runs on data changes). const autoPickInFlight = useRef(false); // Remember the redirect target we last fired for the current // (list-shape, currentOrg) tuple. Without this guard, a state // update inside this effect (setOrganizations clearing a stale // currentOrganization to null) re-runs the effect before the // router has unmounted us, and we hit history.replaceState twice // in the same task — which throws DOMException in Firefox. const lastRedirectKey = useRef(null); // The org id we've already re-synced to the server session this // mount. Without this the stillMember branch would re-POST the // switch on every effect run (e.g. when orgs.data settles), and the // "sync" invalidate on success would re-fire each time → refetch // storm. One sync per (mount, org) is enough. const syncedOrgId = useRef(null); useEffect(() => { if (orgs.isPending) return; const list = orgs.data ?? []; setOrganizations(list); const stillMember = currentOrg ? list.some((o) => o.id === currentOrg.id) : false; if (stillMember && currentOrg) { lastRedirectKey.current = null; // Persisted local pointer is valid, but a fresh login's // session row may still be NULL. Re-POST the switch once so // the server session matches what the UI is showing. // The dashboard boot normally put it there already. if (syncedOrgId.current !== currentOrg.id && !isOrgSynced(currentOrg.id)) { syncedOrgId.current = currentOrg.id; switchOrgMutate(currentOrg.id, { onError: () => { // Let it retry on a later run if the sync failed. syncedOrgId.current = null; }, }); } return; } if (list.length === 0) { const key = "empty"; if (lastRedirectKey.current === key) return; lastRedirectKey.current = key; navigate({ to: "/select-org", replace: true }); return; } if (list.length === 1) { if (autoPickInFlight.current) return; autoPickInFlight.current = true; const only = list[0]; switchOrgMutate(only.id, { onSuccess: () => { syncedOrgId.current = only.id; setCurrentOrganization(only); }, onError: () => navigate({ to: "/select-org", replace: true }), onSettled: () => { autoPickInFlight.current = false; }, }); return; } const key = `multi:${list.map((o) => o.id).join(",")}`; if (lastRedirectKey.current === key) return; lastRedirectKey.current = key; navigate({ to: "/select-org", replace: true }); }, [ orgs.isPending, orgs.data, currentOrg, setOrganizations, setCurrentOrganization, switchOrgMutate, navigate, ]); return null; }