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 ffae24c16b
commit ce77262f81
10 changed files with 119 additions and 19 deletions
+30 -6
View File
@@ -18,10 +18,11 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel
from pydantic import BaseModel, Field
from app.api.deps import CurrentUser, get_current_active_superuser, get_current_user
from app.core import security
from app.core.config import settings
from app.flow.panels import PanelDef, PanelsConfig, find, read_config, write_config
from app.models import Message
@@ -87,15 +88,38 @@ class PairRequest(BaseModel):
code: str
@router.get("/", response_model=PanelsConfig, dependencies=[Depends(get_current_user)])
class PanelsPublic(BaseModel):
"""The panels, and the address a device should be pointed at.
The address is the server's own, because the browser's origin is not a
reliable answer to it: an admin working through the portal is on the
portal's origin, and a screen cannot be sent there — the portal serves a
page only to someone holding a portal session, and the credential it hands
that page is the portal's rather than the panel's.
"""
panels: list[PanelDef] = Field(default_factory=list)
#: Whatever this installation was told it is reachable at. The same setting
#: the password-reset links are built from, so an installation that has it
#: wrong has it wrong in both places.
frontend_host: str = ""
def _public(config: PanelsConfig) -> PanelsPublic:
return PanelsPublic(
panels=config.panels, frontend_host=settings.FRONTEND_HOST.rstrip("/")
)
@router.get("/", response_model=PanelsPublic, dependencies=[Depends(get_current_user)])
async def read_panels() -> Any:
"""Every panel, and what each one shows."""
return await run_in_threadpool(read_config)
"""Every panel, what each one shows, and where to point a device."""
return _public(await run_in_threadpool(read_config))
@router.put(
"/",
response_model=PanelsConfig,
response_model=PanelsPublic,
dependencies=[Depends(get_current_active_superuser)],
)
async def save_panels(body: PanelsConfig) -> Any:
@@ -113,7 +137,7 @@ async def save_panels(body: PanelsConfig) -> Any:
seen.add(panel.id)
await run_in_threadpool(write_config, body)
return body
return _public(body)
@router.post("/pair", response_model=PairStarted)
+3
View File
@@ -56,6 +56,9 @@ def test_assign_and_read_back(
stored = client.get(f"{PREFIX}/", headers=superuser_token_headers).json()
assert stored["panels"][0]["dashboards"] == ["hall_a", "hall_b"]
# The address a device is pointed at comes from the server, because the
# browser's own origin is the portal's when someone administers remotely.
assert stored["frontend_host"] == settings.FRONTEND_HOST.rstrip("/")
assert (
client.get(f"{PREFIX}/hall", headers=superuser_token_headers).json()["title"]
== "Hall"
+26
View File
@@ -2054,6 +2054,32 @@ export const PanelsConfigSchema = {
description: 'Every panel this installation knows about.'
} as const;
export const PanelsPublicSchema = {
properties: {
panels: {
items: {
'$ref': '#/components/schemas/PanelDef'
},
type: 'array',
title: 'Panels'
},
frontend_host: {
type: 'string',
title: 'Frontend Host',
default: ''
}
},
type: 'object',
title: 'PanelsPublic',
description: `The panels, and the address a device should be pointed at.
The address is the server's own, because the browser's origin is not a
reliable answer to it: an admin working through the portal is on the
portal's origin, and a screen cannot be sent there — the portal serves a
page only to someone holding a portal session, and the credential it hands
that page is the portal's rather than the panel's.`
} as const;
export const PlacementSchema = {
properties: {
x: {
+3 -3
View File
@@ -1395,8 +1395,8 @@ export class ObservabilityService {
export class PanelsService {
/**
* Read Panels
* Every panel, and what each one shows.
* @returns PanelsConfig Successful Response
* Every panel, what each one shows, and where to point a device.
* @returns PanelsPublic Successful Response
* @throws ApiError
*/
public static readPanels(): CancelablePromise<PanelsReadPanelsResponse> {
@@ -1414,7 +1414,7 @@ export class PanelsService {
* how a device is unpaired.
* @param data The data for the request.
* @param data.requestBody
* @returns PanelsConfig Successful Response
* @returns PanelsPublic Successful Response
* @throws ApiError
*/
public static savePanels(data: PanelsSavePanelsData): CancelablePromise<PanelsSavePanelsResponse> {
+16 -2
View File
@@ -744,6 +744,20 @@ export type PanelsConfig = {
panels?: Array<PanelDef>;
};
/**
* The panels, and the address a device should be pointed at.
*
* The address is the server's own, because the browser's origin is not a
* reliable answer to it: an admin working through the portal is on the
* portal's origin, and a screen cannot be sent there — the portal serves a
* page only to someone holding a portal session, and the credential it hands
* that page is the portal's rather than the panel's.
*/
export type PanelsPublic = {
panels?: Array<PanelDef>;
frontend_host?: string;
};
/**
* Where a widget sits in its section's grid, in grid units.
*/
@@ -1393,13 +1407,13 @@ export type ObservabilityReadDeadLettersData = {
export type ObservabilityReadDeadLettersResponse = (Array<DeadLetter>);
export type PanelsReadPanelsResponse = (PanelsConfig);
export type PanelsReadPanelsResponse = (PanelsPublic);
export type PanelsSavePanelsData = {
requestBody: PanelsConfig;
};
export type PanelsSavePanelsResponse = (PanelsConfig);
export type PanelsSavePanelsResponse = (PanelsPublic);
export type PanelsStartPairingResponse = (PairStarted);
@@ -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()}
+20
View File
@@ -42,3 +42,23 @@ export function isPortal(): boolean {
export function apiToken(): string {
return portalConfig()?.token ?? localStorage.getItem("access_token") ?? ""
}
/**
* A path in this app, spelled the way the browser has to spell it.
*
* The router is given the portal's basepath, so every `Link` and `navigate`
* carries it already. Anything that steps outside the router — a plain
* anchor, a `location.href` — has to add it back, or it lands on the portal
* itself, which serves nothing at that address.
*/
export function appPath(path: string): string {
return `${portalConfig()?.basePath ?? ""}${path}`
}
/** This page's route inside the app, with any portal prefix taken off again. */
export function appRoute(): string {
const base = portalConfig()?.basePath ?? ""
const path = window.location.pathname
if (!base || !path.startsWith(base)) return path
return path.slice(base.length) || "/"
}
+4 -4
View File
@@ -13,7 +13,7 @@ import { ThemeProvider } from "./components/theme-provider"
import { Toaster } from "./components/ui/sonner"
import "./index.css"
import { connectionStore, offlineDetail } from "./lib/connectionStore"
import { apiToken, portalConfig } from "./lib/portal"
import { apiToken, appPath, appRoute, portalConfig } from "./lib/portal"
import { routeTree } from "./routeTree.gen"
const portal = portalConfig()
@@ -43,10 +43,10 @@ const handleApiError = (error: Error) => {
// 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.
if (window.location.pathname.startsWith("/panel")) {
if (appRoute().startsWith("/panel")) {
if (error instanceof ApiError && error.status === 401) {
localStorage.removeItem("access_token")
window.location.href = "/panel"
window.location.href = appPath("/panel")
}
return
}
@@ -57,7 +57,7 @@ const handleApiError = (error: Error) => {
return
}
localStorage.removeItem("access_token")
window.location.href = "/login"
window.location.href = appPath("/login")
}
}
+2 -1
View File
@@ -3,6 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"
import { useEffect } from "react"
import { PanelsService } from "@/client"
import { appPath } from "@/lib/portal"
/**
* Adopting a screen that has no keyboard.
@@ -57,7 +58,7 @@ function PairPanel() {
// A full load rather than a route change: everything this page asked for
// was asked without a credential, and the socket has to dial again holding
// this one.
window.location.href = `/panel/${status.panel}`
window.location.href = appPath(`/panel/${status.panel}`)
}, [status])
return (