mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
- docs: device.pair.update and the `permanent` option on approve in docs/04-gateway-protocol.md, docs/19-websocket-rpc.md and websocket-protocol.md; the paired-device TTL row in docs/09-security.md now mentions the admin opt-out. - store.ErrPairedDeviceNotFound: SetPairingPermanent wraps it in both stores. device.pair.update maps it to NOT_FOUND and any other store error to INTERNAL, so a DB failure no longer reads as "not found". - web UI: approve and make-permanent/set-expiry now toast the server error and reload the list in `finally`. A partially applied approve (paired, but the permanent write failed) shows up in the table instead of leaving the dialog dead-ended. - SQLite ListPaired: a stored expiry that fails to parse stays 0 (expires, date unknown) rather than being mistaken for permanent; the UI renders it as "--" instead of a 1970 date. Tests: gateway handler error mapping (NOT_FOUND / INTERNAL / OK), sentinel checks in the PG and SQLite store tests, SQLite unreadable-expiry case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
267 lines
12 KiB
TypeScript
267 lines
12 KiB
TypeScript
import { useState, useMemo } from "react";
|
|
import { Link as LinkIcon, RefreshCw, Check, X, Trash2, Infinity as InfinityIcon, Timer } from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Label } from "@/components/ui/label";
|
|
import { PageHeader } from "@/components/shared/page-header";
|
|
import { EmptyState } from "@/components/shared/empty-state";
|
|
import { TableSkeleton } from "@/components/shared/loading-skeleton";
|
|
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
|
|
import { formatDate, formatRelativeTime } from "@/lib/format";
|
|
import {
|
|
useNodes,
|
|
type PendingPairing,
|
|
type PairedDevice,
|
|
} from "./hooks/use-nodes";
|
|
import { useMinLoading } from "@/hooks/use-min-loading";
|
|
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
|
import { useContactResolver } from "@/hooks/use-contact-resolver";
|
|
import { formatUserLabel } from "@/lib/format-user-label";
|
|
|
|
export function NodesPage() {
|
|
const { t } = useTranslation("nodes");
|
|
const {
|
|
pendingPairings,
|
|
pairedDevices,
|
|
loading,
|
|
refresh,
|
|
approvePairing,
|
|
denyPairing,
|
|
revokePairing,
|
|
setPairingPermanent,
|
|
} = useNodes();
|
|
const spinning = useMinLoading(loading);
|
|
const senderIds = useMemo(() => [
|
|
...pendingPairings.map((p) => p.sender_id),
|
|
...pairedDevices.map((d) => d.sender_id),
|
|
...pairedDevices.map((d) => d.paired_by).filter(Boolean),
|
|
].filter(Boolean) as string[], [pendingPairings, pairedDevices]);
|
|
const { resolve } = useContactResolver(senderIds);
|
|
const isEmpty = pendingPairings.length === 0 && pairedDevices.length === 0;
|
|
const showSkeleton = useDeferredLoading(loading && isEmpty);
|
|
const [revokeTarget, setRevokeTarget] = useState<PairedDevice | null>(null);
|
|
const [approveTarget, setApproveTarget] = useState<PendingPairing | null>(null);
|
|
const [approvePermanent, setApprovePermanent] = useState(false);
|
|
const [expiryTarget, setExpiryTarget] = useState<PairedDevice | null>(null);
|
|
const [denyTarget, setDenyTarget] = useState<PendingPairing | null>(null);
|
|
|
|
return (
|
|
<div className="p-4 sm:p-6 pb-10">
|
|
<PageHeader
|
|
title={t("title")}
|
|
description={t("description")}
|
|
actions={
|
|
<Button variant="outline" size="sm" onClick={refresh} disabled={spinning} className="gap-1">
|
|
<RefreshCw className={"h-3.5 w-3.5" + (spinning ? " animate-spin" : "")} /> {t("common:refresh", "Refresh")}
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<div className="mt-4">
|
|
{showSkeleton ? (
|
|
<TableSkeleton rows={4} />
|
|
) : isEmpty ? (
|
|
<EmptyState
|
|
icon={LinkIcon}
|
|
title={t("emptyTitle")}
|
|
description={t("emptyDescription")}
|
|
/>
|
|
) : (
|
|
<div className="space-y-6">
|
|
{/* Pending pairings */}
|
|
{pendingPairings.length > 0 && (
|
|
<div>
|
|
<h3 className="mb-3 text-sm font-medium">
|
|
{t("pendingRequests", { count: pendingPairings.length })}
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{pendingPairings.map((p: PendingPairing) => (
|
|
<div key={p.code} className="flex items-center justify-between rounded-lg border p-4">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline">{p.channel}</Badge>
|
|
<span className="font-mono text-sm font-medium">{p.code}</span>
|
|
</div>
|
|
<div className="mt-1 text-xs text-muted-foreground">
|
|
{t("sender")}{formatUserLabel(p.sender_id, resolve)}
|
|
{p.chat_id && ` | ${t("chat")}${p.chat_id}`}
|
|
{" | "}
|
|
{formatRelativeTime(new Date(p.created_at))}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setDenyTarget(p)}
|
|
className="gap-1"
|
|
>
|
|
<X className="h-3.5 w-3.5" /> {t("deny")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => {
|
|
setApprovePermanent(false);
|
|
setApproveTarget(p);
|
|
}}
|
|
className="gap-1"
|
|
>
|
|
<Check className="h-3.5 w-3.5" /> {t("approve")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Paired devices */}
|
|
{pairedDevices.length > 0 && (
|
|
<div>
|
|
<h3 className="mb-3 text-sm font-medium">
|
|
{t("pairedDevices", { count: pairedDevices.length })}
|
|
</h3>
|
|
<div className="rounded-md border overflow-x-auto">
|
|
<table className="w-full min-w-[600px] text-sm">
|
|
<thead>
|
|
<tr className="border-b bg-muted/50">
|
|
<th className="px-4 py-3 text-left font-medium">{t("columns.channel")}</th>
|
|
<th className="px-4 py-3 text-left font-medium">{t("columns.senderId")}</th>
|
|
<th className="px-4 py-3 text-left font-medium">{t("columns.paired")}</th>
|
|
<th className="px-4 py-3 text-left font-medium">{t("columns.by")}</th>
|
|
<th className="px-4 py-3 text-left font-medium">{t("columns.expires")}</th>
|
|
<th className="px-4 py-3 text-right font-medium">{t("columns.actions")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{pairedDevices.map((d: PairedDevice) => (
|
|
<tr key={`${d.channel}-${d.sender_id}`} className="border-b last:border-0 hover:bg-muted/30">
|
|
<td className="px-4 py-3">
|
|
<Badge variant="outline">{d.channel}</Badge>
|
|
</td>
|
|
<td className="px-4 py-3 font-medium">{formatUserLabel(d.sender_id, resolve)}</td>
|
|
<td className="px-4 py-3 text-muted-foreground">
|
|
{formatDate(new Date(d.paired_at))}
|
|
</td>
|
|
<td className="px-4 py-3 text-muted-foreground">{d.paired_by ? formatUserLabel(d.paired_by, resolve) : "--"}</td>
|
|
<td className="px-4 py-3 text-muted-foreground">
|
|
{d.expires_at == null ? (
|
|
<Badge variant="secondary" className="gap-1">
|
|
<InfinityIcon className="h-3 w-3" /> {t("never")}
|
|
</Badge>
|
|
) : d.expires_at > 0 ? (
|
|
formatDate(new Date(d.expires_at))
|
|
) : (
|
|
"--"
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-right whitespace-nowrap">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setExpiryTarget(d)}
|
|
className="gap-1"
|
|
>
|
|
{d.expires_at == null ? (
|
|
<>
|
|
<Timer className="h-3.5 w-3.5" /> {t("makeExpiring")}
|
|
</>
|
|
) : (
|
|
<>
|
|
<InfinityIcon className="h-3.5 w-3.5" /> {t("makePermanent")}
|
|
</>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setRevokeTarget(d)}
|
|
className="gap-1"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" /> {t("revoke")}
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{approveTarget && (
|
|
<ConfirmDialog
|
|
open
|
|
onOpenChange={() => setApproveTarget(null)}
|
|
title={t("confirmApprove.title")}
|
|
description={t("confirmApprove.description", { channel: approveTarget.channel, senderId: approveTarget.sender_id, code: approveTarget.code })}
|
|
confirmLabel={t("confirmApprove.confirmLabel")}
|
|
onConfirm={async () => {
|
|
await approvePairing(approveTarget.code, approvePermanent);
|
|
setApproveTarget(null);
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<Switch id="approve-permanent" checked={approvePermanent} onCheckedChange={setApprovePermanent} />
|
|
<Label htmlFor="approve-permanent" className="font-normal">
|
|
{t("confirmApprove.permanent")}
|
|
</Label>
|
|
</div>
|
|
</ConfirmDialog>
|
|
)}
|
|
|
|
{expiryTarget && (
|
|
<ConfirmDialog
|
|
open
|
|
onOpenChange={() => setExpiryTarget(null)}
|
|
title={t(expiryTarget.expires_at == null ? "confirmExpiring.title" : "confirmPermanent.title")}
|
|
description={t(
|
|
expiryTarget.expires_at == null ? "confirmExpiring.description" : "confirmPermanent.description",
|
|
{ channel: expiryTarget.channel, senderId: expiryTarget.sender_id },
|
|
)}
|
|
confirmLabel={t(expiryTarget.expires_at == null ? "confirmExpiring.confirmLabel" : "confirmPermanent.confirmLabel")}
|
|
onConfirm={async () => {
|
|
await setPairingPermanent(expiryTarget.sender_id, expiryTarget.channel, expiryTarget.expires_at != null);
|
|
setExpiryTarget(null);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{denyTarget && (
|
|
<ConfirmDialog
|
|
open
|
|
onOpenChange={() => setDenyTarget(null)}
|
|
title={t("confirmDeny.title")}
|
|
description={t("confirmDeny.description", { channel: denyTarget.channel, senderId: denyTarget.sender_id, code: denyTarget.code })}
|
|
confirmLabel={t("confirmDeny.confirmLabel")}
|
|
variant="destructive"
|
|
onConfirm={async () => {
|
|
await denyPairing(denyTarget.code);
|
|
setDenyTarget(null);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{revokeTarget && (
|
|
<ConfirmDialog
|
|
open
|
|
onOpenChange={() => setRevokeTarget(null)}
|
|
title={t("confirmRevoke.title")}
|
|
description={t("confirmRevoke.description", { channel: revokeTarget.channel, senderId: revokeTarget.sender_id })}
|
|
confirmLabel={t("confirmRevoke.confirmLabel")}
|
|
variant="destructive"
|
|
onConfirm={async () => {
|
|
await revokePairing(revokeTarget.sender_id, revokeTarget.channel);
|
|
setRevokeTarget(null);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|