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:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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) || "/"
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user