Point the panel link at the installation, not at the browser's origin

Both links out of the dashboard editor were built root-relative, so a portal
serving the app under `/i/{id}` got a URL to itself: the hub has no route
there and answers a bare 404. That is what a device link and "open what a
wall panel sees" both landed on.

They want different answers. The view link is for the person already looking,
so it takes the router's basepath — `appPath` in `lib/portal` is the same
prefix the router applies to every `Link`, for the places that step outside
it. The device link is for a screen, which cannot go through the portal at
all: the shell is served only to a portal session, and the credential that
page carries is the portal's rather than the panel's. So the server now says
where it answers, and `FRONTEND_HOST` is that answer — the same setting the
password-reset links already use.

Also fixes the panel branch in the query error handler, which compared a raw
pathname and so never fired under a portal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AHpLJHozysQXjsxAyU1WHj
This commit is contained in:
2026-08-20 17:24:20 +02:00
co-authored by Claude Opus 5
parent c0ff1cd5c5
commit 43f606969a
10 changed files with 119 additions and 19 deletions
@@ -51,6 +51,7 @@ import {
import useCustomToast from "@/hooks/useCustomToast"
import { useIsMobile } from "@/hooks/useMobile"
import { slideUp, transitions } from "@/lib/motion"
import { appPath } from "@/lib/portal"
import { cn } from "@/lib/utils"
import { handleError } from "@/utils"
import {
@@ -644,7 +645,7 @@ export function DashboardEditor({
asChild
>
<a
href={`/view/${draft.name}`}
href={appPath(`/view/${draft.name}`)}
target="_blank"
rel="noreferrer"
>
@@ -49,6 +49,11 @@ export function PanelsDialog() {
const panels = config?.panels ?? []
const known = dashboards?.data ?? []
// The installation's own address, not this browser's: administering through
// the portal puts the page on the portal's origin, and a screen cannot be
// sent there — it has no portal session and could not hold a panel
// credential if it had one.
const host = config?.frontend_host ?? ""
const write = (next: PanelsConfig) =>
save.mutate(next, {
@@ -67,7 +72,8 @@ export function PanelsDialog() {
<DialogTitle>Panels</DialogTitle>
<DialogDescription>
A panel is one screen and the dashboards it shows. Point the device at
the link, and it asks for a code you enter here.
the link the installation's own address, reachable from wherever the
screen hangs and it asks for a code you enter here.
</DialogDescription>
</DialogHeader>
@@ -82,6 +88,7 @@ export function PanelsDialog() {
<PanelRow
key={panel.id}
panel={panel}
host={host}
dashboards={known.map((dashboard) => ({
name: dashboard.name,
title: dashboard.title || dashboard.name,
@@ -137,11 +144,14 @@ export function PanelsDialog() {
function PanelRow({
panel,
host,
dashboards,
onChange,
onRemove,
}: {
panel: PanelDef
/** Where this installation answers, as it knows itself. */
host: string
dashboards: { name: string; title: string }[]
onChange: (next: PanelDef) => void
onRemove: () => void
@@ -175,7 +185,7 @@ function PanelRow({
: [...assigned, dashboard],
})
const link = `${window.location.origin}/panel/${panel.id}`
const link = host ? `${host}/panel/${panel.id}` : ""
return (
<div className="grid gap-3" data-testid={`panel-${panel.id}`}>
@@ -240,6 +250,7 @@ function PanelRow({
<Input
readOnly
value={link}
placeholder="This installation has no address set"
aria-label={`Link for ${panel.id}`}
className="text-muted-foreground"
onFocus={(event) => event.currentTarget.select()}