feat(web): "Pair a device" card — native pairing from the console
ci / rust (push) Has been cancelled
ci / rust (push) Has been cancelled
Completes the web-UI native (punktfunk/1) pairing flow the unified host backs. The Pairing page now leads with a native card that arms a window via the mgmt API and DISPLAYS the host PIN (the SPAKE2 ceremony is host-mints / client-enters) with a live countdown + Cancel, plus a paired-devices list with unpair — no journalctl. The existing Moonlight PIN-submit moves into its own section below. Uses the orval-generated `native` hooks (regenerated from the committed OpenAPI on build) + en/de strings. Validated end-to-end through the web server's proxy + cookie auth: login → status → arm (PIN shown) → clients. tsc + production build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,17 @@
|
|||||||
"pairing_submit": "PIN bestätigen",
|
"pairing_submit": "PIN bestätigen",
|
||||||
"pairing_success": "Erfolgreich gekoppelt.",
|
"pairing_success": "Erfolgreich gekoppelt.",
|
||||||
"pairing_failed": "Kopplung fehlgeschlagen — PIN prüfen und erneut versuchen.",
|
"pairing_failed": "Kopplung fehlgeschlagen — PIN prüfen und erneut versuchen.",
|
||||||
|
"pairing_native_title": "Gerät koppeln",
|
||||||
|
"pairing_native_desc": "Zeige hier eine Einmal-PIN an und gib sie in deiner punktfunk-App ein, um dieses Gerät zu koppeln.",
|
||||||
|
"pairing_native_disabled": "Der native Host läuft nicht. Starte ihn mit `serve --native`, um punktfunk-Geräte zu koppeln.",
|
||||||
|
"pairing_native_arm": "Gerät koppeln",
|
||||||
|
"pairing_native_enter": "Gib diese PIN auf deinem Gerät ein:",
|
||||||
|
"pairing_native_expires": "Läuft ab in",
|
||||||
|
"pairing_native_cancel": "Abbrechen",
|
||||||
|
"pairing_native_devices": "Gekoppelte Geräte",
|
||||||
|
"pairing_native_empty": "Noch keine Geräte gekoppelt.",
|
||||||
|
"pairing_native_unpair_confirm": "Dieses Gerät entkoppeln? Es muss sich erneut koppeln, um zu verbinden.",
|
||||||
|
"pairing_moonlight_title": "Moonlight-Kopplung (GameStream)",
|
||||||
"settings_title": "Einstellungen",
|
"settings_title": "Einstellungen",
|
||||||
"settings_token_label": "API-Token",
|
"settings_token_label": "API-Token",
|
||||||
"settings_token_help": "Bearer-Token für die Verwaltungs-API. Bei einem Loopback-Host ohne Token leer lassen.",
|
"settings_token_help": "Bearer-Token für die Verwaltungs-API. Bei einem Loopback-Host ohne Token leer lassen.",
|
||||||
|
|||||||
@@ -48,6 +48,17 @@
|
|||||||
"pairing_submit": "Submit PIN",
|
"pairing_submit": "Submit PIN",
|
||||||
"pairing_success": "Paired successfully.",
|
"pairing_success": "Paired successfully.",
|
||||||
"pairing_failed": "Pairing failed — check the PIN and try again.",
|
"pairing_failed": "Pairing failed — check the PIN and try again.",
|
||||||
|
"pairing_native_title": "Pair a device",
|
||||||
|
"pairing_native_desc": "Show a one-time PIN here, then enter it in your punktfunk app to pair this device.",
|
||||||
|
"pairing_native_disabled": "The native host isn't running. Start it with `serve --native` to pair punktfunk devices.",
|
||||||
|
"pairing_native_arm": "Pair a device",
|
||||||
|
"pairing_native_enter": "Enter this PIN on your device:",
|
||||||
|
"pairing_native_expires": "Expires in",
|
||||||
|
"pairing_native_cancel": "Cancel",
|
||||||
|
"pairing_native_devices": "Paired devices",
|
||||||
|
"pairing_native_empty": "No devices paired yet.",
|
||||||
|
"pairing_native_unpair_confirm": "Unpair this device? It will need to pair again to connect.",
|
||||||
|
"pairing_moonlight_title": "Moonlight (GameStream) pairing",
|
||||||
"settings_title": "Settings",
|
"settings_title": "Settings",
|
||||||
"settings_token_label": "API token",
|
"settings_token_label": "API token",
|
||||||
"settings_token_help": "Bearer token for the management API. Leave empty for a loopback host with no token.",
|
"settings_token_help": "Bearer token for the management API. Leave empty for a loopback host with no token.",
|
||||||
|
|||||||
+207
-49
@@ -1,12 +1,29 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { KeyRound, CheckCircle2 } from 'lucide-react'
|
import { KeyRound, CheckCircle2, Smartphone, Timer, Trash2 } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
useGetNativePairing,
|
||||||
|
useArmNativePairing,
|
||||||
|
useDisarmNativePairing,
|
||||||
|
useListNativeClients,
|
||||||
|
useUnpairNativeClient,
|
||||||
|
getGetNativePairingQueryKey,
|
||||||
|
getListNativeClientsQueryKey,
|
||||||
|
} from '@/api/gen/native/native'
|
||||||
import {
|
import {
|
||||||
useGetPairingStatus,
|
useGetPairingStatus,
|
||||||
useSubmitPairingPin,
|
useSubmitPairingPin,
|
||||||
getGetPairingStatusQueryKey,
|
getGetPairingStatusQueryKey,
|
||||||
} from '@/api/gen/pairing/pairing'
|
} from '@/api/gen/pairing/pairing'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -17,11 +34,157 @@ import { useLocale } from '@/lib/i18n'
|
|||||||
|
|
||||||
export const Route = createFileRoute('/pairing')({ component: PairingPage })
|
export const Route = createFileRoute('/pairing')({ component: PairingPage })
|
||||||
|
|
||||||
|
/** Seconds → `m:ss`. */
|
||||||
|
function fmtTime(secs: number): string {
|
||||||
|
const s = Math.max(0, Math.floor(secs))
|
||||||
|
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
function PairingPage() {
|
function PairingPage() {
|
||||||
useLocale()
|
useLocale()
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-semibold">{m.pairing_title()}</h1>
|
||||||
|
<NativePairingCard />
|
||||||
|
<NativeDevices />
|
||||||
|
<MoonlightPairingCard />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Native (punktfunk/1) pairing: arm a window → DISPLAY the PIN the user enters on their device. */
|
||||||
|
function NativePairingCard() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
// Poll fast while armed (live countdown), slow otherwise.
|
||||||
|
const status = useGetNativePairing({
|
||||||
|
query: { refetchInterval: (q) => (q.state.data?.armed ? 1_000 : 4_000) },
|
||||||
|
})
|
||||||
|
const arm = useArmNativePairing()
|
||||||
|
const disarm = useDisarmNativePairing()
|
||||||
|
const d = status.data
|
||||||
|
const refresh = () => qc.invalidateQueries({ queryKey: getGetNativePairingQueryKey() })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryState isLoading={status.isLoading} error={status.error} refetch={status.refetch}>
|
||||||
|
<Card className="max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Smartphone className="size-4" />
|
||||||
|
{m.pairing_native_title()}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{!d?.enabled ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{m.pairing_native_disabled()}</p>
|
||||||
|
) : d.armed && d.pin ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm">{m.pairing_native_enter()}</p>
|
||||||
|
<div className="rounded-lg border bg-muted/40 py-5 text-center font-mono text-4xl font-semibold tracking-[0.3em]">
|
||||||
|
{d.pin}
|
||||||
|
</div>
|
||||||
|
{d.expires_in_secs != null && (
|
||||||
|
<p className="flex items-center justify-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<Timer className="size-4" />
|
||||||
|
{m.pairing_native_expires()} {fmtTime(d.expires_in_secs)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
disabled={disarm.isPending}
|
||||||
|
onClick={() => disarm.mutate(undefined, { onSuccess: refresh })}
|
||||||
|
>
|
||||||
|
{m.pairing_native_cancel()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-muted-foreground">{m.pairing_native_desc()}</p>
|
||||||
|
<Button
|
||||||
|
disabled={arm.isPending}
|
||||||
|
onClick={() => arm.mutate({ data: { ttl_secs: 120 } }, { onSuccess: refresh })}
|
||||||
|
>
|
||||||
|
<KeyRound className="size-4" />
|
||||||
|
{m.pairing_native_arm()}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</QueryState>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The paired native (punktfunk/1) devices, with unpair. */
|
||||||
|
function NativeDevices() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const clients = useListNativeClients()
|
||||||
|
const unpair = useUnpairNativeClient()
|
||||||
|
const rows = clients.data ?? []
|
||||||
|
|
||||||
|
const onUnpair = (fingerprint: string) => {
|
||||||
|
if (!confirm(m.pairing_native_unpair_confirm())) return
|
||||||
|
unpair.mutate(
|
||||||
|
{ fingerprint },
|
||||||
|
{ onSuccess: () => qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-lg font-medium">{m.pairing_native_devices()}</h2>
|
||||||
|
<QueryState isLoading={clients.isLoading} error={clients.error} refetch={clients.refetch}>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 text-center text-sm text-muted-foreground">
|
||||||
|
{m.pairing_native_empty()}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{m.clients_name()}</TableHead>
|
||||||
|
<TableHead>{m.clients_fingerprint()}</TableHead>
|
||||||
|
<TableHead className="w-12" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((c) => (
|
||||||
|
<TableRow key={c.fingerprint}>
|
||||||
|
<TableCell className="font-medium">{c.name || '—'}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{c.fingerprint.slice(0, 16)}…
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={m.action_unpair()}
|
||||||
|
disabled={unpair.isPending}
|
||||||
|
onClick={() => onUnpair(c.fingerprint)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GameStream/Moonlight pairing: the client shows a PIN, the operator submits it here. */
|
||||||
|
function MoonlightPairingCard() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [pin, setPin] = useState('')
|
const [pin, setPin] = useState('')
|
||||||
// Poll: the host flips pin_pending when a Moonlight client begins pairing.
|
|
||||||
const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } })
|
const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } })
|
||||||
const submit = useSubmitPairingPin()
|
const submit = useSubmitPairingPin()
|
||||||
const pending = pairing.data?.pin_pending ?? false
|
const pending = pairing.data?.pin_pending ?? false
|
||||||
@@ -40,52 +203,47 @@ function PairingPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<QueryState isLoading={pairing.isLoading} error={pairing.error} refetch={pairing.refetch}>
|
||||||
<h1 className="text-2xl font-semibold">{m.pairing_title()}</h1>
|
<Card className="max-w-md">
|
||||||
<QueryState isLoading={pairing.isLoading} error={pairing.error} refetch={pairing.refetch}>
|
<CardHeader>
|
||||||
<Card className="max-w-md">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<CardHeader>
|
<KeyRound className="size-4" />
|
||||||
<CardTitle className="flex items-center gap-2">
|
{m.pairing_moonlight_title()}
|
||||||
<KeyRound className="size-4" />
|
</CardTitle>
|
||||||
{m.pairing_title()}
|
</CardHeader>
|
||||||
</CardTitle>
|
<CardContent>
|
||||||
</CardHeader>
|
{!pending ? (
|
||||||
<CardContent>
|
<p className="text-sm text-muted-foreground">{m.pairing_idle()}</p>
|
||||||
{!pending ? (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">{m.pairing_idle()}</p>
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
) : (
|
<p className="text-sm">{m.pairing_waiting()}</p>
|
||||||
<form onSubmit={onSubmit} className="space-y-4">
|
<div className="space-y-2">
|
||||||
<p className="text-sm">{m.pairing_waiting()}</p>
|
<Label htmlFor="pin">{m.pairing_pin_label()}</Label>
|
||||||
<div className="space-y-2">
|
<Input
|
||||||
<Label htmlFor="pin">{m.pairing_pin_label()}</Label>
|
id="pin"
|
||||||
<Input
|
inputMode="numeric"
|
||||||
id="pin"
|
autoComplete="off"
|
||||||
inputMode="numeric"
|
maxLength={8}
|
||||||
autoComplete="off"
|
value={pin}
|
||||||
maxLength={8}
|
onChange={(e) => setPin(e.target.value.replace(/\D/g, ''))}
|
||||||
value={pin}
|
placeholder="0000"
|
||||||
onChange={(e) => setPin(e.target.value.replace(/\D/g, ''))}
|
className="font-mono text-lg tracking-widest"
|
||||||
placeholder="0000"
|
/>
|
||||||
className="font-mono text-lg tracking-widest"
|
</div>
|
||||||
/>
|
<Button type="submit" disabled={pin.length < 4 || submit.isPending}>
|
||||||
</div>
|
{m.pairing_submit()}
|
||||||
<Button type="submit" disabled={pin.length < 4 || submit.isPending}>
|
</Button>
|
||||||
{m.pairing_submit()}
|
{submit.isSuccess && (
|
||||||
</Button>
|
<p className="flex items-center gap-1.5 text-sm text-[var(--success)]">
|
||||||
{submit.isSuccess && (
|
<CheckCircle2 className="size-4" />
|
||||||
<p className="flex items-center gap-1.5 text-sm text-[var(--success)]">
|
{m.pairing_success()}
|
||||||
<CheckCircle2 className="size-4" />
|
</p>
|
||||||
{m.pairing_success()}
|
)}
|
||||||
</p>
|
{submit.isError && <p className="text-sm text-destructive">{m.pairing_failed()}</p>}
|
||||||
)}
|
</form>
|
||||||
{submit.isError && (
|
)}
|
||||||
<p className="text-sm text-destructive">{m.pairing_failed()}</p>
|
</CardContent>
|
||||||
)}
|
</Card>
|
||||||
</form>
|
</QueryState>
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</QueryState>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user