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 import APIRouter, Depends, HTTPException
|
||||||
from fastapi.concurrency import run_in_threadpool
|
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.api.deps import CurrentUser, get_current_active_superuser, get_current_user
|
||||||
from app.core import security
|
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.flow.panels import PanelDef, PanelsConfig, find, read_config, write_config
|
||||||
from app.models import Message
|
from app.models import Message
|
||||||
|
|
||||||
@@ -87,15 +88,38 @@ class PairRequest(BaseModel):
|
|||||||
code: str
|
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:
|
async def read_panels() -> Any:
|
||||||
"""Every panel, and what each one shows."""
|
"""Every panel, what each one shows, and where to point a device."""
|
||||||
return await run_in_threadpool(read_config)
|
return _public(await run_in_threadpool(read_config))
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
"/",
|
"/",
|
||||||
response_model=PanelsConfig,
|
response_model=PanelsPublic,
|
||||||
dependencies=[Depends(get_current_active_superuser)],
|
dependencies=[Depends(get_current_active_superuser)],
|
||||||
)
|
)
|
||||||
async def save_panels(body: PanelsConfig) -> Any:
|
async def save_panels(body: PanelsConfig) -> Any:
|
||||||
@@ -113,7 +137,7 @@ async def save_panels(body: PanelsConfig) -> Any:
|
|||||||
seen.add(panel.id)
|
seen.add(panel.id)
|
||||||
|
|
||||||
await run_in_threadpool(write_config, body)
|
await run_in_threadpool(write_config, body)
|
||||||
return body
|
return _public(body)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pair", response_model=PairStarted)
|
@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()
|
stored = client.get(f"{PREFIX}/", headers=superuser_token_headers).json()
|
||||||
assert stored["panels"][0]["dashboards"] == ["hall_a", "hall_b"]
|
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 (
|
assert (
|
||||||
client.get(f"{PREFIX}/hall", headers=superuser_token_headers).json()["title"]
|
client.get(f"{PREFIX}/hall", headers=superuser_token_headers).json()["title"]
|
||||||
== "Hall"
|
== "Hall"
|
||||||
|
|||||||
@@ -2054,6 +2054,32 @@ export const PanelsConfigSchema = {
|
|||||||
description: 'Every panel this installation knows about.'
|
description: 'Every panel this installation knows about.'
|
||||||
} as const;
|
} 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 = {
|
export const PlacementSchema = {
|
||||||
properties: {
|
properties: {
|
||||||
x: {
|
x: {
|
||||||
|
|||||||
@@ -1395,8 +1395,8 @@ export class ObservabilityService {
|
|||||||
export class PanelsService {
|
export class PanelsService {
|
||||||
/**
|
/**
|
||||||
* Read Panels
|
* Read Panels
|
||||||
* Every panel, and what each one shows.
|
* Every panel, what each one shows, and where to point a device.
|
||||||
* @returns PanelsConfig Successful Response
|
* @returns PanelsPublic Successful Response
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
public static readPanels(): CancelablePromise<PanelsReadPanelsResponse> {
|
public static readPanels(): CancelablePromise<PanelsReadPanelsResponse> {
|
||||||
@@ -1414,7 +1414,7 @@ export class PanelsService {
|
|||||||
* how a device is unpaired.
|
* how a device is unpaired.
|
||||||
* @param data The data for the request.
|
* @param data The data for the request.
|
||||||
* @param data.requestBody
|
* @param data.requestBody
|
||||||
* @returns PanelsConfig Successful Response
|
* @returns PanelsPublic Successful Response
|
||||||
* @throws ApiError
|
* @throws ApiError
|
||||||
*/
|
*/
|
||||||
public static savePanels(data: PanelsSavePanelsData): CancelablePromise<PanelsSavePanelsResponse> {
|
public static savePanels(data: PanelsSavePanelsData): CancelablePromise<PanelsSavePanelsResponse> {
|
||||||
|
|||||||
@@ -744,6 +744,20 @@ export type PanelsConfig = {
|
|||||||
panels?: Array<PanelDef>;
|
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.
|
* 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 ObservabilityReadDeadLettersResponse = (Array<DeadLetter>);
|
||||||
|
|
||||||
export type PanelsReadPanelsResponse = (PanelsConfig);
|
export type PanelsReadPanelsResponse = (PanelsPublic);
|
||||||
|
|
||||||
export type PanelsSavePanelsData = {
|
export type PanelsSavePanelsData = {
|
||||||
requestBody: PanelsConfig;
|
requestBody: PanelsConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PanelsSavePanelsResponse = (PanelsConfig);
|
export type PanelsSavePanelsResponse = (PanelsPublic);
|
||||||
|
|
||||||
export type PanelsStartPairingResponse = (PairStarted);
|
export type PanelsStartPairingResponse = (PairStarted);
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import {
|
|||||||
import useCustomToast from "@/hooks/useCustomToast"
|
import useCustomToast from "@/hooks/useCustomToast"
|
||||||
import { useIsMobile } from "@/hooks/useMobile"
|
import { useIsMobile } from "@/hooks/useMobile"
|
||||||
import { slideUp, transitions } from "@/lib/motion"
|
import { slideUp, transitions } from "@/lib/motion"
|
||||||
|
import { appPath } from "@/lib/portal"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { handleError } from "@/utils"
|
import { handleError } from "@/utils"
|
||||||
import {
|
import {
|
||||||
@@ -644,7 +645,7 @@ export function DashboardEditor({
|
|||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href={`/view/${draft.name}`}
|
href={appPath(`/view/${draft.name}`)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ export function PanelsDialog() {
|
|||||||
|
|
||||||
const panels = config?.panels ?? []
|
const panels = config?.panels ?? []
|
||||||
const known = dashboards?.data ?? []
|
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) =>
|
const write = (next: PanelsConfig) =>
|
||||||
save.mutate(next, {
|
save.mutate(next, {
|
||||||
@@ -67,7 +72,8 @@ export function PanelsDialog() {
|
|||||||
<DialogTitle>Panels</DialogTitle>
|
<DialogTitle>Panels</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
A panel is one screen and the dashboards it shows. Point the device at
|
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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -82,6 +88,7 @@ export function PanelsDialog() {
|
|||||||
<PanelRow
|
<PanelRow
|
||||||
key={panel.id}
|
key={panel.id}
|
||||||
panel={panel}
|
panel={panel}
|
||||||
|
host={host}
|
||||||
dashboards={known.map((dashboard) => ({
|
dashboards={known.map((dashboard) => ({
|
||||||
name: dashboard.name,
|
name: dashboard.name,
|
||||||
title: dashboard.title || dashboard.name,
|
title: dashboard.title || dashboard.name,
|
||||||
@@ -137,11 +144,14 @@ export function PanelsDialog() {
|
|||||||
|
|
||||||
function PanelRow({
|
function PanelRow({
|
||||||
panel,
|
panel,
|
||||||
|
host,
|
||||||
dashboards,
|
dashboards,
|
||||||
onChange,
|
onChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
panel: PanelDef
|
panel: PanelDef
|
||||||
|
/** Where this installation answers, as it knows itself. */
|
||||||
|
host: string
|
||||||
dashboards: { name: string; title: string }[]
|
dashboards: { name: string; title: string }[]
|
||||||
onChange: (next: PanelDef) => void
|
onChange: (next: PanelDef) => void
|
||||||
onRemove: () => void
|
onRemove: () => void
|
||||||
@@ -175,7 +185,7 @@ function PanelRow({
|
|||||||
: [...assigned, dashboard],
|
: [...assigned, dashboard],
|
||||||
})
|
})
|
||||||
|
|
||||||
const link = `${window.location.origin}/panel/${panel.id}`
|
const link = host ? `${host}/panel/${panel.id}` : ""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3" data-testid={`panel-${panel.id}`}>
|
<div className="grid gap-3" data-testid={`panel-${panel.id}`}>
|
||||||
@@ -240,6 +250,7 @@ function PanelRow({
|
|||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
value={link}
|
value={link}
|
||||||
|
placeholder="This installation has no address set"
|
||||||
aria-label={`Link for ${panel.id}`}
|
aria-label={`Link for ${panel.id}`}
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
onFocus={(event) => event.currentTarget.select()}
|
onFocus={(event) => event.currentTarget.select()}
|
||||||
|
|||||||
@@ -42,3 +42,23 @@ export function isPortal(): boolean {
|
|||||||
export function apiToken(): string {
|
export function apiToken(): string {
|
||||||
return portalConfig()?.token ?? localStorage.getItem("access_token") ?? ""
|
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 { Toaster } from "./components/ui/sonner"
|
||||||
import "./index.css"
|
import "./index.css"
|
||||||
import { connectionStore, offlineDetail } from "./lib/connectionStore"
|
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"
|
import { routeTree } from "./routeTree.gen"
|
||||||
|
|
||||||
const portal = portalConfig()
|
const portal = portalConfig()
|
||||||
@@ -43,10 +43,10 @@ const handleApiError = (error: Error) => {
|
|||||||
// new code instead. Only a 401 is worth throwing its credential away for:
|
// 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
|
// a 403 there is a dashboard it was just unassigned from, which the next
|
||||||
// read of the panel corrects on its own.
|
// 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) {
|
if (error instanceof ApiError && error.status === 401) {
|
||||||
localStorage.removeItem("access_token")
|
localStorage.removeItem("access_token")
|
||||||
window.location.href = "/panel"
|
window.location.href = appPath("/panel")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ const handleApiError = (error: Error) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
localStorage.removeItem("access_token")
|
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 { useEffect } from "react"
|
||||||
|
|
||||||
import { PanelsService } from "@/client"
|
import { PanelsService } from "@/client"
|
||||||
|
import { appPath } from "@/lib/portal"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adopting a screen that has no keyboard.
|
* 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
|
// 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
|
// was asked without a credential, and the socket has to dial again holding
|
||||||
// this one.
|
// this one.
|
||||||
window.location.href = `/panel/${status.panel}`
|
window.location.href = appPath(`/panel/${status.panel}`)
|
||||||
}, [status])
|
}, [status])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user