mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-01 20:41:28 +02:00
Add peer-to-peer file drop
Files move directly between peers over the overlay, with no server in the path. The receiver listens on the WireGuard address only, so the port is unreachable from outside the tunnel, and every offer is matched to a known peer before anything is read. Consent is the default: an offer carries metadata alone, and no payload moves until the receiver accepts. Policy is per profile and device-local — off, ask, or auto-accept, with per-sender exceptions on top. Policy and history live in the profile's preferences, so removing a profile takes its file drop state with it. Transfers interrupted by a restart are settled on load; nothing survives to finish them, and left alone they would sit in the log as permanently pending. The Android bindings pull payload bytes through a chunk-returning stream: gomobile copies a []byte argument into a fresh Java array and never copies it back, so a fill-my-buffer method would hand back the right length with no data.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
export type NavSection = "peers" | "networks";
|
||||
export type NavSection = "peers" | "networks" | "files";
|
||||
|
||||
type NavSectionContextValue = {
|
||||
section: NavSection;
|
||||
|
||||
47
client/ui/frontend/src/lib/filedrop.ts
Normal file
47
client/ui/frontend/src/lib/filedrop.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export const enum TransferState {
|
||||
Pending = 0,
|
||||
Transferring = 1,
|
||||
Completed = 2,
|
||||
Declined = 3,
|
||||
Expired = 4,
|
||||
Cancelled = 5,
|
||||
Failed = 6,
|
||||
}
|
||||
|
||||
export const enum ReceiveMode {
|
||||
Off = 0,
|
||||
Ask = 1,
|
||||
Auto = 2,
|
||||
}
|
||||
|
||||
export const enum PeerRule {
|
||||
Default = 0,
|
||||
Always = 1,
|
||||
Block = 2,
|
||||
}
|
||||
|
||||
export const enum FailureReason {
|
||||
None = 0,
|
||||
Unreachable = 1,
|
||||
}
|
||||
|
||||
export const isTerminalState = (state: number): boolean => state >= TransferState.Completed;
|
||||
|
||||
export const stateLabelKey = (state: number): string => {
|
||||
switch (state) {
|
||||
case TransferState.Pending:
|
||||
return "files.state.pending";
|
||||
case TransferState.Transferring:
|
||||
return "files.state.transferring";
|
||||
case TransferState.Completed:
|
||||
return "files.state.received";
|
||||
case TransferState.Declined:
|
||||
return "files.state.declined";
|
||||
case TransferState.Expired:
|
||||
return "files.state.expired";
|
||||
case TransferState.Cancelled:
|
||||
return "files.state.cancelled";
|
||||
default:
|
||||
return "files.state.failed";
|
||||
}
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import { NotConnectedState } from "@/components/empty-state/NotConnectedState";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
import { Peers } from "@/modules/main/advanced/peers/Peers";
|
||||
import { Networks } from "@/modules/main/advanced/networks/Networks";
|
||||
import { Files } from "@/modules/main/advanced/files/Files";
|
||||
import { NetworksProvider } from "@/contexts/NetworksContext";
|
||||
import { PeerDetailProvider, usePeerDetail } from "@/contexts/PeerDetailContext";
|
||||
import { useRestrictions } from "@/contexts/RestrictionsContext";
|
||||
@@ -44,7 +45,10 @@ const MainBody = () => {
|
||||
const isAdvanced = viewMode === "advanced";
|
||||
|
||||
return (
|
||||
<main className={"wails-draggable flex min-h-0 flex-1"}>
|
||||
// min-w-0 matters here: without it this flex parent keeps its automatic
|
||||
// minimum width, and a long file name in the right panel pushes the
|
||||
// layout wider than the window, which cannot be resized.
|
||||
<main className={"wails-draggable flex min-h-0 min-w-0 flex-1"}>
|
||||
{/* Windows narrower width compensates for the OS frame Wails counts differently than macOS.
|
||||
See https://github.com/wailsapp/wails/issues/3260 */}
|
||||
<div
|
||||
@@ -98,10 +102,14 @@ const AdvancedAppRightPanel = () => {
|
||||
role={"tabpanel"}
|
||||
id={`nb-tabpanel-${section}`}
|
||||
aria-labelledby={`nb-tab-${section}`}
|
||||
className={"flex min-h-0 flex-1 flex-col"}
|
||||
// min-w-0: this is the section's direct parent, so without
|
||||
// it a long file name sets the floor for the whole panel
|
||||
// and the row overflows instead of truncating.
|
||||
className={"flex min-h-0 min-w-0 flex-1 flex-col"}
|
||||
>
|
||||
{section === "peers" && <Peers />}
|
||||
{section === "networks" && <Networks />}
|
||||
{section === "files" && <Files />}
|
||||
</div>
|
||||
</div>
|
||||
{!isConnected && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type ComponentType, type KeyboardEvent, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Layers3Icon, type LucideProps, MonitorSmartphoneIcon } from "lucide-react";
|
||||
import { FolderDownIcon, Layers3Icon, type LucideProps, MonitorSmartphoneIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useNavSection, type NavSection } from "@/contexts/NavSectionContext";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
@@ -40,6 +40,11 @@ export const Navigation = () => {
|
||||
icon: Layers3Icon,
|
||||
});
|
||||
}
|
||||
tabs.push({
|
||||
value: "files",
|
||||
label: t("nav.files.title"),
|
||||
icon: FolderDownIcon,
|
||||
});
|
||||
|
||||
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
|
||||
@@ -103,7 +108,7 @@ export const Navigation = () => {
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
"group relative flex flex-1 items-center justify-center",
|
||||
"group relative flex min-w-0 flex-1 items-center justify-center",
|
||||
"gap-2.5 px-5 py-3.5",
|
||||
"outline-none transition-all",
|
||||
isFirst && "rounded-tl-xl",
|
||||
@@ -113,8 +118,8 @@ export const Navigation = () => {
|
||||
isDisabled ? "cursor-not-allowed opacity-50" : "cursor-default",
|
||||
)}
|
||||
>
|
||||
<Icon size={14} aria-hidden={"true"} />
|
||||
<span className={"text-sm font-normal"}>{tab.label}</span>
|
||||
<Icon size={14} className={"shrink-0"} aria-hidden={"true"} />
|
||||
<span className={"truncate text-sm font-normal"}>{tab.label}</span>
|
||||
<span
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
|
||||
494
client/ui/frontend/src/modules/main/advanced/files/Files.tsx
Normal file
494
client/ui/frontend/src/modules/main/advanced/files/Files.tsx
Normal file
@@ -0,0 +1,494 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
BanIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
FolderDownIcon,
|
||||
FolderOpenIcon,
|
||||
MoreVerticalIcon,
|
||||
ShieldCheckIcon,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { FileDrop } from "@bindings/services";
|
||||
import type { FileDropTransfer } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatBytes } from "@/lib/formatters";
|
||||
import {
|
||||
FailureReason,
|
||||
isTerminalState,
|
||||
PeerRule,
|
||||
stateLabelKey,
|
||||
TransferState,
|
||||
} from "@/lib/filedrop";
|
||||
import { SearchInput } from "@/components/inputs/SearchInput";
|
||||
import { EmptyState } from "@/components/empty-state/EmptyState";
|
||||
import { NoResults } from "@/components/empty-state/NoResults";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/DropdownMenu";
|
||||
|
||||
const EVENT_FILEDROP = "netbird:filedrop";
|
||||
const POLL_MS = 1000;
|
||||
|
||||
const transferTitle = (transfer: FileDropTransfer): string => {
|
||||
const files = transfer.files ?? [];
|
||||
if (files.length === 1 && files[0].isText) {
|
||||
return `“${files[0].text}”`;
|
||||
}
|
||||
return files.map((f) => f.name).join(", ");
|
||||
};
|
||||
|
||||
const isTextTransfer = (transfer: FileDropTransfer): boolean =>
|
||||
(transfer.files ?? []).length === 1 && transfer.files[0].isText;
|
||||
|
||||
const timeLabel = (iso: string): string => {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
};
|
||||
|
||||
// Colours only what the eye should catch scanning the outcome column: a refusal
|
||||
// or failure in red, a completed send in green. Everything else, a received file
|
||||
// included, stays neutral so the exceptions stand out.
|
||||
const outcomeClass = (transfer: FileDropTransfer, inProgress: boolean): string => {
|
||||
if (inProgress) return "text-nb-gray-500";
|
||||
switch (transfer.state) {
|
||||
case TransferState.Declined:
|
||||
case TransferState.Failed:
|
||||
return "text-red-400";
|
||||
case TransferState.Completed:
|
||||
return transfer.outgoing ? "text-green-400" : "text-nb-gray-500";
|
||||
default:
|
||||
return "text-nb-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
export const Files = () => {
|
||||
const { t } = useTranslation();
|
||||
const [transfers, setTransfers] = useState<FileDropTransfer[] | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setTransfers(await FileDrop.List());
|
||||
} catch {
|
||||
setTransfers((prev) => prev ?? []);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current?.focus();
|
||||
void refresh();
|
||||
const id = setInterval(() => void refresh(), POLL_MS);
|
||||
const off = Events.On(EVENT_FILEDROP, () => void refresh());
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
off();
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const all = transfers ?? [];
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return all;
|
||||
return all.filter(
|
||||
(tr) =>
|
||||
transferTitle(tr).toLowerCase().includes(q) ||
|
||||
tr.peerName.toLowerCase().includes(q),
|
||||
);
|
||||
}, [transfers, search]);
|
||||
|
||||
const pending = filtered.filter((tr) => !tr.outgoing && tr.state === TransferState.Pending);
|
||||
const rest = filtered.filter((tr) => tr.outgoing || tr.state !== TransferState.Pending);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const byDay = new Map<string, FileDropTransfer[]>();
|
||||
for (const tr of rest) {
|
||||
const d = new Date(tr.createdAt as unknown as string);
|
||||
const key = Number.isNaN(d.getTime()) ? "" : d.toDateString();
|
||||
const list = byDay.get(key) ?? [];
|
||||
list.push(tr);
|
||||
byDay.set(key, list);
|
||||
}
|
||||
const today = new Date().toDateString();
|
||||
const yesterday = new Date(Date.now() - 86400000).toDateString();
|
||||
return Array.from(byDay.entries()).map(([key, list]) => {
|
||||
let label = key;
|
||||
if (key === today) label = t("files.group.today");
|
||||
else if (key === yesterday) label = t("files.group.yesterday");
|
||||
else if (key) label = new Date(key).toLocaleDateString();
|
||||
return { label, list };
|
||||
});
|
||||
}, [rest, t]);
|
||||
|
||||
if (transfers !== null && transfers.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={FolderDownIcon}
|
||||
title={t("files.empty.title")}
|
||||
description={t("files.empty.description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={"flex h-full min-h-0 w-full flex-col"}>
|
||||
<div className={"flex items-center gap-2 border-b border-nb-gray-910 px-6 py-2.5"}>
|
||||
<div className={"min-w-0 flex-1"}>
|
||||
<SearchInput
|
||||
ref={searchRef}
|
||||
placeholder={t("files.search.placeholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<NoResults />
|
||||
) : (
|
||||
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
<div className={"flex flex-col pb-4 pt-2"}>
|
||||
{pending.map((tr) => (
|
||||
<PendingOfferRow key={tr.id} transfer={tr} onChanged={refresh} />
|
||||
))}
|
||||
{groups.map((group) => (
|
||||
<div key={group.label} className={"flex flex-col"}>
|
||||
<div
|
||||
className={
|
||||
"px-6 pb-1 pt-4 text-xs font-semibold uppercase tracking-wider text-nb-gray-500"
|
||||
}
|
||||
>
|
||||
{group.label}
|
||||
</div>
|
||||
{group.list.map((tr) => (
|
||||
<TransferRow
|
||||
key={tr.id}
|
||||
transfer={tr}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
className={"flex w-1.5 touch-none select-none bg-transparent py-1"}
|
||||
>
|
||||
<ScrollArea.Thumb
|
||||
className={
|
||||
"relative flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700"
|
||||
}
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type RowProps = {
|
||||
transfer: FileDropTransfer;
|
||||
onChanged: () => void;
|
||||
};
|
||||
|
||||
const PendingOfferRow = ({ transfer, onChanged }: RowProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const decide = async (accept: boolean) => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await FileDrop.Decide(transfer.id, accept);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
onChanged();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
// The panel is a fixed ~500px wide, so the buttons sit under the text
|
||||
// rather than beside it: side by side they would squeeze the file name
|
||||
// down to a few characters.
|
||||
<div
|
||||
className={cn(
|
||||
"mx-4 mt-2 flex flex-col gap-2.5 rounded-lg border border-netbird/30",
|
||||
"bg-netbird/5 px-4 py-3",
|
||||
"wails-no-draggable",
|
||||
)}
|
||||
>
|
||||
<div className={"flex min-w-0 items-center gap-2.5"}>
|
||||
<ArrowDownIcon
|
||||
size={16}
|
||||
className={"shrink-0 text-netbird"}
|
||||
aria-hidden={"true"}
|
||||
/>
|
||||
<div className={"flex min-w-0 flex-1 flex-col leading-tight"}>
|
||||
<span className={"truncate text-[0.81rem] font-medium text-nb-gray-100"}>
|
||||
{transferTitle(transfer)}
|
||||
<span className={"font-normal text-nb-gray-400"}>
|
||||
{" · "}
|
||||
{formatBytes(transfer.totalSize)}
|
||||
</span>
|
||||
</span>
|
||||
<span className={"truncate text-xs text-nb-gray-400"}>
|
||||
{t("files.offer.subtitle", { peer: transfer.peerName })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={"flex items-center gap-2 pl-[26px]"}>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"xs"}
|
||||
disabled={busy}
|
||||
onClick={() => void decide(true)}
|
||||
>
|
||||
{t("files.offer.accept")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"xs"}
|
||||
disabled={busy}
|
||||
onClick={() => void decide(false)}
|
||||
>
|
||||
{t("files.offer.decline")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TransferRow = ({ transfer, onChanged }: RowProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const isText = isTextTransfer(transfer);
|
||||
const live = !isTerminalState(transfer.state);
|
||||
const delivered =
|
||||
!transfer.outgoing &&
|
||||
transfer.state === TransferState.Completed &&
|
||||
(transfer.deliveredPaths ?? []).length > 0;
|
||||
const progress =
|
||||
transfer.state === TransferState.Transferring && transfer.totalSize > 0
|
||||
? Math.min(100, Math.floor((transfer.transferred / transfer.totalSize) * 100))
|
||||
: null;
|
||||
|
||||
// The subtitle carries who and how big; the outcome sits on the right next
|
||||
// to the time, so a glance down that column reads the results.
|
||||
const subtitleParts = [
|
||||
transfer.outgoing
|
||||
? t("files.row.to", { peer: transfer.peerName })
|
||||
: t("files.row.from", { peer: transfer.peerName }),
|
||||
];
|
||||
if (!isText) subtitleParts.push(formatBytes(transfer.totalSize));
|
||||
|
||||
let stateLabel: string;
|
||||
if (progress !== null) {
|
||||
stateLabel = t("files.state.progress", { percent: progress });
|
||||
} else if (transfer.state === TransferState.Completed) {
|
||||
stateLabel = t(transfer.outgoing ? "files.state.sent" : "files.state.received");
|
||||
} else if (
|
||||
transfer.state === TransferState.Failed &&
|
||||
transfer.reason === FailureReason.Unreachable
|
||||
) {
|
||||
stateLabel = t("files.state.unreachable");
|
||||
} else {
|
||||
stateLabel = t(stateLabelKey(transfer.state));
|
||||
}
|
||||
|
||||
const stateClass = outcomeClass(transfer, progress !== null);
|
||||
const time = timeLabel(transfer.createdAt as unknown as string);
|
||||
|
||||
const copyText = async () => {
|
||||
await navigator.clipboard.writeText(transfer.files[0]?.text ?? "");
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex items-center gap-2.5 py-2.5 pl-6 pr-4",
|
||||
"transition-colors hover:bg-nb-gray-900/40",
|
||||
"wails-no-draggable",
|
||||
)}
|
||||
>
|
||||
{transfer.outgoing ? (
|
||||
<ArrowUpIcon size={15} className={"shrink-0 text-netbird"} aria-hidden={"true"} />
|
||||
) : (
|
||||
<ArrowDownIcon
|
||||
size={15}
|
||||
className={"shrink-0 text-green-400"}
|
||||
aria-hidden={"true"}
|
||||
/>
|
||||
)}
|
||||
<div className={"flex min-w-0 flex-1 flex-col leading-tight"}>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-[0.81rem] font-medium text-nb-gray-100",
|
||||
isText && "italic",
|
||||
)}
|
||||
>
|
||||
{transferTitle(transfer)}
|
||||
</span>
|
||||
<span className={"truncate text-xs text-nb-gray-400"}>
|
||||
{subtitleParts.join(" · ")}
|
||||
</span>
|
||||
</div>
|
||||
{/* Time over outcome, capped: the window is a fixed 900px and some
|
||||
translations of the state labels are long enough to eat the file
|
||||
name if they share one line with the timestamp. */}
|
||||
<span
|
||||
className={cn(
|
||||
"flex max-w-[8.5rem] shrink-0 flex-col items-end leading-tight",
|
||||
"pl-2 text-xs text-nb-gray-500",
|
||||
"transition-opacity group-hover:opacity-0",
|
||||
)}
|
||||
>
|
||||
<span className={"tabular-nums"}>{time}</span>
|
||||
<span className={cn("max-w-full truncate", stateClass)}>{stateLabel}</span>
|
||||
</span>
|
||||
{/* The window is a fixed 900px wide and cannot be resized, so the
|
||||
row actions overlay the outcome column on hover instead of
|
||||
reserving width that the file name would otherwise lose. */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-4 flex items-center gap-1",
|
||||
// Fades the covered outcome text out rather than sitting on
|
||||
// a flat block, which would not match the row's hover tint.
|
||||
"bg-gradient-to-l from-nb-gray-940 via-nb-gray-940 to-transparent pl-8",
|
||||
"opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
{isText && (
|
||||
<RowIconButton label={t("files.action.copy")} onClick={() => void copyText()}>
|
||||
{copied ? (
|
||||
<CheckIcon size={14} className={"text-green-400"} />
|
||||
) : (
|
||||
<CopyIcon size={14} />
|
||||
)}
|
||||
</RowIconButton>
|
||||
)}
|
||||
{delivered && (
|
||||
<RowIconButton
|
||||
label={t("files.action.reveal")}
|
||||
onClick={() => void FileDrop.Reveal(transfer.deliveredPaths[0])}
|
||||
>
|
||||
<FolderOpenIcon size={14} />
|
||||
</RowIconButton>
|
||||
)}
|
||||
{live && (
|
||||
<RowIconButton
|
||||
label={t("files.action.cancel")}
|
||||
onClick={async () => {
|
||||
await FileDrop.Cancel(transfer.id);
|
||||
onChanged();
|
||||
}}
|
||||
>
|
||||
<XIcon size={14} />
|
||||
</RowIconButton>
|
||||
)}
|
||||
<TransferMenu transfer={transfer} delivered={delivered} onChanged={onChanged} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RowIconButton = ({
|
||||
label,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<Tooltip content={label}>
|
||||
<button
|
||||
type={"button"}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded-md",
|
||||
"text-nb-gray-300 hover:bg-nb-gray-900 hover:text-nb-gray-100",
|
||||
"cursor-default outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const TransferMenu = ({ transfer, delivered, onChanged }: RowProps & { delivered: boolean }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const setRule = async (rule: PeerRule) => {
|
||||
await FileDrop.SetPeerRule(transfer.peerKey, rule);
|
||||
onChanged();
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
aria-label={t("files.action.more")}
|
||||
className={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded-md",
|
||||
"text-nb-gray-300 hover:bg-nb-gray-900 hover:text-nb-gray-100",
|
||||
"cursor-default outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60",
|
||||
)}
|
||||
>
|
||||
<MoreVerticalIcon size={14} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={"end"}>
|
||||
{delivered && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => void FileDrop.Open(transfer.deliveredPaths[0])}
|
||||
>
|
||||
<FolderOpenIcon size={14} className={"mr-2"} />
|
||||
{t("files.action.open")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!transfer.outgoing && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => void setRule(PeerRule.Always)}>
|
||||
<ShieldCheckIcon size={14} className={"mr-2"} />
|
||||
{t("files.action.alwaysAccept")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => void setRule(PeerRule.Block)}>
|
||||
<BanIcon size={14} className={"mr-2"} />
|
||||
{t("files.action.block")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
variant={"danger"}
|
||||
onClick={async () => {
|
||||
await FileDrop.Delete(transfer.id);
|
||||
onChanged();
|
||||
}}
|
||||
>
|
||||
<Trash2Icon size={14} className={"mr-2"} />
|
||||
{t("files.action.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Check as CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronsLeftRightEllipsisIcon,
|
||||
ClipboardIcon,
|
||||
ClockIcon,
|
||||
Copy as CopyIcon,
|
||||
GaugeIcon,
|
||||
@@ -31,9 +32,13 @@ import {
|
||||
MonitorIcon,
|
||||
Radio,
|
||||
RefreshCwIcon,
|
||||
SendIcon,
|
||||
WaypointsIcon,
|
||||
} from "lucide-react";
|
||||
import { FileDrop } from "@bindings/services";
|
||||
import type { PeerStatus } from "@bindings/services/models.js";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { useNavSection } from "@/contexts/NavSectionContext";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
@@ -250,6 +255,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
</div>
|
||||
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
|
||||
<ScrollArea.Viewport className={"h-full w-full"}>
|
||||
<PeerSendActions peer={selected} />
|
||||
<PeerDetails peer={selected} now={now} />
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
@@ -272,6 +278,76 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const PeerSendActions = ({ peer }: { peer: PeerStatus }) => {
|
||||
const { t } = useTranslation();
|
||||
const { setSection } = useNavSection();
|
||||
const { setSelected } = usePeerDetail();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Deliberately not gated on connStatus: an idle peer is the normal resting
|
||||
// state under lazy connections, and the outgoing packets of the transfer are
|
||||
// exactly what wakes it. Only a peer without an overlay address has nothing
|
||||
// to dial.
|
||||
const canSend = peer.ip !== "";
|
||||
|
||||
const finishSend = () => {
|
||||
setSelected(null);
|
||||
setSection("files");
|
||||
};
|
||||
|
||||
const sendFiles = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const paths = await FileDrop.PickFiles();
|
||||
if (!paths || paths.length === 0) return;
|
||||
await FileDrop.Send(peer.pubKey, paths, "");
|
||||
finishSend();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const sendClipboard = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const text = await FileDrop.ClipboardText();
|
||||
if (!text) {
|
||||
setError(t("peers.details.sendClipboard.empty"));
|
||||
return;
|
||||
}
|
||||
await FileDrop.Send(peer.pubKey, [], text);
|
||||
finishSend();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"border-b border-nb-gray-920 px-5 py-3"}>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"xs"}
|
||||
disabled={!canSend}
|
||||
onClick={() => void sendFiles()}
|
||||
>
|
||||
<SendIcon size={12} aria-hidden={"true"} />
|
||||
{t("peers.details.sendFile")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"xs"}
|
||||
disabled={!canSend}
|
||||
onClick={() => void sendClipboard()}
|
||||
>
|
||||
<ClipboardIcon size={12} aria-hidden={"true"} />
|
||||
{t("peers.details.sendClipboard")}
|
||||
</Button>
|
||||
</div>
|
||||
{error && <div className={"mt-2 text-xs text-red-400"}>{error}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
|
||||
const { t } = useTranslation();
|
||||
const formatAge = (unix: number, fallback: string): string => {
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { type KeyboardEvent, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
type DragEvent,
|
||||
type KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
|
||||
import { ChevronRightIcon, MonitorSmartphoneIcon } from "lucide-react";
|
||||
import { ChevronRightIcon, MonitorSmartphoneIcon, SendIcon } from "lucide-react";
|
||||
import { FileDrop } from "@bindings/services";
|
||||
import type { PeerStatus } from "@bindings/services/models.js";
|
||||
import { useNavSection } from "@/contexts/NavSectionContext";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { reconcileOrder } from "@/lib/sorting";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
@@ -41,13 +53,36 @@ export const peerStatusLabelKey = (connStatus: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
const EVENT_FILES_DROPPED = "netbird:files:dropped";
|
||||
|
||||
export const Peers = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const { setSection } = useNavSection();
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||
const dropTargetRef = useRef<string | null>(null);
|
||||
|
||||
const setDropTargetBoth = useCallback((pubKey: string | null) => {
|
||||
dropTargetRef.current = pubKey;
|
||||
setDropTarget(pubKey);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const off = Events.On(EVENT_FILES_DROPPED, (ev: { data: string[] | string[][] }) => {
|
||||
const target = dropTargetRef.current;
|
||||
setDropTargetBoth(null);
|
||||
if (!target) return;
|
||||
const raw = ev.data;
|
||||
const paths = (Array.isArray(raw[0]) ? raw[0] : raw) as string[];
|
||||
if (paths.length === 0) return;
|
||||
void FileDrop.Send(target, paths, "").then(() => setSection("files"));
|
||||
});
|
||||
return off;
|
||||
}, [setDropTargetBoth, setSection]);
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current?.focus();
|
||||
@@ -135,9 +170,26 @@ export const Peers = () => {
|
||||
{filtered.length === 0 ? (
|
||||
<NoResults />
|
||||
) : (
|
||||
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
|
||||
<ScrollArea.Root
|
||||
type={"auto"}
|
||||
className={"min-h-0 flex-1 overflow-hidden"}
|
||||
onDragLeave={(e: DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
|
||||
setDropTargetBoth(null);
|
||||
}
|
||||
}}
|
||||
onDragOver={(e: DragEvent) => e.preventDefault()}
|
||||
onDrop={(e: DragEvent) => e.preventDefault()}
|
||||
>
|
||||
<ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
|
||||
{scrollParent && <PeersList data={filtered} scrollParent={scrollParent} />}
|
||||
{scrollParent && (
|
||||
<PeersList
|
||||
data={filtered}
|
||||
scrollParent={scrollParent}
|
||||
dropTarget={dropTarget}
|
||||
onDropTarget={setDropTargetBoth}
|
||||
/>
|
||||
)}
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation={"vertical"}
|
||||
@@ -163,9 +215,11 @@ const ListTopSpacer = () => <div className={"h-2"} />;
|
||||
type PeersListProps = {
|
||||
data: PeerStatus[];
|
||||
scrollParent: HTMLElement;
|
||||
dropTarget: string | null;
|
||||
onDropTarget: (pubKey: string | null) => void;
|
||||
};
|
||||
|
||||
const PeersList = ({ data, scrollParent }: PeersListProps) => {
|
||||
const PeersList = ({ data, scrollParent, dropTarget, onDropTarget }: PeersListProps) => {
|
||||
const { setSelected } = usePeerDetail();
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const rowRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
@@ -221,9 +275,15 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
|
||||
};
|
||||
|
||||
const ctx = useMemo<PeerRowContext>(
|
||||
() => ({ onKeyDown: handleRowKeyDown, onSelect: setSelected, setRowRef }),
|
||||
() => ({
|
||||
onKeyDown: handleRowKeyDown,
|
||||
onSelect: setSelected,
|
||||
setRowRef,
|
||||
dropTarget,
|
||||
onDropTarget,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[data, setSelected],
|
||||
[data, setSelected, dropTarget, onDropTarget],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -244,6 +304,8 @@ type PeerRowContext = {
|
||||
onKeyDown: (e: KeyboardEvent<Element>, index: number) => void;
|
||||
onSelect: (peer: PeerStatus) => void;
|
||||
setRowRef: (pubKey: string, el: HTMLButtonElement | null) => void;
|
||||
dropTarget: string | null;
|
||||
onDropTarget: (pubKey: string | null) => void;
|
||||
};
|
||||
|
||||
const renderPeerRow = (index: number, peer: PeerStatus, ctx: PeerRowContext): ReactNode => (
|
||||
@@ -253,6 +315,8 @@ const renderPeerRow = (index: number, peer: PeerStatus, ctx: PeerRowContext): Re
|
||||
onKeyDown={ctx.onKeyDown}
|
||||
onSelect={ctx.onSelect}
|
||||
setRowRef={ctx.setRowRef}
|
||||
isDropTarget={ctx.dropTarget === peer.pubKey}
|
||||
onDropTarget={ctx.onDropTarget}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -262,9 +326,19 @@ type PeerRowProps = {
|
||||
onKeyDown: (e: KeyboardEvent<Element>, index: number) => void;
|
||||
onSelect: (peer: PeerStatus) => void;
|
||||
setRowRef: (pubKey: string, el: HTMLButtonElement | null) => void;
|
||||
isDropTarget: boolean;
|
||||
onDropTarget: (pubKey: string | null) => void;
|
||||
};
|
||||
|
||||
const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps) => {
|
||||
const PeerRow = ({
|
||||
peer,
|
||||
index,
|
||||
onKeyDown,
|
||||
onSelect,
|
||||
setRowRef,
|
||||
isDropTarget,
|
||||
onDropTarget,
|
||||
}: PeerRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
const isConnected = peer.connStatus === "Connected";
|
||||
const peerName = shortenDns(peer.fqdn) || peer.ip;
|
||||
@@ -272,12 +346,29 @@ const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps)
|
||||
const handleKey = (e: KeyboardEvent<Element>) => onKeyDown(e, index);
|
||||
return (
|
||||
<div
|
||||
onDragOver={(e: DragEvent) => {
|
||||
if (!isConnected) return;
|
||||
e.preventDefault();
|
||||
onDropTarget(peer.pubKey);
|
||||
}}
|
||||
className={cn(
|
||||
"group relative flex min-w-0 items-start gap-2.5 py-3 pl-6 pr-4",
|
||||
"transition-colors hover:bg-nb-gray-900/40",
|
||||
"wails-no-draggable",
|
||||
)}
|
||||
>
|
||||
{isDropTarget && (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-2 inset-y-0.5 z-10",
|
||||
"flex items-center justify-center gap-2 rounded-lg",
|
||||
"border border-netbird bg-nb-gray-940/90 text-netbird",
|
||||
)}
|
||||
>
|
||||
<SendIcon size={14} aria-hidden={"true"} />
|
||||
<span className={"text-sm"}>{t("peers.dropToSend")}</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type={"button"}
|
||||
tabIndex={0}
|
||||
|
||||
222
client/ui/frontend/src/modules/settings/SettingsFileSharing.tsx
Normal file
222
client/ui/frontend/src/modules/settings/SettingsFileSharing.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BanIcon, CheckIcon, XIcon } from "lucide-react";
|
||||
import { FileDrop } from "@bindings/services";
|
||||
import { FileDropSettings } from "@bindings/services/models.js";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { PeerRule, ReceiveMode } from "@/lib/filedrop";
|
||||
import { shortenDns } from "@/lib/formatters";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { HelpText } from "@/components/typography/HelpText";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useStatus } from "@/contexts/StatusContext";
|
||||
|
||||
const MODES: { value: ReceiveMode; labelKey: string; helpKey: string }[] = [
|
||||
{
|
||||
value: ReceiveMode.Off,
|
||||
labelKey: "settings.fileSharing.mode.off",
|
||||
helpKey: "settings.fileSharing.mode.off.help",
|
||||
},
|
||||
{
|
||||
value: ReceiveMode.Ask,
|
||||
labelKey: "settings.fileSharing.mode.ask",
|
||||
helpKey: "settings.fileSharing.mode.ask.help",
|
||||
},
|
||||
{
|
||||
value: ReceiveMode.Auto,
|
||||
labelKey: "settings.fileSharing.mode.auto",
|
||||
helpKey: "settings.fileSharing.mode.auto.help",
|
||||
},
|
||||
];
|
||||
|
||||
export function SettingsFileSharing() {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const [settings, setSettings] = useState<FileDropSettings | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
FileDrop.GetSettings()
|
||||
.then(setSettings)
|
||||
.catch((e: unknown) => setError(String(e)));
|
||||
}, []);
|
||||
|
||||
const apply = useCallback(
|
||||
async (next: FileDropSettings) => {
|
||||
const prev = settings;
|
||||
setSettings(next);
|
||||
try {
|
||||
await FileDrop.SetSettings(next);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setSettings(prev);
|
||||
setError(String(e));
|
||||
}
|
||||
},
|
||||
[settings],
|
||||
);
|
||||
|
||||
const changeDirectory = async () => {
|
||||
if (!settings) return;
|
||||
const dir = await FileDrop.PickDirectory();
|
||||
if (!dir) return;
|
||||
await apply(new FileDropSettings({ ...settings, destinationDir: dir }));
|
||||
};
|
||||
|
||||
const removeRule = async (peerKey: string) => {
|
||||
if (!settings) return;
|
||||
try {
|
||||
await FileDrop.SetPeerRule(peerKey, PeerRule.Default);
|
||||
setSettings(await FileDrop.GetSettings());
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const peerName = (key: string): string => {
|
||||
const peer = (status?.peers ?? []).find((p) => p.pubKey === key);
|
||||
return peer ? shortenDns(peer.fqdn) || peer.ip : key.slice(0, 12) + "…";
|
||||
};
|
||||
|
||||
if (!settings) {
|
||||
return (
|
||||
<SectionGroup title={t("settings.fileSharing.section")}>
|
||||
<HelpText>{error ?? t("settings.fileSharing.loading")}</HelpText>
|
||||
</SectionGroup>
|
||||
);
|
||||
}
|
||||
|
||||
const rules = Object.entries(settings.peerRules ?? {});
|
||||
|
||||
return (
|
||||
<SectionGroup title={t("settings.fileSharing.section")}>
|
||||
{error && <HelpText className={"text-red-400"}>{error}</HelpText>}
|
||||
<div>
|
||||
<Label>{t("settings.fileSharing.mode.label")}</Label>
|
||||
<HelpText>{t("settings.fileSharing.mode.help")}</HelpText>
|
||||
<div
|
||||
role={"radiogroup"}
|
||||
aria-label={t("settings.fileSharing.mode.label")}
|
||||
className={"mt-2 flex flex-col gap-1.5"}
|
||||
>
|
||||
{MODES.map((mode) => {
|
||||
const active = settings.mode === mode.value;
|
||||
return (
|
||||
<button
|
||||
key={mode.value}
|
||||
type={"button"}
|
||||
role={"radio"}
|
||||
aria-checked={active}
|
||||
onClick={() =>
|
||||
void apply(
|
||||
new FileDropSettings({ ...settings, mode: mode.value }),
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg border px-4 py-3 text-left",
|
||||
"cursor-default outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60",
|
||||
active
|
||||
? "border-netbird/60 bg-netbird/5"
|
||||
: "border-nb-gray-900 hover:border-nb-gray-800",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center rounded-full border",
|
||||
active ? "border-netbird bg-netbird" : "border-nb-gray-600",
|
||||
)}
|
||||
>
|
||||
{active && <CheckIcon size={11} className={"text-white"} />}
|
||||
</span>
|
||||
<span className={"flex flex-col leading-tight"}>
|
||||
<span className={"text-sm text-nb-gray-100"}>
|
||||
{t(mode.labelKey)}
|
||||
</span>
|
||||
<span className={"text-xs text-nb-gray-400"}>
|
||||
{t(mode.helpKey)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("settings.fileSharing.destination.label")}</Label>
|
||||
<HelpText>{t("settings.fileSharing.destination.help")}</HelpText>
|
||||
<div className={"mt-2 flex items-center gap-3"}>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate rounded-md border border-nb-gray-900",
|
||||
"px-3 py-2 font-mono text-xs text-nb-gray-300",
|
||||
)}
|
||||
>
|
||||
{settings.destinationDir || t("settings.fileSharing.destination.unset")}
|
||||
</span>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"xs"}
|
||||
onClick={() => void changeDirectory()}
|
||||
>
|
||||
{t("settings.fileSharing.destination.change")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("settings.fileSharing.exceptions.label")}</Label>
|
||||
<HelpText>{t("settings.fileSharing.exceptions.help")}</HelpText>
|
||||
{rules.length === 0 ? (
|
||||
<HelpText className={"mt-2"}>
|
||||
{t("settings.fileSharing.exceptions.empty")}
|
||||
</HelpText>
|
||||
) : (
|
||||
<ul className={"mt-2 flex flex-col divide-y divide-nb-gray-920"}>
|
||||
{rules.map(([key, rule]) => (
|
||||
<li key={key} className={"flex items-center gap-3 py-2"}>
|
||||
{rule === PeerRule.Block ? (
|
||||
<BanIcon
|
||||
size={14}
|
||||
className={"shrink-0 text-red-400"}
|
||||
aria-hidden={"true"}
|
||||
/>
|
||||
) : (
|
||||
<CheckIcon
|
||||
size={14}
|
||||
className={"shrink-0 text-green-400"}
|
||||
aria-hidden={"true"}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={"min-w-0 flex-1 truncate text-sm text-nb-gray-100"}
|
||||
>
|
||||
{peerName(key)}
|
||||
</span>
|
||||
<span className={"shrink-0 text-xs text-nb-gray-400"}>
|
||||
{rule === PeerRule.Block
|
||||
? t("settings.fileSharing.exceptions.blocked")
|
||||
: t("settings.fileSharing.exceptions.always")}
|
||||
</span>
|
||||
<button
|
||||
type={"button"}
|
||||
aria-label={t("settings.fileSharing.exceptions.remove")}
|
||||
onClick={() => void removeRule(key)}
|
||||
className={cn(
|
||||
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md",
|
||||
"text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-100",
|
||||
"cursor-default outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60",
|
||||
)}
|
||||
>
|
||||
<XIcon size={13} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</SectionGroup>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useClientVersion } from "@/contexts/ClientVersionContext.tsx";
|
||||
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
|
||||
import {
|
||||
BoltIcon,
|
||||
FolderDownIcon,
|
||||
InfoIcon,
|
||||
LifeBuoyIcon,
|
||||
NetworkIcon,
|
||||
@@ -49,6 +50,11 @@ export const SettingsNavigation = () => {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<VerticalTabs.Trigger
|
||||
value={"fileSharing"}
|
||||
icon={FolderDownIcon}
|
||||
title={t("settings.tabs.fileSharing")}
|
||||
/>
|
||||
{!features.disableProfiles && (
|
||||
<VerticalTabs.Trigger
|
||||
value={"profiles"}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx";
|
||||
import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx";
|
||||
import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx";
|
||||
import { ProfilesTab } from "@/modules/profiles/ProfilesTab.tsx";
|
||||
import { SettingsFileSharing } from "@/modules/settings/SettingsFileSharing.tsx";
|
||||
import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx";
|
||||
import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx";
|
||||
import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
|
||||
@@ -24,6 +25,7 @@ const enum Tab {
|
||||
General = "general",
|
||||
Network = "network",
|
||||
Security = "security",
|
||||
FileSharing = "fileSharing",
|
||||
Profiles = "profiles",
|
||||
SSH = "ssh",
|
||||
Advanced = "advanced",
|
||||
@@ -35,6 +37,7 @@ const TAB_CONTENT: Record<Tab, ReactNode> = {
|
||||
[Tab.General]: <SettingsGeneral />,
|
||||
[Tab.Network]: <SettingsNetwork />,
|
||||
[Tab.Security]: <SettingsSecurity />,
|
||||
[Tab.FileSharing]: <SettingsFileSharing />,
|
||||
[Tab.Profiles]: <ProfilesTab />,
|
||||
[Tab.SSH]: <SettingsSSH />,
|
||||
[Tab.Advanced]: <SettingsAdvanced />,
|
||||
@@ -53,6 +56,7 @@ export const SettingsPage = () => {
|
||||
[Tab.General]: true,
|
||||
[Tab.Network]: editable,
|
||||
[Tab.Security]: editable,
|
||||
[Tab.FileSharing]: true,
|
||||
[Tab.Profiles]: !features.disableProfiles,
|
||||
[Tab.SSH]: mdm.allowServerSSH ?? editable,
|
||||
[Tab.Advanced]: editable,
|
||||
|
||||
Reference in New Issue
Block a user