mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c7f0a0f10 | ||
|
|
76541c701c |
No files matched your search
+250
-127
@@ -2,6 +2,7 @@
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import Button from "@components/Button";
|
||||
import { Callout } from "@components/Callout";
|
||||
import Card from "@components/Card";
|
||||
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
@@ -219,7 +220,7 @@ const PeerGeneralInformation = () => {
|
||||
<ModalTrigger>
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md cursor-pointer"
|
||||
"flex h-8 w-8 items-center justify-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 rounded-md cursor-pointer"
|
||||
}
|
||||
>
|
||||
<PencilIcon size={16} />
|
||||
@@ -324,7 +325,7 @@ const PeerGeneralInformation = () => {
|
||||
}
|
||||
interactive={false}
|
||||
className={"w-full block"}
|
||||
disabled={!permission.peers.update}
|
||||
disabled={permission.peers.update}
|
||||
>
|
||||
<FancyToggleSwitch
|
||||
value={ssh}
|
||||
@@ -415,158 +416,200 @@ const PeerOverviewTabs = () => {
|
||||
|
||||
function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
const { isLoading, getRegionByPeer } = useCountries();
|
||||
const { update } = usePeer();
|
||||
const { mutate } = useSWRConfig();
|
||||
const [showEditIPModal, setShowEditIPModal] = useState(false);
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const countryText = useMemo(() => {
|
||||
return getRegionByPeer(peer);
|
||||
}, [getRegionByPeer, peer]);
|
||||
|
||||
return (
|
||||
<Card className={"w-full xl:w-1/2"}>
|
||||
<Card.List>
|
||||
<Card.ListItem
|
||||
copy
|
||||
copyText={"NetBird IP-Address"}
|
||||
label={
|
||||
<>
|
||||
<MapPin size={16} />
|
||||
NetBird IP-Address
|
||||
</>
|
||||
}
|
||||
value={peer.ip}
|
||||
<>
|
||||
<Modal open={showEditIPModal} onOpenChange={setShowEditIPModal}>
|
||||
<EditIPModal
|
||||
onSuccess={(newIP) => {
|
||||
notify({
|
||||
title: peer.name,
|
||||
description: "Peer IP was successfully updated",
|
||||
promise: update({ ip: newIP }).then(() => {
|
||||
mutate("/peers/" + peer.id);
|
||||
setShowEditIPModal(false);
|
||||
}),
|
||||
loadingMessage: "Updating peer IP...",
|
||||
});
|
||||
}}
|
||||
peer={peer}
|
||||
key={showEditIPModal ? 1 : 0}
|
||||
/>
|
||||
|
||||
<Card.ListItem
|
||||
copy
|
||||
copyText={"Public IP-Address"}
|
||||
label={
|
||||
<>
|
||||
<NetworkIcon size={16} />
|
||||
Public IP-Address
|
||||
</>
|
||||
}
|
||||
value={peer.connection_ip}
|
||||
/>
|
||||
|
||||
<Card.ListItem
|
||||
copy
|
||||
copyText={"DNS label"}
|
||||
label={
|
||||
<>
|
||||
<Globe size={16} />
|
||||
Domain Name
|
||||
</>
|
||||
}
|
||||
className={
|
||||
peer?.extra_dns_labels && peer.extra_dns_labels.length > 0
|
||||
? "items-start"
|
||||
: ""
|
||||
}
|
||||
value={peer.dns_label}
|
||||
extraText={peer?.extra_dns_labels}
|
||||
/>
|
||||
|
||||
<Card.ListItem
|
||||
copy
|
||||
copyText={"Hostname"}
|
||||
label={
|
||||
<>
|
||||
<MonitorSmartphoneIcon size={16} />
|
||||
Hostname
|
||||
</>
|
||||
}
|
||||
value={peer.hostname}
|
||||
/>
|
||||
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<FlagIcon size={16} />
|
||||
Region
|
||||
</>
|
||||
}
|
||||
tooltip={false}
|
||||
value={
|
||||
isEmpty(peer.country_code) ? (
|
||||
"Unknown"
|
||||
) : (
|
||||
</Modal>
|
||||
<Card className={"w-full xl:w-1/2"}>
|
||||
<Card.List>
|
||||
<Card.ListItem
|
||||
copy
|
||||
tooltip={false}
|
||||
copyText={"NetBird IP Address"}
|
||||
label={
|
||||
<>
|
||||
{isLoading ? (
|
||||
<Skeleton width={140} />
|
||||
) : (
|
||||
<div className={"flex gap-2 items-center"}>
|
||||
<div className={"border-0 border-nb-gray-800 rounded-full"}>
|
||||
<RoundedFlag country={peer.country_code} size={12} />
|
||||
</div>
|
||||
{countryText}
|
||||
</div>
|
||||
)}
|
||||
<MapPin size={16} />
|
||||
NetBird IP Address
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
valueToCopy={peer.ip}
|
||||
value={
|
||||
<div className="flex items-center gap-2 justify-between w-full">
|
||||
<span>{peer.ip}</span>
|
||||
{permission.peers.update && (
|
||||
<button
|
||||
className="flex w-7 h-7 items-center justify-center gap-2 text-nb-gray-400 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 rounded-md cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowEditIPModal(true);
|
||||
}}
|
||||
>
|
||||
<PencilIcon size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<Cpu size={16} />
|
||||
Operating System
|
||||
</>
|
||||
}
|
||||
value={peer.os}
|
||||
/>
|
||||
<Card.ListItem
|
||||
copy
|
||||
copyText={"Public IP Address"}
|
||||
label={
|
||||
<>
|
||||
<NetworkIcon size={16} />
|
||||
Public IP Address
|
||||
</>
|
||||
}
|
||||
value={peer.connection_ip}
|
||||
/>
|
||||
|
||||
<Card.ListItem
|
||||
copy
|
||||
copyText={"DNS label"}
|
||||
label={
|
||||
<>
|
||||
<Globe size={16} />
|
||||
Domain Name
|
||||
</>
|
||||
}
|
||||
className={
|
||||
peer?.extra_dns_labels && peer.extra_dns_labels.length > 0
|
||||
? "items-start"
|
||||
: ""
|
||||
}
|
||||
value={peer.dns_label}
|
||||
extraText={peer?.extra_dns_labels}
|
||||
/>
|
||||
|
||||
<Card.ListItem
|
||||
copy
|
||||
copyText={"Hostname"}
|
||||
label={
|
||||
<>
|
||||
<MonitorSmartphoneIcon size={16} />
|
||||
Hostname
|
||||
</>
|
||||
}
|
||||
value={peer.hostname}
|
||||
/>
|
||||
|
||||
{peer.serial_number && peer.serial_number !== "" && (
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<Barcode size={16} />
|
||||
Serial Number
|
||||
<FlagIcon size={16} />
|
||||
Region
|
||||
</>
|
||||
}
|
||||
value={peer.serial_number}
|
||||
tooltip={false}
|
||||
value={
|
||||
isEmpty(peer.country_code) ? (
|
||||
"Unknown"
|
||||
) : (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<Skeleton width={140} />
|
||||
) : (
|
||||
<div className={"flex gap-2 items-center"}>
|
||||
<div
|
||||
className={"border-0 border-nb-gray-800 rounded-full"}
|
||||
>
|
||||
<RoundedFlag country={peer.country_code} size={12} />
|
||||
</div>
|
||||
{countryText}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<History size={16} />
|
||||
Last seen
|
||||
</>
|
||||
}
|
||||
value={
|
||||
peer.connected
|
||||
? "just now"
|
||||
: dayjs(peer.last_seen).format("D MMMM, YYYY [at] h:mm A") +
|
||||
" (" +
|
||||
dayjs().to(peer.last_seen) +
|
||||
")"
|
||||
}
|
||||
/>
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<Cpu size={16} />
|
||||
Operating System
|
||||
</>
|
||||
}
|
||||
value={peer.os}
|
||||
/>
|
||||
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<NetBirdIcon size={16} />
|
||||
Agent Version
|
||||
</>
|
||||
}
|
||||
value={peer.version}
|
||||
/>
|
||||
{peer.serial_number && peer.serial_number !== "" && (
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<Barcode size={16} />
|
||||
Serial Number
|
||||
</>
|
||||
}
|
||||
value={peer.serial_number}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<History size={16} />
|
||||
Last seen
|
||||
</>
|
||||
}
|
||||
value={
|
||||
peer.connected
|
||||
? "just now"
|
||||
: dayjs(peer.last_seen).format("D MMMM, YYYY [at] h:mm A") +
|
||||
" (" +
|
||||
dayjs().to(peer.last_seen) +
|
||||
")"
|
||||
}
|
||||
/>
|
||||
|
||||
{peer.ui_version && (
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<NetBirdIcon size={16} />
|
||||
UI Version
|
||||
Agent Version
|
||||
</>
|
||||
}
|
||||
value={peer.ui_version?.replace("netbird-desktop-ui/", "")}
|
||||
value={peer.version}
|
||||
/>
|
||||
)}
|
||||
</Card.List>
|
||||
</Card>
|
||||
|
||||
{peer.ui_version && (
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<NetBirdIcon size={16} />
|
||||
UI Version
|
||||
</>
|
||||
}
|
||||
value={peer.ui_version?.replace("netbird-desktop-ui/", "")}
|
||||
/>
|
||||
)}
|
||||
</Card.List>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -654,3 +697,83 @@ function EditNameModal({ onSuccess, peer, initialName }: Readonly<ModalProps>) {
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditIPModalProps {
|
||||
onSuccess: (ip: string) => void;
|
||||
peer: Peer;
|
||||
}
|
||||
|
||||
function EditIPModal({ onSuccess, peer }: Readonly<EditIPModalProps>) {
|
||||
const [ip, setIP] = useState(peer.ip);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const validateIP = (ipAddress: string) => {
|
||||
const ipRegex =
|
||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
return ipRegex.test(ipAddress);
|
||||
};
|
||||
|
||||
const isDisabled = useMemo(() => {
|
||||
if (ip === peer.ip) return true;
|
||||
const trimmedIP = trim(ip);
|
||||
return trimmedIP.length === 0 || !validateIP(ip);
|
||||
}, [ip, peer.ip]);
|
||||
|
||||
React.useEffect(() => {
|
||||
switch (true) {
|
||||
case ip === peer.ip:
|
||||
setError("");
|
||||
break;
|
||||
case !validateIP(ip):
|
||||
setError("Please enter a valid IP, e.g., 100.64.0.15");
|
||||
break;
|
||||
default:
|
||||
setError("");
|
||||
break;
|
||||
}
|
||||
}, [ip, peer.ip]);
|
||||
|
||||
return (
|
||||
<ModalContent maxWidthClass={"max-w-md"}>
|
||||
<form>
|
||||
<ModalHeader
|
||||
title={"Edit Peer IP Address"}
|
||||
description={"Update the NetBird IP address for this peer."}
|
||||
color={"blue"}
|
||||
/>
|
||||
|
||||
<div className={"p-default flex flex-col gap-4"}>
|
||||
<div>
|
||||
<Input
|
||||
placeholder={"e.g., 100.64.0.15"}
|
||||
value={ip}
|
||||
onChange={(e) => setIP(e.target.value)}
|
||||
error={error}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Callout>Changes take effect when the peer reconnects.</Callout>
|
||||
</div>
|
||||
|
||||
<ModalFooter className={"items-center"} separator={false}>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"} className={"w-full"}>
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"w-full"}
|
||||
onClick={() => onSuccess(ip)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ function CardList({ children }: Props) {
|
||||
type CardListItemProps = {
|
||||
label: React.ReactNode;
|
||||
value: React.ReactNode;
|
||||
valueToCopy?: string;
|
||||
className?: string;
|
||||
copy?: boolean;
|
||||
copyText?: string;
|
||||
@@ -39,6 +40,7 @@ type CardListItemProps = {
|
||||
function CardListItem({
|
||||
label,
|
||||
value,
|
||||
valueToCopy,
|
||||
className,
|
||||
copy = false,
|
||||
copyText,
|
||||
@@ -57,6 +59,7 @@ function CardListItem({
|
||||
<CardTextItem
|
||||
label={label}
|
||||
value={value}
|
||||
valueToCopy={valueToCopy}
|
||||
copy={copy}
|
||||
copyText={copyText}
|
||||
tooltip={tooltip}
|
||||
@@ -79,6 +82,7 @@ function CardListItem({
|
||||
type CardTextItemProps = {
|
||||
label: React.ReactNode;
|
||||
value: React.ReactNode;
|
||||
valueToCopy?: string;
|
||||
copy?: boolean;
|
||||
copyText?: string;
|
||||
tooltip?: boolean;
|
||||
@@ -88,10 +92,11 @@ const CardTextItem = ({
|
||||
label,
|
||||
value,
|
||||
copy = false,
|
||||
valueToCopy,
|
||||
copyText,
|
||||
tooltip = true,
|
||||
}: CardTextItemProps) => {
|
||||
const [, copyToClipBoard] = useCopyToClipboard(value as string);
|
||||
const [, copyToClipBoard] = useCopyToClipboard(valueToCopy ?? `${value}`);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -26,6 +26,7 @@ const PeerContext = React.createContext(
|
||||
loginExpiration?: boolean;
|
||||
inactivityExpiration?: boolean;
|
||||
approval_required?: boolean;
|
||||
ip?: string;
|
||||
}) => Promise<Peer>;
|
||||
openSSHDialog: () => Promise<boolean>;
|
||||
deletePeer: () => void;
|
||||
@@ -36,7 +37,7 @@ const PeerContext = React.createContext(
|
||||
export default function PeerProvider({ children, peer }: Props) {
|
||||
const user = usePeerUser(peer);
|
||||
const { peerGroups, isLoading } = usePeerGroups(peer);
|
||||
const peerRequest = useApiCall<Peer>("/peers");
|
||||
const peerRequest = useApiCall<Peer>("/peers", true);
|
||||
const { confirm } = useDialog();
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
@@ -68,6 +69,7 @@ export default function PeerProvider({ children, peer }: Props) {
|
||||
loginExpiration?: boolean;
|
||||
inactivityExpiration?: boolean;
|
||||
approval_required?: boolean;
|
||||
ip?: string;
|
||||
}) => {
|
||||
return peerRequest.put(
|
||||
{
|
||||
@@ -86,6 +88,7 @@ export default function PeerProvider({ children, peer }: Props) {
|
||||
props?.approval_required == undefined
|
||||
? undefined
|
||||
: props.approval_required,
|
||||
ip: props.ip != undefined ? props.ip : undefined,
|
||||
},
|
||||
`/${peer.id}`,
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface Account {
|
||||
regular_users_view_blocked: boolean;
|
||||
routing_peer_dns_resolution_enabled: boolean;
|
||||
dns_domain: string;
|
||||
network_range?: string;
|
||||
lazy_connection_enabled: boolean;
|
||||
};
|
||||
}
|
||||
@@ -63,11 +63,12 @@ export default function ActivityDescription({ event }: Props) {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "setupkey.peer.add")
|
||||
if (event.activity_code == "peer.setupkey.add")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Peer <Value>{m.name}</Value> <PeerConnectionInfo meta={m} /> was added
|
||||
with the NetBird IP <Value>{m.ip}</Value>
|
||||
with the NetBird IP <Value>{m.ip}</Value> using the setup key{" "}
|
||||
<Value>{m.setup_key_name}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -340,6 +341,14 @@ export default function ActivityDescription({ event }: Props) {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "peer.ip.update")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Peer <Value>{m.name}</Value> IP address was updated from{" "}
|
||||
<Value>{m.old_ip}</Value> to <Value>{m.ip}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Group
|
||||
*/
|
||||
@@ -378,6 +387,15 @@ export default function ActivityDescription({ event }: Props) {
|
||||
if (event.activity_code == "account.setting.peer.login.expiration.disable")
|
||||
return <div className={"inline"}>Global login expiration was disabled</div>;
|
||||
|
||||
if (event.activity_code == "account.network.range.update")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Account network range was updated from{" "}
|
||||
<Value>{m.old_network_range}</Value> to{" "}
|
||||
<Value>{m.new_network_range}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Nameserver
|
||||
*/
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import Button from "@components/Button";
|
||||
import { CommandItem } from "@components/Command";
|
||||
import { DropdownInfoText } from "@components/DropdownInfoText";
|
||||
import { DropdownInput } from "@components/DropdownInput";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
|
||||
import { ScrollArea } from "@components/ScrollArea";
|
||||
import { SmallBadge } from "@components/ui/SmallBadge";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import { cn, generateColorFromString } from "@utils/helpers";
|
||||
import { Command, CommandGroup, CommandInput, CommandList } from "cmdk";
|
||||
import { sortBy, trim, uniqBy } from "lodash";
|
||||
import { ChevronsUpDown, Cog, SearchIcon, UserCircle2 } from "lucide-react";
|
||||
import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList";
|
||||
import { useSearch } from "@hooks/useSearch";
|
||||
import { generateColorFromString } from "@utils/helpers";
|
||||
import { sortBy, uniqBy } from "lodash";
|
||||
import { ChevronsUpDown, Cog, UserCircle2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
import { useElementSize } from "@/hooks/useElementSize";
|
||||
import { SmallUserAvatar } from "@/modules/users/SmallUserAvatar";
|
||||
|
||||
@@ -29,17 +29,35 @@ export type UserSelectOption = {
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
const searchPredicate = (item: UserSelectOption, query: string) => {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
if (
|
||||
item.email === "NetBird" &&
|
||||
"NetBird System".toLowerCase().includes(lowerCaseQuery)
|
||||
)
|
||||
return true;
|
||||
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
|
||||
if (item.email?.toLowerCase().includes(lowerCaseQuery)) return true;
|
||||
return item.id.toLowerCase().startsWith(lowerCaseQuery);
|
||||
};
|
||||
|
||||
export function UsersDropdownSelector({
|
||||
onChange,
|
||||
value,
|
||||
disabled = false,
|
||||
popoverWidth = 250,
|
||||
options,
|
||||
}: Props) {
|
||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||
}: Readonly<Props>) {
|
||||
const [filteredItems, search, setSearch] = useSearch(
|
||||
options.concat({
|
||||
id: "all-users",
|
||||
name: "All Users",
|
||||
email: "Include all users",
|
||||
}),
|
||||
searchPredicate,
|
||||
{ filter: true, debounce: 150 },
|
||||
);
|
||||
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const search = useDebounce(searchInput, 500);
|
||||
|
||||
const toggle = (item: string | undefined) => {
|
||||
const isSelected = value == item;
|
||||
@@ -47,7 +65,7 @@ export function UsersDropdownSelector({
|
||||
onChange && onChange(undefined);
|
||||
} else {
|
||||
onChange && onChange(item);
|
||||
setSearchInput("");
|
||||
setSearch("");
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
@@ -55,11 +73,17 @@ export function UsersDropdownSelector({
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const sortedOptions = useMemo(() => {
|
||||
return sortBy(
|
||||
uniqBy(options, (o) => o.email),
|
||||
const sorted = sortBy(
|
||||
uniqBy(filteredItems, (o) => o.email),
|
||||
["external", "name"],
|
||||
);
|
||||
}, [options]);
|
||||
const allUsersIndex = sorted.findIndex((o) => o.id === "all-users");
|
||||
if (allUsersIndex > -1) {
|
||||
const allUsers = sorted.splice(allUsersIndex, 1)[0];
|
||||
sorted.unshift(allUsers);
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredItems]);
|
||||
|
||||
const selectedUser = useMemo(() => {
|
||||
return options.find((user) => user.email == value);
|
||||
@@ -71,7 +95,7 @@ export function UsersDropdownSelector({
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setTimeout(() => {
|
||||
setSearchInput("");
|
||||
setSearch("");
|
||||
}, 100);
|
||||
}
|
||||
setOpen(isOpen);
|
||||
@@ -137,145 +161,105 @@ export function UsersDropdownSelector({
|
||||
side={"bottom"}
|
||||
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",
|
||||
)}
|
||||
ref={searchRef}
|
||||
value={searchInput}
|
||||
onValueChange={setSearchInput}
|
||||
placeholder={"Search user..."}
|
||||
/>
|
||||
<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>
|
||||
<div className={"w-full"}>
|
||||
<DropdownInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={"Search user..."}
|
||||
hideEnterIcon={true}
|
||||
/>
|
||||
|
||||
<ScrollArea
|
||||
className={
|
||||
"max-h-[380px] overflow-y-hidden flex flex-col gap-1 pl-2 py-2 pr-3"
|
||||
}
|
||||
>
|
||||
<CommandGroup>
|
||||
<div className={"grid grid-cols-1 gap-1"}>
|
||||
<CommandItem
|
||||
value={undefined}
|
||||
className={"py-1 px-2"}
|
||||
onSelect={() => {
|
||||
toggle(undefined);
|
||||
}}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
{options.length == 0 && !search && (
|
||||
<div className={"max-w-xs mx-auto"}>
|
||||
<DropdownInfoText>
|
||||
{"No users available to select."}
|
||||
</DropdownInfoText>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredItems.length == 0 && search != "" && (
|
||||
<div className={"px-10"}>
|
||||
<DropdownInfoText>
|
||||
There are no users matching your search.
|
||||
</DropdownInfoText>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedOptions.length > 0 && (
|
||||
<VirtualScrollAreaList
|
||||
items={sortedOptions}
|
||||
estimatedItemHeight={52}
|
||||
maxHeight={380}
|
||||
scrollAreaClassName={"pt-0"}
|
||||
onSelect={(item) => {
|
||||
if (item.id === "all-users") {
|
||||
toggle(undefined);
|
||||
return;
|
||||
}
|
||||
toggle(item.email);
|
||||
}}
|
||||
renderItem={(user) => {
|
||||
const isSystemUser = user.email === "NetBird";
|
||||
|
||||
return (
|
||||
<div className={"flex items-center gap-2 w-full"}>
|
||||
{user.id === "all-users" ? (
|
||||
<div
|
||||
className={
|
||||
"w-7 h-7 rounded-full flex items-center justify-center uppercase text-[9px] font-medium bg-sky-400 text-white"
|
||||
"w-7 h-7 shrink-0 rounded-full flex items-center justify-center uppercase text-[9px] font-medium bg-sky-400 text-white"
|
||||
}
|
||||
>
|
||||
<UserCircle2 size={16} />
|
||||
</div>
|
||||
) : (
|
||||
<SmallUserAvatar
|
||||
name={user?.name}
|
||||
email={user?.email}
|
||||
id={user?.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={"flex flex-col text-xs"}>
|
||||
<span className={"text-nb-gray-200"}>All Users</span>
|
||||
<span className={"text-nb-gray-400 font-light"}>
|
||||
Include all users
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
|
||||
{sortedOptions.map((user) => {
|
||||
const isSystemUser = user.email === "NetBird";
|
||||
const searchValue = isSystemUser
|
||||
? "NetBird System"
|
||||
: user.name + " " + user.id + " " + user.email;
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={user.id}
|
||||
value={searchValue}
|
||||
className={"py-1 px-2"}
|
||||
onSelect={() => {
|
||||
toggle(user.email);
|
||||
}}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
<div className={"flex flex-col text-xs w-full"}>
|
||||
<span
|
||||
className={
|
||||
"text-nb-gray-200 flex items-center gap-1.5 w-full"
|
||||
}
|
||||
>
|
||||
<div className={"flex items-center gap-2 w-full"}>
|
||||
<SmallUserAvatar
|
||||
name={user?.name}
|
||||
email={user?.email}
|
||||
id={user?.id}
|
||||
/>
|
||||
|
||||
<div className={"flex flex-col text-xs w-full"}>
|
||||
<span
|
||||
className={
|
||||
"text-nb-gray-200 flex items-center gap-1.5 w-full"
|
||||
}
|
||||
>
|
||||
<TextWithTooltip
|
||||
text={
|
||||
isSystemUser
|
||||
? "System"
|
||||
: user?.name || user?.id
|
||||
}
|
||||
maxChars={20}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"text-nb-gray-400 font-light flex items-center gap-1"
|
||||
}
|
||||
>
|
||||
<TextWithTooltip
|
||||
text={user?.email || "NetBird"}
|
||||
maxChars={20}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
{user.external && (
|
||||
<span
|
||||
className={"flex items-center ml-auto relative"}
|
||||
>
|
||||
<SmallBadge
|
||||
text={"External"}
|
||||
variant={"sky"}
|
||||
className={
|
||||
"text-[8.5px] py-[0.15rem] px-[.32rem] leading-none rounded-full -top-0"
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
</Command>
|
||||
<TextWithTooltip
|
||||
text={
|
||||
isSystemUser ? "System" : user?.name || user?.id
|
||||
}
|
||||
maxChars={20}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"text-nb-gray-400 font-light flex items-center gap-1"
|
||||
}
|
||||
>
|
||||
<TextWithTooltip
|
||||
text={user?.email || "NetBird"}
|
||||
maxChars={20}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
{user.external && (
|
||||
<span className={"flex items-center ml-auto relative"}>
|
||||
<SmallBadge
|
||||
text={"External"}
|
||||
variant={"sky"}
|
||||
className={
|
||||
"text-[8.5px] py-[0.15rem] px-[.32rem] leading-none rounded-full -top-0"
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { validator } from "@utils/helpers";
|
||||
import { isNetBirdHosted } from "@utils/netbird";
|
||||
import cidr from "ip-cidr";
|
||||
import { ExternalLinkIcon, GlobeIcon, NetworkIcon } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
@@ -34,6 +35,9 @@ export default function NetworkSettingsTab({ account }: Readonly<Props>) {
|
||||
const [customDNSDomain, setCustomDNSDomain] = useState(
|
||||
account.settings.dns_domain || "",
|
||||
);
|
||||
const [networkRange, setNetworkRange] = useState(
|
||||
account.settings.network_range || "",
|
||||
);
|
||||
|
||||
const toggleNetworkDNSSetting = async (toggle: boolean) => {
|
||||
notify({
|
||||
@@ -57,25 +61,37 @@ export default function NetworkSettingsTab({ account }: Readonly<Props>) {
|
||||
});
|
||||
};
|
||||
|
||||
const { hasChanges, updateRef } = useHasChanges([customDNSDomain]);
|
||||
const { hasChanges, updateRef } = useHasChanges([
|
||||
customDNSDomain,
|
||||
networkRange,
|
||||
]);
|
||||
|
||||
const saveChanges = async () => {
|
||||
const updatedSettings = {
|
||||
...account.settings,
|
||||
};
|
||||
|
||||
if (customDNSDomain !== "" || account.settings.dns_domain) {
|
||||
updatedSettings.dns_domain = customDNSDomain;
|
||||
}
|
||||
|
||||
if (networkRange !== "") {
|
||||
updatedSettings.network_range = networkRange;
|
||||
}
|
||||
|
||||
notify({
|
||||
title: "Custom DNS Domain",
|
||||
description: `Custom DNS Domain successfully updated.`,
|
||||
title: "Network Settings",
|
||||
description: `Network settings successfully updated.`,
|
||||
promise: saveRequest
|
||||
.put({
|
||||
id: account.id,
|
||||
settings: {
|
||||
...account.settings,
|
||||
dns_domain: customDNSDomain || "",
|
||||
},
|
||||
settings: updatedSettings,
|
||||
})
|
||||
.then(() => {
|
||||
mutate("/accounts");
|
||||
updateRef([customDNSDomain]);
|
||||
updateRef([customDNSDomain, networkRange]);
|
||||
}),
|
||||
loadingMessage: "Updating Custom DNS domain...",
|
||||
loadingMessage: "Updating network settings...",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -90,6 +106,24 @@ export default function NetworkSettingsTab({ account }: Readonly<Props>) {
|
||||
}
|
||||
}, [customDNSDomain]);
|
||||
|
||||
const networkRangeError = useMemo(() => {
|
||||
if (networkRange == "") {
|
||||
if (account.settings.network_range) {
|
||||
return "Network range cannot be empty";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const validCIDR = cidr.isValidCIDR(networkRange);
|
||||
if (!validCIDR) {
|
||||
return "Please enter a valid IPv4 CIDR range, e.g. 100.64.0.0/16 or 192.168.1.0/24";
|
||||
}
|
||||
} catch (error) {
|
||||
return "Please enter a valid IPv4 CIDR range, e.g. 100.64.0.0/16 or 192.168.1.0/24";
|
||||
}
|
||||
}, [networkRange, account.settings.network_range]);
|
||||
|
||||
return (
|
||||
<Tabs.Content value={"networks"}>
|
||||
<div className={"p-default py-6 max-w-2xl"}>
|
||||
@@ -112,7 +146,12 @@ export default function NetworkSettingsTab({ account }: Readonly<Props>) {
|
||||
</div>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
disabled={!hasChanges || !permission.settings.update}
|
||||
disabled={
|
||||
!hasChanges ||
|
||||
!permission.settings.update ||
|
||||
!!domainError ||
|
||||
!!networkRangeError
|
||||
}
|
||||
onClick={saveChanges}
|
||||
>
|
||||
Save Changes
|
||||
@@ -130,7 +169,8 @@ export default function NetworkSettingsTab({ account }: Readonly<Props>) {
|
||||
<Label>DNS Domain</Label>
|
||||
<HelpText>
|
||||
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.
|
||||
not point to a domain that is already in use elsewhere, to
|
||||
avoid overriding DNS results.
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={"w-full"}>
|
||||
@@ -149,6 +189,33 @@ export default function NetworkSettingsTab({ account }: Readonly<Props>) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div
|
||||
className={
|
||||
"flex flex-col gap-1 sm:flex-row w-full sm:gap-4 items-center"
|
||||
}
|
||||
>
|
||||
<div className={"min-w-[330px]"}>
|
||||
<Label>Network Range</Label>
|
||||
<HelpText>
|
||||
Specify a custom IPv4 range for your network in CIDR format.
|
||||
All peer IPs will be re-allocated when changed.
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={"w-full"}>
|
||||
<Input
|
||||
placeholder={"e.g. 100.64.0.0/16"}
|
||||
errorTooltip={true}
|
||||
errorTooltipPosition={"top"}
|
||||
error={networkRangeError}
|
||||
value={networkRange}
|
||||
disabled={!permission.settings.update}
|
||||
onChange={(e) => setNetworkRange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FancyToggleSwitch
|
||||
value={routingPeerDNSSetting}
|
||||
onChange={toggleNetworkDNSSetting}
|
||||
|
||||
Reference in new issue
Block a user