Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e2cbe1d2a | ||
|
|
8a08583225 | ||
|
|
1defac4e34 | ||
|
|
fa68f98cd0 | ||
|
|
3f6e4c4e4f |
8
package-lock.json
generated
8
package-lock.json
generated
@@ -53,7 +53,7 @@
|
||||
"ip-cidr": "^3.1.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.479.0",
|
||||
"lucide-react": "^0.481.0",
|
||||
"next": "^14.2.28",
|
||||
"next-themes": "^0.2.1",
|
||||
"punycode": "^2.3.1",
|
||||
@@ -6621,9 +6621,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.479.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.479.0.tgz",
|
||||
"integrity": "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ==",
|
||||
"version": "0.481.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.481.0.tgz",
|
||||
"integrity": "sha512-NrvUDNFwgLIvHiwTEq9boa5Kiz1KdUT8RJ+wmNijwxdn9U737Fw42c43sRxJTMqhL+ySHpGRVCWpwiF+abrEjw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
"ip-cidr": "^3.1.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.479.0",
|
||||
"lucide-react": "^0.481.0",
|
||||
"next": "^14.2.28",
|
||||
"next-themes": "^0.2.1",
|
||||
"punycode": "^2.3.1",
|
||||
|
||||
@@ -157,20 +157,19 @@ const PeerGeneralInformation = () => {
|
||||
* Detect if there are changes in the peer information, if there are changes, then enable the save button.
|
||||
*/
|
||||
const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([
|
||||
name,
|
||||
ssh,
|
||||
selectedGroups,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
]);
|
||||
|
||||
const updatePeer = async () => {
|
||||
const updatePeer = async (newName?: string) => {
|
||||
let batchCall: Promise<any>[] = [];
|
||||
const groupCalls = getAllGroupCalls();
|
||||
|
||||
if (permission.peers.update) {
|
||||
const updateRequest = update({
|
||||
name,
|
||||
name: newName ?? name,
|
||||
ssh,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
@@ -187,7 +186,6 @@ const PeerGeneralInformation = () => {
|
||||
mutate("/peers/" + peer.id);
|
||||
mutate("/groups");
|
||||
updateHasChangedRef([
|
||||
name,
|
||||
ssh,
|
||||
selectedGroups,
|
||||
loginExpiration,
|
||||
@@ -229,8 +227,10 @@ const PeerGeneralInformation = () => {
|
||||
</ModalTrigger>
|
||||
<EditNameModal
|
||||
onSuccess={(newName) => {
|
||||
setName(newName);
|
||||
setShowEditNameModal(false);
|
||||
updatePeer(newName).then(() => {
|
||||
setName(newName);
|
||||
setShowEditNameModal(false);
|
||||
});
|
||||
}}
|
||||
peer={peer}
|
||||
initialName={name}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
AlertOctagonIcon,
|
||||
FolderGit2Icon,
|
||||
LockIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
NetworkIcon,
|
||||
ShieldIcon,
|
||||
} from "lucide-react";
|
||||
@@ -16,6 +17,7 @@ import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { useAccount } from "@/modules/account/useAccount";
|
||||
import AuthenticationTab from "@/modules/settings/AuthenticationTab";
|
||||
import ClientSettingsTab from "@/modules/settings/ClientSettingsTab";
|
||||
import DangerZoneTab from "@/modules/settings/DangerZoneTab";
|
||||
import GroupsTab from "@/modules/settings/GroupsTab";
|
||||
import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab";
|
||||
@@ -63,6 +65,10 @@ export default function NetBirdSettings() {
|
||||
<NetworkIcon size={14} />
|
||||
Networks
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value="clients">
|
||||
<MonitorSmartphoneIcon size={14} />
|
||||
Clients
|
||||
</VerticalTabs.Trigger>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -77,6 +83,7 @@ export default function NetBirdSettings() {
|
||||
{account && <PermissionsTab account={account} />}
|
||||
{account && <GroupsTab account={account} />}
|
||||
{account && <NetworkSettingsTab account={account} />}
|
||||
{account && <ClientSettingsTab account={account} />}
|
||||
{account && <DangerZoneTab account={account} />}
|
||||
</div>
|
||||
</RestrictedAccess>
|
||||
|
||||
@@ -8,6 +8,7 @@ interface Props extends React.HTMLAttributes<HTMLDivElement>, BadgeVariants {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
useHover?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const variants = cva("", {
|
||||
@@ -53,6 +54,7 @@ export default function Badge({
|
||||
className,
|
||||
variant = "blue",
|
||||
useHover = false,
|
||||
disabled = false,
|
||||
...props
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
@@ -61,6 +63,7 @@ export default function Badge({
|
||||
"relative z-10 cursor-inherit whitespace-nowrap rounded-md text-[12px] py-1.5 px-3 font-normal flex gap-1.5 items-center justify-center transition-all",
|
||||
className,
|
||||
variants({ variant, hover: useHover ? variant : "none" }),
|
||||
disabled && "cursor-not-allowed opacity-50 select-none",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -1,26 +1,37 @@
|
||||
import { cn } from "@utils/helpers";
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
type CalloutVariants = VariantProps<typeof calloutVariants>;
|
||||
|
||||
type Props = {
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
} & CalloutVariants;
|
||||
|
||||
export const calloutVariants = cva(
|
||||
["px-4 py-3.5 rounded-md border text-sm font-normal flex gap-3 font-light"],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-nb-gray-900/60 border-nb-gray-800/80 text-nb-gray-300",
|
||||
warning: "bg-netbird-500/10 border-netbird-400/20 text-netbird-150",
|
||||
info: "bg-sky-400/10 border-sky-400/20 text-sky-100",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const Callout = ({
|
||||
children,
|
||||
icon = <InfoIcon size={14} className={"shrink-0 relative top-[1px]"} />,
|
||||
icon = <InfoIcon size={14} className={"shrink-0 relative top-[2px]"} />,
|
||||
className,
|
||||
variant = "default",
|
||||
}: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 py-3 rounded-md border text-sm font-normal flex gap-3",
|
||||
"bg-nb-gray-900/60 border-nb-gray-800/80 text-nb-gray-300",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn(calloutVariants({ variant }), className)}>
|
||||
{icon}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,261 +1,328 @@
|
||||
import Badge from "@components/Badge";
|
||||
import { Callout } from "@components/Callout";
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import { CommandItem } from "@components/Command";
|
||||
import { DropdownInfoText } from "@components/DropdownInfoText";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
|
||||
import { IconArrowBack } from "@tabler/icons-react";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Command, CommandGroup, CommandInput, CommandList } from "cmdk";
|
||||
import { trim } from "lodash";
|
||||
import { orderBy, trim } from "lodash";
|
||||
import { ChevronsUpDown, SearchIcon, XIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useElementSize } from "@/hooks/useElementSize";
|
||||
import { PortRange } from "@/interfaces/Policy";
|
||||
|
||||
interface MultiSelectProps {
|
||||
values: number[];
|
||||
onChange: React.Dispatch<React.SetStateAction<number[]>>;
|
||||
ports: number[];
|
||||
onPortsChange: React.Dispatch<React.SetStateAction<number[]>>;
|
||||
portRanges?: PortRange[];
|
||||
onPortRangesChange?: React.Dispatch<React.SetStateAction<PortRange[]>>;
|
||||
max?: number;
|
||||
disabled?: boolean;
|
||||
popoverWidth?: "auto" | number;
|
||||
showAll?: boolean;
|
||||
}
|
||||
|
||||
const isValidPort = (p: number) => p >= 1 && p <= 65535;
|
||||
|
||||
const parseRange = (value: string): PortRange | undefined => {
|
||||
const parts = value.split("-").map((x) => Number(trim(x)));
|
||||
if (parts.length !== 2) return undefined;
|
||||
const [start, end] = parts;
|
||||
if (!isValidPort(start) || !isValidPort(end) || start >= end)
|
||||
return undefined;
|
||||
return { start, end };
|
||||
};
|
||||
|
||||
const parsePortInput = (value: string): number | PortRange | undefined => {
|
||||
const trimmed = trim(value);
|
||||
if (/^\d{1,5}-\d{1,5}$/.test(trimmed)) return parseRange(trimmed);
|
||||
const port = Number(trimmed);
|
||||
return isValidPort(port) ? port : undefined;
|
||||
};
|
||||
|
||||
export function PortSelector({
|
||||
onChange,
|
||||
values,
|
||||
max,
|
||||
onPortsChange,
|
||||
ports,
|
||||
portRanges = [],
|
||||
onPortRangesChange,
|
||||
disabled = false,
|
||||
popoverWidth = "auto",
|
||||
showAll = false,
|
||||
}: MultiSelectProps) {
|
||||
}: Readonly<MultiSelectProps>) {
|
||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const toggle = (x: number) => {
|
||||
if (isNaN(Number(x))) return;
|
||||
const port = Number(x);
|
||||
if (port < 1 || port > 65535) return;
|
||||
const [portsInput, setPortsInput] = useState<string[]>(() => {
|
||||
const p = ports.map(String);
|
||||
const pr = portRanges.map((r) => {
|
||||
if (r.start === r.end) return String(r.start);
|
||||
return `${r.start}-${r.end}`;
|
||||
});
|
||||
return orderBy([...p, ...pr], [(x) => Number(x.split("-")[0])], ["asc"]);
|
||||
});
|
||||
|
||||
const isSelected = values.includes(port);
|
||||
if (isSelected) {
|
||||
onChange((previous) => previous.filter((y) => y !== port));
|
||||
} else {
|
||||
onChange((previous) => [...previous, port]);
|
||||
setSearch("");
|
||||
}
|
||||
useEffect(() => {
|
||||
const parsed = portsInput.map(parsePortInput).filter(Boolean);
|
||||
const newPorts: number[] = [];
|
||||
const newRanges: PortRange[] = [];
|
||||
parsed.forEach((entry) => {
|
||||
if (typeof entry === "number") newPorts.push(entry);
|
||||
else if (entry !== undefined) newRanges.push(entry);
|
||||
});
|
||||
onPortsChange(newPorts);
|
||||
onPortRangesChange?.(newRanges);
|
||||
}, [portsInput]);
|
||||
|
||||
const toggle = (value: string) => {
|
||||
if (disabled) return;
|
||||
setPortsInput((prev) =>
|
||||
prev.includes(value) ? prev.filter((e) => e !== value) : [...prev, value],
|
||||
);
|
||||
setSearch("");
|
||||
};
|
||||
|
||||
const notFound = useMemo(() => {
|
||||
const isSearching = search.length > 0;
|
||||
const found =
|
||||
values.filter((item) => item == Number(trim(search))).length == 0;
|
||||
return isSearching && found;
|
||||
}, [search, values]);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const trimmed = trim(search);
|
||||
return (
|
||||
trimmed &&
|
||||
!portsInput.includes(trimmed) &&
|
||||
parsePortInput(trimmed) &&
|
||||
isSearching
|
||||
);
|
||||
}, [search, portsInput]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setTimeout(() => {
|
||||
setSearch("");
|
||||
}, 100);
|
||||
}
|
||||
setOpen(isOpen);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"min-h-[48px] w-full relative items-center",
|
||||
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3",
|
||||
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer hover:dark:bg-nb-gray-900/50",
|
||||
)}
|
||||
data-cy={"port-selector"}
|
||||
disabled={disabled}
|
||||
ref={inputRef}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-2 border-nb-gray-700 flex-wrap h-full"
|
||||
}
|
||||
>
|
||||
{values.length === 0 && showAll && (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
className={"uppercase tracking-wider font-medium py-1"}
|
||||
>
|
||||
All
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{values.map((x) => (
|
||||
<Badge
|
||||
key={x}
|
||||
variant={"gray"}
|
||||
onClick={() => toggle(x)}
|
||||
className={"uppercase tracking-wider font-medium py-1"}
|
||||
>
|
||||
{x}
|
||||
<XIcon
|
||||
size={12}
|
||||
className={"cursor-pointer group-hover:text-black"}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
{values.length == 0 && <span>Select ports...</span>}
|
||||
</div>
|
||||
|
||||
<ChevronsUpDown size={18} className={"shrink-0"} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950"
|
||||
style={{
|
||||
width: popoverWidth === "auto" ? width : popoverWidth,
|
||||
<>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setTimeout(() => {
|
||||
setSearch("");
|
||||
}, 100);
|
||||
}
|
||||
setOpen(isOpen);
|
||||
}}
|
||||
align="start"
|
||||
side={"top"}
|
||||
sideOffset={10}
|
||||
>
|
||||
<Command
|
||||
className={"w-full flex"}
|
||||
loop
|
||||
filter={(value, search) => {
|
||||
const formatValue = trim(value.toLowerCase());
|
||||
const formatSearch = trim(search.toLowerCase());
|
||||
if (formatValue.includes(formatSearch)) return 1;
|
||||
return 0;
|
||||
}}
|
||||
>
|
||||
<CommandList className={"w-full"}>
|
||||
<div className={"relative"}>
|
||||
<CommandInput
|
||||
className={cn(
|
||||
"min-h-[42px] w-full relative",
|
||||
"border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center",
|
||||
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
|
||||
"dark:placeholder:text-neutral-500 font-light placeholder:text-neutral-500 pl-10",
|
||||
)}
|
||||
data-cy={"port-input"}
|
||||
typeof={"number"}
|
||||
ref={searchRef}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder={'Add new ports by pressing "Enter"...'}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
"absolute left-0 top-0 h-full flex items-center pl-4"
|
||||
}
|
||||
>
|
||||
<div className={"flex items-center"}>
|
||||
<SearchIcon size={14} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"absolute right-0 top-0 h-full flex items-center pr-4"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex items-center bg-nb-gray-800 py-1 px-1.5 rounded-[4px] border border-nb-gray-500"
|
||||
}
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"min-h-[48px] w-full relative items-center",
|
||||
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3",
|
||||
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer hover:dark:bg-nb-gray-900/50",
|
||||
)}
|
||||
data-cy={"port-selector"}
|
||||
disabled={disabled}
|
||||
ref={inputRef}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-2 border-nb-gray-700 flex-wrap h-full"
|
||||
}
|
||||
>
|
||||
{portsInput.length === 0 && showAll && (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
className={"uppercase tracking-wider font-medium py-1"}
|
||||
>
|
||||
<IconArrowBack size={10} />
|
||||
</div>
|
||||
</div>
|
||||
All
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{portsInput.map((x) => (
|
||||
<Badge
|
||||
key={x}
|
||||
variant={"gray"}
|
||||
onClick={() => toggle(x)}
|
||||
className={"uppercase tracking-wider font-medium py-1"}
|
||||
>
|
||||
{x}
|
||||
<XIcon
|
||||
size={12}
|
||||
className={"cursor-pointer group-hover:text-black"}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
{ports.length == 0 && <span>Select ports...</span>}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2",
|
||||
values.length != 0 && "p-2",
|
||||
values.length != 0 && search && "p-2",
|
||||
values.length == 0 && search && "p-2",
|
||||
)}
|
||||
>
|
||||
{notFound && (
|
||||
<ChevronsUpDown size={18} className={"shrink-0"} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950"
|
||||
style={{
|
||||
width: popoverWidth === "auto" ? width : popoverWidth,
|
||||
}}
|
||||
align="start"
|
||||
side={"top"}
|
||||
sideOffset={10}
|
||||
>
|
||||
<Command
|
||||
className={"w-full flex"}
|
||||
loop
|
||||
filter={(value, search) => {
|
||||
const formatValue = trim(value.toLowerCase());
|
||||
const formatSearch = trim(search.toLowerCase());
|
||||
if (formatValue.includes(formatSearch)) return 1;
|
||||
return 0;
|
||||
}}
|
||||
>
|
||||
<CommandList className={"w-full"}>
|
||||
<div className={"relative"}>
|
||||
<CommandInput
|
||||
className={cn(
|
||||
"min-h-[42px] w-full relative",
|
||||
"border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center",
|
||||
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
|
||||
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10",
|
||||
)}
|
||||
data-cy={"port-input"}
|
||||
typeof={"number"}
|
||||
ref={searchRef}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder={
|
||||
'Add a port or a range e.g. 80 or 1-1023 and press "Enter" to add...'
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
"absolute left-0 top-0 h-full flex items-center pl-4"
|
||||
}
|
||||
>
|
||||
<div className={"flex items-center"}>
|
||||
<SearchIcon size={14} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"absolute right-0 top-0 h-full flex items-center pr-4"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex items-center bg-nb-gray-800 py-1 px-1.5 rounded-[4px] border border-nb-gray-500"
|
||||
}
|
||||
>
|
||||
<IconArrowBack size={10} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2",
|
||||
portsInput.length != 0 && "p-2",
|
||||
portsInput.length != 0 && search && "p-2",
|
||||
notFound && "p-2",
|
||||
)}
|
||||
>
|
||||
{!notFound && search && !portsInput.includes(search) && (
|
||||
<div className={"text-sm"}>
|
||||
<DropdownInfoText className={"mb-[18px] pt-[4px]"}>
|
||||
{
|
||||
"Please add a valid port or port range (e.g. 80, 443, 1-1023)"
|
||||
}
|
||||
</DropdownInfoText>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notFound && (
|
||||
<CommandGroup>
|
||||
<div
|
||||
className={cn(
|
||||
"max-h-[180px] overflow-y-auto flex flex-col gap-1",
|
||||
)}
|
||||
>
|
||||
<CommandItem
|
||||
key={search}
|
||||
onSelect={() => {
|
||||
toggle(search);
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
value={search}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
className={
|
||||
"uppercase tracking-wider font-medium py-1"
|
||||
}
|
||||
>
|
||||
{search}
|
||||
</Badge>
|
||||
<div
|
||||
className={"text-neutral-500 dark:text-nb-gray-300"}
|
||||
>
|
||||
Add this port or range by pressing{" "}
|
||||
<span className={"font-bold text-netbird"}>
|
||||
{"'Enter'"}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</div>
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
<CommandGroup>
|
||||
<div
|
||||
className={cn(
|
||||
"max-h-[180px] overflow-y-auto flex flex-col gap-1",
|
||||
)}
|
||||
>
|
||||
<CommandItem
|
||||
key={search}
|
||||
onSelect={() => {
|
||||
toggle(Number(search));
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
value={search}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
className={"uppercase tracking-wider font-medium py-1"}
|
||||
>
|
||||
{search}
|
||||
</Badge>
|
||||
<div className={"text-neutral-500 dark:text-nb-gray-300"}>
|
||||
Add this port by pressing{" "}
|
||||
<span className={"font-bold text-netbird"}>
|
||||
{"'Enter'"}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</div>
|
||||
</CommandGroup>
|
||||
)}
|
||||
{portsInput.map((option) => {
|
||||
const isSelected = portsInput.includes(option);
|
||||
return (
|
||||
<CommandItem
|
||||
key={option}
|
||||
value={option.toString()}
|
||||
onSelect={() => {
|
||||
toggle(option);
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
className={
|
||||
"uppercase tracking-wider font-medium py-1"
|
||||
}
|
||||
>
|
||||
{option}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<CommandGroup>
|
||||
<div
|
||||
className={cn(
|
||||
"max-h-[180px] overflow-y-auto flex flex-col gap-1",
|
||||
)}
|
||||
>
|
||||
{values.map((option) => {
|
||||
const isSelected = values.includes(option);
|
||||
return (
|
||||
<CommandItem
|
||||
key={option}
|
||||
value={option.toString()}
|
||||
onSelect={() => {
|
||||
toggle(option);
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
<div
|
||||
className={
|
||||
"uppercase tracking-wider font-medium py-1"
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
{option}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
<Checkbox checked={isSelected} />
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CommandGroup>
|
||||
</div>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Checkbox checked={isSelected} />
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CommandGroup>
|
||||
</div>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{portRanges?.length > 0 && (
|
||||
<Callout variant={"info"} className={"mt-4"}>
|
||||
Port ranges requires NetBird client{" "}
|
||||
<span className={"text-white font-normal"}>v0.48</span> or higher.
|
||||
</Callout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,11 @@ import { Modal, ModalTrigger } from "@components/modal/Modal";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { PlusCircle } from "lucide-react";
|
||||
import React, { memo, useState } from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
function AddPeerButton() {
|
||||
const { permission } = usePermissions();
|
||||
const { data: peers } = useFetchApi<Peer[]>("/peers");
|
||||
const { oidcUser: user } = useOidcUser();
|
||||
|
||||
@@ -41,12 +39,7 @@ function AddPeerButton() {
|
||||
<>
|
||||
<Modal open={installModal} onOpenChange={handleOpenChange}>
|
||||
<ModalTrigger asChild>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"sm"}
|
||||
className={"ml-auto"}
|
||||
disabled={!permission.peers.create}
|
||||
>
|
||||
<Button variant={"primary"} size={"sm"} className={"ml-auto"}>
|
||||
<PlusCircle size={16} />
|
||||
Add Peer
|
||||
</Button>
|
||||
|
||||
@@ -14,6 +14,8 @@ type Props = {
|
||||
onError?: (error: boolean) => void;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
preventLeadingAndTrailingDots?: boolean;
|
||||
allowWildcard?: boolean;
|
||||
};
|
||||
enum ActionType {
|
||||
ADD = "ADD",
|
||||
@@ -40,6 +42,8 @@ export default function InputDomain({
|
||||
onRemove,
|
||||
onError,
|
||||
disabled,
|
||||
preventLeadingAndTrailingDots,
|
||||
allowWildcard = true,
|
||||
}: Readonly<Props>) {
|
||||
const [name, setName] = useState(value?.name || "");
|
||||
|
||||
@@ -52,7 +56,11 @@ export default function InputDomain({
|
||||
if (name == "") {
|
||||
return "";
|
||||
}
|
||||
const valid = validator.isValidDomain(name);
|
||||
const valid = validator.isValidDomain(name, {
|
||||
allowOnlyTld: true,
|
||||
allowWildcard,
|
||||
preventLeadingAndTrailingDots,
|
||||
});
|
||||
if (!valid) {
|
||||
return "Please enter a valid domain, e.g. example.com or intra.example.com";
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useEventCallback } from "@/hooks/useEventCallback";
|
||||
@@ -20,8 +21,10 @@ export function useLocalStorage<T>(
|
||||
key: string,
|
||||
initialValue: T,
|
||||
enabled: boolean = true,
|
||||
overrideValue?: T,
|
||||
): [T, SetValue<T>] {
|
||||
const [tempValue, setTempValue] = useState(initialValue);
|
||||
const [tempValue, setTempValue] = useState(overrideValue ?? initialValue);
|
||||
const isInitialRender = useRef(true);
|
||||
|
||||
// Get from local storage then
|
||||
// parse stored json or return initialValue
|
||||
@@ -31,6 +34,11 @@ export function useLocalStorage<T>(
|
||||
return initialValue;
|
||||
}
|
||||
|
||||
if (isInitialRender.current && overrideValue !== undefined) {
|
||||
isInitialRender.current = false;
|
||||
return overrideValue;
|
||||
}
|
||||
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? (parseJSON(item) as T) : initialValue;
|
||||
@@ -95,6 +103,13 @@ export function useLocalStorage<T>(
|
||||
[key, readValue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (overrideValue) {
|
||||
setValue(overrideValue);
|
||||
setStoredValue(overrideValue);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// this only works for other documents, not the current one
|
||||
useEventListener("storage", handleStorageChange);
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export const useRedirect = (
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// If redirect is disabled or the url is already in the callback urls or the url is the current path then do not redirect
|
||||
// If redirect is disabled or the url is already in the callback urls then do not redirect
|
||||
if (!enable || callBackUrls.current.includes(url) || url === currentPath)
|
||||
return;
|
||||
|
||||
|
||||
@@ -19,5 +19,6 @@ export interface Account {
|
||||
regular_users_view_blocked: boolean;
|
||||
routing_peer_dns_resolution_enabled: boolean;
|
||||
dns_domain: string;
|
||||
lazy_connection_enabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,10 +22,16 @@ export interface PolicyRule {
|
||||
action: string;
|
||||
protocol: Protocol;
|
||||
ports: string[];
|
||||
port_ranges?: PortRange[];
|
||||
sourceResource?: PolicyRuleResource;
|
||||
destinationResource?: PolicyRuleResource;
|
||||
}
|
||||
|
||||
export interface PortRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface PolicyRuleResource {
|
||||
id: string;
|
||||
type: "domain" | "host" | "subnet" | undefined;
|
||||
|
||||
@@ -154,6 +154,8 @@ export function AccessControlModalContent({
|
||||
getPolicyData,
|
||||
destinationResource,
|
||||
setDestinationResource,
|
||||
portRanges,
|
||||
setPortRanges,
|
||||
} = useAccessControl({
|
||||
policy,
|
||||
postureCheckTemplates,
|
||||
@@ -166,15 +168,13 @@ export function AccessControlModalContent({
|
||||
const [tab, setTab] = useState(() => {
|
||||
if (!cell) return "policy";
|
||||
if (cell == "posture_checks") return "posture_checks";
|
||||
if (cell == "name") return "general";
|
||||
return "policy";
|
||||
});
|
||||
|
||||
const continuePostureChecksDisabled = useMemo(() => {
|
||||
if (direction != "bi" && ports.length == 0) return true;
|
||||
if (sourceGroups.length > 0 && destinationResource) return false;
|
||||
if (sourceGroups.length == 0 || destinationGroups.length == 0) return true;
|
||||
}, [sourceGroups, destinationGroups, direction, ports, destinationResource]);
|
||||
}, [sourceGroups, destinationGroups, destinationResource]);
|
||||
|
||||
const submitDisabled = useMemo(() => {
|
||||
if (name.length == 0) return true;
|
||||
@@ -185,9 +185,11 @@ export function AccessControlModalContent({
|
||||
setProtocol(p);
|
||||
if (p == "icmp") {
|
||||
setPorts([]);
|
||||
setPortRanges([]);
|
||||
}
|
||||
if (p == "all") {
|
||||
setPorts([]);
|
||||
setPortRanges([]);
|
||||
}
|
||||
if (p == "tcp" || p == "udp") {
|
||||
setDirection("in");
|
||||
@@ -340,14 +342,16 @@ export function AccessControlModalContent({
|
||||
</Label>
|
||||
<HelpText>
|
||||
Allow network traffic and access only to specified ports.
|
||||
Select ports between 1 and 65535.
|
||||
Select ports or port ranges between 1 and 65535.
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={""}>
|
||||
<PortSelector
|
||||
showAll={direction == "bi"}
|
||||
values={ports}
|
||||
onChange={setPorts}
|
||||
showAll={true}
|
||||
ports={ports}
|
||||
onPortsChange={setPorts}
|
||||
portRanges={portRanges}
|
||||
onPortRangesChange={setPortRanges}
|
||||
disabled={portAndDirectionDisabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,29 +5,45 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@components/Tooltip";
|
||||
import { orderBy } from "lodash";
|
||||
import React, { useMemo } from "react";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
};
|
||||
export default function AccessControlPortsCell({ policy }: Props) {
|
||||
const firstRule = useMemo(() => {
|
||||
|
||||
export default function AccessControlPortsCell({ policy }: Readonly<Props>) {
|
||||
const rule = useMemo(() => {
|
||||
if (policy.rules.length > 0) return policy.rules[0];
|
||||
return undefined;
|
||||
}, [policy]);
|
||||
|
||||
const hasPorts = firstRule?.ports && firstRule?.ports.length > 0;
|
||||
const hasPorts = rule?.ports && rule?.ports?.length > 0;
|
||||
const hasPortRanges = rule?.port_ranges && rule?.port_ranges?.length > 0;
|
||||
const hasAnyPorts = hasPorts || hasPortRanges;
|
||||
|
||||
const allPorts = useMemo(() => {
|
||||
const ports = rule?.ports ?? [];
|
||||
const portRanges =
|
||||
rule?.port_ranges?.map((r) => {
|
||||
if (r.start === r.end) return `${r.start}`;
|
||||
return `${r.start}-${r.end}`;
|
||||
}) ?? [];
|
||||
return orderBy(
|
||||
[...portRanges, ...ports],
|
||||
[(p) => Number(p.split("-")[0])],
|
||||
["asc"],
|
||||
);
|
||||
}, [rule]);
|
||||
|
||||
const firstTwoPorts = useMemo(() => {
|
||||
if (!hasPorts) return [];
|
||||
return firstRule?.ports.slice(0, 2) ?? [];
|
||||
}, [hasPorts, firstRule]);
|
||||
return allPorts?.slice(0, 2) ?? [];
|
||||
}, [allPorts]);
|
||||
|
||||
const otherPorts = useMemo(() => {
|
||||
if (!hasPorts) return [];
|
||||
return firstRule?.ports.slice(2) ?? [];
|
||||
}, [hasPorts, firstRule]);
|
||||
return allPorts?.slice(2) ?? [];
|
||||
}, [allPorts]);
|
||||
|
||||
return (
|
||||
<div className={"flex-1"}>
|
||||
@@ -35,7 +51,7 @@ export default function AccessControlPortsCell({ policy }: Props) {
|
||||
<Tooltip delayDuration={1}>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<div className={"inline-flex items-center gap-2"}>
|
||||
{!hasPorts && (
|
||||
{!hasAnyPorts && (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
className={"uppercase tracking-wider font-medium"}
|
||||
@@ -44,20 +60,19 @@ export default function AccessControlPortsCell({ policy }: Props) {
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{firstTwoPorts &&
|
||||
firstTwoPorts.map((port) => {
|
||||
return (
|
||||
<Badge
|
||||
key={port}
|
||||
variant={"gray"}
|
||||
className={
|
||||
"px-3 gap-2 whitespace-nowrap uppercase tracking-wider font-medium"
|
||||
}
|
||||
>
|
||||
{port}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
{firstTwoPorts?.map((port) => {
|
||||
return (
|
||||
<Badge
|
||||
key={port}
|
||||
variant={"gray"}
|
||||
className={
|
||||
"px-3 gap-2 whitespace-nowrap uppercase tracking-wider font-medium"
|
||||
}
|
||||
>
|
||||
{port}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
|
||||
{otherPorts && otherPorts.length > 0 && (
|
||||
<Badge
|
||||
@@ -73,9 +88,17 @@ export default function AccessControlPortsCell({ policy }: Props) {
|
||||
</TooltipTrigger>
|
||||
{otherPorts && otherPorts.length > 0 && (
|
||||
<TooltipContent>
|
||||
<div className={"flex flex-col gap-2 items-start mt-3 mb-2"}>
|
||||
<div
|
||||
className={
|
||||
"flex gap-2 items-start mt-3 mb-2 flex-wrap max-w-sm"
|
||||
}
|
||||
>
|
||||
{otherPorts.map((port) => {
|
||||
return <Badge key={port}>{port}</Badge>;
|
||||
return (
|
||||
<Badge key={port} variant={"gray"}>
|
||||
{port}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
|
||||
@@ -2,12 +2,17 @@ import Badge from "@components/Badge";
|
||||
import { IconCirclePlus } from "@tabler/icons-react";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import React from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
};
|
||||
export default function AccessControlPostureCheckCell({ policy }: Props) {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const isDisabled = !permission.policies.create || !permission.policies.update;
|
||||
|
||||
return policy.source_posture_checks &&
|
||||
policy.source_posture_checks.length > 0 ? (
|
||||
<div className={"flex"}>
|
||||
@@ -18,7 +23,14 @@ export default function AccessControlPostureCheckCell({ policy }: Props) {
|
||||
</div>
|
||||
) : (
|
||||
<div className={"flex"}>
|
||||
<Badge variant={"gray"} useHover={true}>
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
useHover={!isDisabled}
|
||||
onClick={(e) => {
|
||||
if (isDisabled) e.stopPropagation();
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<IconCirclePlus size={14} />
|
||||
Add Posture Check
|
||||
</Badge>
|
||||
|
||||
@@ -8,6 +8,7 @@ import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
|
||||
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||
import GetStartedTest from "@components/ui/GetStartedTest";
|
||||
import type { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import { removeAllSpaces } from "@utils/helpers";
|
||||
import { ExternalLinkIcon, PlusCircle } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
@@ -37,16 +38,20 @@ type Props = {
|
||||
|
||||
export const AccessControlTableColumns: ColumnDef<Policy>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
id: "name",
|
||||
accessorFn: (row) => removeAllSpaces(row?.name),
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Name</DataTableHeader>;
|
||||
},
|
||||
sortingFn: "text",
|
||||
filterFn: "fuzzy",
|
||||
cell: ({ cell }) => <AccessControlNameCell policy={cell.row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
id: "description",
|
||||
accessorFn: (row) => removeAllSpaces(row?.description),
|
||||
sortingFn: "text",
|
||||
filterFn: "fuzzy",
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { usePolicies } from "@/contexts/PoliciesProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Policy, Protocol } from "@/interfaces/Policy";
|
||||
import { Policy, PortRange, Protocol } from "@/interfaces/Policy";
|
||||
import { PostureCheck } from "@/interfaces/PostureCheck";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import { usePostureCheck } from "@/modules/posture-checks/usePostureCheck";
|
||||
@@ -83,6 +83,15 @@ export const useAccessControl = ({
|
||||
return [];
|
||||
});
|
||||
|
||||
const [portRanges, setPortRanges] = useState<PortRange[]>(() => {
|
||||
if (!firstRule) return [];
|
||||
if (firstRule.port_ranges == undefined) return [];
|
||||
if (firstRule.port_ranges.length > 0) {
|
||||
return firstRule.port_ranges;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const [protocol, setProtocol] = useState<Protocol>(
|
||||
firstRule ? firstRule.protocol : "all",
|
||||
);
|
||||
@@ -139,6 +148,11 @@ export const useAccessControl = ({
|
||||
destinations = tmp;
|
||||
}
|
||||
|
||||
const [newPorts, newPortRanges] = parseAccessControlPorts(
|
||||
ports,
|
||||
portRanges,
|
||||
);
|
||||
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
@@ -155,7 +169,8 @@ export const useAccessControl = ({
|
||||
action: "accept",
|
||||
protocol,
|
||||
enabled,
|
||||
ports: ports.length > 0 ? ports.map((p) => p.toString()) : undefined,
|
||||
ports: newPorts,
|
||||
port_ranges: newPortRanges,
|
||||
},
|
||||
],
|
||||
} as Policy;
|
||||
@@ -206,6 +221,11 @@ export const useAccessControl = ({
|
||||
destinations = tmp;
|
||||
}
|
||||
|
||||
const [newPorts, newPortRanges] = parseAccessControlPorts(
|
||||
ports,
|
||||
portRanges,
|
||||
);
|
||||
|
||||
const policyObj = {
|
||||
name,
|
||||
description,
|
||||
@@ -224,7 +244,8 @@ export const useAccessControl = ({
|
||||
sources,
|
||||
destinations: destinationResource ? undefined : destinations,
|
||||
destinationResource: destinationResource || undefined,
|
||||
ports: ports.length > 0 ? ports.map((p) => p.toString()) : undefined,
|
||||
ports: newPorts,
|
||||
port_ranges: newPortRanges,
|
||||
},
|
||||
],
|
||||
} as Policy;
|
||||
@@ -267,6 +288,8 @@ export const useAccessControl = ({
|
||||
setEnabled,
|
||||
ports,
|
||||
setPorts,
|
||||
portRanges,
|
||||
setPortRanges,
|
||||
sourceGroups,
|
||||
setSourceGroups,
|
||||
destinationGroups,
|
||||
@@ -281,3 +304,19 @@ export const useAccessControl = ({
|
||||
setDestinationResource,
|
||||
} as const;
|
||||
};
|
||||
|
||||
const parseAccessControlPorts = (ports: number[], portRanges: PortRange[]) => {
|
||||
const hasRanges = portRanges.length > 0;
|
||||
const hasPorts = ports.length > 0;
|
||||
if (!hasPorts && !hasRanges) return [undefined, undefined];
|
||||
if (!hasRanges) return [ports.map(String), undefined];
|
||||
if (!hasPorts) return [undefined, portRanges];
|
||||
|
||||
const portRangesFromPorts = ports.map((port) => ({
|
||||
start: port,
|
||||
end: port,
|
||||
})) as PortRange[];
|
||||
|
||||
const allRanges = [...portRanges, ...portRangesFromPorts];
|
||||
return [undefined, allRanges];
|
||||
};
|
||||
|
||||
@@ -371,6 +371,8 @@ export function NameserverModalContent({
|
||||
{domains.map((domain, i) => {
|
||||
return (
|
||||
<InputDomain
|
||||
preventLeadingAndTrailingDots={true}
|
||||
allowWildcard={false}
|
||||
key={domain.id}
|
||||
value={domain}
|
||||
onChange={(d) =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { isEmpty } from "lodash";
|
||||
import { isEmpty, orderBy } from "lodash";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
@@ -20,13 +20,13 @@ export default function useGroupHelper({ initial = [], peer }: Props) {
|
||||
const initialGroups = useMemo(() => {
|
||||
if (!initial) return [];
|
||||
const isArrayOfStrings = initial.every((item) => typeof item === "string");
|
||||
if (!isArrayOfStrings) return initial as Group[];
|
||||
if (!isArrayOfStrings) return orderBy(initial as Group[], ["name"]);
|
||||
const foundGroups = initial
|
||||
.map((id) => {
|
||||
return groups?.find((g) => g.id === id);
|
||||
})
|
||||
.filter((g) => g !== undefined) as Group[];
|
||||
return foundGroups ?? [];
|
||||
return orderBy(foundGroups, ["name"]) ?? [];
|
||||
}, [groups, initial]);
|
||||
|
||||
const [selectedGroups, setSelectedGroups] = useState<Group[]>(initialGroups);
|
||||
|
||||
@@ -173,7 +173,7 @@ export function ResourceModalContent({
|
||||
<ResourceSingleAddressInput value={address} onChange={setAddress} />
|
||||
|
||||
<div>
|
||||
<Label>Assigned Groups</Label>
|
||||
<Label>Destination Groups</Label>
|
||||
<HelpText>
|
||||
Add this resource to groups and use them as destinations when
|
||||
creating policies
|
||||
|
||||
@@ -314,6 +314,7 @@ function RoutingPeerModalContent({
|
||||
value={masquerade}
|
||||
onChange={setMasquerade}
|
||||
disabled={isNonLinuxRoutingPeer}
|
||||
routingPeerGroupId={routingPeerGroups?.[0]?.id}
|
||||
/>
|
||||
|
||||
<div className={cn("flex justify-between")}>
|
||||
|
||||
@@ -1,34 +1,51 @@
|
||||
import { Callout } from "@components/Callout";
|
||||
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { VenetianMask } from "lucide-react";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { AlertCircleIcon, VenetianMask } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { GroupPeer } from "@/interfaces/Group";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
|
||||
type Props = {
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
disabled?: boolean;
|
||||
routingPeerGroupId?: string;
|
||||
};
|
||||
export const RoutingPeerMasqueradeSwitch = ({
|
||||
disabled = false,
|
||||
value,
|
||||
onChange,
|
||||
routingPeerGroupId,
|
||||
}: Props) => {
|
||||
return (
|
||||
<RoutingPeerMasqueradeTooltip show={disabled}>
|
||||
<FancyToggleSwitch
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
label={
|
||||
<>
|
||||
<VenetianMask size={15} />
|
||||
Masquerade
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
"Allow access to your private networks without configuring routes on your local routers or other devices."
|
||||
}
|
||||
/>
|
||||
<div className={"flex flex-col gap-4"}>
|
||||
<FancyToggleSwitch
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
label={
|
||||
<>
|
||||
<VenetianMask size={15} />
|
||||
Masquerade
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
"Allow access to your private networks without configuring routes on your local routers or other devices."
|
||||
}
|
||||
/>
|
||||
{routingPeerGroupId && !value && (
|
||||
<RoutingPeerGroupNonLinuxWarning
|
||||
routingPeerGroupId={routingPeerGroupId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</RoutingPeerMasqueradeTooltip>
|
||||
);
|
||||
};
|
||||
@@ -52,9 +69,51 @@ export const RoutingPeerMasqueradeTooltip = ({
|
||||
delayDuration={250}
|
||||
skipDelayDuration={350}
|
||||
disabled={!show}
|
||||
className={"cursor-help"}
|
||||
className={cn(show && "cursor-help")}
|
||||
>
|
||||
{children}
|
||||
</FullTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const RoutingPeerGroupNonLinuxWarning = ({
|
||||
routingPeerGroupId,
|
||||
}: {
|
||||
routingPeerGroupId: string;
|
||||
}) => {
|
||||
const { groups } = useGroups();
|
||||
const { data: peers } = useFetchApi<Peer[]>("/peers", true);
|
||||
const group = groups?.find((g) => g.id === routingPeerGroupId);
|
||||
|
||||
const hasNonLinuxPeer = React.useMemo(() => {
|
||||
try {
|
||||
return group?.peers?.some((groupPeer) => {
|
||||
const peer = peers?.find((p) => p.id === (groupPeer as GroupPeer).id);
|
||||
if (!peer) return false;
|
||||
const os = getOperatingSystem(peer.os);
|
||||
return os !== OperatingSystem.LINUX;
|
||||
});
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}, [group?.peers, peers]);
|
||||
|
||||
return (
|
||||
hasNonLinuxPeer && (
|
||||
<Callout
|
||||
variant={"warning"}
|
||||
icon={
|
||||
<AlertCircleIcon
|
||||
size={14}
|
||||
className={"shrink-0 relative top-[3px] text-netbird"}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Group <span className={"text-netbird font-normal"}>{group?.name}</span>{" "}
|
||||
contains at least one non-Linux peer.
|
||||
<br /> Disabled Masquerade will have no effect on non-Linux routing
|
||||
peers.
|
||||
</Callout>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -147,7 +147,9 @@ export default function PostureCheckTable({
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"ml-auto"}
|
||||
disabled={!permission.policies.create}
|
||||
disabled={
|
||||
!permission.policies.create || !permission.policies.update
|
||||
}
|
||||
onClick={() => {
|
||||
setCurrentRow(undefined);
|
||||
setPostureCheckModal(true);
|
||||
@@ -176,6 +178,9 @@ export default function PostureCheckTable({
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"ml-auto"}
|
||||
disabled={
|
||||
!permission.policies.create || !permission.policies.update
|
||||
}
|
||||
onClick={() => setPostureCheckModal(true)}
|
||||
>
|
||||
<IconCirclePlus size={16} />
|
||||
|
||||
@@ -723,6 +723,7 @@ export function RouteModalContent({
|
||||
value={masquerade}
|
||||
onChange={setMasquerade}
|
||||
disabled={isNonLinuxRoutingPeer}
|
||||
routingPeerGroupId={routingPeerGroups?.[0]?.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -461,6 +461,7 @@ function RouteUpdateModalContent({ onSuccess, route, cell }: ModalProps) {
|
||||
value={masquerade}
|
||||
onChange={setMasquerade}
|
||||
disabled={isNonLinuxRoutingPeer}
|
||||
routingPeerGroupId={routingPeerGroups?.[0]?.id}
|
||||
/>
|
||||
)}
|
||||
<div className={cn("flex justify-between")}>
|
||||
|
||||
119
src/modules/settings/ClientSettingsTab.tsx
Normal file
119
src/modules/settings/ClientSettingsTab.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { notify } from "@components/Notification";
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import {
|
||||
ClockFadingIcon,
|
||||
ExternalLinkIcon,
|
||||
FlaskConicalIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
} from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import SettingsIcon from "@/assets/icons/SettingsIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Account } from "@/interfaces/Account";
|
||||
|
||||
type Props = {
|
||||
account: Account;
|
||||
};
|
||||
|
||||
export default function ClientSettingsTab({ account }: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
const saveRequest = useApiCall<Account>("/accounts/" + account.id, true);
|
||||
|
||||
const [lazyConnection, setLazyConnection] = useState(
|
||||
account.settings?.lazy_connection_enabled ?? false,
|
||||
);
|
||||
|
||||
const toggleLazyConnection = async (toggle: boolean) => {
|
||||
notify({
|
||||
title: "Lazy Connections",
|
||||
description: `Lazy Connections successfully ${
|
||||
toggle ? "enabled" : "disabled"
|
||||
}.`,
|
||||
promise: saveRequest
|
||||
.put({
|
||||
id: account.id,
|
||||
settings: {
|
||||
...account.settings,
|
||||
lazy_connection_enabled: toggle,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
setLazyConnection(toggle);
|
||||
mutate("/accounts");
|
||||
}),
|
||||
loadingMessage: "Updating Lazy Connections setting...",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs.Content value={"clients"}>
|
||||
<div className={"p-default py-6 max-w-xl"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/settings"}
|
||||
label={"Settings"}
|
||||
icon={<SettingsIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/settings?tab=clients"}
|
||||
label={"Clients"}
|
||||
icon={<MonitorSmartphoneIcon size={14} />}
|
||||
active
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<div className={"flex items-start justify-between"}>
|
||||
<h1>Clients</h1>
|
||||
</div>
|
||||
|
||||
<div className={"flex flex-col gap-6 w-full mt-8"}>
|
||||
<div className={"mt-0"}>
|
||||
<h2 className={"text-lg font-medium"}>
|
||||
Experimental
|
||||
<FlaskConicalIcon
|
||||
size={16}
|
||||
className={"inline ml-1.5 relative -top-[2px]"}
|
||||
/>
|
||||
</h2>
|
||||
<div className={"text-sm text-gray-400"}>
|
||||
Lazy connections are an experimental feature. Functionality and
|
||||
behavior may evolve. Instead of maintaining always-on connections,
|
||||
NetBird activates them on-demand based on activity or signaling.{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/lazy-connection"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Learn more
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</div>
|
||||
</div>
|
||||
<FancyToggleSwitch
|
||||
value={lazyConnection}
|
||||
onChange={toggleLazyConnection}
|
||||
label={
|
||||
<>
|
||||
<ClockFadingIcon size={15} />
|
||||
Enable Lazy Connections
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
<>
|
||||
Allow to establish connections between peers only when required.
|
||||
This requires NetBird client v0.45 or higher. Changes will only
|
||||
take effect after restarting the clients.
|
||||
</>
|
||||
}
|
||||
disabled={!permission.settings.update}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
);
|
||||
}
|
||||
@@ -129,8 +129,8 @@ export default function NetworkSettingsTab({ account }: Readonly<Props>) {
|
||||
<div className={"min-w-[330px]"}>
|
||||
<Label>DNS Domain</Label>
|
||||
<HelpText>
|
||||
Specify a custom DNS domain for your network. This will be
|
||||
used for all your peers.
|
||||
Specify a custom peer DNS domain for your network. This should
|
||||
not point to a domain that is already in use elsewhere, to avoid overriding DNS results.
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={"w-full"}>
|
||||
@@ -168,6 +168,7 @@ export default function NetworkSettingsTab({ account }: Readonly<Props>) {
|
||||
"https://docs.netbird.io/how-to/accessing-entire-domains-within-networks#enabling-dns-wildcard-routing"
|
||||
}
|
||||
target={"_blank"}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Learn more
|
||||
<ExternalLinkIcon size={12} />
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function MacOSTab({
|
||||
{GRPC_API_ORIGIN && (
|
||||
<Steps.Step step={2}>
|
||||
<p>
|
||||
{`Click on "Settings" from the NetBird icon in your system tray and enter the following "Management URL"`}
|
||||
{`Click on "Settings" then "Advanced Settings" from the NetBird icon in your system tray and enter the following "Management URL"`}
|
||||
</p>
|
||||
<Code>
|
||||
<Code.Line>{GRPC_API_ORIGIN}</Code.Line>
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function WindowsTab({
|
||||
{GRPC_API_ORIGIN && (
|
||||
<Steps.Step step={2}>
|
||||
<p>
|
||||
{`Click on "Settings" from the NetBird icon in your system tray and enter the following "Management URL"`}
|
||||
{`Click on "Settings" then "Advanced Settings" from the NetBird icon in your system tray and enter the following "Management URL"`}
|
||||
</p>
|
||||
<Code>
|
||||
<Code.Line>{GRPC_API_ORIGIN}</Code.Line>
|
||||
|
||||
@@ -2,6 +2,7 @@ import FullTooltip from "@components/FullTooltip";
|
||||
import { ScrollArea } from "@components/ScrollArea";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import { cn, generateColorFromString } from "@utils/helpers";
|
||||
import { orderBy } from "lodash";
|
||||
import * as React from "react";
|
||||
import { User } from "@/interfaces/User";
|
||||
import { SmallUserAvatar } from "@/modules/users/SmallUserAvatar";
|
||||
@@ -19,7 +20,7 @@ export const HorizontalUsersStack = ({
|
||||
avatarClassName,
|
||||
side = "top",
|
||||
}: Props) => {
|
||||
let usersToDisplay = users?.slice(0, max) || [];
|
||||
let usersToDisplay = orderBy(users?.slice(0, max) || [], ["name"]);
|
||||
|
||||
return (
|
||||
<FullTooltip
|
||||
@@ -33,7 +34,7 @@ export const HorizontalUsersStack = ({
|
||||
className={"flex flex-col gap-2.5"}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{users?.map((user, index) => (
|
||||
{orderBy(users, ["name"])?.map((user, index) => (
|
||||
<div
|
||||
className={"flex items-center gap-2 first:pt-2 last:pb-2 pr-6"}
|
||||
key={user?.id || index}
|
||||
@@ -92,7 +93,7 @@ export const HorizontalUsersStack = ({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2 text-xs ml-1.5 transition-colors",
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2 text-xs ml-1.5 transition-colors whitespace-nowrap",
|
||||
users.length > 0 && "group-hover/user-stack:text-nb-gray-200 ",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -29,6 +29,8 @@ interface MultiSelectProps {
|
||||
hideOwner?: boolean;
|
||||
currentUser?: User;
|
||||
customTrigger?: React.ReactNode;
|
||||
side?: "top" | "bottom" | "left" | "right";
|
||||
align?: "start" | "center" | "end";
|
||||
}
|
||||
|
||||
export const UserRoles = [
|
||||
@@ -72,6 +74,8 @@ export function UserRoleSelector({
|
||||
hideOwner = false,
|
||||
currentUser,
|
||||
customTrigger,
|
||||
side = "bottom",
|
||||
align = "start",
|
||||
}: Readonly<MultiSelectProps>) {
|
||||
const [inputRef, { width }] = useElementSize<
|
||||
HTMLButtonElement | HTMLDivElement
|
||||
@@ -124,13 +128,15 @@ export function UserRoleSelector({
|
||||
>
|
||||
<PopoverTrigger asChild={true}>
|
||||
{customTrigger ? (
|
||||
<div ref={inputRef}>{customTrigger}</div>
|
||||
<div ref={inputRef} className={"group/user-role-selector"}>
|
||||
{customTrigger}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant={"input"}
|
||||
disabled={disabled}
|
||||
ref={inputRef}
|
||||
className={"w-full"} // [data-state] open
|
||||
className={"w-full group/user-role-selector"}
|
||||
data-cy={"user-role-selector"}
|
||||
>
|
||||
<div className={"w-full flex justify-between items-center gap-2"}>
|
||||
@@ -157,8 +163,8 @@ export function UserRoleSelector({
|
||||
style={{
|
||||
width: popoverWidth === "auto" ? width : popoverWidth,
|
||||
}}
|
||||
align="start"
|
||||
side={"bottom"}
|
||||
align={align}
|
||||
side={side}
|
||||
sideOffset={10}
|
||||
>
|
||||
<Command
|
||||
|
||||
@@ -24,8 +24,10 @@ export function removeAllSpaces(str: string) {
|
||||
return str.replace(/\s/g, "");
|
||||
}
|
||||
|
||||
export const generateColorFromString = (str: string) => {
|
||||
export const generateColorFromString = (str?: string) => {
|
||||
if (!str) return "#f68330";
|
||||
if (str.includes("System")) return "#808080";
|
||||
if (str.toLowerCase().startsWith("netbird")) return "#f68330";
|
||||
let hash = 0;
|
||||
str.split("").forEach((char) => {
|
||||
hash = char.charCodeAt(0) + ((hash << 5) - hash);
|
||||
@@ -59,22 +61,49 @@ export const sleep = (ms: number) => {
|
||||
export const validator = {
|
||||
isValidDomain: (
|
||||
domain: string,
|
||||
options?: { allowWildcard?: boolean; allowOnlyTld?: boolean },
|
||||
options?: {
|
||||
allowWildcard?: boolean;
|
||||
allowOnlyTld?: boolean;
|
||||
preventLeadingAndTrailingDots?: boolean;
|
||||
},
|
||||
) => {
|
||||
const { allowWildcard = true, allowOnlyTld = true } = options || {
|
||||
const {
|
||||
allowWildcard = true,
|
||||
allowOnlyTld = true,
|
||||
preventLeadingAndTrailingDots = false,
|
||||
} = options || {
|
||||
allowWildcard: true,
|
||||
allowOnlyTld: true,
|
||||
preventLeadingAndTrailingDots: false,
|
||||
};
|
||||
|
||||
try {
|
||||
const includesAtLeastOneDot = domain.includes(".");
|
||||
const includesAtLeastOneDot = allowOnlyTld ? true : domain.includes(".");
|
||||
const hasWhitespace = domain.includes(" ");
|
||||
const domainRegex =
|
||||
/^(?!-)[a-z0-9\u00a1-\uffff-*]{0,63}(?<!-)(\.[a-z0-9\u00a1-\uffff-*]{0,63})*$/i;
|
||||
/**
|
||||
* Do not start or end with hyphen
|
||||
* Allow any Unicode character
|
||||
* Allow any Unicode number
|
||||
* Allow hyphen, dot and asterisks
|
||||
*/
|
||||
const domainRegex = /^(?!-)[\p{L}\p{N}.*-]+(?<!-)$/u;
|
||||
const isValidUnicodeDomain = domainRegex.test(domain);
|
||||
|
||||
if (
|
||||
preventLeadingAndTrailingDots &&
|
||||
(domain.startsWith(".") || domain.endsWith("."))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (domain.length < 1 || domain.length > 255) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!allowWildcard && domain.includes("*")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!allowWildcard && domain.startsWith("*.")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ const config: Config = {
|
||||
"50": "#f4f6f7",
|
||||
"100": "#e4e7e9",
|
||||
"200": "#cbd2d6",
|
||||
"250": "#b7c0c6",
|
||||
"300": "#a7b1b9",
|
||||
"350": "#8f9ca8",
|
||||
"400": "#7c8994",
|
||||
@@ -34,7 +35,8 @@ const config: Config = {
|
||||
DEFAULT: "#f68330",
|
||||
"50": "#fff6ed",
|
||||
"100": "#feecd6",
|
||||
"200": "#fcd5ac",
|
||||
"150": "#ffdfb8",
|
||||
"200": "#ffd4a6",
|
||||
"300": "#fab677",
|
||||
"400": "#f68330",
|
||||
"500": "#f46d1b",
|
||||
|
||||
Reference in New Issue
Block a user