import { MutationCache, QueryCache, QueryClient, QueryClientProvider, } from "@tanstack/react-query" import { createRouter, RouterProvider } from "@tanstack/react-router" import { MotionConfig } from "motion/react" import { StrictMode } from "react" import ReactDOM from "react-dom/client" import { ApiError, OpenAPI } from "./client" import { PageLoading } from "./components/Common/Loading" import { Notifications } from "./components/Common/Notifications" import { ThemeProvider } from "./components/theme-provider" import "./index.css" import { connectionStore, offlineDetail } from "./lib/connectionStore" import { notify } from "./lib/notificationStore" import { apiToken, appPath, appRoute, portalConfig } from "./lib/portal" import { safeStorage } from "./lib/safeStorage" import { registerSW } from "./lib/webpush" import { routeTree } from "./routeTree.gen" const portal = portalConfig() // Served through a portal, the API is a path on this same origin and the // credential arrives with the page. Everywhere else this is the build-time URL // and the token in storage, exactly as before. OpenAPI.BASE = portal ? window.location.origin + portal.apiBase : import.meta.env.VITE_API_URL OpenAPI.TOKEN = async () => apiToken() /** * A credential the server will not accept, so the session is over. * * Only a 401 says that. `get_current_user` answers 401 for every * authentication failure it has, which leaves 403 meaning the opposite: signed * in, and reaching past what this account is allowed. */ const isSessionGone = (error: unknown) => error instanceof ApiError && error.status === 401 /** Signed in, but not permitted this. Nothing to do but say so. */ const isForbidden = (error: unknown) => error instanceof ApiError && error.status === 403 /** Neither answer changes on a second ask, so a retry only delays the news. */ const isPointlessToRetry = (error: unknown) => isSessionGone(error) || isForbidden(error) const handleApiError = (error: Error) => { const offline = offlineDetail(error) if (offline) { // The instance is unreachable, not the session invalid: keep the user // where they are and let the banner explain. connectionStore.setOffline(offline.lastSeen) return } if (isSessionGone(error)) { // A paired wall panel has no login screen to go back to — it asks for a // new code instead. if (appRoute().startsWith("/panel")) { safeStorage.remove("access_token") window.location.href = appPath("/panel") return } if (portal) { // The portal knows whether they are still signed in; it can mint a fresh // handoff or send them to the login screen. window.location.href = `${portal.portalUrl}?reauth=${portal.instanceId}` return } safeStorage.remove("access_token") window.location.href = appPath("/login") return } if (isForbidden(error) && !appRoute().startsWith("/panel")) { // The session stands, so stay put — but a refused change that says nothing // reads as a broken button. On a panel there is nobody there to read it: a // 403 there is a dashboard it was just unassigned from, which its next read // of the panel corrects on its own. notify("You do not have permission to do that", "error") } } const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: handleApiError, // Any answer at all means the tunnel is up again. onSuccess: () => connectionStore.setOnline(), }), mutationCache: new MutationCache({ onError: handleApiError, }), defaultOptions: { queries: { // Retrying an expired session only delays the trip to the login screen. retry: (count, error) => !isPointlessToRetry(error) && count < 3, // Below every poll interval on any screen, so nothing polls less often // than it did — but a route that mounts twice in a few seconds, or a // tab coming back to the foreground, stops refetching everything it // touches. Home alone reads about ten queries. staleTime: 5_000, // The socket pushes what changes, and a reconnect invalidates what it // feeds, so a focus event has nothing of its own to tell us. refetchOnWindowFocus: false, }, mutations: { retry: false, }, }, }) const router = createRouter({ routeTree, basepath: portal?.basePath, // Routes are code-split, so going somewhere new fetches a chunk first. Under // the threshold that arrives faster than a spinner would be worth looking at. defaultPendingComponent: PageLoading, defaultPendingMs: 250, }) declare module "@tanstack/react-router" { interface Register { router: typeof router } } // The worker only exists to receive pushes, so registering it costs a request // and nothing else. A browser that cannot have one is left alone. registerSW() ReactDOM.createRoot(document.getElementById("root")!).render( , )