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
@@ -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"