Files
netbird/client/ui/frontend/src/modules/approval/ApprovalDialog.tsx
T

198 lines
8.1 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { MonitorIcon } from "lucide-react";
import { Button } from "@/components/buttons/Button";
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { SquareIcon } from "@/components/SquareIcon";
import { Approval, WindowManager } from "@bindings/services";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
const WINDOW_WIDTH = 360;
// Fallback window so a missing/unparseable expires_at can't leave the prompt open forever.
const FALLBACK_SECONDS = 13;
// The window raises itself over whatever the user is doing, so the accept
// actions stay inert briefly after it appears: a keystroke or click already in
// flight must not be what grants a remote session.
const ARMING_MS = 800;
// shortFingerprint groups a hex key as XXXX-XXXX-XXXX-XXXX (16 chars). Mirrors the
// daemon's approval.ShortKeyFingerprint so the value matches an out-of-band reference.
function shortFingerprint(hexKey: string): string {
if (hexKey.length < 8) return "";
const src = hexKey.slice(0, 16);
return src.match(/.{1,4}/g)?.join("-") ?? src;
}
type Row = { label: string; value: string; mono?: boolean };
export default function ApprovalDialog() {
const { t } = useTranslation();
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
const [params] = useSearchParams();
const [busy, setBusy] = useState(false);
const requestID = params.get("request_id") ?? "";
const kind = params.get("kind") ?? "";
const initiator = params.get("initiator") ?? "";
const peerName = params.get("peer_name") ?? "";
const sourceIP = params.get("source_ip") ?? "";
const username = params.get("username") ?? "";
const peerPubKey = params.get("peer_pubkey") ?? "";
const expiresAt = params.get("expires_at") ?? "";
const deadline = useMemo(() => {
const parsed = Date.parse(expiresAt);
return Number.isFinite(parsed) ? parsed : Date.now() + FALLBACK_SECONDS * 1000;
}, [expiresAt]);
const title = useMemo(() => {
switch (kind) {
case "vnc":
return t("approval.title.vnc");
case "ssh":
return t("approval.title.ssh");
default:
return t("approval.title.default");
}
}, [kind, t]);
const rows = useMemo<Row[]>(() => {
const out: Row[] = [];
// The display name is dashboard-supplied and not cryptographically
// asserted; the key fingerprint below IS, so show both.
if (initiator) out.push({ label: t("approval.field.user"), value: initiator });
const fp = shortFingerprint(peerPubKey);
if (fp) out.push({ label: t("approval.field.keyFingerprint"), value: fp, mono: true });
if (peerName) out.push({ label: t("approval.field.peer"), value: peerName });
if (sourceIP && sourceIP !== peerName)
out.push({ label: t("approval.field.sourceIp"), value: sourceIP, mono: true });
if (username) out.push({ label: t("approval.field.osUser"), value: username });
return out;
}, [initiator, peerPubKey, peerName, sourceIP, username, t]);
const respond = useCallback(
async (accept: boolean, viewOnly: boolean) => {
if (busy) return;
setBusy(true);
try {
if (requestID) {
await Approval.Respond(requestID, accept, viewOnly);
}
} catch (e) {
console.error("respond approval failed", e);
} finally {
WindowManager.CloseApproval().catch(console.error);
}
},
[busy, requestID],
);
const [armed, setArmed] = useState(false);
useEffect(() => {
const id = globalThis.setTimeout(() => setArmed(true), ARMING_MS);
return () => globalThis.clearTimeout(id);
}, []);
// The dialog is non-modal, so the browser's own Escape-to-cancel does not
// apply and denying has to be wired up by hand.
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
e.preventDefault();
void respond(false, false);
};
globalThis.addEventListener("keydown", onKeyDown);
return () => globalThis.removeEventListener("keydown", onKeyDown);
}, [respond]);
const secondsLeft = () => Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
const [remaining, setRemaining] = useState(secondsLeft);
const closedRef = useRef(false);
useEffect(() => {
const id = globalThis.setInterval(() => {
const left = secondsLeft();
setRemaining(left);
// On the deadline the daemon auto-denies; just close the prompt.
if (left <= 0 && !closedRef.current) {
closedRef.current = true;
WindowManager.CloseApproval().catch(console.error);
}
}, 1000);
return () => globalThis.clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deadline]);
const showViewOnly = kind === "vnc";
return (
<ConfirmDialog ref={contentRef} aria-labelledby={"nb-approval-title"}>
<SquareIcon icon={MonitorIcon} />
<DialogHeading id={"nb-approval-title"}>{title}</DialogHeading>
{rows.length > 0 && (
<dl className={"w-full space-y-1 text-left text-sm"}>
{rows.map((row) => (
<div key={row.label} className={"flex justify-between gap-4"}>
<dt className={"shrink-0 text-nb-gray-400"}>{row.label}</dt>
<dd
className={`min-w-0 truncate text-nb-gray-100 ${
row.mono ? "font-mono" : ""
}`}
title={row.value}
>
{row.value}
</dd>
</div>
))}
</dl>
)}
<div className={"text-sm tabular-nums text-nb-gray-400"} aria-live={"polite"}>
{t("approval.countdown", { seconds: remaining })}
</div>
{/* Deny and Allow sit side by side, in the same order as the app's
other confirmations. The view-only variant is a wordier label than
either, so it gets its own row rather than squeezing all three. */}
<DialogActions className={"max-w-[260px] gap-2.5"}>
{showViewOnly && (
<Button
variant={"secondary"}
size={"sm"}
className={"w-full"}
onClick={() => respond(true, true)}
disabled={busy || !armed}
>
{t("approval.action.allowViewOnly")}
</Button>
)}
<div className={"flex flex-row gap-2.5"}>
<Button
autoFocus
variant={"danger"}
size={"sm"}
className={"flex-1"}
onClick={() => respond(false, false)}
disabled={busy}
>
{t("approval.action.deny")}
</Button>
<Button
variant={"primary"}
size={"sm"}
className={"flex-1"}
onClick={() => respond(true, false)}
disabled={busy || !armed}
>
{t("approval.action.allow")}
</Button>
</div>
</DialogActions>
</ConfirmDialog>
);
}