mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-23 07:09:08 +02:00
198 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
}
|