From f8c2ecf85f3ef7da3b0caa6983c61c7c8ab8c92c Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 11 Jun 2026 10:02:33 +0000 Subject: [PATCH] =?UTF-8?q?feat(web):=20"Pair=20a=20device"=20card=20?= =?UTF-8?q?=E2=80=94=20native=20pairing=20from=20the=20console?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/messages/de.json | 11 ++ web/messages/en.json | 11 ++ web/src/routes/pairing.tsx | 256 ++++++++++++++++++++++++++++++------- 3 files changed, 229 insertions(+), 49 deletions(-) diff --git a/web/messages/de.json b/web/messages/de.json index b320022..cfcde5a 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -48,6 +48,17 @@ "pairing_submit": "PIN bestätigen", "pairing_success": "Erfolgreich gekoppelt.", "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_token_label": "API-Token", "settings_token_help": "Bearer-Token für die Verwaltungs-API. Bei einem Loopback-Host ohne Token leer lassen.", diff --git a/web/messages/en.json b/web/messages/en.json index 7981445..f81046f 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -48,6 +48,17 @@ "pairing_submit": "Submit PIN", "pairing_success": "Paired successfully.", "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_token_label": "API token", "settings_token_help": "Bearer token for the management API. Leave empty for a loopback host with no token.", diff --git a/web/src/routes/pairing.tsx b/web/src/routes/pairing.tsx index dbbb81c..f8bc694 100644 --- a/web/src/routes/pairing.tsx +++ b/web/src/routes/pairing.tsx @@ -1,12 +1,29 @@ import { useState } from 'react' import { createFileRoute } from '@tanstack/react-router' 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 { useGetPairingStatus, useSubmitPairingPin, getGetPairingStatusQueryKey, } 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 { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -17,11 +34,157 @@ import { useLocale } from '@/lib/i18n' 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() { useLocale() + return ( +
+

{m.pairing_title()}

+ + + +
+ ) +} + +/** 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 ( + + + + + + {m.pairing_native_title()} + + + + {!d?.enabled ? ( +

{m.pairing_native_disabled()}

+ ) : d.armed && d.pin ? ( +
+

{m.pairing_native_enter()}

+
+ {d.pin} +
+ {d.expires_in_secs != null && ( +

+ + {m.pairing_native_expires()} {fmtTime(d.expires_in_secs)} +

+ )} + +
+ ) : ( + <> +

{m.pairing_native_desc()}

+ + + )} +
+
+
+ ) +} + +/** 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 ( +
+

{m.pairing_native_devices()}

+ + {rows.length === 0 ? ( + + + {m.pairing_native_empty()} + + + ) : ( + + + + + + {m.clients_name()} + {m.clients_fingerprint()} + + + + + {rows.map((c) => ( + + {c.name || '—'} + + {c.fingerprint.slice(0, 16)}… + + + + + + ))} + +
+
+
+ )} +
+
+ ) +} + +/** GameStream/Moonlight pairing: the client shows a PIN, the operator submits it here. */ +function MoonlightPairingCard() { const qc = useQueryClient() const [pin, setPin] = useState('') - // Poll: the host flips pin_pending when a Moonlight client begins pairing. const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } }) const submit = useSubmitPairingPin() const pending = pairing.data?.pin_pending ?? false @@ -40,52 +203,47 @@ function PairingPage() { } return ( -
-

{m.pairing_title()}

- - - - - - {m.pairing_title()} - - - - {!pending ? ( -

{m.pairing_idle()}

- ) : ( -
-

{m.pairing_waiting()}

-
- - setPin(e.target.value.replace(/\D/g, ''))} - placeholder="0000" - className="font-mono text-lg tracking-widest" - /> -
- - {submit.isSuccess && ( -

- - {m.pairing_success()} -

- )} - {submit.isError && ( -

{m.pairing_failed()}

- )} -
- )} -
-
-
-
+ + + + + + {m.pairing_moonlight_title()} + + + + {!pending ? ( +

{m.pairing_idle()}

+ ) : ( +
+

{m.pairing_waiting()}

+
+ + setPin(e.target.value.replace(/\D/g, ''))} + placeholder="0000" + className="font-mono text-lg tracking-widest" + /> +
+ + {submit.isSuccess && ( +

+ + {m.pairing_success()} +

+ )} + {submit.isError &&

{m.pairing_failed()}

} +
+ )} +
+
+
) }