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:
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user