From 43f606969a9d3dfdef0037e5f205b6d05e4e35cf Mon Sep 17 00:00:00 2001 From: stroblme Date: Thu, 20 Aug 2026 17:24:20 +0200 Subject: [PATCH] Point the panel link at the installation, not at the browser's origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01AHpLJHozysQXjsxAyU1WHj --- backend/app/api/routes/panels.py | 36 +++++++++++++++---- backend/tests/api/routes/test_panels.py | 3 ++ frontend/src/client/schemas.gen.ts | 26 ++++++++++++++ frontend/src/client/sdk.gen.ts | 6 ++-- frontend/src/client/types.gen.ts | 18 ++++++++-- .../components/Dashboard/DashboardEditor.tsx | 3 +- .../src/components/Dashboard/PanelsDialog.tsx | 15 ++++++-- frontend/src/lib/portal.ts | 20 +++++++++++ frontend/src/main.tsx | 8 ++--- frontend/src/routes/panel.index.tsx | 3 +- 10 files changed, 119 insertions(+), 19 deletions(-) diff --git a/backend/app/api/routes/panels.py b/backend/app/api/routes/panels.py index 09e62f2..a13ab4f 100644 --- a/backend/app/api/routes/panels.py +++ b/backend/app/api/routes/panels.py @@ -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) diff --git a/backend/tests/api/routes/test_panels.py b/backend/tests/api/routes/test_panels.py index 1f6fe2f..075d6cc 100644 --- a/backend/tests/api/routes/test_panels.py +++ b/backend/tests/api/routes/test_panels.py @@ -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" diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index aecc169..8a6af0d 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -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: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 187eb5b..99d34b7 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -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 { @@ -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 { diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index c2945d9..d38cdcd 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -744,6 +744,20 @@ export type PanelsConfig = { panels?: Array; }; +/** + * 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; + 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); -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); diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx index 0ad5e57..3124816 100644 --- a/frontend/src/components/Dashboard/DashboardEditor.tsx +++ b/frontend/src/components/Dashboard/DashboardEditor.tsx @@ -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 > diff --git a/frontend/src/components/Dashboard/PanelsDialog.tsx b/frontend/src/components/Dashboard/PanelsDialog.tsx index 1bdc663..482cccb 100644 --- a/frontend/src/components/Dashboard/PanelsDialog.tsx +++ b/frontend/src/components/Dashboard/PanelsDialog.tsx @@ -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() { Panels 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. @@ -82,6 +88,7 @@ export function PanelsDialog() { ({ 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 (
@@ -240,6 +250,7 @@ function PanelRow({ event.currentTarget.select()} diff --git a/frontend/src/lib/portal.ts b/frontend/src/lib/portal.ts index 8619686..925aedc 100644 --- a/frontend/src/lib/portal.ts +++ b/frontend/src/lib/portal.ts @@ -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) || "/" +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index db91d01..41125e0 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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") } } diff --git a/frontend/src/routes/panel.index.tsx b/frontend/src/routes/panel.index.tsx index 89a0414..217071e 100644 --- a/frontend/src/routes/panel.index.tsx +++ b/frontend/src/routes/panel.index.tsx @@ -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 (