Files
app/frontend/src/main.tsx
T
stroblme 8ef78678a0 Replace sonner toasts with one top-centre notification stack
The app said things in two places: sonner's bottom-right toasts and the
custom top-centre chrome. This is the second of those, generalised — a
module-level store anyone can call, drawn as a stack of frosted cards that go
on their own unless raised as persistent, with room for buttons and for a
message longer than a line.

useCustomToast keeps its signatures, so the forty-odd call sites behind it and
handleError are untouched. The component and its store are byte-identical with
the portal's copy and checked by `make design-check`.
2026-08-27 16:00:47 +02:00

130 lines
4.4 KiB
TypeScript

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 { 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 installation 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")) {
localStorage.removeItem("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.installationId}`
return
}
localStorage.removeItem("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,
},
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
}
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<StrictMode>
<ThemeProvider defaultTheme="system" storageKey="fluksio-ui-theme">
<MotionConfig reducedMotion="user">
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<Notifications />
</QueryClientProvider>
</MotionConfig>
</ThemeProvider>
</StrictMode>,
)