Pair a wall panel through the portal

A screen somewhere this installation is not reachable from asks the portal for
a code instead, and the portal mints its credential — because a token signed
here is one such a device could never present.

Where it was minted changes nothing about what it may do. The panel gate moved
off the branch that decodes a local panel token and onto whatever claims name
a panel, so the portal's and this installation's are bounded by the same check
against the same panel's dashboards. A token of that scope naming no panel is
refused rather than left holding the account it borrows.

The connector marks what arrives on its socket, since that is the only thing
that makes it true, and the approval screen now names what is holding a code —
approving adopts whatever answers, so it is worth a look first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017F9RnYCJgASuBTcAjxmnsp
This commit is contained in:
2026-08-20 23:42:58 +02:00
co-authored by Claude Opus 5
parent bf531309e9
commit 4c8339e643
17 changed files with 695 additions and 175 deletions
+23 -3
View File
@@ -2075,9 +2075,29 @@ export const PanelsPublicSchema = {
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.`
portal's origin, and this one is for a screen on this network.
An installation enrolled with a portal has a second address, built by the
dialog from what \`\`/cloud/status\`\` reports rather than from here — a panel
is not the thing that knows whether remote access is on.`
} as const;
export const PendingDeviceSchema = {
properties: {
device: {
type: 'string',
title: 'Device'
},
remote: {
type: 'boolean',
title: 'Remote',
default: false
}
},
type: 'object',
required: ['device'],
title: 'PendingDevice',
description: 'Who is asking, as far as the request itself says.'
} as const;
export const PlacementSchema = {
+33 -4
View File
@@ -3,7 +3,7 @@
import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request';
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen';
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen';
export class AlertsService {
/**
@@ -1435,7 +1435,9 @@ export class PanelsService {
*
* All this hands out is a code that means nothing until somebody with an
* account approves it, so the worst an unwelcome caller achieves is a line in
* a dictionary that expires ten minutes later.
* a dictionary that expires ten minutes later. Reachable from the internet
* when this installation is enrolled with a portal, which is what the cap and
* the portal's own per-address limits are between.
* @returns PairStarted Successful Response
* @throws ApiError
*/
@@ -1475,12 +1477,39 @@ export class PanelsService {
});
}
/**
* Pending Device
* What is waiting on this code, before anyone says what it is.
*
* Approving a code adopts whatever is holding it, so it is worth seeing that
* it looks like the screen you just hung.
* @param data The data for the request.
* @param data.code
* @returns PendingDevice Successful Response
* @throws ApiError
*/
public static pendingDevice(data: PanelsPendingDeviceData): CancelablePromise<PanelsPendingDeviceResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/panels/pair/{code}/device',
path: {
code: data.code
},
errors: {
422: 'Validation Error'
}
});
}
/**
* Approve Pairing
* Say which panel the device showing this code is.
*
* The credential names the approver, so what the panel does stays
* attributable to a person rather than to nobody.
* A credential minted here names the approver, so what the panel does stays
* attributable to a person rather than to nobody. One minted by the portal —
* for a device that reached this installation only through it — names the
* account this installation was enrolled with instead, since that is the one
* every portal-borne request already acts as.
* @param data The data for the request.
* @param data.panelId
* @param data.requestBody
+19 -3
View File
@@ -749,15 +749,25 @@ export type PanelsConfig = {
*
* 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.
* portal's origin, and this one is for a screen on this network.
*
* An installation enrolled with a portal has a second address, built by the
* dialog from what ``/cloud/status`` reports rather than from here — a panel
* is not the thing that knows whether remote access is on.
*/
export type PanelsPublic = {
panels?: Array<PanelDef>;
frontend_host?: string;
};
/**
* Who is asking, as far as the request itself says.
*/
export type PendingDevice = {
device: string;
remote?: boolean;
};
/**
* Where a widget sits in its section's grid, in grid units.
*/
@@ -1424,6 +1434,12 @@ export type PanelsPollPairingData = {
export type PanelsPollPairingResponse = (PairStatus);
export type PanelsPendingDeviceData = {
code: string;
};
export type PanelsPendingDeviceResponse = (PendingDevice);
export type PanelsApprovePairingData = {
panelId: string;
requestBody: PairRequest;
@@ -4,6 +4,7 @@ import { useState } from "react"
import {
type ApiError,
CloudService,
type PanelDef,
type PanelsConfig,
PanelsService,
@@ -23,9 +24,13 @@ import {
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import useAuth from "@/hooks/useAuth"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
/** As many characters as a device puts on the wall. */
const CODE_LENGTH = 6
/** The store only accepts this shape, so say so before the request does. */
const slugify = (value: string) =>
value
@@ -43,6 +48,23 @@ const slugify = (value: string) =>
export function PanelsDialog() {
const { data: config } = useQuery(panelsQueryOptions())
const { data: dashboards } = useQuery(dashboardsQueryOptions())
const { user } = useAuth()
// Where a screen that cannot reach this installation pairs instead. The
// issuer is the portal as a browser reaches it, which is not always the
// address this machine dialled — enrolment may have named a container.
const { data: cloud } = useQuery({
queryKey: ["cloud", "status"],
queryFn: async () =>
(await CloudService.readStatus()) as {
enrolled: boolean
issuer: string | null
installation_id: string | null
},
})
const remoteHost =
cloud?.enrolled && cloud.issuer && cloud.installation_id
? `${cloud.issuer.replace(/\/$/, "")}/i/${cloud.installation_id}`
: ""
const save = useSavePanels()
const { showErrorToast } = useCustomToast()
const [name, setName] = useState("")
@@ -72,8 +94,9 @@ export function PanelsDialog() {
<DialogTitle>Panels</DialogTitle>
<DialogDescription>
A panel is one screen and the dashboards it shows. Point the device at
the link the installation's own address, reachable from wherever the
screen hangs and it asks for a code you enter here.
a link and it asks for a code you enter here this installation's own
address for a screen on your network, or the portal's for one hanging
where this machine is not reachable.
</DialogDescription>
</DialogHeader>
@@ -89,6 +112,8 @@ export function PanelsDialog() {
key={panel.id}
panel={panel}
host={host}
remoteHost={remoteHost}
canPair={Boolean(user?.is_superuser)}
dashboards={known.map((dashboard) => ({
name: dashboard.name,
title: dashboard.title || dashboard.name,
@@ -145,6 +170,8 @@ export function PanelsDialog() {
function PanelRow({
panel,
host,
remoteHost,
canPair,
dashboards,
onChange,
onRemove,
@@ -152,6 +179,10 @@ function PanelRow({
panel: PanelDef
/** Where this installation answers, as it knows itself. */
host: string
/** Where the portal serves this installation, when it is enrolled. */
remoteHost: string
/** Approving a code is a superuser's, and so is asking what holds one. */
canPair: boolean
dashboards: { name: string; title: string }[]
onChange: (next: PanelDef) => void
onRemove: () => void
@@ -159,12 +190,22 @@ function PanelRow({
const { showSuccessToast, showErrorToast } = useCustomToast()
const [code, setCode] = useState("")
const assigned = panel.dashboards ?? []
const typed = code.trim().toUpperCase()
// Approving a code adopts whatever is holding it, so say what that is while
// there is still time to stop.
const { data: waiting } = useQuery({
queryKey: ["pending-device", typed],
queryFn: () => PanelsService.pendingDevice({ code: typed }),
enabled: canPair && typed.length === CODE_LENGTH,
retry: false,
})
const pair = useMutation({
mutationFn: () =>
PanelsService.approvePairing({
panelId: panel.id,
requestBody: { code: code.trim().toUpperCase() },
requestBody: { code: typed },
}),
onSuccess: () => {
setCode("")
@@ -186,6 +227,7 @@ function PanelRow({
})
const link = host ? `${host}/panel/${panel.id}` : ""
const remoteLink = remoteHost ? `${remoteHost}/panel` : ""
return (
<div className="grid gap-3" data-testid={`panel-${panel.id}`}>
@@ -255,31 +297,60 @@ function PanelRow({
className="text-muted-foreground"
onFocus={(event) => event.currentTarget.select()}
/>
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault()
if (code.trim()) pair.mutate()
}}
>
<Input
value={code}
placeholder="Code shown on the screen"
aria-label={`Pairing code for ${panel.id}`}
autoComplete="off"
maxLength={6}
data-testid={`pair-code-${panel.id}`}
onChange={(event) => setCode(event.target.value.toUpperCase())}
/>
<Button
type="submit"
variant="outline"
disabled={!code.trim() || pair.isPending}
data-testid={`pair-${panel.id}`}
>
Pair device
</Button>
</form>
{remoteLink ? (
<div className="flex items-center gap-2">
<Input
readOnly
value={remoteLink}
aria-label={`Portal link for ${panel.id}`}
className="text-muted-foreground"
data-testid={`remote-link-${panel.id}`}
onFocus={(event) => event.currentTarget.select()}
/>
<span className="shrink-0 text-xs text-muted-foreground">
via portal
</span>
</div>
) : null}
{canPair ? (
<>
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault()
if (typed) pair.mutate()
}}
>
<Input
value={code}
placeholder="Code shown on the screen"
aria-label={`Pairing code for ${panel.id}`}
autoComplete="off"
maxLength={CODE_LENGTH}
data-testid={`pair-code-${panel.id}`}
onChange={(event) => setCode(event.target.value.toUpperCase())}
/>
<Button
type="submit"
variant="outline"
disabled={!typed || pair.isPending}
data-testid={`pair-${panel.id}`}
>
Pair device
</Button>
</form>
{typed.length === CODE_LENGTH ? (
<p
className="text-xs text-muted-foreground"
data-testid={`pending-${panel.id}`}
>
{waiting
? `${waiting.device}${waiting.remote ? " · via portal" : ""}`
: "No device is waiting on that code."}
</p>
) : null}
</>
) : null}
</div>
</div>
)
+19
View File
@@ -43,6 +43,25 @@ export function apiToken(): string {
return portalConfig()?.token ?? localStorage.getItem("access_token") ?? ""
}
/**
* Hand a freshly paired credential to the hub, which keeps it in its cookie.
*
* Under the portal the page is served with its token injected, so there is
* nowhere in this app to put one a device just collected — and a credential
* that lasts a year is exactly what must not ride in a query string, where
* access logs and browser history would keep it.
*/
export async function openPortalSession(token: string): Promise<void> {
const config = portalConfig()
if (!config) return
const response = await fetch(`${config.basePath}/session`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token }),
})
if (!response.ok) throw new Error(`The portal refused the credential`)
}
/**
* A path in this app, spelled the way the browser has to spell it.
*
+35 -10
View File
@@ -2,8 +2,8 @@ import { useMutation, useQuery } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router"
import { useEffect } from "react"
import { PanelsService } from "@/client"
import { appPath } from "@/lib/portal"
import { ApiError, PanelsService } from "@/client"
import { appPath, isPortal, openPortalSession } from "@/lib/portal"
/**
* Adopting a screen that has no keyboard.
@@ -15,6 +15,11 @@ import { appPath } from "@/lib/portal"
*
* Outside both shells like `/view/{name}`: until it is paired, this device has
* no session and there is nothing to put around it.
*
* Reached through the portal as well, for a screen hanging where this
* installation is not: the hub serves this one page without a session and
* forwards these two calls down the tunnel, because a device with no
* credential is what they are for.
*/
export const Route = createFileRoute("/panel/")({
component: PairPanel,
@@ -24,7 +29,13 @@ export const Route = createFileRoute("/panel/")({
function PairPanel() {
// A code lives ten minutes. Asking for one is the mount, and asking again is
// what happens when this one is no longer recognised.
const start = useMutation({ mutationFn: () => PanelsService.startPairing() })
const start = useMutation({
mutationFn: () => PanelsService.startPairing(),
// A screen hung while the link is down has nobody to tell, so it keeps
// asking rather than showing six dots until someone power-cycles it.
retry: true,
retryDelay: 5000,
})
const request = start.mutate
// biome-ignore lint/correctness/useExhaustiveDependencies: asked for once, when the screen goes up.
@@ -47,18 +58,32 @@ function PairPanel() {
})
// Expired, or collected already. Ask for another rather than leaving a
// number on the wall that no longer works.
// number on the wall that no longer works — but only for that answer: over
// the tunnel a 502 or 503 is an ordinary hiccup, and changing the code on
// the wall while somebody is typing it is worse than waiting.
useEffect(() => {
if (error) request()
if (error instanceof ApiError && error.status === 404) request()
}, [error, request])
useEffect(() => {
if (!status?.access_token || !status.panel) return
localStorage.setItem("access_token", status.access_token)
// 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 = appPath(`/panel/${status.panel}`)
const token = status.access_token
const panel = status.panel
const land = () => {
// 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 = appPath(`/panel/${panel}`)
}
if (isPortal()) {
// Under the portal the credential belongs in the hub's own cookie: the
// page is served with its config injected, and localStorage is not read
// there. Landing anyway if it fails would loop on a page with nothing.
openPortalSession(token).then(land).catch(land)
return
}
localStorage.setItem("access_token", token)
land()
}, [status])
return (