diff --git a/config.json b/config.json index b040858b..67198511 100644 --- a/config.json +++ b/config.json @@ -1,18 +1,10 @@ { - "auth0Auth": "$USE_AUTH0", - "authAuthority": "$AUTH_AUTHORITY", - "authClientId": "$AUTH_CLIENT_ID", - "authClientSecret": "$AUTH_CLIENT_SECRET", - "authScopesSupported": "$AUTH_SUPPORTED_SCOPES", - "authAudience": "$AUTH_AUDIENCE", - "apiOrigin": "$NETBIRD_MGMT_API_ENDPOINT", - "grpcApiOrigin": "$NETBIRD_MGMT_GRPC_API_ENDPOINT", - "redirectURI": "$AUTH_REDIRECT_URI", - "silentRedirectURI": "$AUTH_SILENT_REDIRECT_URI", - "tokenSource": "$NETBIRD_TOKEN_SOURCE", - "dragQueryParams": "$NETBIRD_DRAG_QUERY_PARAMS", - "hotjarTrackID": "$NETBIRD_HOTJAR_TRACK_ID", - "googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID", - "googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID", - "wasmPath": "$NETBIRD_WASM_PATH" -} \ No newline at end of file + "auth0Auth": "true", + "authAuthority": "https://netbird-localdev.eu.auth0.com", + "authClientId": "kBRMAOqIZ7hvpVCaypQLCJvTzkYYIXVt", + "authScopesSupported": "openid profile email api offline_access email_verified", + "authAudience": "http://localhost:3000/", + "apiOrigin": "http://localhost", + "grpcApiOrigin": "http://localhost:80", + "latestVersion": "v0.6.3" +} diff --git a/package-lock.json b/package-lock.json index 3cb2162e..91dfef30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "flowbite": "^1.8.1", "flowbite-react": "^0.6.4", "framer-motion": "^10.16.4", + "ip-address": "^10.1.0", "ip-cidr": "^3.1.0", "js-cookie": "^3.0.5", "lodash": "^4.17.21", @@ -6598,15 +6599,12 @@ } }, "node_modules/ip-address": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-7.1.0.tgz", - "integrity": "sha512-V9pWC/VJf2lsXqP7IWJ+pe3P1/HCYGBMZrrnT62niLGjAfCbeiwXMUxaeHvnVlz19O27pvXP4azs+Pj/A0x+SQ==", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "1.1.2" - }, + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", "engines": { - "node": ">= 10" + "node": ">= 12" } }, "node_modules/ip-cidr": { @@ -6621,6 +6619,19 @@ "node": ">=10.0.0" } }, + "node_modules/ip-cidr/node_modules/ip-address": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-7.1.0.tgz", + "integrity": "sha512-V9pWC/VJf2lsXqP7IWJ+pe3P1/HCYGBMZrrnT62niLGjAfCbeiwXMUxaeHvnVlz19O27pvXP4azs+Pj/A0x+SQ==", + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "1.1.2" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -8561,7 +8572,8 @@ "node_modules/sprintf-js": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", - "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==" + "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==", + "license": "BSD-3-Clause" }, "node_modules/stable-hash": { "version": "0.0.5", diff --git a/package.json b/package.json index 7fd4f6dd..3d9b3c34 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "flowbite": "^1.8.1", "flowbite-react": "^0.6.4", "framer-motion": "^10.16.4", + "ip-address": "^10.1.0", "ip-cidr": "^3.1.0", "js-cookie": "^3.0.5", "lodash": "^4.17.21", diff --git a/src/app/(dashboard)/dns/nameservers/page.tsx b/src/app/(dashboard)/dns/nameservers/page.tsx index 7e66f2b6..b287ea09 100644 --- a/src/app/(dashboard)/dns/nameservers/page.tsx +++ b/src/app/(dashboard)/dns/nameservers/page.tsx @@ -7,7 +7,7 @@ import SkeletonTable from "@components/skeletons/SkeletonTable"; import { RestrictedAccess } from "@components/ui/RestrictedAccess"; import { usePortalElement } from "@hooks/usePortalElement"; import useFetchApi from "@utils/api"; -import { ExternalLinkIcon, ServerIcon } from "lucide-react"; +import { ExternalLinkIcon } from "lucide-react"; import React, { lazy, Suspense } from "react"; import DNSIcon from "@/assets/icons/DNSIcon"; import { usePermissions } from "@/contexts/PermissionsProvider"; @@ -15,7 +15,7 @@ import { NameserverGroup } from "@/interfaces/Nameserver"; import PageContainer from "@/layouts/PageContainer"; const NameserverGroupTable = lazy( - () => import("@/modules/dns-nameservers/table/NameserverGroupTable"), + () => import("@/modules/dns/nameservers/table/NameserverGroupTable"), ); export default function NameServers() { @@ -40,7 +40,7 @@ export default function NameServers() { href={"/dns/nameservers"} label={"Nameservers"} active - icon={} + icon={} />

Nameservers

diff --git a/src/app/(dashboard)/dns/zones/layout.tsx b/src/app/(dashboard)/dns/zones/layout.tsx new file mode 100644 index 00000000..640fa1fc --- /dev/null +++ b/src/app/(dashboard)/dns/zones/layout.tsx @@ -0,0 +1,8 @@ +import { globalMetaTitle } from "@utils/meta"; +import type { Metadata } from "next"; +import BlankLayout from "@/layouts/BlankLayout"; + +export const metadata: Metadata = { + title: `Zones - DNS - ${globalMetaTitle}`, +}; +export default BlankLayout; diff --git a/src/app/(dashboard)/dns/zones/page.tsx b/src/app/(dashboard)/dns/zones/page.tsx new file mode 100644 index 00000000..0abf50f1 --- /dev/null +++ b/src/app/(dashboard)/dns/zones/page.tsx @@ -0,0 +1,70 @@ +"use client"; + +import Breadcrumbs from "@components/Breadcrumbs"; +import InlineLink from "@components/InlineLink"; +import Paragraph from "@components/Paragraph"; +import SkeletonTable from "@components/skeletons/SkeletonTable"; +import { RestrictedAccess } from "@components/ui/RestrictedAccess"; +import { usePortalElement } from "@hooks/usePortalElement"; +import useFetchApi from "@utils/api"; +import { ExternalLinkIcon } from "lucide-react"; +import React, { lazy, Suspense } from "react"; +import DNSIcon from "@/assets/icons/DNSIcon"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { DNS_ZONE_DOCS_LINK, DNSZone } from "@/interfaces/DNS"; +import PageContainer from "@/layouts/PageContainer"; +import { DNSZonesProvider } from "@/modules/dns/zones/DNSZonesProvider"; +import DNSZoneIcon from "@/assets/icons/DNSZoneIcon"; + +const DNSZonesTable = lazy( + () => import("@/modules/dns/zones/table/DNSZonesTable"), +); + +export default function DNSZonePage() { + const { permission } = usePermissions(); + + const { data: zones, isLoading } = useFetchApi("/dns/zones"); + + const { ref: headingRef, portalTarget } = + usePortalElement(); + + return ( + +
+ + } /> + } + /> + +

Zones

+ + Manage DNS zones to control domain name resolution for your network. + + + Learn more about + + DNS Zones + + + in our documentation. + +
+ + + }> + + + + + +
+ ); +} diff --git a/src/app/(dashboard)/group/page.tsx b/src/app/(dashboard)/group/page.tsx index df9d0d1e..38118a48 100644 --- a/src/app/(dashboard)/group/page.tsx +++ b/src/app/(dashboard)/group/page.tsx @@ -15,6 +15,7 @@ import { useSearchParams } from "next/navigation"; import React, { useState } from "react"; import AccessControlIcon from "@/assets/icons/AccessControlIcon"; import DNSIcon from "@/assets/icons/DNSIcon"; +import DNSZoneIcon from "@/assets/icons/DNSZoneIcon"; import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon"; import PeerIcon from "@/assets/icons/PeerIcon"; import SetupKeysIcon from "@/assets/icons/SetupKeysIcon"; @@ -24,6 +25,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider"; import RoutesProvider from "@/contexts/RoutesProvider"; import { Group, GROUP_TOOLTIP_TEXT } from "@/interfaces/Group"; import PageContainer from "@/layouts/PageContainer"; +import { GroupDNSZonesSection } from "@/modules/groups/details/GroupDNSZonesSection"; import { GroupNameserversSection } from "@/modules/groups/details/GroupNameserversSection"; import { GroupNetworkRoutesSection } from "@/modules/groups/details/GroupNetworkRoutesSection"; import { GroupPeersSection } from "@/modules/groups/details/GroupPeersSection"; @@ -134,7 +136,9 @@ const validAllGroupTabs = [ "resources", "network-routes", "nameservers", + "zones", ]; + const validOtherGroupTabs = ["users", "peers", "setup-keys"]; const GroupOverviewTabs = ({ group }: { group: Group }) => { @@ -162,6 +166,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => { const resourcesCount = groupDetails?.resources_count || 0; const routesCount = groupDetails?.routes?.length || 0; const nameserversCount = groupDetails?.nameservers?.length || 0; + const zonesCount = groupDetails?.zones?.length || 0; const setupKeysCount = groupDetails?.setupKeys?.length || 0; return ( @@ -249,6 +254,19 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => { {singularize("Nameservers", nameserversCount)} + + + {singularize("Zones", zonesCount)} + + {group.name !== "All" && ( { /> + + + + { - let id = peer?.id ?? ""; - let expiration = peer?.login_expiration_enabled ? "1" : "0"; - return `${id}-${expiration}`; - }, [peer]); - if (isRestricted) { return ( @@ -106,7 +98,7 @@ export default function PeerPage() { return peer && !isLoading ? ( - + ) : ( @@ -142,12 +134,6 @@ const PeerGeneralInformation = () => { const { peer, user, peerGroups, update } = usePeer(); const [name, setName] = useState(peer.name); const [showEditNameModal, setShowEditNameModal] = useState(false); - const [loginExpiration, setLoginExpiration] = useState( - peer.login_expiration_enabled, - ); - const [inactivityExpiration, setInactivityExpiration] = useState( - peer.inactivity_expiration_enabled, - ); const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] = useGroupHelper({ initial: peerGroups?.filter((g) => g?.name !== "All"), @@ -159,8 +145,6 @@ const PeerGeneralInformation = () => { */ const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([ selectedGroups, - loginExpiration, - inactivityExpiration, ]); const updatePeer = async (newName?: string) => { @@ -170,8 +154,6 @@ const PeerGeneralInformation = () => { if (permission.peers.update) { const updateRequest = update({ name: newName ?? name, - loginExpiration, - inactivityExpiration, }); batchCall = groupCalls ? [...groupCalls, updateRequest] : [updateRequest]; } else { @@ -184,11 +166,7 @@ const PeerGeneralInformation = () => { promise: Promise.all(batchCall).then(() => { mutate("/peers/" + peer.id); mutate("/groups"); - updateHasChangedRef([ - selectedGroups, - loginExpiration, - inactivityExpiration, - ]); + updateHasChangedRef([selectedGroups]); }), loadingMessage: "Saving the peer...", }); @@ -284,41 +262,7 @@ const PeerGeneralInformation = () => {
-
- } - onChange={(state) => { - setLoginExpiration(state); - !state && setInactivityExpiration(false); - }} - /> - {permission.peers.update && !!peer?.user_id && ( -
- -
- )} -
+ diff --git a/src/assets/icons/DNSZoneIcon.tsx b/src/assets/icons/DNSZoneIcon.tsx new file mode 100644 index 00000000..b08b37b1 --- /dev/null +++ b/src/assets/icons/DNSZoneIcon.tsx @@ -0,0 +1,19 @@ +import { iconProperties, IconProps } from "@/assets/icons/IconProperties"; + +export default function DNSZoneIcon(props: IconProps) { + return ( + + + + ); +} diff --git a/src/assets/icons/SlackIcon.tsx b/src/assets/icons/SlackIcon.tsx new file mode 100644 index 00000000..92abe6fe --- /dev/null +++ b/src/assets/icons/SlackIcon.tsx @@ -0,0 +1,30 @@ +import { iconProperties, IconProps } from "@/assets/icons/IconProperties"; + +export default function SlackIcon(props: Readonly) { + return ( + + + + + + + ); +} diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 3c1fde42..a3e0947c 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -76,6 +76,7 @@ export const buttonVariants = cva( "default-outline": [ "dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20", "dark:bg-transparent dark:text-nb-gray-400 dark:border-transparent dark:hover:text-white dark:hover:bg-nb-gray-900/30 dark:hover:border-nb-gray-800/50", + "data-[state=open]:dark:text-white data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:border-nb-gray-800/50", ], danger: [ "", // TODO - add danger button styles for light mode diff --git a/src/components/DropdownMenu.tsx b/src/components/DropdownMenu.tsx index 529e6a1d..5fa0a820 100644 --- a/src/components/DropdownMenu.tsx +++ b/src/components/DropdownMenu.tsx @@ -93,25 +93,53 @@ const DropdownMenuItem = React.forwardRef< React.ComponentPropsWithoutRef & { inset?: boolean; variant?: "default" | "danger"; + href?: string; + target?: string; + rel?: string; } ->(({ className, inset, variant = "default", onClick, ...props }, ref) => ( - ( + ( + { className, - )} - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - onClick && onClick(e); - }} - {...props} - /> -)); + inset, + variant = "default", + onClick, + href, + target, + rel, + ...props + }, + ref, + ) => { + return ( + { + if (href) return; + e.preventDefault(); + e.stopPropagation(); + onClick && onClick(e); + }} + {...props} + > + {href ? ( + + {props.children} + + ) : ( + props.children + )} + + ); + }, +); DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; const DropdownMenuCheckboxItem = React.forwardRef< diff --git a/src/components/Input.tsx b/src/components/Input.tsx index 5309044a..7ed035a6 100644 --- a/src/components/Input.tsx +++ b/src/components/Input.tsx @@ -17,7 +17,7 @@ export interface InputProps icon?: React.ReactNode; error?: string; errorTooltip?: boolean; - errorTooltipPosition?: "top" | "top-right"; + errorTooltipPosition?: "top" | "top-right" | "bottom"; prefixClassName?: string; showPasswordToggle?: boolean; } diff --git a/src/components/table/Table.tsx b/src/components/table/Table.tsx index fc119926..ac0b0cb3 100644 --- a/src/components/table/Table.tsx +++ b/src/components/table/Table.tsx @@ -104,7 +104,7 @@ const TableRow = React.forwardRef< " transition-colors group/table-row data-[state=selected]:bg-neutral-100 dark:data-[state=selected]:bg-nb-gray-930/70", "dark:data-[state=selected]:border-nb-gray-900", minimal - ? "dark:hover:bg-nb-gray-900/10" + ? "dark:hover:bg-nb-gray-910/[15%]" : "border-b dark:border-zinc-700/40 dark:hover:bg-nb-gray-940 hover:bg-neutral-100/50", className, )} diff --git a/src/components/ui/HelpAndSupportButton.tsx b/src/components/ui/HelpAndSupportButton.tsx new file mode 100644 index 00000000..f6426ee2 --- /dev/null +++ b/src/components/ui/HelpAndSupportButton.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@components/DropdownMenu"; +import { + ArrowUpRightIcon, + BookText, + CircleQuestionMark, + MailIcon, + MessageSquareShare, + MessagesSquareIcon, + TriangleAlert, +} from "lucide-react"; +import { useState } from "react"; +import Button from "@components/Button"; +import { cn } from "@utils/helpers"; +import SlackIcon from "@/assets/icons/SlackIcon"; +import { isNetBirdHosted } from "@utils/netbird"; + +export default function HelpAndSupportButton() { + const [dropdownOpen, setDropdownOpen] = useState(false); + + return ( + + + + + + +
+
+ Help and Support +
+
+
+ + +
+ + Documentation +
+ + + +
+ +
+ + Troubleshooting +
+ + + +
+ + {isNetBirdHosted() && ( + +
+ + support@netbird.io +
+
+ )} + + + + +
+ + NetBird Forum +
+ + + +
+ +
+ + NetBird Slack +
+ + + +
+ + + + +
+ + Feedback +
+ + + +
+
+
+ ); +} diff --git a/src/components/ui/NoResults.tsx b/src/components/ui/NoResults.tsx index 39466506..aa0a540e 100644 --- a/src/components/ui/NoResults.tsx +++ b/src/components/ui/NoResults.tsx @@ -14,7 +14,9 @@ type Props = { className?: string; hasFiltersApplied?: boolean; onResetFilters?: () => void; + contentClassName?: string; }; + export default function NoResults({ icon, title = "Could not find any results", @@ -23,6 +25,7 @@ export default function NoResults({ className, hasFiltersApplied = false, onResetFilters, + contentClassName, }: Props) { const router = useRouter(); const pathname = usePathname(); @@ -65,7 +68,9 @@ export default function NoResults({
-
+
{ - return dropdownOptions?.find((g) => g.name === group?.name); - }, [group, dropdownOptions]); + const options = dropdownOptions?.find((g) => g.name === group?.name); + return options ?? groups?.find((g) => g.name === group?.name); + }, [group, dropdownOptions, groups]); const peerCount = useMemo(() => { let peerCount = currentGroup?.peers_count ?? 0; diff --git a/src/components/ui/UserAvatar.tsx b/src/components/ui/UserAvatar.tsx index 58d61039..c2bb999f 100644 --- a/src/components/ui/UserAvatar.tsx +++ b/src/components/ui/UserAvatar.tsx @@ -1,7 +1,7 @@ import { cn, generateColorFromUser } from "@utils/helpers"; -import { Avatar } from "flowbite-react"; import * as React from "react"; import { useState } from "react"; +import Image from "next/image"; import { useApplicationContext } from "@/contexts/ApplicationProvider"; type Props = { @@ -13,26 +13,27 @@ export const UserAvatar = ({ size = "default" }: Props) => { const [pictureLoaded, setPictureLoaded] = useState(true); const getAvatarSize = () => { - if (size === "small") return "sm"; - if (size === "large") return "lg"; - return "md"; + if (size === "small") return 32; + if (size === "default") return 40; + if (size === "large") return 48; + return 35.2; }; - return pictureLoaded ? ( - setPictureLoaded(false)} - size={getAvatarSize()} - className={"shrink-0"} + width={getAvatarSize()} + height={getAvatarSize()} + className={"rounded-full"} /> ) : (
-
+
e.preventDefault()} + onPointerDownOutside={(e) => e.preventDefault()} >
-
+
+
diff --git a/src/layouts/Navigation.tsx b/src/layouts/Navigation.tsx index dabf6c90..38e1da74 100644 --- a/src/layouts/Navigation.tsx +++ b/src/layouts/Navigation.tsx @@ -143,6 +143,12 @@ export default function Navigation({ href={"/dns/nameservers"} visible={permission.nameservers.read} /> + [] = [ { diff --git a/src/modules/dns-nameservers/table/NameserverMatchDomainsCell.tsx b/src/modules/dns/nameservers/table/NameserverMatchDomainsCell.tsx similarity index 100% rename from src/modules/dns-nameservers/table/NameserverMatchDomainsCell.tsx rename to src/modules/dns/nameservers/table/NameserverMatchDomainsCell.tsx diff --git a/src/modules/dns-nameservers/table/NameserverNameCell.tsx b/src/modules/dns/nameservers/table/NameserverNameCell.tsx similarity index 100% rename from src/modules/dns-nameservers/table/NameserverNameCell.tsx rename to src/modules/dns/nameservers/table/NameserverNameCell.tsx diff --git a/src/modules/dns-nameservers/table/NameserverNameserversCell.tsx b/src/modules/dns/nameservers/table/NameserverNameserversCell.tsx similarity index 100% rename from src/modules/dns-nameservers/table/NameserverNameserversCell.tsx rename to src/modules/dns/nameservers/table/NameserverNameserversCell.tsx diff --git a/src/modules/dns/zones/DNSRecordModal.tsx b/src/modules/dns/zones/DNSRecordModal.tsx new file mode 100644 index 00000000..7e59b2b1 --- /dev/null +++ b/src/modules/dns/zones/DNSRecordModal.tsx @@ -0,0 +1,359 @@ +import Button from "@components/Button"; +import HelpText from "@components/HelpText"; +import InlineLink from "@components/InlineLink"; +import { Input } from "@components/Input"; +import { Label } from "@components/Label"; +import { + Modal, + ModalClose, + ModalContent, + ModalFooter, + ModalTrigger, +} from "@components/modal/Modal"; +import ModalHeader from "@components/modal/ModalHeader"; +import Paragraph from "@components/Paragraph"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@components/Select"; +import Separator from "@components/Separator"; +import { validator } from "@utils/helpers"; +import { Address4, Address6 } from "ip-address"; +import { ClockIcon, ExternalLinkIcon, GlobeIcon } from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { + DNS_RECORDS_DOCS_LINK, + DNSRecord, + DNSRecordType, + DNSZone, +} from "@/interfaces/DNS"; +import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; + +type Props = { + children?: React.ReactNode; + open: boolean; + onOpenChange: (open: boolean) => void; + zone: DNSZone; + record?: DNSRecord; +}; + +export default function DNSRecordModal({ + children, + open, + onOpenChange, + zone, + record, +}: Readonly) { + return ( + + {children && {children}} + {open && ( + onOpenChange(false)} + onSuccessAdded={() => { + setTimeout(() => { + const row = document.querySelector( + `[data-row-id="${zone.id}"]`, + ); + if (row?.getAttribute("data-accordion") === "closed") { + row?.click(); + } + row?.scrollIntoView({ behavior: "smooth" }); + }, 200); + onOpenChange(false); + }} + zone={zone} + record={record} + /> + )} + + ); +} + +type ModalProps = { + onSuccess?: () => void; + onSuccessAdded?: () => void; + zone: DNSZone; + record?: DNSRecord; +}; + +export function DNSRecordModalContent({ + onSuccess, + onSuccessAdded, + zone, + record, +}: Readonly) { + const { addRecord, updateRecord } = useDNSZones(); + + const getInitialDomain = () => { + if (!record) return ""; + if (record.name === zone.domain) return ""; + return record.name.replace(`.${zone.domain}`, ""); + }; + + const [domain, setDomain] = useState(record?.name ? getInitialDomain() : ""); + const [ttl, setTtl] = useState(record ? record.ttl.toString() : "300"); + const [type, setType] = useState(record?.type ?? "A"); + const [recordValue, setRecordValue] = useState(record?.content ?? ""); + + const domainError = useMemo(() => { + if (domain == "") return ""; + const valid = validator.isValidDomain(domain, { + allowWildcard: false, + allowOnlyTld: true, + }); + if (!valid) { + return "Please enter a valid domain, e.g. example.com or intra.example.com"; + } + }, [domain]); + + const ipv4Error = useMemo(() => { + if (recordValue === "" || type !== "A") return ""; + const valid = Address4.isValid(recordValue); + if (!valid) { + return "Please enter a valid IPv4 address, e.g. 192.168.1.1"; + } + }, [recordValue, type]); + + const ipv6Error = useMemo(() => { + if (recordValue === "" || type !== "AAAA") return ""; + const valid = Address6.isValid(recordValue); + if (!valid) { + return "Please enter a valid IPv6 address, e.g. 2001:0db8:85a3::8a2e:0370:7334"; + } + }, [recordValue, type]); + + const cnameError = useMemo(() => { + if (recordValue === "" || type !== "CNAME") return ""; + const valid = validator.isValidDomain(recordValue, { + allowWildcard: false, + allowOnlyTld: false, + }); + if (!valid) { + return "Please enter a valid domain, e.g. example.com or server.example.com"; + } + }, [recordValue, type]); + + const handleAddRecord = async () => { + const name = domain !== "" ? `${domain}.${zone.domain}` : zone.domain; + + if (record) { + updateRecord(zone, { + id: record.id, + name, + type, + content: recordValue, + ttl: parseInt(ttl), + }).then(onSuccess); + } else { + addRecord(zone, { + name, + type, + content: recordValue, + ttl: parseInt(ttl), + }).then(onSuccessAdded); + } + }; + + const canUpdateOrCreate = + !cnameError && + !ipv6Error && + !ipv4Error && + !domainError && + recordValue !== ""; + + return ( + + } + /> + +
+
+
+ + + Select the type of record you want to add + +
+
+ +
+
+
+ + + Enter a subdomain or leave empty to use the primary domain. + +
+ setDomain(e.target.value)} + /> +
+ .{zone.domain} +
+
+
+ +
+ {type === "A" && ( +
+ + setRecordValue(e.target.value)} + /> +
+ )} + + {type === "AAAA" && ( +
+ + setRecordValue(e.target.value)} + /> +
+ )} + + {type === "CNAME" && ( +
+ + setRecordValue(e.target.value)} + /> +
+ )} + +
+ +
+ +
+
+
+
+ + +
+ + Learn more about + + DNS Records + + + +
+ +
+ <> + + + + + +
+
+
+ ); +} + +export const getTTLLabel = (seconds: number): string => { + if (seconds < 60) return `${seconds} Sec.`; + if (seconds < 3600) { + const minutes = seconds / 60; + return minutes === 1 ? "1 Min." : `${minutes} Min.`; + } + if (seconds < 86400) { + const hours = seconds / 3600; + return hours === 1 ? "1 Hour" : `${hours} Hours`; + } + const days = seconds / 86400; + return days === 1 ? "1 Day" : `${days} Days`; +}; diff --git a/src/modules/dns/zones/DNSZoneModal.tsx b/src/modules/dns/zones/DNSZoneModal.tsx new file mode 100644 index 00000000..4767a6d6 --- /dev/null +++ b/src/modules/dns/zones/DNSZoneModal.tsx @@ -0,0 +1,225 @@ +import Button from "@components/Button"; +import FancyToggleSwitch from "@components/FancyToggleSwitch"; +import HelpText from "@components/HelpText"; +import InlineLink from "@components/InlineLink"; +import { Input } from "@components/Input"; +import { Label } from "@components/Label"; +import { + Modal, + ModalClose, + ModalContent, + ModalFooter, + ModalTrigger, +} from "@components/modal/Modal"; +import ModalHeader from "@components/modal/ModalHeader"; +import Paragraph from "@components/Paragraph"; +import { PeerGroupSelector } from "@components/PeerGroupSelector"; +import Separator from "@components/Separator"; +import { validator } from "@utils/helpers"; +import { ExternalLinkIcon, Power, ScanSearch } from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { DNS_ZONE_DOCS_LINK, DNSZone } from "@/interfaces/DNS"; +import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; +import useGroupHelper from "@/modules/groups/useGroupHelper"; +import { Group } from "@/interfaces/Group"; +import DNSZoneIcon from "@/assets/icons/DNSZoneIcon"; + +type Props = { + children?: React.ReactNode; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: (zone: DNSZone) => void; + onSuccessAdded?: (zone: DNSZone) => void; + initialDistributionGroups?: Group[]; + zone?: DNSZone; +}; + +export default function DNSZoneModal({ + children, + open, + onOpenChange, + onSuccess, + onSuccessAdded, + initialDistributionGroups, + zone, +}: Readonly) { + return ( + + {children && {children}} + {open && ( + { + onOpenChange(false); + onSuccess?.(z); + }} + onSuccessAdded={(z) => { + onOpenChange(false); + onSuccessAdded?.(z); + }} + zone={zone} + initialDistributionGroups={initialDistributionGroups} + /> + )} + + ); +} + +type ModalProps = { + onSuccess?: (zone: DNSZone) => void; + onSuccessAdded?: (zone: DNSZone) => void; + initialDistributionGroups?: Group[]; + zone?: DNSZone; +}; + +export function DNSZoneModalContent({ + onSuccess, + onSuccessAdded, + zone, + initialDistributionGroups, +}: Readonly) { + const { createZone, updateZone } = useDNSZones(); + const [domain, setDomain] = useState(zone?.domain ?? ""); + const [enabled, setEnabled] = useState(zone?.enabled ?? true); + const [searchDomainsEnabled, setSearchDomainsEnabled] = useState( + zone?.enable_search_domain ?? false, + ); + const [groups, setGroups, { save: saveGroups }] = useGroupHelper({ + initial: initialDistributionGroups ?? zone?.distribution_groups ?? [], + }); + + const domainError = useMemo(() => { + if (domain == "") return ""; + const valid = validator.isValidDomain(domain, { + allowWildcard: false, + allowOnlyTld: false, + }); + if (!valid) { + return "Please enter a valid domain, e.g. company.internal or intra.example.com"; + } + }, [domain]); + + const handleOnSubmit = async () => { + return saveGroups().then((distributionGroups) => { + const groupIds = distributionGroups.map((group) => group.id as string); + + if (zone) { + updateZone({ + id: zone.id, + domain, + name: domain, + distribution_groups: groupIds, + enabled, + enable_search_domain: searchDomainsEnabled, + } as DNSZone).then(onSuccess); + } else { + createZone({ + domain, + name: domain, + distribution_groups: groupIds, + enabled, + enable_search_domain: searchDomainsEnabled, + } as DNSZone).then(onSuccessAdded); + } + }); + }; + + const canUpdateOrCreate = !domainError && groups?.length > 0 && domain !== ""; + + return ( + + } + title={zone ? "Update DNS Zone" : "Add DNS Zone"} + description={ + "Use a zone to control domain name resolution for your network." + } + color={"netbird"} + /> + + + +
+
+ + + Enter a domain for this zone (e.g., company.internal, + intra.example.com) + + setDomain(e.target.value)} + /> +
+
+ + + Advertise this zone and its records to peers that belong to the + following groups + + +
+ + + + Enable Search Domains + + } + helpText={ + "E.g., 'server.company.internal' will be accessible with 'server'" + } + /> + + + + Enable DNS Zone + + } + helpText={"Use this switch to enable or disable the dns zone."} + /> +
+ + +
+ + Learn more about + + DNS Zones + + + +
+
+ + + + +
+
+
+ ); +} diff --git a/src/modules/dns/zones/DNSZonesProvider.tsx b/src/modules/dns/zones/DNSZonesProvider.tsx new file mode 100644 index 00000000..c3b3e4d3 --- /dev/null +++ b/src/modules/dns/zones/DNSZonesProvider.tsx @@ -0,0 +1,264 @@ +import { notify } from "@components/Notification"; +import { useApiCall } from "@utils/api"; +import * as React from "react"; +import { useState } from "react"; +import { useSWRConfig } from "swr"; +import { useDialog } from "@/contexts/DialogProvider"; +import { DNSRecord, DNSZone } from "@/interfaces/DNS"; +import { Group } from "@/interfaces/Group"; +import DNSRecordModal from "@/modules/dns/zones/DNSRecordModal"; +import DNSZoneModal from "@/modules/dns/zones/DNSZoneModal"; + +type Props = { + children?: React.ReactNode; +}; + +const DNSZonesContext = React.createContext( + {} as { + createZone: (zone: DNSZone) => Promise; + updateZone: (zone: DNSZone) => Promise; + deleteZone: (zone: DNSZone) => Promise; + openZoneModal: ( + zone?: DNSZone, + initialDistributionGroups?: Group[], + ) => void; + openRecordModal: (zone: DNSZone, record?: DNSRecord) => void; + addRecord: (zone: DNSZone, record: DNSRecord) => Promise; + updateRecord: (zone: DNSZone, record: DNSRecord) => Promise; + deleteRecord: (zone: DNSZone, record: DNSRecord) => Promise; + askForRecord: (zone: DNSZone) => void; + }, +); + +export const DNSZonesProvider = ({ children }: Props) => { + const { mutate } = useSWRConfig(); + const zoneRequest = useApiCall("/dns/zones", true); + const recordRequest = useApiCall("/dns/zones", true); + const [dnsModal, setDnsModal] = useState(false); + const [recordModal, setRecordModal] = useState(false); + const [currentZone, setCurrentZone] = useState(); + const [currentRecord, setCurrentRecord] = useState(); + const [initialDistributionGroups, setInitialDistributionGroups] = + useState(); + const { confirm } = useDialog(); + + const createZone = async (zone: DNSZone): Promise => { + const promise = zoneRequest.post(zone).then((zone) => { + mutate("/dns/zones"); + return Promise.resolve(zone); + }); + + notify({ + title: `DNS Zone '${zone.domain}'`, + description: `DNS Zone was added successfully.`, + promise: promise, + loadingMessage: "Adding DNS Zone...", + }); + + return promise; + }; + + const updateZone = async (zone: DNSZone): Promise => { + if (!zone?.id) return Promise.reject("Can not update DNS Zone without ID"); + const promise = zoneRequest.put(zone, `/${zone.id}`).then((zone) => { + mutate("/dns/zones"); + return Promise.resolve(zone); + }); + + notify({ + title: `DNS Zone '${zone.domain}'`, + description: `DNS Zone was updated successfully.`, + promise: promise, + loadingMessage: "Updating DNS Zone...", + }); + + return promise; + }; + + const deleteZone = async (zone: DNSZone): Promise => { + if (!zone?.id) return Promise.reject("Can not delete DNS Zone without ID"); + + const choice = await confirm({ + title: `Delete zone '${zone.domain}'?`, + description: + "Are you sure you want to delete this zone? This action cannot be undone.", + confirmText: "Delete", + cancelText: "Cancel", + type: "danger", + maxWidthClass: "max-w-md", + }); + if (!choice) return Promise.resolve(zone); + + const promise = zoneRequest.del({}, `/${zone.id}`).then((zone) => { + mutate("/dns/zones"); + return Promise.resolve(zone); + }); + + notify({ + title: `DNS Zone '${zone.domain}'`, + description: `DNS Zone was deleted successfully.`, + promise: promise, + loadingMessage: "Deleting DNS Zone...", + }); + + return promise; + }; + + const addRecord = async ( + zone: DNSZone, + record: DNSRecord, + ): Promise => { + if (!zone?.id) + return Promise.reject("Can not add DNS Record without DNS Zone"); + const promise = recordRequest + .post(record, `/${zone.id}/records`) + .then((record) => { + mutate("/dns/zones"); + return Promise.resolve(record); + }); + + notify({ + title: `${record.type} Record '${record.name}'`, + description: `DNS Record was added successfully.`, + promise: promise, + loadingMessage: "Adding DNS Record...", + }); + + return promise; + }; + + const updateRecord = async ( + zone: DNSZone, + record: DNSRecord, + ): Promise => { + if (!zone?.id) + return Promise.reject("Can not update DNS Record without DNS Zone"); + if (!record?.id) + return Promise.reject("Can not update DNS Record without ID"); + const promise = recordRequest + .put(record, `/${zone.id}/records/${record.id}`) + .then((record) => { + mutate("/dns/zones"); + return Promise.resolve(record); + }); + + notify({ + title: `${record.type} Record '${record.name}'`, + description: `DNS Record was updated successfully.`, + promise: promise, + loadingMessage: "Updating DNS Record...", + }); + + return promise; + }; + + const deleteRecord = async ( + zone: DNSZone, + record: DNSRecord, + ): Promise => { + if (!zone?.id) + return Promise.reject("Can not delete DNS Record without DNS Zone"); + if (!record?.id) + return Promise.reject("Can not delete DNS Record without ID"); + + const choice = await confirm({ + title: `Delete record '${record.name}'?`, + description: + "Are you sure you want to delete this record? This action cannot be undone.", + confirmText: "Delete", + cancelText: "Cancel", + type: "danger", + maxWidthClass: "max-w-md", + }); + if (!choice) return Promise.resolve(record); + + const promise = recordRequest + .del({}, `/${zone.id}/records/${record.id}`) + .then((record) => { + mutate("/dns/zones"); + return Promise.resolve(record); + }); + + notify({ + title: `${record.type} Record '${record.name}'`, + description: `DNS Record was deleted successfully.`, + promise: promise, + loadingMessage: "Deleting DNS Record...", + }); + + return promise; + }; + + const openZoneModal = (zone?: DNSZone, distributionGroups?: Group[]) => { + if (zone) setCurrentZone(zone); + if (distributionGroups) setInitialDistributionGroups(distributionGroups); + setDnsModal(true); + }; + + const openRecordModal = (zone: DNSZone, record?: DNSRecord) => { + setCurrentZone(zone); + if (record) setCurrentRecord(record); + setRecordModal(true); + }; + + const askForRecord = async (zone: DNSZone) => { + const choice = await confirm({ + title: `Add new record to '${zone.name}'?`, + description: + "Add either an A, AAAA or a CNAME record to control domain name resolution for your network.", + confirmText: "Add Record", + cancelText: "Later", + type: "default", + maxWidthClass: "max-w-md", + }); + if (!choice) return; + openRecordModal(zone); + }; + + return ( + + {children} + { + setDnsModal(open); + if (!open) { + setCurrentZone(undefined); + setInitialDistributionGroups(undefined); + } + }} + onSuccessAdded={(z) => askForRecord(z)} + zone={currentZone} + initialDistributionGroups={initialDistributionGroups} + /> + {currentZone && ( + { + setRecordModal(open); + if (!open) { + setCurrentZone(undefined); + setCurrentRecord(undefined); + } + }} + zone={currentZone} + record={currentRecord} + /> + )} + + ); +}; + +export const useDNSZones = () => React.useContext(DNSZonesContext); diff --git a/src/modules/dns/zones/records/DNSRecordActionCell.tsx b/src/modules/dns/zones/records/DNSRecordActionCell.tsx new file mode 100644 index 00000000..399206ee --- /dev/null +++ b/src/modules/dns/zones/records/DNSRecordActionCell.tsx @@ -0,0 +1,40 @@ +import Button from "@components/Button"; +import { PenSquare, Trash2 } from "lucide-react"; +import * as React from "react"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { DNSRecord } from "@/interfaces/DNS"; +import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; +import { useDNSZone } from "@/modules/dns/zones/records/DNSRecordsTable"; + +type Props = { + record: DNSRecord; +}; + +export const DNSRecordActionCell = ({ record }: Props) => { + const { permission } = usePermissions(); + const { deleteRecord, openRecordModal } = useDNSZones(); + const zone = useDNSZone(); + + return ( +
+ + +
+ ); +}; diff --git a/src/modules/dns/zones/records/DNSRecordContentCell.tsx b/src/modules/dns/zones/records/DNSRecordContentCell.tsx new file mode 100644 index 00000000..7e04974d --- /dev/null +++ b/src/modules/dns/zones/records/DNSRecordContentCell.tsx @@ -0,0 +1,19 @@ +import CopyToClipboardText from "@components/CopyToClipboardText"; +import * as React from "react"; +import { DNSRecord } from "@/interfaces/DNS"; + +type Props = { + record: DNSRecord; +}; + +export const DNSRecordContentCell = ({ record }: Props) => { + return ( +
+ + + {record.content} + + +
+ ); +}; diff --git a/src/modules/dns/zones/records/DNSRecordNameCell.tsx b/src/modules/dns/zones/records/DNSRecordNameCell.tsx new file mode 100644 index 00000000..3eed5c78 --- /dev/null +++ b/src/modules/dns/zones/records/DNSRecordNameCell.tsx @@ -0,0 +1,17 @@ +import CopyToClipboardText from "@components/CopyToClipboardText"; +import * as React from "react"; +import { DNSRecord } from "@/interfaces/DNS"; + +type Props = { + record: DNSRecord; +}; + +export const DNSRecordNameCell = ({ record }: Props) => { + return ( +
+ + {record.name} + +
+ ); +}; diff --git a/src/modules/dns/zones/records/DNSRecordTimeToLiveCell.tsx b/src/modules/dns/zones/records/DNSRecordTimeToLiveCell.tsx new file mode 100644 index 00000000..0dccd9bf --- /dev/null +++ b/src/modules/dns/zones/records/DNSRecordTimeToLiveCell.tsx @@ -0,0 +1,21 @@ +import { ClockIcon } from "lucide-react"; +import * as React from "react"; +import { DNSRecord } from "@/interfaces/DNS"; +import { getTTLLabel } from "@/modules/dns/zones/DNSRecordModal"; + +type Props = { + record: DNSRecord; +}; + +export const DNSRecordTimeToLiveCell = ({ record }: Props) => { + return ( +
+ + {getTTLLabel(record.ttl)} +
+ ); +}; diff --git a/src/modules/dns/zones/records/DNSRecordTypeCell.tsx b/src/modules/dns/zones/records/DNSRecordTypeCell.tsx new file mode 100644 index 00000000..5b8faaf9 --- /dev/null +++ b/src/modules/dns/zones/records/DNSRecordTypeCell.tsx @@ -0,0 +1,20 @@ +import Badge from "@components/Badge"; +import * as React from "react"; +import { DNSRecord } from "@/interfaces/DNS"; + +type Props = { + record: DNSRecord; +}; + +export const DNSRecordTypeCell = ({ record }: Props) => { + return ( +
+ + {record.type} + +
+ ); +}; diff --git a/src/modules/dns/zones/records/DNSRecordsTable.tsx b/src/modules/dns/zones/records/DNSRecordsTable.tsx new file mode 100644 index 00000000..ff3fa302 --- /dev/null +++ b/src/modules/dns/zones/records/DNSRecordsTable.tsx @@ -0,0 +1,80 @@ +import { DataTable } from "@components/table/DataTable"; +import DataTableHeader from "@components/table/DataTableHeader"; +import { ColumnDef, SortingState } from "@tanstack/react-table"; +import React, { createContext, useContext, useState } from "react"; +import { DNSRecord, DNSZone } from "@/interfaces/DNS"; +import { DNSRecordActionCell } from "@/modules/dns/zones/records/DNSRecordActionCell"; +import { DNSRecordContentCell } from "@/modules/dns/zones/records/DNSRecordContentCell"; +import { DNSRecordNameCell } from "@/modules/dns/zones/records/DNSRecordNameCell"; +import { DNSRecordTimeToLiveCell } from "@/modules/dns/zones/records/DNSRecordTimeToLiveCell"; +import { DNSRecordTypeCell } from "@/modules/dns/zones/records/DNSRecordTypeCell"; + +type Props = { + zone: DNSZone; +}; + +export const DNSRecordsTableColumns: ColumnDef[] = [ + { + accessorKey: "type", + header: ({ column }) => { + return Type; + }, + cell: ({ row }) => , + }, + { + accessorKey: "name", + header: ({ column }) => { + return Hostname; + }, + cell: ({ row }) => , + }, + { + accessorKey: "content", + header: ({ column }) => { + return Content; + }, + cell: ({ row }) => , + }, + { + accessorKey: "ttl", + header: ({ column }) => { + return TTL; + }, + cell: ({ row }) => , + }, + { + accessorKey: "id", + header: "", + cell: ({ row }) => , + }, +]; + +const ZoneContext = createContext({} as DNSZone); + +export default function DNSRecordsTable({ zone }: Props) { + const [sorting, setSorting] = useState([]); + + return ( + + + + ); +} + +export const useDNSZone = () => useContext(ZoneContext); diff --git a/src/modules/dns/zones/table/DNSZonesActionCell.tsx b/src/modules/dns/zones/table/DNSZonesActionCell.tsx new file mode 100644 index 00000000..aa1a490d --- /dev/null +++ b/src/modules/dns/zones/table/DNSZonesActionCell.tsx @@ -0,0 +1,58 @@ +import Button from "@components/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@components/DropdownMenu"; +import { MoreVertical, SquarePenIcon, Trash2 } from "lucide-react"; +import * as React from "react"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { DNSZone } from "@/interfaces/DNS"; +import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; + +type Props = { + zone: DNSZone; +}; + +export const DNSZonesActionCell = ({ zone }: Props) => { + const { permission } = usePermissions(); + const { openZoneModal, deleteZone } = useDNSZones(); + + return ( +
+ + { + e.stopPropagation(); + e.preventDefault(); + }} + > + + + + openZoneModal(zone)}> +
+ + Edit +
+
+ + deleteZone(zone)} + variant={"danger"} + disabled={!permission?.dns?.delete} + > +
+ + Delete +
+
+
+
+
+ ); +}; diff --git a/src/modules/dns/zones/table/DNSZonesActiveCell.tsx b/src/modules/dns/zones/table/DNSZonesActiveCell.tsx new file mode 100644 index 00000000..35daa15e --- /dev/null +++ b/src/modules/dns/zones/table/DNSZonesActiveCell.tsx @@ -0,0 +1,32 @@ +import { ToggleSwitch } from "@components/ToggleSwitch"; +import * as React from "react"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { DNSZone } from "@/interfaces/DNS"; +import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; + +type Props = { + zone: DNSZone; +}; + +export const DNSZonesActiveCell = ({ zone }: Props) => { + const { permission } = usePermissions(); + const { updateZone } = useDNSZones(); + + return ( +
+ { + e.preventDefault(); + e.stopPropagation(); + updateZone({ + ...zone, + enabled: !zone.enabled, + }); + }} + /> +
+ ); +}; diff --git a/src/modules/dns/zones/table/DNSZonesGroupCell.tsx b/src/modules/dns/zones/table/DNSZonesGroupCell.tsx new file mode 100644 index 00000000..3b3fa487 --- /dev/null +++ b/src/modules/dns/zones/table/DNSZonesGroupCell.tsx @@ -0,0 +1,60 @@ +import * as React from "react"; +import { useMemo, useState } from "react"; +import { useGroups } from "@/contexts/GroupsProvider"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { DNSZone } from "@/interfaces/DNS"; +import { Group } from "@/interfaces/Group"; +import EmptyRow from "@/modules/common-table-rows/EmptyRow"; +import GroupsRow from "@/modules/common-table-rows/GroupsRow"; +import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; + +type Props = { + zone: DNSZone; +}; + +export const DNSZonesGroupCell = ({ zone }: Props) => { + const { groups } = useGroups(); + const { updateZone } = useDNSZones(); + const [modal, setModal] = useState(false); + const { permission } = usePermissions(); + + const allGroups = zone?.distribution_groups + .map((group) => { + return groups?.find((g) => g.id == group); + }) + .filter((g) => g != undefined) as Group[]; + + const groupIDs = useMemo(() => { + return allGroups + ?.map((group) => group.id) + .filter((id) => id !== undefined) as string[]; + }, [allGroups]); + + const handleSave = async (promises: Promise[]) => { + const groups = await Promise.all(promises); + const groupIds = groups?.map((g) => g.id as string); + await updateZone({ + ...zone, + distribution_groups: groupIds, + }).then(() => { + setModal(false); + }); + }; + + if (!zone?.distribution_groups) return ; + + return ( + + ); +}; diff --git a/src/modules/dns/zones/table/DNSZonesNameCell.tsx b/src/modules/dns/zones/table/DNSZonesNameCell.tsx new file mode 100644 index 00000000..d534c03d --- /dev/null +++ b/src/modules/dns/zones/table/DNSZonesNameCell.tsx @@ -0,0 +1,38 @@ +import { cn } from "@utils/helpers"; +import { ChevronDown, ChevronRightIcon } from "lucide-react"; +import * as React from "react"; +import { DNSZone } from "@/interfaces/DNS"; +import ActiveInactiveRow from "@/modules/common-table-rows/ActiveInactiveRow"; + +type Props = { + zone: DNSZone; +}; + +export const DNSZonesNameCell = ({ zone }: Props) => { + const hasRecords = (zone?.records?.length ?? 0) > 0; + + return ( +
+ + + +
+ ); +}; diff --git a/src/modules/dns/zones/table/DNSZonesRecordsCell.tsx b/src/modules/dns/zones/table/DNSZonesRecordsCell.tsx new file mode 100644 index 00000000..2607f428 --- /dev/null +++ b/src/modules/dns/zones/table/DNSZonesRecordsCell.tsx @@ -0,0 +1,47 @@ +import Badge from "@components/Badge"; +import Button from "@components/Button"; +import { GlobeIcon, PlusCircle } from "lucide-react"; +import * as React from "react"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { DNSZone } from "@/interfaces/DNS"; +import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; + +type Props = { + zone: DNSZone; +}; + +export const DNSZonesRecordsCell = ({ zone }: Props) => { + const { permission } = usePermissions(); + const { openRecordModal } = useDNSZones(); + + const recordsCount = zone?.records?.length ?? 0; + + return ( +
+ {recordsCount > 0 && ( + void 0} + > + +
+ {recordsCount} +
+
+ )} + + +
+ ); +}; diff --git a/src/modules/dns/zones/table/DNSZonesSearchDomainCell.tsx b/src/modules/dns/zones/table/DNSZonesSearchDomainCell.tsx new file mode 100644 index 00000000..2fd613d4 --- /dev/null +++ b/src/modules/dns/zones/table/DNSZonesSearchDomainCell.tsx @@ -0,0 +1,32 @@ +import { ToggleSwitch } from "@components/ToggleSwitch"; +import * as React from "react"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { DNSZone } from "@/interfaces/DNS"; +import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; + +type Props = { + zone: DNSZone; +}; + +export const DNSZonesSearchDomainCell = ({ zone }: Props) => { + const { permission } = usePermissions(); + const { updateZone } = useDNSZones(); + + return ( +
+ { + e.preventDefault(); + e.stopPropagation(); + updateZone({ + ...zone, + enable_search_domain: !zone.enable_search_domain, + }); + }} + /> +
+ ); +}; diff --git a/src/modules/dns/zones/table/DNSZonesTable.tsx b/src/modules/dns/zones/table/DNSZonesTable.tsx new file mode 100644 index 00000000..ff7e946f --- /dev/null +++ b/src/modules/dns/zones/table/DNSZonesTable.tsx @@ -0,0 +1,303 @@ +import Button from "@components/Button"; +import ButtonGroup from "@components/ButtonGroup"; +import Card from "@components/Card"; +import InlineLink from "@components/InlineLink"; +import SquareIcon from "@components/SquareIcon"; +import { DataTable } from "@components/table/DataTable"; +import DataTableHeader from "@components/table/DataTableHeader"; +import DataTableRefreshButton from "@components/table/DataTableRefreshButton"; +import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage"; +import GetStartedTest from "@components/ui/GetStartedTest"; +import NoResults from "@components/ui/NoResults"; +import { ColumnDef, SortingState } from "@tanstack/react-table"; +import { ExternalLinkIcon, PlusCircle } from "lucide-react"; +import { usePathname } from "next/navigation"; +import React, { useMemo } from "react"; +import { useSWRConfig } from "swr"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { useLocalStorage } from "@/hooks/useLocalStorage"; +import { DNS_ZONE_DOCS_LINK, DNSZone } from "@/interfaces/DNS"; +import { useDNSZones } from "@/modules/dns/zones/DNSZonesProvider"; +import DNSRecordsTable from "@/modules/dns/zones/records/DNSRecordsTable"; +import { DNSZonesActionCell } from "@/modules/dns/zones/table/DNSZonesActionCell"; +import { DNSZonesActiveCell } from "@/modules/dns/zones/table/DNSZonesActiveCell"; +import { DNSZonesGroupCell } from "@/modules/dns/zones/table/DNSZonesGroupCell"; +import { DNSZonesNameCell } from "@/modules/dns/zones/table/DNSZonesNameCell"; +import { DNSZonesRecordsCell } from "@/modules/dns/zones/table/DNSZonesRecordsCell"; +import { DNSZonesSearchDomainCell } from "@/modules/dns/zones/table/DNSZonesSearchDomainCell"; +import { Group } from "@/interfaces/Group"; +import DNSZoneIcon from "@/assets/icons/DNSZoneIcon"; +import { useGroups } from "@/contexts/GroupsProvider"; + +export const DNSZonesColumns: ColumnDef[] = [ + { + accessorKey: "domain", + header: ({ column }) => ( + Zone + ), + sortingFn: "text", + cell: ({ row }) => , + }, + { + accessorKey: "enabled", + header: ({ column }) => ( + Active + ), + cell: ({ row }) => , + }, + { + accessorKey: "records", + header: ({ column }) => ( + Records + ), + sortingFn: "text", + cell: ({ row }) => , + }, + { + accessorKey: "distribution_groups", + header: ({ column }) => ( + Distribution Groups + ), + cell: ({ row }) => , + }, + { + accessorKey: "enable_search_domain", + header: ({ column }) => ( + Search Domain + ), + cell: ({ row }) => , + }, + { + accessorKey: "id", + header: () => "", + cell: ({ row }) => , + }, + { + id: "searchString", + accessorFn: (row) => { + return [ + row?.groups_search, + row?.name, + row?.domain, + row?.records?.map((r) => r.name).join(""), + row?.records?.map((r) => r.content).join(""), + row?.records?.map((r) => r.type).join(""), + ]?.join(""); + }, + }, +]; + +type Props = { + isLoading: boolean; + data?: DNSZone[]; + headingTarget?: HTMLHeadingElement | null; + isGroupPage?: boolean; + distributionGroups?: Group[]; +}; + +export default function DNSZonesTable({ + data, + isLoading, + headingTarget, + isGroupPage = false, + distributionGroups, +}: Props) { + const { mutate } = useSWRConfig(); + const path = usePathname(); + const { groups } = useGroups(); + + // Default sorting state of the table + const [sorting, setSorting] = useLocalStorage( + "netbird-table-sort" + path, + [ + { + id: "domain", + desc: true, + }, + { + id: "id", + desc: true, + }, + ], + !isGroupPage, + ); + + const zonesWithGroups = useMemo(() => { + return ( + data?.map((zone) => { + return { + ...zone, + groups_search: groups + ?.map((g) => + zone?.distribution_groups?.includes(g?.id ?? "") ? g.name : "", + ) + .join(""), + } as DNSZone; + }) ?? [] + ); + }, [data, groups]); + + return ( + { + const hasRecords = (zone?.records?.length ?? 0) > 0; + if (!hasRecords) return; + return ( + <> + +
+ + ); + }} + getStartedCard={ + isGroupPage ? ( + } + className={"py-4"} + contentClassName={"max-w-lg"} + title={"This group is not used within any zones yet"} + description={ + "Assign this group as a distribution group in your zones to see them listed here." + } + > +
+ +
+
+ ) : ( + } + color={"gray"} + size={"large"} + /> + } + title={"Create New Zone"} + description={ + "It looks like you don't have any zones. Control domain name resolution for your network by adding a zone." + } + button={ +
+ +
+ } + learnMore={ + <> + Learn more about + + DNS Zones + + + + } + /> + ) + } + rightSide={() => ( + <> + {data && data?.length > 0 && ( +
+ +
+ )} + + )} + > + {(table) => ( + <> + + { + table.setPageIndex(0); + table.getColumn("enabled")?.setFilterValue(undefined); + }} + disabled={data?.length == 0} + variant={ + table.getColumn("enabled")?.getFilterValue() === undefined + ? "tertiary" + : "secondary" + } + > + All + + { + table.setPageIndex(0); + table.getColumn("enabled")?.setFilterValue(true); + }} + disabled={data?.length == 0} + variant={ + table.getColumn("enabled")?.getFilterValue() === true + ? "tertiary" + : "secondary" + } + > + Active + + { + table.setPageIndex(0); + table.getColumn("enabled")?.setFilterValue(false); + }} + disabled={data?.length == 0} + variant={ + table.getColumn("enabled")?.getFilterValue() === false + ? "tertiary" + : "secondary" + } + > + Inactive + + + + { + mutate("/dns/zones").then(); + mutate("/groups").then(); + }} + /> + + )} +
+ ); +} + +type AddZoneButtonProps = { + distributionGroups?: Group[]; +}; + +const AddZoneButton = ({ distributionGroups }: AddZoneButtonProps) => { + const { permission } = usePermissions(); + const { openZoneModal } = useDNSZones(); + + return ( + + ); +}; diff --git a/src/modules/groups/details/GroupDNSZonesSection.tsx b/src/modules/groups/details/GroupDNSZonesSection.tsx new file mode 100644 index 00000000..fecac435 --- /dev/null +++ b/src/modules/groups/details/GroupDNSZonesSection.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { useGroupContext } from "@/contexts/GroupProvider"; +import { DNSZone } from "@/interfaces/DNS"; +import { DNSZonesProvider } from "@/modules/dns/zones/DNSZonesProvider"; +import DNSZonesTable from "@/modules/dns/zones/table/DNSZonesTable"; +import { GroupDetailsTableContainer } from "@/modules/groups/details/GroupDetailsTableContainer"; + +export const GroupDNSZonesSection = ({ + zones, + isLoading = true, +}: { + zones?: DNSZone[]; + isLoading?: boolean; +}) => { + const { group } = useGroupContext(); + + return ( + + + + + + ); +}; diff --git a/src/modules/groups/details/GroupNameserversSection.tsx b/src/modules/groups/details/GroupNameserversSection.tsx index f4bd7014..15a1fa85 100644 --- a/src/modules/groups/details/GroupNameserversSection.tsx +++ b/src/modules/groups/details/GroupNameserversSection.tsx @@ -4,7 +4,7 @@ import { NameserverGroup } from "@/interfaces/Nameserver"; import { GroupDetailsTableContainer } from "@/modules/groups/details/GroupDetailsTableContainer"; const NameserverGroupTable = lazy( - () => import("@/modules/dns-nameservers/table/NameserverGroupTable"), + () => import("@/modules/dns/nameservers/table/NameserverGroupTable"), ); type Props = { diff --git a/src/modules/groups/details/useGroupDetails.ts b/src/modules/groups/details/useGroupDetails.ts index ad66e008..4f0bd9c9 100644 --- a/src/modules/groups/details/useGroupDetails.ts +++ b/src/modules/groups/details/useGroupDetails.ts @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { DNSZone } from "@/interfaces/DNS"; import { Group, GroupPeer, GroupResource } from "@/interfaces/Group"; import { NameserverGroup } from "@/interfaces/Nameserver"; import { @@ -16,6 +17,7 @@ import useFetchApi from "@/utils/api"; export interface GroupDetails extends Group { policies: Policy[]; nameservers: NameserverGroup[]; + zones?: DNSZone[]; routes: Route[]; setupKeys: SetupKey[]; users: User[]; @@ -31,6 +33,8 @@ export default function useGroupDetails(groupId: string) { useFetchApi(`/policies`); const { data: nameservers, isLoading: isNameserversLoading } = useFetchApi(`/dns/nameservers`); + const { data: zones, isLoading: isZonesLoading } = + useFetchApi(`/dns/zones`); const { data: routes, isLoading: isRoutesLoading } = useFetchApi(`/routes`); const { data: setupKeys, isLoading: isSetupKeysLoading } = @@ -65,6 +69,12 @@ export default function useGroupDetails(groupId: string) { return nameservers?.filter((ns) => ns.groups?.includes(groupId)) || []; }, [nameservers, groupId]); + const linkedZones = useMemo(() => { + return ( + zones?.filter((ns) => ns.distribution_groups?.includes(groupId)) || [] + ); + }, [zones, groupId]); + const linkedRoutes = useMemo(() => { return ( routes?.filter((route) => { @@ -117,6 +127,7 @@ export default function useGroupDetails(groupId: string) { isGroupsLoading || isPoliciesLoading || isNameserversLoading || + isZonesLoading || isRoutesLoading || isSetupKeysLoading || isUsersLoading || @@ -131,6 +142,7 @@ export default function useGroupDetails(groupId: string) { ...group, policies: linkedPolicies, nameservers: linkedNameservers, + zones: linkedZones, routes: linkedRoutes, setupKeys: linkedSetupKeys, users: linkedUsers, @@ -142,6 +154,7 @@ export default function useGroupDetails(groupId: string) { group, linkedPolicies, linkedNameservers, + linkedZones, linkedRoutes, linkedSetupKeys, linkedUsers, diff --git a/src/modules/groups/table/GroupsTable.tsx b/src/modules/groups/table/GroupsTable.tsx index e0894bfe..67addd2a 100644 --- a/src/modules/groups/table/GroupsTable.tsx +++ b/src/modules/groups/table/GroupsTable.tsx @@ -3,6 +3,7 @@ import { DataTable } from "@components/table/DataTable"; import DataTableHeader from "@components/table/DataTableHeader"; import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage"; import { ColumnDef, SortingState } from "@tanstack/react-table"; +import { removeAllSpaces } from "@utils/helpers"; import { Layers3Icon } from "lucide-react"; import { usePathname } from "next/navigation"; import React from "react"; @@ -19,7 +20,7 @@ import GroupsActionCell from "@/modules/groups/table/GroupsActionCell"; import GroupsCountCell from "@/modules/groups/table/GroupsCountCell"; import GroupsNameCell from "@/modules/groups/table/GroupsNameCell"; import useGroupsUsage, { GroupUsage } from "@/modules/groups/useGroupsUsage"; -import { removeAllSpaces } from "@utils/helpers"; +import DNSZoneIcon from "@/assets/icons/DNSZoneIcon"; export const GroupsTableColumns: ColumnDef[] = [ { @@ -178,6 +179,28 @@ export const GroupsTableColumns: ColumnDef[] = [ /> ), }, + { + accessorKey: "zones_count", + header: ({ column }) => { + return ( + Zones
} + > + + + ); + }, + cell: ({ row }) => ( + } + groupName={row.original.name} + href={`/group?id=${row.original.id}&tab=zones`} + text={"Zone(s)"} + count={row.original.zones_count} + /> + ), + }, { accessorKey: "setup_keys_count", header: ({ column }) => { @@ -216,7 +239,8 @@ export const GroupsTableColumns: ColumnDef[] = [ row.routes_count > 0 || row.setup_keys_count > 0 || row.users_count > 0 || - row.resources_count > 0 + row.resources_count > 0 || + row.zones_count ); }, }, diff --git a/src/modules/groups/useGroupsUsage.tsx b/src/modules/groups/useGroupsUsage.tsx index 22fef68d..1fb5e5bc 100644 --- a/src/modules/groups/useGroupsUsage.tsx +++ b/src/modules/groups/useGroupsUsage.tsx @@ -1,5 +1,6 @@ import useFetchApi from "@utils/api"; import { useMemo } from "react"; +import { DNSZone } from "@/interfaces/DNS"; import { Group } from "@/interfaces/Group"; import { NameserverGroup } from "@/interfaces/Nameserver"; import { Policy } from "@/interfaces/Policy"; @@ -11,6 +12,7 @@ export interface GroupUsage extends Group { peers_count: number; policies_count: number; nameservers_count: number; + zones_count: number; routes_count: number; setup_keys_count: number; users_count: number; @@ -24,6 +26,8 @@ export default function useGroupsUsage() { useFetchApi(`/policies`); // Policies const { data: nameservers, isLoading: isNameserversLoading } = useFetchApi(`/dns/nameservers`); // DNS + const { data: zones, isLoading: isZonesLoading } = + useFetchApi(`/dns/zones`); // DNS Zones const { data: routes, isLoading: isRoutesLoading } = useFetchApi(`/routes`); // Routes const { data: setupKeys, isLoading: isSetupKeysLoading } = @@ -57,6 +61,14 @@ export default function useGroupsUsage() { .filter((u) => u !== undefined); }, [nameservers, isNameserversLoading]); + const zonesGroups = useMemo(() => { + if (isZonesLoading) return; + if (!zones) return []; + return zones + ?.map((zone) => zone.distribution_groups) + .filter((u) => u !== undefined); + }, [zones, isZonesLoading]); + const setupKeysGroups = useMemo(() => { if (isSetupKeysLoading) return; if (!setupKeys) return []; @@ -78,6 +90,7 @@ export default function useGroupsUsage() { isGroupsLoading || isPoliciesLoading || isNameserversLoading || + isZonesLoading || isRoutesLoading || isSetupKeysLoading || isUsersLoading @@ -86,6 +99,7 @@ export default function useGroupsUsage() { isGroupsLoading, isPoliciesLoading, isNameserversLoading, + isZonesLoading, isRoutesLoading, isSetupKeysLoading, isUsersLoading, @@ -104,6 +118,10 @@ export default function useGroupsUsage() { return nameserver.includes(group.id as string); }).length; + const zonesCount = zonesGroups?.filter((zone) => { + return zone.includes(group.id as string); + }).length; + const routeCount = ( routes?.filter((route) => { const groupId = group.id as string; @@ -133,6 +151,7 @@ export default function useGroupsUsage() { resources_count: group.resources_count, policies_count: policyCount, nameservers_count: nameserverCount, + zones_count: zonesCount, routes_count: routeCount, setup_keys_count: setupKeyCount, users_count: userCount, @@ -143,6 +162,7 @@ export default function useGroupsUsage() { groups, policiesGroups, nameserversGroups, + zonesGroups, routes, isRoutesLoading, setupKeysGroups, diff --git a/src/modules/peer/PeerExpirationSettings.tsx b/src/modules/peer/PeerExpirationSettings.tsx new file mode 100644 index 00000000..878a0def --- /dev/null +++ b/src/modules/peer/PeerExpirationSettings.tsx @@ -0,0 +1,105 @@ +import * as React from "react"; +import { useState } from "react"; +import { PeerExpirationToggle } from "@/modules/peer/PeerExpirationToggle"; +import { usePeer } from "@/contexts/PeerProvider"; +import { TimerResetIcon } from "lucide-react"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { notify } from "@components/Notification"; +import { useSWRConfig } from "swr"; +import { cn } from "@utils/helpers"; +import { useAccount } from "@/modules/account/useAccount"; + +export const PeerExpirationSettings = () => { + const { peer, update } = usePeer(); + const { permission } = usePermissions(); + const { mutate } = useSWRConfig(); + const account = useAccount(); + + const [peerLoginExpiration, setPeerLoginExpiration] = useState( + peer.login_expiration_enabled, + ); + const [peerInactivityExpiration, setPeerInactivityExpiration] = useState( + peer.inactivity_expiration_enabled, + ); + + const updateExpiration = async ({ + loginExpiration, + inactivityExpiration, + }: { + loginExpiration?: boolean; + inactivityExpiration?: boolean; + }) => { + if (!permission?.peers.update) return; + + const promise = update({ + loginExpiration, + inactivityExpiration, + }).then(() => { + mutate("/peers/" + peer.id); + }); + + notify({ + title: peer.name, + description: "Expiration was successfully updated", + promise, + loadingMessage: "Updating setting...", + }); + + return promise; + }; + + const isAccountInactivityExpirationDisabled = + account && account?.settings?.peer_inactivity_expiration_enabled === false; + + return ( +
+ } + type={"login-expiration"} + onChange={async (state) => { + setPeerLoginExpiration(state); + !state && setPeerInactivityExpiration(false); + + await updateExpiration({ + loginExpiration: state, + inactivityExpiration: !state ? false : undefined, + }); + }} + /> + {permission?.peers.update && !!peer?.user_id && ( +
+ { + setPeerInactivityExpiration(state); + await updateExpiration({ + inactivityExpiration: state, + }); + }} + title={"Require login after disconnect"} + description={ + "Enable to require authentication after users disconnect from management for 10 minutes." + } + className={ + !peerLoginExpiration ? "opacity-40 pointer-events-none" : "" + } + /> +
+ )} +
+ ); +}; diff --git a/src/modules/peer/PeerExpirationToggle.tsx b/src/modules/peer/PeerExpirationToggle.tsx index 1ce9ba1a..2a209bd4 100644 --- a/src/modules/peer/PeerExpirationToggle.tsx +++ b/src/modules/peer/PeerExpirationToggle.tsx @@ -3,10 +3,13 @@ import FancyToggleSwitch, { } from "@components/FancyToggleSwitch"; import FullTooltip from "@components/FullTooltip"; import { IconInfoCircle } from "@tabler/icons-react"; -import { LockIcon } from "lucide-react"; +import { ArrowUpRightIcon, LockIcon } from "lucide-react"; import * as React from "react"; +import { useMemo } from "react"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { Peer } from "@/interfaces/Peer"; +import InlineLink from "@components/InlineLink"; +import { useAccount } from "@/modules/account/useAccount"; type Props = { peer: Peer; @@ -16,6 +19,7 @@ type Props = { description?: string; icon?: React.ReactNode; className?: string; + type?: "login-expiration" | "inactivity-expiration"; } & FancyToggleSwitchVariants; export const PeerExpirationToggle = ({ @@ -27,12 +31,26 @@ export const PeerExpirationToggle = ({ icon, className, variant = "default", + type = "login-expiration", }: Props) => { const { permission } = usePermissions(); + const account = useAccount(); - return ( - { + if (noPermissionOrNoUser) { + return (
{!peer.user_id ? ( <> @@ -50,14 +68,37 @@ export const PeerExpirationToggle = ({ )}
- } + ); + } + if (isGlobalSettingDisabled) { + const text = + type === "login-expiration" + ? "'Peer Session Expiration'" + : "'Require login after disconnect'"; + return ( +
+
+ Global setting {text} is currently disabled. Enable the global + setting to be able to toggle it individually per peer.{" "} + + Go to Settings + +
+
+ ); + } + }, [noPermissionOrNoUser, peer, type, isGlobalSettingDisabled]); + + return ( + { const { permission } = usePermissions(); const { peer, toggleSSH, setSSHInstructionsModal } = usePeer(); - const { data: policies, isLoading } = useFetchApi("/policies"); + const { data: policies } = useFetchApi( + "/policies", + true, + true, + permission?.policies.read, + ); const [tooltipOpen, setTooltipOpen] = useState(false); const [policyModal, setPolicyModal] = useState(false); const [sshPolicyModal, setSshPolicyModal] = useState(false); @@ -201,7 +206,11 @@ export const PeerSSHToggle = () => {
{isSSHClientEnabled ? ( - @@ -301,29 +310,31 @@ export const PeerSSHToggle = () => { )}
- - { - setPolicyModal(state); - setCurrentPolicy(undefined); - }} - > - { - setPolicyModal(false); + {permission?.policies.create && ( + + { + setPolicyModal(state); setCurrentPolicy(undefined); }} + > + { + setPolicyModal(false); + setCurrentPolicy(undefined); + }} + /> + + - - - + + )}
); }; diff --git a/src/modules/routes/RouteTable.tsx b/src/modules/routes/RouteTable.tsx index 64e941c8..1d628b40 100644 --- a/src/modules/routes/RouteTable.tsx +++ b/src/modules/routes/RouteTable.tsx @@ -114,7 +114,7 @@ export default function RouteTable({ row }: Props) { desc: true, }, ]); - + const hasAtLeastOneExitNode = useMemo(() => { return row.routes?.some((route) => route.network === "0.0.0.0/0"); }, [row.routes]); @@ -147,7 +147,7 @@ export default function RouteTable({ row }: Props) { tableClassName={"mt-0"} minimal={true} showSearchAndFilters={false} - className={"bg-neutral-900/50 py-2"} + className={"bg-nb-gray-960 py-2"} inset={true} text={"Network Routes"} manualPagination={true} diff --git a/src/modules/settings/AuthenticationTab.tsx b/src/modules/settings/AuthenticationTab.tsx index 3b25c42d..40a37c15 100644 --- a/src/modules/settings/AuthenticationTab.tsx +++ b/src/modules/settings/AuthenticationTab.tsx @@ -84,9 +84,7 @@ export default function AuthenticationTab({ account }: Readonly) { peerInactivityExpirationEnabled, setPeerInactivityExpirationEnabled, peerInactivityExpiresIn, - setPeerInactivityExpiresIn, peerInactivityExpireInterval, - setPeerInactivityExpireInterval, ] = useExpirationState({ enabled: account.settings.peer_inactivity_expiration_enabled, expirationInSeconds: account.settings.peer_inactivity_expiration || 600, @@ -111,10 +109,6 @@ export default function AuthenticationTab({ account }: Readonly) { const saveChanges = async () => { const expiration = convertToSeconds(expiresIn, expireInterval); - const peerInactivityExpiration = convertToSeconds( - peerInactivityExpiresIn, - peerInactivityExpireInterval, - ); notify({ title: "Save Authentication Settings", diff --git a/tailwind.config.ts b/tailwind.config.ts index a4321202..58e21f41 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -29,8 +29,9 @@ const config: Config = { "925": "#1e2123", "930": "#25282c", "935": "#1f2124", - "940": "#1c1d21", + "940": "#1c1e21", "950": "#181a1d", + "960": "#15171a", }, netbird: { DEFAULT: "#f68330",