feat(pairing): delegated approval (§8b-1) — approve an unpaired device from the console
ci / web (push) Failing after 40s
ci / rust (push) Successful in 1m6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
apple / swift (push) Successful in 1m20s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
ci / docs-site (push) Failing after 46s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
docker / deploy-docs (push) Successful in 16s
ci / web (push) Failing after 40s
ci / rust (push) Successful in 1m6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
apple / swift (push) Successful in 1m20s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
ci / docs-site (push) Failing after 46s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 18s
docker / deploy-docs (push) Successful in 16s
An identified-but-unpaired device that knocks on a pairing-required host is now
held as a pending request the operator approves from the web console — pairing it
with no PIN fetched out of band — instead of a flat reject.
- core: Hello gains an optional trailing device name (len u8 || UTF-8, ≤64,
same trailing-back-compat pattern as compositor/gamepad/bitrate). client-rs
--name sends it; the connector sends None (fingerprint-derived label).
- native_pairing: in-memory pending queue (note_pending dedups by fingerprint,
evicts the least-recently-active past a 32 cap, 10-min TTL); approve_pending
pins the fingerprint, deny drops it. Names are sanitized (strip control/ANSI/
bidi — untrusted wire input); add()/remove() roll back in-memory on a persist
failure; pairing clears any stale pending knock.
- m3: the require_pairing gate records the knock (sanitized label) before
rejecting; anonymous (certless) clients record nothing.
- mgmt: GET /native/pending, POST /native/pending/{id}/approve (optional {name})
and /deny; OpenAPI + tests; docs/api/openapi.json regenerated.
- web: a "Waiting for approval" section on the Pairing page (live-poll, Approve/
Deny, error-surfaced via QueryState); en+de strings.
- Also completes an in-progress NativeClient Sync refactor (receivers behind
per-plane mutexes) that was left half-applied in the tree.
Adversarially reviewed (4 lenses + 3-vote verify); the confirmed findings are
fixed here. Validated live on the GNOME box: knock (with a wire name, and a
malicious ANSI/bidi name that got neutralized) → pending → approve → the same
identity streams real video. Full workspace tests + clippy + fmt green; web tsc
clean. Roadmap §8b-1 marked done; §8b-2 (peer-push approval) is the client
follow-up. See docs-site pairing page.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -58,6 +58,14 @@
|
||||
"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_pending_title": "Warten auf Freigabe",
|
||||
"pairing_pending_desc": "Diese Geräte haben versucht, sich zu verbinden. Eine Freigabe koppelt das Gerät sofort — ohne PIN.",
|
||||
"pairing_pending_approve": "Freigeben",
|
||||
"pairing_pending_deny": "Ablehnen",
|
||||
"pairing_pending_name_prompt": "Gerät benennen:",
|
||||
"pairing_pending_age_just_now": "gerade eben",
|
||||
"pairing_pending_age_secs": "vor {s}s",
|
||||
"pairing_pending_age_mins": "vor {min} min",
|
||||
"pairing_moonlight_title": "Moonlight-Kopplung (GameStream)",
|
||||
"settings_title": "Einstellungen",
|
||||
"settings_token_label": "API-Token",
|
||||
|
||||
@@ -58,6 +58,14 @@
|
||||
"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_pending_title": "Waiting for approval",
|
||||
"pairing_pending_desc": "These devices tried to connect. Approving pairs a device immediately — no PIN needed.",
|
||||
"pairing_pending_approve": "Approve",
|
||||
"pairing_pending_deny": "Deny",
|
||||
"pairing_pending_name_prompt": "Name this device:",
|
||||
"pairing_pending_age_just_now": "just now",
|
||||
"pairing_pending_age_secs": "{s}s ago",
|
||||
"pairing_pending_age_mins": "{min} min ago",
|
||||
"pairing_moonlight_title": "Moonlight (GameStream) pairing",
|
||||
"settings_title": "Settings",
|
||||
"settings_token_label": "API token",
|
||||
|
||||
+197
-56
@@ -1,21 +1,33 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { KeyRound, CheckCircle2, Smartphone, Timer, Trash2 } from 'lucide-react'
|
||||
import { useState } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
KeyRound,
|
||||
CheckCircle2,
|
||||
Smartphone,
|
||||
Timer,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useGetNativePairing,
|
||||
useArmNativePairing,
|
||||
useDisarmNativePairing,
|
||||
useListNativeClients,
|
||||
useUnpairNativeClient,
|
||||
useListPendingDevices,
|
||||
useApprovePendingDevice,
|
||||
useDenyPendingDevice,
|
||||
getGetNativePairingQueryKey,
|
||||
getListNativeClientsQueryKey,
|
||||
} from '@/api/gen/native/native'
|
||||
getListPendingDevicesQueryKey,
|
||||
} from "@/api/gen/native/native";
|
||||
import {
|
||||
useGetPairingStatus,
|
||||
useSubmitPairingPin,
|
||||
getGetPairingStatusQueryKey,
|
||||
} from '@/api/gen/pairing/pairing'
|
||||
} from "@/api/gen/pairing/pairing";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -23,49 +35,151 @@ import {
|
||||
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'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { m } from '@/paraglide/messages'
|
||||
import { useLocale } from '@/lib/i18n'
|
||||
} 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";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { m } from "@/paraglide/messages";
|
||||
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')}`
|
||||
const s = Math.max(0, Math.floor(secs));
|
||||
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function PairingPage() {
|
||||
useLocale()
|
||||
useLocale();
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold">{m.pairing_title()}</h1>
|
||||
<PendingDevices />
|
||||
<NativePairingCard />
|
||||
<NativeDevices />
|
||||
<MoonlightPairingCard />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Seconds since a knock → a short relative label. */
|
||||
function fmtAge(secs: number): string {
|
||||
if (secs < 10) return m.pairing_pending_age_just_now();
|
||||
if (secs < 60) return m.pairing_pending_age_secs({ s: Math.floor(secs) });
|
||||
return m.pairing_pending_age_mins({ min: Math.floor(secs / 60) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Devices awaiting delegated approval: an unpaired device that tried to connect shows up here,
|
||||
* and Approve pairs it on the spot — no PIN fetched out of band. Renders nothing while empty
|
||||
* (the common case); polls so a knock appears while the operator is looking at the page.
|
||||
*/
|
||||
function PendingDevices() {
|
||||
const qc = useQueryClient();
|
||||
const pending = useListPendingDevices({ query: { refetchInterval: 3_000 } });
|
||||
const approve = useApprovePendingDevice();
|
||||
const deny = useDenyPendingDevice();
|
||||
const rows = pending.data ?? [];
|
||||
// Stay out of the way when there's nothing pending and the fetch is healthy — but DON'T swallow
|
||||
// a real error (a 500 etc.); fall through to QueryState below so it surfaces like every other
|
||||
// section. (A 401 is handled globally by the fetcher's redirect-to-login.)
|
||||
if (rows.length === 0 && !pending.error) return null;
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: getListPendingDevicesQueryKey() });
|
||||
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() });
|
||||
};
|
||||
const onApprove = (id: number, currentName: string) => {
|
||||
const name = prompt(m.pairing_pending_name_prompt(), currentName);
|
||||
if (name == null) return; // operator cancelled
|
||||
approve.mutate(
|
||||
{ id, data: { name: name.trim() ? name.trim() : null } },
|
||||
{ onSuccess: refresh },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h2 className="flex items-center gap-2 text-lg font-medium">
|
||||
<UserPlus className="size-4" />
|
||||
{m.pairing_pending_title()}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{m.pairing_pending_desc()}
|
||||
</p>
|
||||
<QueryState
|
||||
isLoading={pending.isLoading}
|
||||
error={pending.error}
|
||||
refetch={pending.refetch}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{rows.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-medium">{p.name}</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{p.fingerprint.slice(0, 16)}…
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{fmtAge(p.age_secs)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={approve.isPending || deny.isPending}
|
||||
onClick={() => onApprove(p.id, p.name)}
|
||||
>
|
||||
{m.pairing_pending_approve()}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={m.pairing_pending_deny()}
|
||||
disabled={approve.isPending || deny.isPending}
|
||||
onClick={() =>
|
||||
deny.mutate({ id: p.id }, { onSuccess: refresh })
|
||||
}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</QueryState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Native (punktfunk/1) pairing: arm a window → DISPLAY the PIN the user enters on their device. */
|
||||
function NativePairingCard() {
|
||||
const qc = useQueryClient()
|
||||
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() })
|
||||
});
|
||||
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}>
|
||||
<QueryState
|
||||
isLoading={status.isLoading}
|
||||
error={status.error}
|
||||
refetch={status.refetch}
|
||||
>
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -75,7 +189,9 @@ function NativePairingCard() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!d?.enabled ? (
|
||||
<p className="text-sm text-muted-foreground">{m.pairing_native_disabled()}</p>
|
||||
<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>
|
||||
@@ -99,10 +215,17 @@ function NativePairingCard() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">{m.pairing_native_desc()}</p>
|
||||
<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 })}
|
||||
onClick={() =>
|
||||
arm.mutate(
|
||||
{ data: { ttl_secs: 120 } },
|
||||
{ onSuccess: refresh },
|
||||
)
|
||||
}
|
||||
>
|
||||
<KeyRound className="size-4" />
|
||||
{m.pairing_native_arm()}
|
||||
@@ -112,28 +235,35 @@ function NativePairingCard() {
|
||||
</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 qc = useQueryClient();
|
||||
const clients = useListNativeClients();
|
||||
const unpair = useUnpairNativeClient();
|
||||
const rows = clients.data ?? [];
|
||||
|
||||
const onUnpair = (fingerprint: string) => {
|
||||
if (!confirm(m.pairing_native_unpair_confirm())) return
|
||||
if (!confirm(m.pairing_native_unpair_confirm())) return;
|
||||
unpair.mutate(
|
||||
{ fingerprint },
|
||||
{ onSuccess: () => qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() }) },
|
||||
)
|
||||
}
|
||||
{
|
||||
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}>
|
||||
<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">
|
||||
@@ -154,7 +284,9 @@ function NativeDevices() {
|
||||
<TableBody>
|
||||
{rows.map((c) => (
|
||||
<TableRow key={c.fingerprint}>
|
||||
<TableCell className="font-medium">{c.name || '—'}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{c.name || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{c.fingerprint.slice(0, 16)}…
|
||||
</TableCell>
|
||||
@@ -178,32 +310,36 @@ function NativeDevices() {
|
||||
)}
|
||||
</QueryState>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** GameStream/Moonlight pairing: the client shows a PIN, the operator submits it here. */
|
||||
function MoonlightPairingCard() {
|
||||
const qc = useQueryClient()
|
||||
const [pin, setPin] = useState('')
|
||||
const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } })
|
||||
const submit = useSubmitPairingPin()
|
||||
const pending = pairing.data?.pin_pending ?? false
|
||||
const qc = useQueryClient();
|
||||
const [pin, setPin] = useState("");
|
||||
const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } });
|
||||
const submit = useSubmitPairingPin();
|
||||
const pending = pairing.data?.pin_pending ?? false;
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
submit.mutate(
|
||||
{ data: { pin } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setPin('')
|
||||
qc.invalidateQueries({ queryKey: getGetPairingStatusQueryKey() })
|
||||
setPin("");
|
||||
qc.invalidateQueries({ queryKey: getGetPairingStatusQueryKey() });
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<QueryState isLoading={pairing.isLoading} error={pairing.error} refetch={pairing.refetch}>
|
||||
<QueryState
|
||||
isLoading={pairing.isLoading}
|
||||
error={pairing.error}
|
||||
refetch={pairing.refetch}
|
||||
>
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -225,12 +361,15 @@ function MoonlightPairingCard() {
|
||||
autoComplete="off"
|
||||
maxLength={8}
|
||||
value={pin}
|
||||
onChange={(e) => setPin(e.target.value.replace(/\D/g, ''))}
|
||||
onChange={(e) => setPin(e.target.value.replace(/\D/g, ""))}
|
||||
placeholder="0000"
|
||||
className="font-mono text-lg tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={pin.length < 4 || submit.isPending}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={pin.length < 4 || submit.isPending}
|
||||
>
|
||||
{m.pairing_submit()}
|
||||
</Button>
|
||||
{submit.isSuccess && (
|
||||
@@ -239,11 +378,13 @@ function MoonlightPairingCard() {
|
||||
{m.pairing_success()}
|
||||
</p>
|
||||
)}
|
||||
{submit.isError && <p className="text-sm text-destructive">{m.pairing_failed()}</p>}
|
||||
{submit.isError && (
|
||||
<p className="text-sm text-destructive">{m.pairing_failed()}</p>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</QueryState>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user