Hold a new dashboard back until someone publishes it

A dashboard went live the moment it was created — an empty document straight to
the panels — while a new flow starts as a draft. It now works the way flows do:
published means `dashboard.json` exists, so every dashboard on every running
installation is already published and nothing needs migrating. Only the ones
created from here on start as drafts.

Mirroring FlowStore turned up a latent 500: discarding the draft of a dashboard
that had never been published unlinked its only file, and the read that followed
raised out of a 200 handler. It answers 400 now, the way a flow does.

Publishing all of them was 2N requests, because a publish has to name the
version it expects and the summaries did not carry one. They do now — and so do
the flow summaries, which had the same defect nobody had written down.

A panel had no way to hear about any of this. A publish, or a change to which
dashboards a panel carries, now puts one event on the bus and the screen
refetches what changed: no reload, so a wall display never blanks or asks for
its credential again. The subtle half is that a socket's message allowlist was
computed once at handshake — a reassigned panel would have fetched its new
document and then shown tiles that never updated.

The panels dialog logged non-superusers out. Every write in it needs a
superuser, not only the checkboxes the report mentioned, so the dialog is
read-only for everyone else. The logout itself was `main.tsx` treating 403 as a
dead session, against the contract deps.py spells out: only a 401 ends a
session, and a 403 now says so rather than silently signing someone out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
This commit is contained in:
2026-08-21 14:32:57 +02:00
co-authored by Claude Opus 5
parent 000c5abf91
commit c3ea884d72
14 changed files with 268 additions and 115 deletions
+31 -12
View File
@@ -8,6 +8,7 @@ import { createRouter, RouterProvider } from "@tanstack/react-router"
import { MotionConfig } from "motion/react"
import { StrictMode } from "react"
import ReactDOM from "react-dom/client"
import { toast } from "sonner"
import { ApiError, OpenAPI } from "./client"
import { ThemeProvider } from "./components/theme-provider"
import { Toaster } from "./components/ui/sonner"
@@ -26,9 +27,23 @@ OpenAPI.BASE = portal
: import.meta.env.VITE_API_URL
OpenAPI.TOKEN = async () => apiToken()
/** A session the server will not accept, whatever we do next. */
const isAuthFailure = (error: unknown) =>
error instanceof ApiError && [401, 403].includes(error.status)
/**
* 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)
@@ -38,16 +53,12 @@ const handleApiError = (error: Error) => {
connectionStore.setOffline(offline.lastSeen)
return
}
if (isAuthFailure(error)) {
if (isSessionGone(error)) {
// A paired wall panel has no login screen to go back to — it asks for a
// new code instead. Only a 401 is worth throwing its credential away for:
// a 403 there is a dashboard it was just unassigned from, which the next
// read of the panel corrects on its own.
// new code instead.
if (appRoute().startsWith("/panel")) {
if (error instanceof ApiError && error.status === 401) {
localStorage.removeItem("access_token")
window.location.href = appPath("/panel")
}
localStorage.removeItem("access_token")
window.location.href = appPath("/panel")
return
}
if (portal) {
@@ -58,6 +69,14 @@ const handleApiError = (error: Error) => {
}
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 to read a toast: a
// 403 there is a dashboard it was just unassigned from, which its next read
// of the panel corrects on its own.
toast.error("You do not have permission to do that")
}
}
@@ -73,7 +92,7 @@ const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Retrying an expired session only delays the trip to the login screen.
retry: (count, error) => !isAuthFailure(error) && count < 3,
retry: (count, error) => !isPointlessToRetry(error) && count < 3,
},
mutations: {
retry: false,