Files
punktfunk/web/src/sections/Pairing/PendingDevices.tsx
T
enricobuehler 3be7d1d4f8
ci / bun-nix (pull_request) Successful in 30s
ci / web (pull_request) Successful in 1m7s
ci / docs-site (pull_request) Successful in 1m12s
ci / rust-arm64 (pull_request) Successful in 1m36s
ci / rust (pull_request) Successful in 6m32s
feat(web): the console asks its own questions
Follow-up to a85e8452, closing the three items that sweep flagged and left.

SIXTEEN BROWSER DIALOGS, GONE. Every destructive action in an otherwise fully
branded console handed off to `window.confirm` — a grey OS box with the page's
URL in it, no brand, no red on a delete, and untouchable by any story or
screenshot, which is part of why it survived this long.

They are replaced by one promise-based surface (components/dialogs.tsx) rather
than a dialog per call site. The native calls were EXPRESSIONS — `if
(!confirm(…)) return;` — threaded through mutation handlers; rewriting each into
"hold the pending action in state, render a dialog, run it from onConfirm" would
have put dialog machinery in every section file and turned each linear handler
inside out. Returning a promise keeps them the shape they already were, and it
is what let the navigation guard come along too: TanStack's `shouldBlockFn`
accepts `Promise<boolean>`. `beforeunload` necessarily stays native — a reload
is the browser's dialog to draw, and it will not wait on ours.

No warning copy was rewritten. Each message was SPLIT at its existing sentence
boundary: the question becomes the dialog's title, the consequence its body,
and "Continue?" is dropped where the affirmative button now carries the verb
("Delete", "Uninstall", "Unpair", "Stop every session"). 16 new keys, en and de
in parity at 629.

Verified by driving the real dialogs in a headless browser — all seven contract
checks pass, including the two that would be invisible until they bit: Escape
SETTLES the promise (an unsettled one would hang a mutation handler forever with
no error), and a cancelled prompt resolves null rather than "", so a caller can
still tell "backed out" from "cleared the field".

FOUR OF THE SEVEN NUMERIC FIELDS became InputNumber; three deliberately did not,
and now say why in place. The layout X/Y pair had a real defect: a screen left
of the origin has a negative coordinate, and `Number("-") || 0` rewrote the lone
minus sign to "0" before the digits could be typed. Measured on the built page:
the field can now be emptied to retype instead of snapping to its floor, and 900
in a 1..=16 field clamps to 16. The three left alone cannot take it — the grace
seconds field writes to the HOST on blur (InputNumber commits while typing, so
its clamp would race the apply), and the library's year/players are OPTIONAL,
where `value: number` has no way to say "unset" and would invent a year for
every entry without one.

The select's highlighted row moves off @unom/ui's neutral grey onto the brand
wash the nav and the preset cards already use.

The Displays story earned its keep immediately: adding `useDialogs` to that page
broke it in Storybook, because the provider was mounted in __root and nowhere
else. It belongs beside the other app-level providers in .storybook/preview.
2026-08-07 22:56:46 +02:00

163 lines
5.9 KiB
TypeScript

import { useQueryClient } from "@tanstack/react-query";
import { UserPlus, X } from "lucide-react";
import type { FC } from "react";
import type { PendingDevice } from "@/api/gen/model";
import {
getListNativeClientsQueryKey,
getListPendingDevicesQueryKey,
useApprovePendingDevice,
useDenyPendingDevice,
useListPendingDevices,
} from "@/api/gen/native/native";
import { useDialogs } from "@/components/dialogs";
import { QueryState } from "@/components/query-state";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
import type { Loadable } from "@/lib/query";
import { fmtAge } from "@/lib/utils";
import { m } from "@/paraglide/messages";
/**
* Container: devices awaiting delegated approval. Polls so a knock appears while
* looking; approving pairs the device, so it also refreshes the paired-clients
* list (owned by the PairedDevices subsection — invalidated here by query key).
*/
export const PendingDevicesSection: FC = () => {
const qc = useQueryClient();
// A knock arrives as a `pairing.pending` event (api/events.ts), so the timer is the fallback —
// but it stays reasonably brisk: this list is the one the operator is actively waiting on, and
// the rows carry an age that should not visibly lag.
const { promptText } = useDialogs();
const pending = useListPendingDevices({ query: { refetchInterval: 10_000 } });
const approve = useApprovePendingDevice();
const deny = useDenyPendingDevice();
const refresh = () => {
qc.invalidateQueries({ queryKey: getListPendingDevicesQueryKey() });
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() });
};
const onApprove = async (id: number, currentName: string) => {
const name = await promptText({
title: m.pairing_pending_name_title(),
label: m.pairing_pending_name_prompt(),
defaultValue: currentName,
confirmLabel: m.pairing_pending_approve(),
});
if (name == null) return; // operator cancelled
approve.mutate(
{ id, data: { name: name.trim() ? name.trim() : null } },
{ onSuccess: refresh },
);
};
const onDeny = (id: number) => deny.mutate({ id }, { onSuccess: refresh });
// The id of the row whose approve/deny is in flight — only that row's buttons disable.
const pendingId =
(approve.isPending ? approve.variables?.id : undefined) ??
(deny.isPending ? deny.variables?.id : undefined) ??
null;
return (
<PendingDevices
pending={pending}
onApprove={onApprove}
onDeny={onDeny}
pendingId={pendingId}
/>
);
};
/**
* Devices awaiting delegated approval: an unpaired device that tried to connect
* shows up here, and Approve pairs it on the spot. Renders nothing while empty
* (the common case) unless there's an error to surface.
*/
export const PendingDevices: FC<{
pending: Loadable<PendingDevice[]>;
onApprove: (id: number, currentName: string) => void;
onDeny: (id: number) => void;
/** Id of the row whose approve/deny is in flight, or null — only that row disables. */
pendingId: number | null;
}> = ({ pending, onApprove, onDeny, pendingId }) => {
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;
return (
<Card>
<CardContent flush>
<CardHeader>
<CardTitle>
<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>
</CardTitle>
</CardHeader>
<QueryState
isLoading={pending.isLoading}
error={pending.error}
refetch={pending.refetch}
>
<Table>
<TableBody>
{rows.map((p) => (
<TableRow className="h-18" key={p.id}>
{/* The row must keep the actions on-canvas in a portrait phone
viewport: the name flexes and truncates (w-full + max-w-0),
and the fingerprint/age columns collapse into a sub-line
here below md/sm instead of widening the row past the
screen (the table wrapper scrolls, the page doesn't — an
off-canvas Approve button is unreachable on mobile). */}
<TableCell className="w-full max-w-0 font-medium">
<div className="truncate">{p.name}</div>
<div className="truncate font-mono text-xs font-normal text-muted-foreground md:hidden">
{p.fingerprint.slice(0, 16)}
<span className="ml-2 font-sans sm:hidden">
{fmtAge(p.age_secs)}
</span>
</div>
</TableCell>
<TableCell className="hidden font-mono text-xs text-muted-foreground md:table-cell">
{p.fingerprint.slice(0, 16)}
</TableCell>
<TableCell className="hidden text-xs text-muted-foreground sm:table-cell">
{fmtAge(p.age_secs)}
</TableCell>
<TableCell className="whitespace-nowrap text-right">
<div className="flex justify-end gap-2">
<Button
size="sm"
disabled={pendingId === p.id}
onClick={() => onApprove(p.id, p.name)}
>
{m.pairing_pending_approve()}
</Button>
<Button
size="sm"
variant="ghost"
aria-label={m.pairing_pending_deny()}
disabled={pendingId === p.id}
onClick={() => onDeny(p.id)}
>
<X className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</QueryState>
</CardContent>
</Card>
);
};