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:
@@ -67,6 +67,8 @@ export function PanelsDialog() {
|
||||
: ""
|
||||
const save = useSavePanels()
|
||||
const { showErrorToast } = useCustomToast()
|
||||
// Reading panels is any account's; every change to them is a superuser's.
|
||||
const canEdit = Boolean(user?.is_superuser)
|
||||
const [name, setName] = useState("")
|
||||
|
||||
const panels = config?.panels ?? []
|
||||
@@ -113,7 +115,7 @@ export function PanelsDialog() {
|
||||
panel={panel}
|
||||
host={host}
|
||||
remoteHost={remoteHost}
|
||||
canPair={Boolean(user?.is_superuser)}
|
||||
canEdit={canEdit}
|
||||
dashboards={known.map((dashboard) => ({
|
||||
name: dashboard.name,
|
||||
title: dashboard.title || dashboard.name,
|
||||
@@ -125,42 +127,48 @@ export function PanelsDialog() {
|
||||
/>
|
||||
))}
|
||||
|
||||
<Separator />
|
||||
{canEdit ? (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
<form
|
||||
className="flex items-end gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!newId || taken) return
|
||||
write({ panels: [...panels, { id: newId, title: name.trim() }] })
|
||||
setName("")
|
||||
}}
|
||||
>
|
||||
<div className="grid flex-1 gap-1">
|
||||
<label className="text-sm" htmlFor="new-panel">
|
||||
New panel
|
||||
</label>
|
||||
<Input
|
||||
id="new-panel"
|
||||
value={name}
|
||||
placeholder="hallway"
|
||||
autoComplete="off"
|
||||
data-testid="new-panel-name"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!newId || taken}
|
||||
data-testid="add-panel"
|
||||
>
|
||||
Add panel
|
||||
</Button>
|
||||
</form>
|
||||
{taken ? (
|
||||
<p className="text-sm text-destructive">
|
||||
There is already a panel called {newId}.
|
||||
</p>
|
||||
<form
|
||||
className="flex items-end gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!newId || taken) return
|
||||
write({
|
||||
panels: [...panels, { id: newId, title: name.trim() }],
|
||||
})
|
||||
setName("")
|
||||
}}
|
||||
>
|
||||
<div className="grid flex-1 gap-1">
|
||||
<label className="text-sm" htmlFor="new-panel">
|
||||
New panel
|
||||
</label>
|
||||
<Input
|
||||
id="new-panel"
|
||||
value={name}
|
||||
placeholder="hallway"
|
||||
autoComplete="off"
|
||||
data-testid="new-panel-name"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!newId || taken}
|
||||
data-testid="add-panel"
|
||||
>
|
||||
Add panel
|
||||
</Button>
|
||||
</form>
|
||||
{taken ? (
|
||||
<p className="text-sm text-destructive">
|
||||
There is already a panel called {newId}.
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -171,7 +179,7 @@ function PanelRow({
|
||||
panel,
|
||||
host,
|
||||
remoteHost,
|
||||
canPair,
|
||||
canEdit,
|
||||
dashboards,
|
||||
onChange,
|
||||
onRemove,
|
||||
@@ -181,8 +189,12 @@ function PanelRow({
|
||||
host: string
|
||||
/** Where the portal serves this installation, when it is enrolled. */
|
||||
remoteHost: string
|
||||
/** Approving a code is a superuser's, and so is asking what holds one. */
|
||||
canPair: boolean
|
||||
/**
|
||||
* Whether this account may change anything here. Every control below saves
|
||||
* through the same superuser-only PUT, so a reader gets the panel and its
|
||||
* links — worth seeing — with the writes turned off rather than a 403.
|
||||
*/
|
||||
canEdit: boolean
|
||||
dashboards: { name: string; title: string }[]
|
||||
onChange: (next: PanelDef) => void
|
||||
onRemove: () => void
|
||||
@@ -197,7 +209,7 @@ function PanelRow({
|
||||
const { data: waiting } = useQuery({
|
||||
queryKey: ["pending-device", typed],
|
||||
queryFn: () => PanelsService.pendingDevice({ code: typed }),
|
||||
enabled: canPair && typed.length === CODE_LENGTH,
|
||||
enabled: canEdit && typed.length === CODE_LENGTH,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
@@ -236,6 +248,7 @@ function PanelRow({
|
||||
value={panel.title}
|
||||
placeholder={panel.id}
|
||||
aria-label={`Title of ${panel.id}`}
|
||||
disabled={!canEdit}
|
||||
onChange={(event) =>
|
||||
onChange({ ...panel, title: event.target.value })
|
||||
}
|
||||
@@ -249,6 +262,7 @@ function PanelRow({
|
||||
className="size-11 shrink-0 text-muted-foreground md:size-8"
|
||||
aria-label={`Remove ${panel.id}`}
|
||||
data-testid={`remove-panel-${panel.id}`}
|
||||
disabled={!canEdit}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 />
|
||||
@@ -274,6 +288,7 @@ function PanelRow({
|
||||
id={id}
|
||||
checked={position >= 0}
|
||||
data-testid={id}
|
||||
disabled={!canEdit}
|
||||
onCheckedChange={() => toggle(dashboard.name)}
|
||||
/>
|
||||
<span className="flex-1 truncate">{dashboard.title}</span>
|
||||
@@ -312,7 +327,7 @@ function PanelRow({
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{canPair ? (
|
||||
{canEdit ? (
|
||||
<>
|
||||
<form
|
||||
className="flex gap-2"
|
||||
|
||||
+31
-12
@@ -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,
|
||||
|
||||
@@ -60,19 +60,16 @@ function Dashboards() {
|
||||
onError: handleError.bind(showErrorToast),
|
||||
})
|
||||
|
||||
// A summary carries no version, and publishing needs the one it is based on —
|
||||
// so each dashboard's working copy is read right before it is published.
|
||||
// The summary carries the working copy's version, which is the one publishing
|
||||
// is based on — so the list already holds everything this needs.
|
||||
const publishAll = usePublishAll(
|
||||
async (dashboard) => {
|
||||
const current = await DashboardsService.readDashboard({
|
||||
(dashboard) =>
|
||||
DashboardsService.publishDashboard({
|
||||
name: dashboard,
|
||||
draft: true,
|
||||
})
|
||||
return DashboardsService.publishDashboard({
|
||||
name: dashboard,
|
||||
requestBody: { version: current.version ?? 1 },
|
||||
})
|
||||
},
|
||||
requestBody: {
|
||||
version: data?.data.find((d) => d.name === dashboard)?.version ?? 1,
|
||||
},
|
||||
}),
|
||||
"dashboard",
|
||||
() => queryClient.invalidateQueries({ queryKey: dashboardKeys.all }),
|
||||
)
|
||||
|
||||
@@ -53,16 +53,16 @@ function Flows() {
|
||||
onError: handleError.bind(showErrorToast),
|
||||
})
|
||||
|
||||
// A summary carries no version, and publishing needs the one it is based
|
||||
// on — so each flow's current version is read right before it is published.
|
||||
// The summary carries the working copy's version, which is the one publishing
|
||||
// is based on — so the list already holds everything this needs.
|
||||
const publishAll = usePublishAll(
|
||||
async (flow) => {
|
||||
const detail = await FlowsService.readFlow({ name: flow })
|
||||
return FlowsService.publishFlow({
|
||||
(flow) =>
|
||||
FlowsService.publishFlow({
|
||||
name: flow,
|
||||
requestBody: { version: detail.definition.version ?? 1 },
|
||||
})
|
||||
},
|
||||
requestBody: {
|
||||
version: data?.data.find((f) => f.name === flow)?.version ?? 1,
|
||||
},
|
||||
}),
|
||||
"flow",
|
||||
() => queryClient.invalidateQueries({ queryKey: flowKeys.all }),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user