- {title}
+
+ {loading ? loadingTitle || title : title}
+
void;
placeholder?: string;
+ customTrigger?: React.ReactNode;
+ align?: "start" | "end";
+ side?: "top" | "bottom";
+ users?: User[];
}
export function PeerGroupSelector({
onChange,
@@ -81,11 +87,17 @@ export function PeerGroupSelector({
resource,
onResourceChange,
placeholder = "Add or select group(s)...",
+ customTrigger,
+ align = "start",
+ side = "bottom",
+ users,
}: Readonly) {
const { groups, dropdownOptions, setDropdownOptions, addDropdownOptions } =
useGroups();
const searchRef = React.useRef(null);
- const [inputRef, { width }] = useElementSize();
+ const [inputRef, { width }] = useElementSize<
+ HTMLButtonElement | HTMLSpanElement
+ >();
const [search, setSearch] = useState("");
const { data: resources, isLoading } = useFetchApi(
"/networks/resources",
@@ -251,97 +263,105 @@ export function PeerGroupSelector({
}}
>
-
+
+
+
+
+ )}
)}
-
- {peerIcon}
- {peerCount} Peer(s)
+
+ {!users ? (
+
+ {peerIcon}
+ {peerCount} Peer(s)
+
+ ) : (
+
+ )}
+
@@ -555,6 +586,34 @@ const TabTriggers = ({
);
};
+const UsersCounter = ({
+ group,
+ users,
+ selected,
+}: {
+ group: Group;
+ users: User[];
+ selected: boolean;
+}) => {
+ const usersOfGroup =
+ users?.filter((user) => user.auto_groups.includes(group.id as string)) ||
+ [];
+
+ if (usersOfGroup.length === 0) return null;
+
+ return (
+
+ );
+};
+
const ResourcesCounter = ({ group }: { group: Group }) => {
return group?.resources_count && group.resources_count > 0 ? (
;
+
+export const popoverVariants = cva([], {
+ variants: {
+ variant: {
+ lighter: [
+ "rounded-md border border-neutral-200 bg-white px-5 py-3 text-sm text-neutral-950 shadow-md",
+ "dark:border-nb-gray-800 dark:bg-nb-gray-920 dark:text-neutral-50",
+ ],
+ dark: [
+ "rounded-md border border-neutral-200 bg-white px-5 py-3 text-sm text-neutral-950 shadow-md",
+ "dark:border-nb-gray-900 dark:bg-nb-gray-940 dark:text-gray-50",
+ ],
+ },
+ },
+});
+
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef
,
- React.ComponentPropsWithoutRef
->(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
-
-
-
-));
+ React.ComponentPropsWithoutRef &
+ PopoverVariants
+>(
+ (
+ {
+ className,
+ align = "center",
+ sideOffset = 4,
+ variant = "lighter",
+ ...props
+ },
+ ref,
+ ) => (
+
+
+
+ ),
+);
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverContent, PopoverTrigger };
diff --git a/src/components/SidebarItem.tsx b/src/components/SidebarItem.tsx
index 067d40c2..0b16d9e4 100644
--- a/src/components/SidebarItem.tsx
+++ b/src/components/SidebarItem.tsx
@@ -1,8 +1,9 @@
"use client";
import * as Collapsible from "@radix-ui/react-collapsible";
+import { cn } from "@utils/helpers";
import classNames from "classnames";
-import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
+import { ChevronDownIcon, ChevronUpIcon, DotIcon } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import React, { useMemo } from "react";
import { useApplicationContext } from "@/contexts/ApplicationProvider";
@@ -18,7 +19,10 @@ export type SidebarItemProps = {
href?: string;
exactPathMatch?: boolean;
target?: string;
+ labelClassName?: string;
+ visible: boolean;
};
+
export default function SidebarItem({
icon,
children,
@@ -29,11 +33,14 @@ export default function SidebarItem({
href = "",
exactPathMatch = false,
target = "_self",
-}: SidebarItemProps) {
+ labelClassName,
+ visible,
+}: Readonly) {
const [open, setOpen] = React.useState(false);
const path = usePathname();
const router = useRouter();
- const { mobileNavOpen, toggleMobileNav } = useApplicationContext();
+ const { mobileNavOpen, toggleMobileNav, isNavigationCollapsed } =
+ useApplicationContext();
const handleClick = () => {
const preventRedirect = href
@@ -54,14 +61,15 @@ export default function SidebarItem({
return href ? (exactPathMatch ? path == href : path.includes(href)) : false;
}, [path, href, exactPathMatch, collapsible]);
+ if (!visible) return;
+
return (
-
+
+ {isChild && isNavigationCollapsed && !mobileNavOpen && (
+
+
+
+ )}
{icon}
-
+
+
{label}
{collapsible &&
(open ? (
-
+
) : (
-
+
))}
diff --git a/src/components/UserSelector.tsx b/src/components/UserSelector.tsx
new file mode 100644
index 00000000..7c762117
--- /dev/null
+++ b/src/components/UserSelector.tsx
@@ -0,0 +1,219 @@
+import { DropdownInfoText } from "@components/DropdownInfoText";
+import { DropdownInput } from "@components/DropdownInput";
+import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
+import TextWithTooltip from "@components/ui/TextWithTooltip";
+import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList";
+import { useSearch } from "@hooks/useSearch";
+import { cn } from "@utils/helpers";
+import { ChevronsUpDown, MapPin } from "lucide-react";
+import * as React from "react";
+import { memo, useState } from "react";
+import { useElementSize } from "@/hooks/useElementSize";
+import { User } from "@/interfaces/User";
+import { SmallUserAvatar } from "@/modules/users/SmallUserAvatar";
+
+const MapPinIcon = memo(() => );
+MapPinIcon.displayName = "MapPinIcon";
+
+interface MultiSelectProps {
+ value?: User;
+ onChange: React.Dispatch>;
+ excludedPeers?: string[];
+ disabled?: boolean;
+ options?: User[];
+ placeholder?: string;
+}
+
+const searchPredicate = (u: User, query: string) => {
+ const lowerCaseQuery = query.toLowerCase();
+ try {
+ if (u.name.toLowerCase().includes(lowerCaseQuery)) return true;
+ return !!u?.email?.toLowerCase().includes(lowerCaseQuery);
+ } catch (e) {
+ return false;
+ }
+};
+
+export function UserSelector({
+ onChange,
+ value,
+ disabled = false,
+ options = [],
+ placeholder = "Select a user...",
+}: MultiSelectProps) {
+ const [inputRef, { width }] = useElementSize();
+
+ const [filteredItems, search, setSearch] = useSearch(
+ options,
+ searchPredicate,
+ { filter: true, debounce: 150 },
+ );
+
+ const toggleUser = (user: User) => {
+ const isSelected = value && value.id == user.id;
+ if (isSelected) {
+ onChange(undefined);
+ } else {
+ onChange(user);
+ setSearch("");
+ }
+ setOpen(false);
+ };
+
+ const [open, setOpen] = useState(false);
+
+ return (
+ {
+ if (!isOpen) {
+ setTimeout(() => {
+ setSearch("");
+ }, 100);
+ }
+ setOpen(isOpen);
+ }}
+ >
+
+
+
+ {value ? (
+
+ ) : (
+ {placeholder}
+ )}
+
+
+
+
+
+
+
+
+
+ {options.length == 0 && !search && (
+
+
+ {
+ "There are no users to select. Invite some users for this tenant before unlinking."
+ }
+
+
+ )}
+
+ {filteredItems.length == 0 && search != "" && (
+
+ There are no users matching your search.
+
+ )}
+
+ {filteredItems.length > 0 && (
+
{
+ return (
+
+
+
+ );
+ }}
+ />
+ )}
+
+
+
+ );
+}
+
+type UserListItemProps = {
+ user: User;
+ className?: string;
+ variant?: "default" | "selected";
+};
+
+export const UserListItem = ({
+ user,
+ className,
+ variant,
+}: UserListItemProps) => {
+ const isSystemUser = user?.email === "NetBird" || user?.email === "";
+ const maxChars = variant === "selected" ? 30 : 20;
+
+ return (
+
+ );
+};
diff --git a/src/components/VirtualScrollAreaList.tsx b/src/components/VirtualScrollAreaList.tsx
index c46fa80b..64fc7226 100644
--- a/src/components/VirtualScrollAreaList.tsx
+++ b/src/components/VirtualScrollAreaList.tsx
@@ -10,15 +10,25 @@ import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
type Props = {
items: T[];
onSelect: (item: T) => void;
- renderItem?: (item: T) => React.ReactNode;
+ renderItem?: (item: T, selected?: boolean) => React.ReactNode;
+ renderBeforeItem?: (item: T) => React.ReactNode;
itemClassName?: string;
+ itemWrapperClassName?: string;
+ scrollAreaClassName?: string;
+ maxHeight?: number;
+ estimatedItemHeight?: number;
};
export function VirtualScrollAreaList({
items,
onSelect,
renderItem,
+ renderBeforeItem,
itemClassName,
+ itemWrapperClassName,
+ scrollAreaClassName,
+ maxHeight,
+ estimatedItemHeight = 36,
}: Readonly>) {
const virtuosoRef = useRef(null);
const [selected, setSelected] = useState(0);
@@ -67,31 +77,47 @@ export function VirtualScrollAreaList({
const renderMemoizedItem = useMemo(() => renderItem, [renderItem]);
+ const scrollAreaHeight = { maxHeight: maxHeight ?? 195 };
+
+ const virtuosoHeight = {
+ height: Math.min(items.length * estimatedItemHeight + 8, maxHeight ?? 195),
+ };
+
return (
items[index].id as string}
context={{ selected, setSelected, onClick: onSelect }}
itemContent={(index, option, { selected, setSelected, onClick }) => {
return (
- setSelected(index)}
- id={option.id}
- onClick={() => onClick(option)}
- ariaSelected={selected === index}
- className={itemClassName}
- >
- {renderMemoizedItem ? renderMemoizedItem(option) : option.id}
-
+
+ {renderBeforeItem?.(option)}
+ setSelected(index)}
+ id={option.id}
+ onClick={() => onClick(option)}
+ ariaSelected={selected === index}
+ itemClassName={itemClassName}
+ className={itemWrapperClassName}
+ isLast={index === items.length - 1}
+ >
+ {renderMemoizedItem
+ ? renderMemoizedItem(option, selected === index)
+ : option.id}
+
+
);
}}
- style={{ height: 195 }}
+ style={virtuosoHeight}
components={{
Scroller: MemoizedScrollAreaViewport,
}}
@@ -107,6 +133,8 @@ type ItemWrapperProps = {
onClick?: () => void;
ariaSelected?: boolean;
className?: string;
+ itemClassName?: string;
+ isLast?: boolean;
};
export const VirtualScrollListItemWrapper = memo(
@@ -117,11 +145,17 @@ export const VirtualScrollListItemWrapper = memo(
onMouseEnter,
ariaSelected,
className,
+ itemClassName,
+ isLast,
}: ItemWrapperProps) => {
return (
@@ -129,7 +163,7 @@ export const VirtualScrollListItemWrapper = memo(
className={cn(
"text-xs flex justify-between py-2 px-3 cursor-pointer items-center rounded-md",
"bg-transparent dark:aria-selected:bg-nb-gray-800/50",
- className,
+ itemClassName,
)}
aria-selected={ariaSelected}
role={"listitem"}
diff --git a/src/components/modal/Modal.tsx b/src/components/modal/Modal.tsx
index 8f2abbba..f28e68f4 100644
--- a/src/components/modal/Modal.tsx
+++ b/src/components/modal/Modal.tsx
@@ -5,6 +5,7 @@ import { DialogTriggerProps } from "@radix-ui/react-dialog";
import { cn } from "@utils/helpers";
import { X } from "lucide-react";
import * as React from "react";
+import { headerHeight } from "@/layouts/Header";
const Modal = DialogPrimitive.Root;
@@ -33,7 +34,7 @@ const ModalOverlay = React.forwardRef<
className={cn(
"fixed top-0 left-0 bottom-0 right-0 grid z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 ",
"mx-auto place-items-start overflow-y-auto md:py-16",
- "bg-black/30 dark:bg-black/50 backdrop-blur-sm",
+ "bg-black/30 dark:bg-black/40 backdrop-blur-sm",
className,
)}
{...props}
@@ -66,7 +67,7 @@ const ModalContent = React.forwardRef<
,
+ React.ComponentPropsWithoutRef &
+ ModalContentProps
+>(
+ (
+ {
+ className,
+ children,
+ showClose = true,
+ maxWidthClass = "max-w-3xl",
+ ...props
+ },
+ ref,
+ ) => {
+ return (
+
+
+ e.stopPropagation()}
+ >
+ <>
+ {children}
+ {showClose && (
+
+
+ Close
+
+ )}
+ >
+
+
+
+ );
+ },
+);
+SidebarModalContent.displayName = DialogPrimitive.Content.displayName;
+
type ModalFooterProps = {
variant?: "setup" | "default";
separator?: boolean;
@@ -158,4 +215,5 @@ export {
ModalPortal,
ModalTitle,
ModalTrigger,
+ SidebarModalContent,
};
diff --git a/src/components/modal/ModalHeader.tsx b/src/components/modal/ModalHeader.tsx
index 4c9b716e..35ef4fc2 100644
--- a/src/components/modal/ModalHeader.tsx
+++ b/src/components/modal/ModalHeader.tsx
@@ -25,7 +25,7 @@ export default function ModalHeader({
center,
}: Props) {
return (
-
+
{icon &&
}
diff --git a/src/components/table/DataTable.tsx b/src/components/table/DataTable.tsx
index 56935b3d..e281c528 100644
--- a/src/components/table/DataTable.tsx
+++ b/src/components/table/DataTable.tsx
@@ -101,7 +101,7 @@ const arrIncludesSomeExact: FilterFn
= (
value: string[],
) => {
const rowValue = row.getValue(columnId);
- if (!rowValue) return false;
+ if (!rowValue && rowValue !== 0) return false;
return value.some((val) => val === rowValue);
};
@@ -302,8 +302,11 @@ export function DataTableContent({
setGlobalSearch("");
setRowSelection?.({});
onFilterReset?.();
+ setSearchKey((prev) => (prev === 0 ? 1 : 0));
};
+ const [searchKey, setSearchKey] = useState(0);
+
return (
{showSearchAndFilters && (
@@ -316,6 +319,7 @@ export function DataTableContent
({
{
table.setPageIndex(0);
diff --git a/src/components/table/DataTableFilter.tsx b/src/components/table/DataTableFilter.tsx
new file mode 100644
index 00000000..856f4353
--- /dev/null
+++ b/src/components/table/DataTableFilter.tsx
@@ -0,0 +1,276 @@
+import Button from "@components/Button";
+import { Checkbox } from "@components/Checkbox";
+import { DropdownInfoText } from "@components/DropdownInfoText";
+import { DropdownInput } from "@components/DropdownInput";
+import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
+import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList";
+import { useSearch } from "@hooks/useSearch";
+import { Table } from "@tanstack/react-table";
+import { concat, sortBy, uniqBy } from "lodash";
+import { FilterIcon } from "lucide-react";
+import * as React from "react";
+import { useCallback, useMemo, useState } from "react";
+
+interface Props {
+ table: Table;
+ filters: Filter[];
+ disabled?: boolean;
+}
+
+/**
+ * Filter
+ * @param columnId - Column ID to filter
+ * @param group - Group name for the filter
+ * @param item - Function to render the filter item
+ */
+interface Filter {
+ columnId: keyof TData | string;
+ group?: string;
+ item: (item: TData, value: string) => string | React.ReactNode;
+ exclude?: string[];
+}
+
+interface FilterItem {
+ id: string;
+ value: string;
+ showGroupHeading: boolean;
+ columnId: keyof TData | string;
+ group?: string;
+ original: TData;
+ renderItem: () => React.ReactNode;
+}
+
+type SearchPredicate = (
+ item: FilterItem,
+ query: string,
+) => boolean;
+
+const searchPredicate: SearchPredicate = (item, query) => {
+ const lowerCaseQuery = query.toLowerCase();
+ let itemValue = String(item?.value || "").toLowerCase();
+ return itemValue.includes(lowerCaseQuery);
+};
+
+/**
+ * @desc Generic filter button. Filters are based on the table data and are displayed in a popover with search functionality.
+ * @param table - Table instance from tanstack/react-table
+ * @param filters - Array of filters to display
+ * @param filters.columnId Id of the column to filter. This column must have a filterFn: "arrIncludesSomeExact" in the column definition of the table.
+ * @param filters.group - Group name for the filter
+ * @param filters.item - Function to render the filter item
+ * @param disabled - Disable the filter button
+ * @returns React.ReactNode
+ * @example
+ * item.name,
+ * }]}
+ * />
+ */
+export function DataTableFilter({
+ table,
+ filters,
+ disabled = false,
+}: Readonly>) {
+ const searchRef = React.useRef(null);
+ const [open, setOpen] = useState(false);
+
+ const options = useMemo(
+ () =>
+ filters.flatMap((filter) => {
+ const getTableColumnValues = (columnId: string) => {
+ return [
+ ...new Set(
+ table
+ .getPreFilteredRowModel()
+ .rows.map((row) => {
+ return {
+ value: row?.getValue(columnId),
+ original: row.original,
+ };
+ })
+ .filter((value) => value !== undefined),
+ ),
+ ];
+ };
+
+ // Get unique values from table rows
+ let tableRows = uniqBy(
+ getTableColumnValues(filter.columnId as string),
+ "value",
+ );
+
+ // Filter out excluded values
+ if (filter.exclude) {
+ tableRows = tableRows.filter(
+ (row) => !filter.exclude?.includes(row.value as string),
+ );
+ }
+
+ // Sort values
+ tableRows = sortBy(tableRows, (row) => {
+ return isNaN(Number(row?.value)) ? row?.value : Number(row?.value);
+ });
+
+ const groupCounts: Record = {};
+ return tableRows.map((row) => {
+ const groupKey = filter?.group ?? "Ungrouped";
+ groupCounts[groupKey] = (groupCounts[groupKey] || 0) + 1;
+
+ return {
+ id: `${String(filter.columnId)}-${row.value}`,
+ value: row.value,
+ showGroupHeading: groupCounts[groupKey] === 1,
+ columnId: filter.columnId,
+ group: filter?.group,
+ original: row.original,
+ renderItem: () => filter?.item(row.original, String(row.value)),
+ } as FilterItem;
+ });
+ }),
+ [],
+ );
+
+ const [filteredItems, search, setSearch] = useSearch>(
+ options,
+ searchPredicate,
+ {
+ filter: true,
+ debounce: 150,
+ },
+ );
+
+ const onOpenChange = (isOpen: boolean) => {
+ if (!isOpen) {
+ setTimeout(() => {
+ setSearch("");
+ }, 100);
+ }
+ setOpen(isOpen);
+ };
+
+ const getCurrentTableFilters = useCallback((columnId: string) => {
+ return table.getColumn(columnId)?.getFilterValue() as string[] | undefined;
+ }, []);
+
+ const onSelect = (item: FilterItem) => {
+ table.setPageIndex(0);
+
+ const currentFilters = getCurrentTableFilters(item.columnId as string);
+ const column = table.getColumn(item.columnId as string);
+
+ const newFilters = currentFilters?.includes(item.value)
+ ? currentFilters.filter((f) => f !== item.value)
+ : concat(currentFilters ?? [], item.value);
+
+ if (newFilters.length == 0) {
+ column?.setFilterValue(undefined);
+ } else {
+ column?.setFilterValue(newFilters);
+ }
+
+ searchRef.current?.focus();
+ };
+
+ const activeFiltersCount = useMemo(() => {
+ let columnIds = filters.map((filter) => filter.columnId as string);
+ let activeFilters = columnIds.map((columnId) => {
+ return getCurrentTableFilters(columnId);
+ });
+ return activeFilters.flat().filter((filter) => filter !== undefined).length;
+ }, [filters, getCurrentTableFilters]);
+
+ return (
+
+
+
+
+
+
+ {activeFiltersCount > 0 && activeFiltersCount}
+
+ {activeFiltersCount > 0 ? ` Filter(s)` : "Filter"}
+
+
+
+
+
+
+
+ {filteredItems.length == 0 && search != "" && (
+
+ There are no filters matching your search.
+
+ )}
+
+
{
+ const currentTableFilters = getCurrentTableFilters(
+ option.columnId as string,
+ );
+ const isActive = currentTableFilters?.includes(option.value);
+
+ return (
+
+
+
{option?.renderItem()}
+
+
+
+ );
+ }}
+ onSelect={onSelect}
+ />
+
+
+
+ );
+}
+
+const ListItemHeading = ({
+ children,
+ show = false,
+}: {
+ children: React.ReactNode;
+ show: boolean;
+}) => {
+ if (!show) return null;
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/components/table/DataTableGlobalSearch.tsx b/src/components/table/DataTableGlobalSearch.tsx
index 89d1b340..b998fd45 100644
--- a/src/components/table/DataTableGlobalSearch.tsx
+++ b/src/components/table/DataTableGlobalSearch.tsx
@@ -1,7 +1,8 @@
import { Input } from "@components/Input";
import Kbd from "@components/Kbd";
+import { useDebounce } from "@hooks/useDebounce";
import { Search } from "lucide-react";
-import React from "react";
+import React, { useEffect, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
interface Props extends React.InputHTMLAttributes {
@@ -17,9 +18,16 @@ export default function DataTableGlobalSearch({
...props
}: Props) {
const ref = React.useRef(null);
+ const [inputValue, setInputValue] = useState(globalSearch || "");
+ const debouncedValue = useDebounce(inputValue, 300);
+
+ // Call setGlobalSearch when debounced value changes
+ useEffect(() => {
+ setGlobalSearch(debouncedValue);
+ }, [debouncedValue]);
const handleChange = (e: React.ChangeEvent) => {
- setGlobalSearch(e.target.value);
+ setInputValue(e.target.value);
};
useHotkeys("mod+k", () => ref.current?.focus(), []);
@@ -29,7 +37,7 @@ export default function DataTableGlobalSearch({
{...props}
ref={ref}
icon={}
- value={globalSearch}
+ value={inputValue} // Shows immediate updates
onChange={handleChange}
maxWidthClass={className}
customSuffix={⌘ K}
diff --git a/src/components/table/DataTablePagination.tsx b/src/components/table/DataTablePagination.tsx
index 03d57753..58e54c9c 100644
--- a/src/components/table/DataTablePagination.tsx
+++ b/src/components/table/DataTablePagination.tsx
@@ -7,6 +7,7 @@ import {
ChevronsLeft,
ChevronsRight,
} from "lucide-react";
+import { useEffect } from "react";
interface DataTablePaginationProps {
table: Table;
@@ -27,6 +28,13 @@ export function DataTablePagination({
const showingTo = isLastPage ? allRows : showingFrom + rowsPerPage - 1;
const pageCount = table.getPageCount();
+ // Reset page index if it's greater than the page count
+ useEffect(() => {
+ if (currentPage > pageCount) {
+ table.setPageIndex(0);
+ }
+ }, []);
+
return pageCount > 1 ? (
@@ -53,7 +61,7 @@ export function DataTablePagination
({
- {table.getState().pagination.pageIndex + 1} of {pageCount}
+ {currentPage} of {pageCount}
void;
+};
+export const AbsoluteDateTimeInput = ({ value, onChange }: Props) => {
+ return (
+
+
+
+
+
-
+
+
+
+
+ );
+};
+
+const Time = ({
+ value,
+ onChange,
+}: {
+ value?: Date;
+ onChange?: (date?: Date) => void;
+}) => {
+ const { getRootProps, getInputProps, options, update } = useTimescape({
+ date: value,
+ minDate: undefined,
+ maxDate: undefined,
+ hour12: true,
+ digits: "2-digit",
+ wrapAround: false,
+ snapToStep: false,
+ wheelControl: true,
+ disallowPartial: false,
+ onChangeDate: onChange,
+ });
+
+ useEffect(() => {
+ if (options.date?.getTime() !== value?.getTime()) {
+ update({ ...options, date: value });
+ }
+ }, [value]);
+
+ return (
+
+ );
+};
diff --git a/src/components/ui/AddPeerButton.tsx b/src/components/ui/AddPeerButton.tsx
index 0d7b3e6f..3e7a2d06 100644
--- a/src/components/ui/AddPeerButton.tsx
+++ b/src/components/ui/AddPeerButton.tsx
@@ -3,37 +3,57 @@ import Button from "@components/Button";
import { Modal, ModalTrigger } from "@components/modal/Modal";
import useFetchApi from "@utils/api";
import { PlusCircle } from "lucide-react";
-import { memo, useState } from "react";
+import React, { memo, useState } from "react";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { Peer } from "@/interfaces/Peer";
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
function AddPeerButton() {
+ const { permission } = usePermissions();
const { data: peers } = useFetchApi("/peers");
const { oidcUser: user } = useOidcUser();
+ const [hasOnboardingFormCompleted] = useLocalStorage(
+ "netbird-onboarding-modal",
+ false,
+ );
+
const [isFirstRun, setIsFirstRun] = useLocalStorage(
"netbird-first-run",
!(peers && peers.length > 0),
);
- const [setupModal, setSetupModal] = useState(isFirstRun);
+ const [installModal, setInstallModal] = useState(
+ !hasOnboardingFormCompleted
+ ? process.env.APP_ENV !== "test"
+ ? false
+ : isFirstRun
+ : isFirstRun,
+ );
const handleOpenChange = (open: boolean) => {
- setSetupModal(open);
+ setInstallModal(open);
setIsFirstRun(false);
};
return (
-
-
-
-
- Add Peer
-
-
-
-
+ <>
+
+
+
+
+ Add Peer
+
+
+
+
+ >
);
}
diff --git a/src/components/ui/GetStartedTest.tsx b/src/components/ui/GetStartedTest.tsx
index 048aa894..e9c52ddc 100644
--- a/src/components/ui/GetStartedTest.tsx
+++ b/src/components/ui/GetStartedTest.tsx
@@ -1,5 +1,6 @@
import Card from "@components/Card";
import Paragraph from "@components/Paragraph";
+import { cn } from "@utils/helpers";
import React from "react";
import Skeleton from "react-loading-skeleton";
@@ -51,7 +52,9 @@ export default function GetStartedTest({
>
{title}
-
+
{description}
diff --git a/src/components/ui/GroupBadge.tsx b/src/components/ui/GroupBadge.tsx
index 6b033f8b..a9b6bc86 100644
--- a/src/components/ui/GroupBadge.tsx
+++ b/src/components/ui/GroupBadge.tsx
@@ -14,6 +14,9 @@ type Props = {
children?: React.ReactNode;
className?: string;
showNewBadge?: boolean;
+ maxChars?: number;
+ maxWidth?: string;
+ hideTooltip?: boolean;
};
export default function GroupBadge({
@@ -23,12 +26,15 @@ export default function GroupBadge({
children,
className,
showNewBadge = false,
+ maxChars = 20,
+ maxWidth,
+ hideTooltip = false,
}: Readonly
) {
const isNew = !group?.id;
return (
-
+
{children}
{isNew && showNewBadge && }
{showX && (
diff --git a/src/components/ui/InputDomain.tsx b/src/components/ui/InputDomain.tsx
index 36549def..b17d9cea 100644
--- a/src/components/ui/InputDomain.tsx
+++ b/src/components/ui/InputDomain.tsx
@@ -13,6 +13,7 @@ type Props = {
onRemove: () => void;
onError?: (error: boolean) => void;
error?: string;
+ disabled?: boolean;
};
enum ActionType {
ADD = "ADD",
@@ -38,6 +39,7 @@ export default function InputDomain({
onChange,
onRemove,
onError,
+ disabled,
}: Readonly) {
const [name, setName] = useState(value?.name || "");
@@ -74,6 +76,7 @@ export default function InputDomain({
value={name}
error={domainError}
onChange={handleNameChange}
+ disabled={disabled}
/>
@@ -81,6 +84,7 @@ export default function InputDomain({
className={"h-[42px]"}
variant={"default-outline"}
onClick={onRemove}
+ disabled={disabled}
>
diff --git a/src/components/ui/MultipleGroups.tsx b/src/components/ui/MultipleGroups.tsx
index 8b347872..97dfe462 100644
--- a/src/components/ui/MultipleGroups.tsx
+++ b/src/components/ui/MultipleGroups.tsx
@@ -8,6 +8,7 @@ import {
} from "@components/Tooltip";
import GroupBadge from "@components/ui/GroupBadge";
import PeerBadge from "@components/ui/PeerBadge";
+import { cn } from "@utils/helpers";
import { orderBy } from "lodash";
import { ArrowRightIcon } from "lucide-react";
import * as React from "react";
@@ -18,25 +19,34 @@ type Props = {
groups: Group[];
label?: string;
description?: string;
+ onClick?: () => void;
+ className?: string;
};
export default function MultipleGroups({
groups,
label = "Assigned Groups",
description = "Use groups to control what this peer can access",
-}: Props) {
+ onClick,
+ className,
+}: Readonly) {
if (!groups) return ;
const orderedGroups = orderBy(groups, ["peers_count", "name"], ["desc"]);
const firstGroup = orderedGroups.length > 0 ? orderedGroups[0] : undefined;
const otherGroups = orderedGroups.length > 0 ? orderedGroups.slice(1) : [];
return (
-
-
+
+
{firstGroup && }
{otherGroups && otherGroups.length > 0 && (
@@ -51,7 +61,10 @@ export default function MultipleGroups({
{orderedGroups && orderedGroups.length > 0 && (
-
+ e.stopPropagation()}
+ >
{label}
diff --git a/src/components/ui/PageNotFound.tsx b/src/components/ui/PageNotFound.tsx
new file mode 100644
index 00000000..b641cbab
--- /dev/null
+++ b/src/components/ui/PageNotFound.tsx
@@ -0,0 +1,93 @@
+import Button from "@components/Button";
+import Card from "@components/Card";
+import Paragraph from "@components/Paragraph";
+import SquareIcon from "@components/SquareIcon";
+import { CircleAlertIcon, Undo2Icon } from "lucide-react";
+import { useRouter } from "next/navigation";
+import * as React from "react";
+import Skeleton from "react-loading-skeleton";
+import PageContainer from "@/layouts/PageContainer";
+
+type Props = {
+ title?: string;
+ description?: string;
+};
+export const PageNotFound = ({
+ title = "The requested page was not found",
+ description = "The page you are attempting to access cannot be found. Please verify the URL or return to the dashboard to continue browsing.",
+}: Props) => {
+ const router = useRouter();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {" "}
+ }
+ color={"netbird"}
+ size={"large"}
+ />
+
+
+
+ {title}
+
+
+ {description}
+
+
router.back()}
+ >
+
+ Go Back
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/ui/RestrictedAccess.tsx b/src/components/ui/RestrictedAccess.tsx
index bb785cbd..50cec24d 100644
--- a/src/components/ui/RestrictedAccess.tsx
+++ b/src/components/ui/RestrictedAccess.tsx
@@ -4,28 +4,21 @@ import SquareIcon from "@components/SquareIcon";
import { LockIcon } from "lucide-react";
import * as React from "react";
import Skeleton from "react-loading-skeleton";
-import { useLoggedInUser } from "@/contexts/UsersProvider";
-import { Role } from "@/interfaces/User";
type Props = {
- children: React.ReactNode;
- allow?: Role[];
+ children?: React.ReactNode;
+ hasAccess?: boolean;
page?: string;
};
+
export const RestrictedAccess = ({
children,
- allow = [Role.Admin, Role.Owner],
+ hasAccess = false,
page = "this page",
}: Props) => {
- const { loggedInUser } = useLoggedInUser();
-
- const isAllowed = loggedInUser
- ? allow.includes(loggedInUser?.role as Role)
- : false;
+ if (hasAccess) return children;
- return isAllowed ? (
- <>{children}>
- ) : (
+ return (
-
+
{" "}
@@ -66,13 +59,13 @@ export const RestrictedAccess = ({
{"You don't have access to"}
{page}
{
- "Seems like you don't have access to this page. Only users with admin access can visit this page. Please contact your network administrator for further information."
+ "Seems like you don't have access to this page. Only users with proper permissions can visit this page. Please contact your network administrator for further information."
}
diff --git a/src/components/ui/SmallBadge.tsx b/src/components/ui/SmallBadge.tsx
index cbf22c08..a4948f8e 100644
--- a/src/components/ui/SmallBadge.tsx
+++ b/src/components/ui/SmallBadge.tsx
@@ -6,8 +6,10 @@ const smallBadgeVariants = cva("", {
variants: {
variant: {
green: "bg-green-900 border border-green-500/20 text-green-400",
+ blue: "bg-blue-900 border border-blue-500/20 text-blue-400",
white: "bg-white/20 border border-white/10 text-white",
sky: "bg-sky-900 border border-sky-500/20 text-white",
+ netbird: "bg-netbird-900 border border-netbird-400 text-netbird-300",
},
},
});
@@ -15,12 +17,14 @@ const smallBadgeVariants = cva("", {
type Props = {
text?: string;
className?: string;
+ textClassName?: string;
children?: React.ReactNode;
} & VariantProps
;
export const SmallBadge = ({
text = "NEW",
className,
+ textClassName,
variant = "green",
children,
}: Props) => {
@@ -33,7 +37,7 @@ export const SmallBadge = ({
)}
>
{children}
- {text}
+ {text}
);
};
diff --git a/src/components/ui/TextWithTooltip.tsx b/src/components/ui/TextWithTooltip.tsx
index 5b50d632..d37443fc 100644
--- a/src/components/ui/TextWithTooltip.tsx
+++ b/src/components/ui/TextWithTooltip.tsx
@@ -34,7 +34,7 @@ export default function TextWithTooltip({
}
>
) {
+ const [isOverflowing, setIsOverflowing] = useState(false);
+ const [open, setOpen] = useState(false);
+ const contentRef = React.useRef
(null);
+
const charCount = useMemo(() => {
if (!text) return 0;
return text.length;
}, [text]);
- const isDisabled = charCount <= maxChars || hideTooltip;
+ // Check for overflow on mount and when text/maxWidth changes
+ React.useEffect(() => {
+ const element = contentRef.current;
+ if (element) {
+ setIsOverflowing(element.scrollWidth > element.clientWidth);
+ }
+ }, [text, maxWidth]);
- const [open, setOpen] = useState(false);
+ // If maxWidth is provided, use overflow detection
+ // Otherwise, fall back to character count logic
+ const isDisabled = maxWidth
+ ? !isOverflowing || hideTooltip
+ : charCount <= maxChars || hideTooltip;
+
+ const containerStyle = maxWidth
+ ? { maxWidth }
+ : { maxWidth: `${maxChars - 2}ch` };
if (isDisabled) {
return (
-
-
{text}
+
);
}
@@ -45,13 +62,10 @@ export default function TruncatedText({
onOpenChange={setOpen}
>
-
-
{text}
+
@@ -61,13 +75,13 @@ export default function TruncatedText({
alignOffset={20}
sideOffset={4}
className={cn(
- "z-[9999] overflow-hidden rounded-md border border-neutral-200 bg-white text-sm text-neutral-950 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-nb-gray-930 dark:bg-nb-gray-940 dark:text-neutral-50",
+ "z-[9999] overflow-hidden rounded-md border border-neutral-200 bg-white text-sm text-neutral-950 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-nb-gray-930 dark:bg-nb-gray-940 dark:text-neutral-50",
className,
"px-3 py-1.5",
)}
>
-
-
+
diff --git a/src/components/ui/UserAvatar.tsx b/src/components/ui/UserAvatar.tsx
index 5864428d..58d61039 100644
--- a/src/components/ui/UserAvatar.tsx
+++ b/src/components/ui/UserAvatar.tsx
@@ -1,24 +1,30 @@
-import { cn, generateColorFromString } from "@utils/helpers";
+import { cn, generateColorFromUser } from "@utils/helpers";
import { Avatar } from "flowbite-react";
import * as React from "react";
import { useState } from "react";
import { useApplicationContext } from "@/contexts/ApplicationProvider";
type Props = {
- size?: "default" | "small" | "large";
+ size?: "default" | "small" | "large" | "medium";
};
export const UserAvatar = ({ size = "default" }: Props) => {
const { user } = useApplicationContext();
const [pictureLoaded, setPictureLoaded] = useState(true);
+ const getAvatarSize = () => {
+ if (size === "small") return "sm";
+ if (size === "large") return "lg";
+ return "md";
+ };
+
return pictureLoaded ? (
setPictureLoaded(false)}
- size={size == "small" ? "sm" : size == "large" ? "lg" : "md"}
+ size={getAvatarSize()}
className={"shrink-0"}
/>
) : (
@@ -26,13 +32,12 @@ export const UserAvatar = ({ size = "default" }: Props) => {
className={cn(
"rounded-full flex items-center justify-center bg-nb-gray-900 text-netbird uppercase",
size == "small" && "w-8 h-8",
+ size == "medium" && "w-[2.3rem] h-[2.3rem]",
size == "default" && "w-10 h-10",
size == "large" && "w-12 h-12",
)}
style={{
- color: user?.name
- ? generateColorFromString(user?.name || user?.id || "System User")
- : "#808080",
+ color: generateColorFromUser(user),
}}
>
{user?.name?.charAt(0) || user?.id?.charAt(0)}
diff --git a/src/components/ui/UserDropdown.tsx b/src/components/ui/UserDropdown.tsx
index c6c5ce87..3779d9f4 100644
--- a/src/components/ui/UserDropdown.tsx
+++ b/src/components/ui/UserDropdown.tsx
@@ -1,6 +1,5 @@
"use client";
-import { useOidc } from "@axa-fr/react-oidc";
import {
DropdownMenu,
DropdownMenuContent,
@@ -17,25 +16,19 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { useApplicationContext } from "@/contexts/ApplicationProvider";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import useOSDetection from "@/hooks/useOperatingSystem";
-import loadConfig from "@/utils/config";
-
-const config = loadConfig();
export default function UserDropdown() {
- const { logout } = useOidc();
+ const [dropdownOpen, setDropdownOpen] = useState(false);
const { user } = useApplicationContext();
- const { loggedInUser } = useLoggedInUser();
+ const { loggedInUser, logout } = useLoggedInUser();
+ const { isRestricted, permission } = usePermissions();
const isMac = useOSDetection();
const router = useRouter();
- const logoutSession = async () => {
- logout("/", { client_id: config.clientId }).then();
- };
- useHotkeys("shift+mod+l", () => logoutSession(), []);
- const { permission } = useLoggedInUser();
- const [dropdownOpen, setDropdownOpen] = useState(false);
+ useHotkeys("shift+mod+l", () => logout(), []);
return (
-
+
@@ -68,23 +61,18 @@ export default function UserDropdown() {
- {permission.dashboard_view !== "blocked" && (
- {
setDropdownOpen(false);
if (loggedInUser) {
router.push(`/team/user?id=${loggedInUser.id}`);
}
}}
- >
-
-
- Profile Settings
-
-
+ />
)}
-
+
Log out
@@ -95,3 +83,14 @@ export default function UserDropdown() {
);
}
+
+const ProfileSettingsDropdownItem = ({ onClick }: { onClick: () => void }) => {
+ return (
+
+
+
+ Profile Settings
+
+
+ );
+};
diff --git a/src/contexts/AnalyticsProvider.tsx b/src/contexts/AnalyticsProvider.tsx
index 72c2251a..d629bf0a 100644
--- a/src/contexts/AnalyticsProvider.tsx
+++ b/src/contexts/AnalyticsProvider.tsx
@@ -1,6 +1,7 @@
import loadConfig from "@utils/config";
import { isProduction } from "@utils/netbird";
import { usePathname } from "next/navigation";
+import Script from "next/script";
import React, { useEffect, useState } from "react";
import ReactGA from "react-ga4";
import { hotjar } from "react-hotjar";
@@ -12,6 +13,7 @@ type Props = {
declare global {
interface Window {
_DATADOG_SYNTHETICS_BROWSER: any;
+ dataLayer: any[];
}
}
@@ -20,11 +22,18 @@ const AnalyticsContext = React.createContext(
initialized: boolean;
trackPageView: () => void;
trackEvent: (category: string, action: string, label: string) => void;
+ trackEventV2: (
+ category: string,
+ name: string,
+ value?: string,
+ userID?: string,
+ ) => void;
+ trackGTMCustomEvent: (name: string) => void;
},
);
const config = loadConfig();
-export default function AnalyticsProvider({ children }: Props) {
+export default function AnalyticsProvider({ children }: Readonly
) {
const [initialized, setInitialized] = useState(false);
const path = usePathname();
@@ -62,13 +71,78 @@ export default function AnalyticsProvider({ children }: Props) {
}
};
+ const trackEventV2 = (
+ category: string,
+ name: string,
+ value?: string,
+ userID?: string,
+ ) => {
+ // Track custom event
+ if (isProduction() && ReactGA.isInitialized) {
+ ReactGA.event("nb_event", {
+ category: category,
+ action: name,
+ value: value,
+ userID: userID,
+ });
+ }
+ };
+
+ const trackGTMCustomEvent = (name: string) => {
+ try {
+ window.dataLayer = window.dataLayer || [];
+ window.dataLayer.push({
+ event: name,
+ });
+ } catch (e) {}
+ };
+
return (
+
{children}
);
}
+export const GoogleTagManagerHeadScript = () => {
+ if (!config.googleTagManagerID) return null;
+ return (
+ isProduction() && (
+
+ )
+ );
+};
+
+const GoogleTageManagerBodyScript = () => {
+ if (!config.googleTagManagerID) return null;
+ return (
+ isProduction() && (
+
+ )
+ );
+};
+
export const useAnalytics = () => React.useContext(AnalyticsContext);
diff --git a/src/contexts/AnnouncementProvider.tsx b/src/contexts/AnnouncementProvider.tsx
index 8e478976..d50b1cfc 100644
--- a/src/contexts/AnnouncementProvider.tsx
+++ b/src/contexts/AnnouncementProvider.tsx
@@ -2,7 +2,7 @@ import { AnnouncementVariant } from "@components/ui/AnnouncementBanner";
import { useLocalStorage } from "@hooks/useLocalStorage";
import md5 from "crypto-js/md5";
import React, { useEffect, useState } from "react";
-import { useLoggedInUser } from "@/contexts/UsersProvider";
+import { usePermissions } from "@/contexts/PermissionsProvider";
const initialAnnouncements: Announcement[] = [];
@@ -38,17 +38,18 @@ const AnnouncementContext = React.createContext(
const bannerHeight = 40;
-export default function AnnouncementProvider({ children }: Props) {
+export default function AnnouncementProvider({ children }: Readonly) {
const [height, setHeight] = useState(0);
const [closedAnnouncements, setClosedAnnouncements] = useLocalStorage<
string[]
>("netbird-closed-announcements", []);
const [announcements, setAnnouncements] = useState();
- const { permission } = useLoggedInUser();
+ const { isRestricted } = usePermissions();
useEffect(() => {
if (announcements && announcements.length > 0) return;
- if (permission?.dashboard_view === "blocked") return;
+
+ if (isRestricted) return;
const initial = initialAnnouncements.map((announcement) => {
const hash = md5(announcement.text).toString();
const isOpen = !closedAnnouncements.some((h) => h === hash);
diff --git a/src/contexts/ApplicationProvider.tsx b/src/contexts/ApplicationProvider.tsx
index 073b5dca..31ecfae2 100644
--- a/src/contexts/ApplicationProvider.tsx
+++ b/src/contexts/ApplicationProvider.tsx
@@ -1,6 +1,6 @@
import { useOidcUser } from "@axa-fr/react-oidc";
import FullScreenLoading from "@components/ui/FullScreenLoading";
-import { useApiCall } from "@utils/api";
+import { Params, useApiCall } from "@utils/api";
import { useIsMd } from "@utils/responsive";
import { getLatestNetbirdRelease } from "@utils/version";
import React, {
@@ -26,6 +26,10 @@ const ApplicationContext = React.createContext(
toggleMobileNav: () => void;
mobileNavOpen: boolean;
user: any;
+ globalApiParams?: Params;
+ setGlobalApiParams?: (p?: Params) => void;
+ isNavigationCollapsed: boolean;
+ toggleNavigation: () => void;
},
);
@@ -36,11 +40,19 @@ export default function ApplicationProvider({ children }: Props) {
const { oidcUser: user } = useOidcUser();
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const isMd = useIsMd();
- const userRequest = useApiCall("/users", true);
+ const userRequest = useApiCall(`/users`, true);
const [show, setShow] = useState(false);
+ const [isNavigationCollapsed, setIsNavigationCollapsed] = useLocalStorage(
+ "netbird-nav-collapsed",
+ false,
+ );
const requestCalled = useRef(false);
const maxTries = 3;
+ const [globalApiParams, setGlobalApiParams] = useLocalStorage<
+ Params | undefined
+ >("netbird-api-params", undefined);
+
const populateCache = useCallback(
async (tries = 0) => {
if (tries >= maxTries) {
@@ -57,6 +69,10 @@ export default function ApplicationProvider({ children }: Props) {
[userRequest, setShow],
);
+ const toggleNavigation = useCallback(() => {
+ setIsNavigationCollapsed((prev) => !prev);
+ }, []);
+
useEffect(() => {
if (!requestCalled.current) {
populateCache().then();
@@ -98,7 +114,17 @@ export default function ApplicationProvider({ children }: Props) {
return show ? (
{children}
diff --git a/src/contexts/CountryProvider.tsx b/src/contexts/CountryProvider.tsx
index b51d7b9e..1b6d42a4 100644
--- a/src/contexts/CountryProvider.tsx
+++ b/src/contexts/CountryProvider.tsx
@@ -1,6 +1,6 @@
import useFetchApi from "@utils/api";
import React, { useCallback } from "react";
-import { useLoggedInUser } from "@/contexts/UsersProvider";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { Country } from "@/interfaces/Country";
import { Peer } from "@/interfaces/Peer";
@@ -13,17 +13,24 @@ const CountryContext = React.createContext(
countries: Country[] | undefined;
isLoading: boolean;
getRegionByPeer: (peer: Peer) => string;
+ getRegionText: (country_code: string, city_name: string) => string;
},
);
export default function CountryProvider({ children }: Props) {
- const { permission } = useLoggedInUser();
+ const { isRestricted } = usePermissions();
const getRegionByPeer = (peer: Peer) => "Unknown";
+ const getRegionText = (country_code: string, city_name: string) => "Unknown";
- return permission?.dashboard_view != "full" ? (
+ return isRestricted ? (
{children}
@@ -39,21 +46,28 @@ function CountryProviderContent({ children }: Props) {
false,
);
- const getRegionByPeer = useCallback(
- (peer: Peer) => {
+ const getRegionText = useCallback(
+ (country_code: string, city_name: string) => {
if (!countries) return "Unknown";
- const country = countries.find(
- (c) => c.country_code === peer.country_code,
- );
+ const country = countries.find((c) => c.country_code === country_code);
if (!country) return "Unknown";
- if (!peer.city_name) return country.country_name;
- return `${country.country_name}, ${peer.city_name}`;
+ if (!city_name) return country.country_name;
+ return `${country.country_name}, ${city_name}`;
},
[countries],
);
+ const getRegionByPeer = useCallback(
+ (peer: Peer) => {
+ return getRegionText(peer.country_code, peer.city_name);
+ },
+ [getRegionText],
+ );
+
return (
-
+
{children}
);
diff --git a/src/contexts/DialogProvider.tsx b/src/contexts/DialogProvider.tsx
index 48165166..0cf10d7d 100644
--- a/src/contexts/DialogProvider.tsx
+++ b/src/contexts/DialogProvider.tsx
@@ -26,6 +26,7 @@ type DialogOptions = {
cancelText?: string;
type?: "default" | "warning" | "danger" | "center";
children?: React.ReactNode;
+ maxWidthClass?: string;
};
export default function DialogProvider({ children }: Props) {
@@ -62,7 +63,10 @@ export default function DialogProvider({ children }: Props) {
onOpenChange={(open) => fn.current && fn.current(open)}
>
{dialogOptions && (
-
+
{children}>
) : (
- {children}
+ {children}
);
}
type ProviderContentProps = {
children: React.ReactNode;
- isUser: boolean;
};
export function GroupsProviderContent({
children,
- isUser,
}: Readonly) {
+ const { permission } = usePermissions();
+
const {
data: groups,
mutate,
isLoading,
- } = useFetchApi("/groups", false, true, !isUser);
+ } = useFetchApi("/groups", false, true, permission.groups.read);
const groupRequest = useApiCall("/groups", true);
const [dropdownOptions, setDropdownOptions] = useState([]);
@@ -103,10 +103,10 @@ export function GroupsProviderContent({
}) as string[];
let resources = group?.resources?.map((r) => {
- let isString = typeof r === "string";
- if (isString) return r;
- let resource = r as GroupResource;
- return resource.id;
+ let isString = typeof r === "string";
+ if (isString) return r;
+ let resource = r as GroupResource;
+ return resource.id;
}) as string[];
if (group.name === "All") return Promise.resolve(group);
diff --git a/src/contexts/PermissionsProvider.tsx b/src/contexts/PermissionsProvider.tsx
new file mode 100644
index 00000000..4856dee6
--- /dev/null
+++ b/src/contexts/PermissionsProvider.tsx
@@ -0,0 +1,39 @@
+import React, { useMemo } from "react";
+import { Permissions } from "@/interfaces/Permission";
+import { User } from "@/interfaces/User";
+
+type Props = {
+ children: React.ReactNode;
+ user: User;
+};
+
+const PermissionsContext = React.createContext(
+ {} as {
+ isRestricted: boolean;
+ permission: Permissions["modules"];
+ },
+);
+
+export default function PermissionsProvider({
+ children,
+ user,
+}: Readonly) {
+ const permissions = useMemo(() => {
+ return user.permissions;
+ }, [user]);
+
+ const data = useMemo(() => {
+ return {
+ isRestricted: permissions.is_restricted,
+ permission: permissions.modules,
+ };
+ }, [permissions]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export const usePermissions = () => React.useContext(PermissionsContext);
diff --git a/src/contexts/UsersProvider.tsx b/src/contexts/UsersProvider.tsx
index 67591516..19faab58 100644
--- a/src/contexts/UsersProvider.tsx
+++ b/src/contexts/UsersProvider.tsx
@@ -1,9 +1,14 @@
+import { useOidc } from "@axa-fr/react-oidc";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import useFetchApi from "@utils/api";
+import loadConfig from "@utils/config";
import React, { useMemo } from "react";
-import { Permission } from "@/interfaces/Permission";
+import { useApplicationContext } from "@/contexts/ApplicationProvider";
+import PermissionsProvider from "@/contexts/PermissionsProvider";
import { Role, User } from "@/interfaces/User";
+const config = loadConfig();
+
type Props = {
children: React.ReactNode;
};
@@ -12,44 +17,83 @@ const UsersContext = React.createContext(
{} as {
users: User[] | undefined;
refresh: () => void;
+ isLoading: boolean;
+ },
+);
+
+const UserProfileContext = React.createContext(
+ {} as {
loggedInUser: User | undefined;
},
);
-export default function UsersProvider({ children }: Props) {
+export default function UsersProvider({ children }: Readonly) {
const { data: users, mutate, isLoading } = useFetchApi("/users");
const refresh = () => {
mutate().then();
};
+ return (
+
+ {children}
+
+ );
+}
+
+export const useUsers = () => React.useContext(UsersContext);
+
+const UserProfileProvider = ({ children }: Props) => {
+ const { users, isLoading: isAllUsersLoading } = useUsers();
+ const {
+ data: user,
+ error,
+ isLoading,
+ } = useFetchApi("/users/current", true, true, true, {
+ key: "user-profile",
+ });
+
const loggedInUser = useMemo(() => {
- return users?.find((user) => user.is_current);
- }, [users]);
+ if (isLoading) return undefined;
+ if (user) return user;
+ if (isAllUsersLoading) return undefined;
+ if (!user || error) {
+ return users?.find((u) => u?.is_current);
+ }
+ }, [user, error, users, isLoading, isAllUsersLoading]);
+
+ const data = useMemo(() => {
+ return {
+ loggedInUser,
+ };
+ }, [loggedInUser]);
return !isLoading && loggedInUser ? (
-
- {children}
-
+
+ {children}
+
) : (
);
-}
+};
-export const useUsers = () => React.useContext(UsersContext);
+export const useUserProfile = () => React.useContext(UserProfileContext);
export const useLoggedInUser = () => {
- const { loggedInUser } = useUsers();
+ const { loggedInUser } = useUserProfile();
+ const { logout: oidcLogout } = useOidc();
+ const { setGlobalApiParams } = useApplicationContext();
const isOwner = loggedInUser ? loggedInUser?.role === Role.Owner : false;
const isAdmin = loggedInUser ? loggedInUser?.role === Role.Admin : false;
+
const isUser = !isOwner && !isAdmin;
const isOwnerOrAdmin = isOwner || isAdmin;
- const permission = useMemo(() => {
- return {
- dashboard_view: loggedInUser?.permissions.dashboard_view || "blocked",
- } as Permission;
- }, [loggedInUser]);
+ const logout = async () => {
+ return oidcLogout("/", { client_id: config.clientId }).then(() => {
+ setGlobalApiParams?.({});
+ });
+ };
return {
loggedInUser,
@@ -57,6 +101,6 @@ export const useLoggedInUser = () => {
isAdmin,
isUser,
isOwnerOrAdmin,
- permission,
+ logout,
} as const;
};
diff --git a/src/interfaces/Account.ts b/src/interfaces/Account.ts
index a484d17e..688dfb84 100644
--- a/src/interfaces/Account.ts
+++ b/src/interfaces/Account.ts
@@ -1,5 +1,9 @@
export interface Account {
id: string;
+ domain: string;
+ domain_category: string;
+ created_at: string;
+ created_by: string;
settings: {
extra: {
peer_approval_enabled: boolean;
diff --git a/src/interfaces/Pagination.ts b/src/interfaces/Pagination.ts
new file mode 100644
index 00000000..1651c037
--- /dev/null
+++ b/src/interfaces/Pagination.ts
@@ -0,0 +1,7 @@
+export interface Pagination {
+ data: T;
+ page: number;
+ page_size: number;
+ total_pages: number;
+ total_records: number;
+}
diff --git a/src/interfaces/Permission.ts b/src/interfaces/Permission.ts
index 5bf318ca..a0b80602 100644
--- a/src/interfaces/Permission.ts
+++ b/src/interfaces/Permission.ts
@@ -1,3 +1,43 @@
+export interface Permissions {
+ is_restricted: boolean;
+ modules: {
+ peers: Permission;
+ groups: Permission;
+
+ setup_keys: Permission;
+
+ policies: Permission;
+ assistant: Permission;
+
+ networks: Permission;
+ routes: Permission;
+ nameservers: Permission;
+ dns: Permission;
+
+ users: Permission;
+ pats: Permission;
+
+ events: Permission;
+
+ settings: Permission;
+ accounts: Permission;
+ billing: Permission;
+
+ edr: Permission;
+ event_streaming: Permission;
+ idp: Permission;
+
+ msp: Permission;
+ tenants: Permission;
+
+ proxy: Permission;
+ proxy_configuration: Permission;
+ };
+}
+
export interface Permission {
- dashboard_view: "limited" | "full" | "blocked";
+ create: boolean;
+ read: boolean;
+ update: boolean;
+ delete: boolean;
}
diff --git a/src/interfaces/User.ts b/src/interfaces/User.ts
index 84df502a..53d10ba5 100644
--- a/src/interfaces/User.ts
+++ b/src/interfaces/User.ts
@@ -1,4 +1,4 @@
-import { Permission } from "@/interfaces/Permission";
+import { Permissions } from "@/interfaces/Permission";
export interface User {
id: string;
@@ -11,11 +11,14 @@ export interface User {
is_service_user?: boolean;
is_blocked?: boolean;
last_login?: Date;
- permissions: Permission;
+ permissions: Permissions;
}
export enum Role {
User = "user",
Admin = "admin",
Owner = "owner",
+ BillingAdmin = "billing_admin",
+ Auditor = "auditor",
+ NetworkAdmin = "network_admin",
}
diff --git a/src/layouts/AppLayout.tsx b/src/layouts/AppLayout.tsx
index 0d94acec..35d3cd6c 100644
--- a/src/layouts/AppLayout.tsx
+++ b/src/layouts/AppLayout.tsx
@@ -6,12 +6,15 @@ import { TooltipProvider } from "@components/Tooltip";
import { cn } from "@utils/helpers";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
-import { Viewport } from "next/dist/lib/metadata/types/extra-types";
+import { Viewport } from "next";
import localFont from "next/font/local";
-import React from "react";
+import React, { Suspense } from "react";
import { Toaster } from "react-hot-toast";
import OIDCProvider from "@/auth/OIDCProvider";
-import AnalyticsProvider from "@/contexts/AnalyticsProvider";
+import FullScreenLoading from "@/components/ui/FullScreenLoading";
+import AnalyticsProvider, {
+ GoogleTagManagerHeadScript,
+} from "@/contexts/AnalyticsProvider";
import DialogProvider from "@/contexts/DialogProvider";
import ErrorBoundaryProvider from "@/contexts/ErrorBoundary";
import { GlobalThemeProvider } from "@/contexts/GlobalThemeProvider";
@@ -30,31 +33,38 @@ export const viewport: Viewport = {
initialScale: 1,
};
-export default function AppLayout({ children }: { children: React.ReactNode }) {
+export default function AppLayout({
+ children,
+}: Readonly<{ children: React.ReactNode }>) {
return (
+
+
+
-
-
-
-
-
-
- {children}
-
-
-
-
-
-
-
-
-
+ }>
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+
+
+
);
diff --git a/src/layouts/DashboardLayout.tsx b/src/layouts/DashboardLayout.tsx
index 5ec0d86c..3655ddb3 100644
--- a/src/layouts/DashboardLayout.tsx
+++ b/src/layouts/DashboardLayout.tsx
@@ -17,15 +17,16 @@ import ApplicationProvider, {
} from "@/contexts/ApplicationProvider";
import CountryProvider from "@/contexts/CountryProvider";
import GroupsProvider from "@/contexts/GroupsProvider";
-import UsersProvider, { useLoggedInUser } from "@/contexts/UsersProvider";
+import { usePermissions } from "@/contexts/PermissionsProvider";
+import UsersProvider from "@/contexts/UsersProvider";
import Navigation from "@/layouts/Navigation";
-import Navbar, { headerHeight } from "./Header";
+import Header, { headerHeight } from "./Header";
export default function DashboardLayout({
children,
-}: {
+}: Readonly<{
children: React.ReactNode;
-}) {
+}>) {
return (
@@ -41,14 +42,16 @@ export default function DashboardLayout({
);
}
-function DashboardPageContent({ children }: { children: React.ReactNode }) {
+function DashboardPageContent({
+ children,
+}: Readonly<{ children: React.ReactNode }>) {
const { oidcUser: user } = useOidcUser();
const { mobileNavOpen, toggleMobileNav } = useApplicationContext();
const isSm = useIsSm();
const isXs = useIsXs();
- const { permission } = useLoggedInUser();
+ const { isRestricted } = usePermissions();
- const navOpenPageWidth = isSm ? "50%" : isXs ? "65%" : "80%";
+ const navOpenPageWidth = isSm ? "45%" : isXs ? "60%" : "80%";
const { bannerHeight } = useAnnouncement();
return (
@@ -117,7 +120,7 @@ function DashboardPageContent({ children }: { children: React.ReactNode }) {
}}
animate={{
x: mobileNavOpen ? navOpenPageWidth : 0,
- width: mobileNavOpen ? "100%" : "100%",
+ width: "100%",
height: mobileNavOpen ? "90vh" : "auto",
y: mobileNavOpen ? "6.5%" : 0,
}}
@@ -150,17 +153,14 @@ function DashboardPageContent({ children }: { children: React.ReactNode }) {
mass: 0.1,
}}
>
-
-
+
- {permission.dashboard_view !== "blocked" && (
-
- )}
+ {!isRestricted && }
{children}
diff --git a/src/layouts/Header.tsx b/src/layouts/Header.tsx
index 60246417..d28e5cde 100644
--- a/src/layouts/Header.tsx
+++ b/src/layouts/Header.tsx
@@ -2,10 +2,9 @@
import Button from "@components/Button";
import { AnnouncementBanner } from "@components/ui/AnnouncementBanner";
-import DarkModeToggle from "@components/ui/DarkModeToggle";
import UserDropdown from "@components/ui/UserDropdown";
import { cn } from "@utils/helpers";
-import { MenuIcon } from "lucide-react";
+import { MenuIcon, PanelLeftCloseIcon, PanelLeftOpenIcon } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import React, { useMemo } from "react";
@@ -13,7 +12,7 @@ import NetBirdLogo from "@/assets/netbird.svg";
import NetBirdLogoFull from "@/assets/netbird-full.svg";
import { useAnnouncement } from "@/contexts/AnnouncementProvider";
import { useApplicationContext } from "@/contexts/ApplicationProvider";
-import { useLoggedInUser } from "@/contexts/UsersProvider";
+import { usePermissions } from "@/contexts/PermissionsProvider";
export const headerHeight = 75;
@@ -32,7 +31,7 @@ export default function NavbarWithDropdown() {
src={NetBirdLogo}
width={30}
alt={"NetBird Logo"}
- className={"md:hidden"}
+ className={"md:hidden ml-4"}
/>
>
);
@@ -40,7 +39,7 @@ export default function NavbarWithDropdown() {
const { toggleMobileNav } = useApplicationContext();
const { bannerHeight } = useAnnouncement();
- const { permission } = useLoggedInUser();
+ const { isRestricted } = usePermissions();
return (
<>
@@ -62,8 +61,7 @@ export default function NavbarWithDropdown() {
- router.push("/peers")}
- className={"cursor-pointer hover:opacity-70 transition-all"}
- >
- {Logo}
+
+ router.push("/peers")}
+ className={
+ "cursor-pointer hover:opacity-70 transition-all mr-auto"
+ }
+ >
+ {Logo}
+
+
-
@@ -97,3 +96,26 @@ export default function NavbarWithDropdown() {
>
);
}
+
+const ToggleCollapsableNavigationButton = () => {
+ const { isRestricted } = usePermissions();
+ const { toggleNavigation, isNavigationCollapsed } = useApplicationContext();
+
+ return (
+ !isRestricted && (
+
+ {isNavigationCollapsed ? (
+
+ ) : (
+
+ )}
+
+ )
+ );
+};
diff --git a/src/layouts/Navigation.tsx b/src/layouts/Navigation.tsx
index 1b3771ce..40509aa2 100644
--- a/src/layouts/Navigation.tsx
+++ b/src/layouts/Navigation.tsx
@@ -2,8 +2,6 @@
import { ScrollArea } from "@components/ScrollArea";
import { cn } from "@utils/helpers";
-import { CustomFlowbiteTheme, Sidebar } from "flowbite-react";
-import { SidebarItemGroupProps } from "flowbite-react/lib/esm/components/Sidebar/SidebarItemGroup";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import ActivityIcon from "@/assets/icons/ActivityIcon";
import DNSIcon from "@/assets/icons/DNSIcon";
@@ -14,16 +12,11 @@ import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
import TeamIcon from "@/assets/icons/TeamIcon";
import SidebarItem from "@/components/SidebarItem";
import { useAnnouncement } from "@/contexts/AnnouncementProvider";
-import { useLoggedInUser } from "@/contexts/UsersProvider";
+import { useApplicationContext } from "@/contexts/ApplicationProvider";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { headerHeight } from "@/layouts/Header";
import { NetworkNavigation } from "@/modules/networks/misc/NetworkNavigation";
-const customTheme: CustomFlowbiteTheme["sidebar"] = {
- root: {
- inner: "bg-gray-50 dark:bg-nb-gray",
- },
-};
-
type Props = {
fullWidth?: boolean;
hideOnMobile?: boolean;
@@ -32,28 +25,27 @@ type Props = {
export default function Navigation({
fullWidth = false,
hideOnMobile = false,
-}: Props) {
- const { isUser } = useLoggedInUser();
- const { isOwnerOrAdmin } = useLoggedInUser();
+}: Readonly
) {
const { bannerHeight } = useAnnouncement();
+ const { isNavigationCollapsed } = useApplicationContext();
+ const { permission, isRestricted } = usePermissions();
return (
-
-
+
}
label="Peers"
href={"/peers"}
+ visible={!isRestricted}
/>
- {!isUser && (
- <>
- }
- label="Setup Keys"
- href={"/setup-keys"}
- />
- }
- label="Access Control"
- collapsible
- >
-
-
-
+ }
+ label="Setup Keys"
+ href={"/setup-keys"}
+ visible={permission.setup_keys.read}
+ />
+ }
+ label="Access Control"
+ collapsible
+ visible={permission.policies.read}
+ >
+
+
+
-
+
- }
- label="DNS"
- collapsible
- exactPathMatch={true}
- >
-
-
-
- } label="Team" collapsible>
-
-
-
- }
- label="Activity"
- href={"/activity"}
- />
- >
- )}
+ }
+ label="DNS"
+ collapsible
+ exactPathMatch={true}
+ visible={permission.dns.read || permission.nameservers.read}
+ >
+
+
+
+ }
+ label="Team"
+ collapsible
+ visible={permission.users.read}
+ >
+
+
+
+ }
+ label="Activity"
+ href={"/events/audit"}
+ exactPathMatch={true}
+ visible={permission.events.read}
+ />
- {isOwnerOrAdmin && (
- }
- label="Settings"
- href={"/settings"}
- exactPathMatch={true}
- />
- )}
+ }
+ label="Settings"
+ href={"/settings"}
+ exactPathMatch={true}
+ visible={permission.settings.read}
+ />
}
href={"https://docs.netbird.io/"}
target={"_blank"}
label="Documentation"
+ visible={true}
/>
-
-
+
+
);
}
-export function SidebarItemGroup(props: SidebarItemGroupProps) {
+type SidebarItemGroupProps = {
+ children: React.ReactNode;
+};
+
+export function SidebarItemGroup({ children }: SidebarItemGroupProps) {
return (
-
- {props.children}
-
+ {children}
+
);
}
diff --git a/src/layouts/PageContainer.tsx b/src/layouts/PageContainer.tsx
index e033e50d..5ba1e926 100644
--- a/src/layouts/PageContainer.tsx
+++ b/src/layouts/PageContainer.tsx
@@ -1,17 +1,22 @@
import { cn } from "@utils/helpers";
import React from "react";
+import { useApplicationContext } from "@/contexts/ApplicationProvider";
type Props = {
children: React.ReactNode;
className?: string;
};
-export default function PageContainer({ children, className }: Props) {
+export default function PageContainer({
+ children,
+ className,
+}: Readonly) {
+ const { isNavigationCollapsed } = useApplicationContext();
return (
{children}
diff --git a/src/modules/access-control/AccessControlModal.tsx b/src/modules/access-control/AccessControlModal.tsx
index b86efc59..5bebc257 100644
--- a/src/modules/access-control/AccessControlModal.tsx
+++ b/src/modules/access-control/AccessControlModal.tsx
@@ -41,6 +41,7 @@ import {
} from "lucide-react";
import React, { useMemo, useState } from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import { Policy, Protocol } from "@/interfaces/Policy";
import { PostureCheck } from "@/interfaces/PostureCheck";
@@ -126,6 +127,8 @@ export function AccessControlModalContent({
initialName,
initialDescription,
}: Readonly
) {
+ const { permission } = usePermissions();
+
const {
portAndDirectionDisabled,
destinationGroups,
@@ -250,6 +253,9 @@ export function AccessControlModalContent({
@@ -344,6 +356,9 @@ export function AccessControlModalContent({
@@ -373,6 +388,9 @@ export function AccessControlModalContent({
data-cy={"policy-name"}
onChange={(e) => setName(e.target.value)}
placeholder={"e.g., Devs to Servers"}
+ disabled={
+ !permission.policies.update || !permission.policies.create
+ }
/>
@@ -388,6 +406,9 @@ export function AccessControlModalContent({
"e.g., Devs are allowed to access servers and servers are allowed to access Devs."
}
rows={3}
+ disabled={
+ !permission.policies.update || !permission.policies.create
+ }
/>
@@ -453,7 +474,7 @@ export function AccessControlModalContent({
@@ -470,7 +491,7 @@ export function AccessControlModalContent({
{
if (useSave) {
submit();
diff --git a/src/modules/access-control/table/AccessControlActionCell.tsx b/src/modules/access-control/table/AccessControlActionCell.tsx
index 41c9ce6a..c85d715f 100644
--- a/src/modules/access-control/table/AccessControlActionCell.tsx
+++ b/src/modules/access-control/table/AccessControlActionCell.tsx
@@ -5,16 +5,18 @@ import { Trash2 } from "lucide-react";
import * as React from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { Policy } from "@/interfaces/Policy";
import { Route } from "@/interfaces/Route";
type Props = {
policy: Policy;
};
-export default function AccessControlActionCell({ policy }: Props) {
+export default function AccessControlActionCell({ policy }: Readonly) {
const { confirm } = useDialog();
const policyRequest = useApiCall("/policies");
const { mutate } = useSWRConfig();
+ const { permission } = usePermissions();
const deleteRule = async () => {
notify({
@@ -42,7 +44,12 @@ export default function AccessControlActionCell({ policy }: Props) {
return (
-
+
Delete
diff --git a/src/modules/access-control/table/AccessControlActiveCell.tsx b/src/modules/access-control/table/AccessControlActiveCell.tsx
index ef2b3698..18c8cb2c 100644
--- a/src/modules/access-control/table/AccessControlActiveCell.tsx
+++ b/src/modules/access-control/table/AccessControlActiveCell.tsx
@@ -2,6 +2,7 @@ import { ToggleSwitch } from "@components/ToggleSwitch";
import { cloneDeep } from "@utils/helpers";
import React, { useMemo } from "react";
import { mutate } from "swr";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { usePolicies } from "@/contexts/PoliciesProvider";
import { Group } from "@/interfaces/Group";
import { Policy } from "@/interfaces/Policy";
@@ -11,6 +12,7 @@ type Props = {
};
export default function AccessControlActiveCell({ policy }: Readonly) {
const { updatePolicy } = usePolicies();
+ const { permission } = usePermissions();
const isChecked = useMemo(() => {
return policy.enabled;
@@ -52,6 +54,7 @@ export default function AccessControlActiveCell({ policy }: Readonly) {
return (
update(!isChecked)}
diff --git a/src/modules/access-control/table/AccessControlTable.tsx b/src/modules/access-control/table/AccessControlTable.tsx
index b0fe62dc..dcf7d714 100644
--- a/src/modules/access-control/table/AccessControlTable.tsx
+++ b/src/modules/access-control/table/AccessControlTable.tsx
@@ -13,6 +13,7 @@ import { usePathname } from "next/navigation";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import type { Policy } from "@/interfaces/Policy";
import AccessControlModal, {
@@ -166,9 +167,10 @@ export default function AccessControlTable({
policies,
isLoading,
headingTarget,
-}: Props) {
+}: Readonly) {
const { mutate } = useSWRConfig();
const path = usePathname();
+ const { permission } = usePermissions();
// Default sorting state of the table
const [sorting, setSorting] = useLocalStorage(
@@ -230,7 +232,10 @@ export default function AccessControlTable({
button={
-
+
Add Policy
@@ -256,7 +261,11 @@ export default function AccessControlTable({
{policies && policies?.length > 0 && (
-
+
Add Policy
diff --git a/src/modules/access-control/useAccessControl.ts b/src/modules/access-control/useAccessControl.ts
index 0505e957..06a403c8 100644
--- a/src/modules/access-control/useAccessControl.ts
+++ b/src/modules/access-control/useAccessControl.ts
@@ -169,7 +169,10 @@ export const useAccessControl = ({
);
const groups = await Promise.all(
createOrUpdateGroups.map((call) => call()),
- );
+ ).then((groups) => {
+ mutate("/groups");
+ return groups;
+ });
// Create posture checks if they don't have an ID
let hasError = false;
diff --git a/src/modules/access-tokens/AccessTokenActionCell.tsx b/src/modules/access-tokens/AccessTokenActionCell.tsx
index fc63a101..93d8054c 100644
--- a/src/modules/access-tokens/AccessTokenActionCell.tsx
+++ b/src/modules/access-tokens/AccessTokenActionCell.tsx
@@ -5,6 +5,7 @@ import { Trash2 } from "lucide-react";
import * as React from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { useUserContext } from "@/contexts/UserProvider";
import { AccessToken } from "@/interfaces/AccessToken";
import { SetupKey } from "@/interfaces/SetupKey";
@@ -12,8 +13,11 @@ import { SetupKey } from "@/interfaces/SetupKey";
type Props = {
access_token: AccessToken;
};
-export default function AccessTokenActionCell({ access_token }: Props) {
+export default function AccessTokenActionCell({
+ access_token,
+}: Readonly) {
const { user } = useUserContext();
+ const { permission } = usePermissions();
const { confirm } = useDialog();
const { mutate } = useSWRConfig();
const deleteRequest = useApiCall(
@@ -47,6 +51,7 @@ export default function AccessTokenActionCell({ access_token }: Props) {
return (
[] = [
},
];
-export default function AccessTokensTable({ user }: Props) {
+export default function AccessTokensTable({ user }: Readonly) {
const { data: tokens } = useFetchApi(
`/users/${user.id}/tokens`,
+ true,
);
const path = usePathname();
@@ -83,35 +84,33 @@ export default function AccessTokensTable({ user }: Props) {
);
return (
- <>
-
-
- {tokens && tokens.length > 0 ? (
-
+
+ {tokens && tokens.length > 0 ? (
+
+ ) : (
+
+
}
/>
- ) : (
-
- }
- />
-
- )}
-
-
- >
+
+ )}
+
+
);
}
diff --git a/src/modules/access-tokens/CreateAccessTokenModal.tsx b/src/modules/access-tokens/CreateAccessTokenModal.tsx
index 876e0a59..05a92bae 100644
--- a/src/modules/access-tokens/CreateAccessTokenModal.tsx
+++ b/src/modules/access-tokens/CreateAccessTokenModal.tsx
@@ -37,7 +37,10 @@ type Props = {
user: User;
};
const copyMessage = "Access token was copied to your clipboard!";
-export default function CreateAccessTokenModal({ children, user }: Props) {
+export default function CreateAccessTokenModal({
+ children,
+ user,
+}: Readonly) {
const [modal, setModal] = useState(false);
const [successModal, setSuccessModal] = useState(false);
const [token, setToken] = useState("");
@@ -125,7 +128,10 @@ type ModalProps = {
user: User;
};
-export function AccessTokenModalContent({ onSuccess, user }: ModalProps) {
+export function AccessTokenModalContent({
+ onSuccess,
+ user,
+}: Readonly) {
const tokenRequest = useApiCall(`/users/${user.id}/tokens`);
const { mutate } = useSWRConfig();
diff --git a/src/modules/account/useAccount.tsx b/src/modules/account/useAccount.tsx
index cc3f89f3..4b78db9d 100644
--- a/src/modules/account/useAccount.tsx
+++ b/src/modules/account/useAccount.tsx
@@ -1,9 +1,17 @@
import useFetchApi from "@utils/api";
import { useMemo } from "react";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { Account } from "@/interfaces/Account";
export const useAccount = () => {
- const { data: accounts } = useFetchApi("/accounts", true, true);
+ const { permission } = usePermissions();
+
+ const { data: accounts } = useFetchApi(
+ "/accounts",
+ true,
+ true,
+ permission.accounts.read,
+ );
return useMemo(() => {
if (!accounts) return;
diff --git a/src/modules/activity/ActivityEntryRow.tsx b/src/modules/activity/ActivityEntryRow.tsx
index 74aa3a58..5a10a045 100644
--- a/src/modules/activity/ActivityEntryRow.tsx
+++ b/src/modules/activity/ActivityEntryRow.tsx
@@ -1,33 +1,59 @@
import Card from "@components/Card";
+import { SmallBadge } from "@components/ui/SmallBadge";
import TextWithTooltip from "@components/ui/TextWithTooltip";
-import { cn, generateColorFromString } from "@utils/helpers";
+import { cn, generateColorFromUser } from "@utils/helpers";
import dayjs from "dayjs";
import { AlertCircle, ArrowUpRight, Cog, PlusIcon, XIcon } from "lucide-react";
import React, { useMemo } from "react";
import { useUsers } from "@/contexts/UsersProvider";
import { ActivityEvent } from "@/interfaces/ActivityEvent";
+import { User } from "@/interfaces/User";
import ActivityDescription from "@/modules/activity/ActivityDescription";
import ActivityTypeIcon from "@/modules/activity/ActivityTypeIcon";
import { getColorFromCode } from "@/modules/activity/utils";
+export type ActionColor = "green" | "red" | "blue-darker" | "netbird";
+
+const ActionIcons: Record = {
+ green: ,
+ red: ,
+ "blue-darker": ,
+ netbird: ,
+};
+
export const ActivityEntryRow = ({ event }: { event: ActivityEvent }) => {
const { users } = useUsers();
- const user = users
- ? users.find((user) => user.id === event.initiator_id)
- : undefined;
+ const getActivityUser = () => {
+ let user;
+ const findFromCurrentUsers = users?.find(
+ (user) => user.id === event.initiator_id,
+ );
+ if (findFromCurrentUsers) {
+ user = findFromCurrentUsers;
+ return user;
+ }
- const icons = {
- green: ,
- "blue-darker": ,
- red: ,
- netbird: ,
+ // Check if user has an email & name
+ if (event?.initiator_email && event?.initiator_name) {
+ return {
+ id: event.initiator_id,
+ email: event.initiator_email,
+ name: event.initiator_name,
+ } as User;
+ }
+
+ return undefined;
};
+ const user = getActivityUser();
+
const color = useMemo(() => {
return getColorFromCode(event.activity_code);
}, [event.activity_code]);
+ const isExternal = !!event?.meta?.external;
+
return (
@@ -47,7 +73,7 @@ export const ActivityEntryRow = ({ event }: { event: ActivityEvent }) => {
color == "netbird" && "bg-netbird-950 text-netbird-500",
)}
>
- {color && icons[color]}
+ {color && ActionIcons[color as ActionColor]}
@@ -60,11 +86,7 @@ export const ActivityEntryRow = ({ event }: { event: ActivityEvent }) => {
"w-4 h-4 rounded-full flex items-center justify-center text-white uppercase text-[9px] font-medium bg-nb-gray-900"
}
style={{
- color: user?.name
- ? generateColorFromString(
- user?.name || user?.id || "System User",
- )
- : "#808080",
+ color: generateColorFromUser(user),
}}
>
{!user?.name && !user?.id && }
@@ -80,6 +102,17 @@ export const ActivityEntryRow = ({ event }: { event: ActivityEvent }) => {
+ {isExternal && (
+
+
+
+ )}
diff --git a/src/modules/activity/ActivityEventCodeSelector.tsx b/src/modules/activity/ActivityEventCodeSelector.tsx
index ba080e2e..37f83867 100644
--- a/src/modules/activity/ActivityEventCodeSelector.tsx
+++ b/src/modules/activity/ActivityEventCodeSelector.tsx
@@ -3,7 +3,6 @@ import { Checkbox } from "@components/Checkbox";
import { CommandItem } from "@components/Command";
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
import { ScrollArea } from "@components/ScrollArea";
-import { IconArrowBack } from "@tabler/icons-react";
import { cn } from "@utils/helpers";
import { Command, CommandGroup, CommandInput, CommandList } from "cmdk";
import { trim, uniqBy } from "lodash";
@@ -116,7 +115,7 @@ export function ActivityEventCodeSelector({
"min-h-[42px] w-full relative",
"border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center",
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
- "dark:placeholder:text-neutral-500 font-light placeholder:text-neutral-500 pl-10",
+ "dark:placeholder:text-nb-gray-400 font-light placeholder:text-nb-gray-500 pl-10",
)}
ref={searchRef}
value={search}
@@ -132,19 +131,6 @@ export function ActivityEventCodeSelector({
-
{
+ const uniqueUsers = uniqBy(events, (event) => event.initiator_email);
+ return uniqueUsers.map((event) => {
+ return {
+ name: event.initiator_name,
+ id: event.initiator_id,
+ email: event.initiator_email || "NetBird",
+ external: !!event?.meta?.external,
+ } as UserSelectOption;
+ });
+ }, [events]);
+
return (
)}
{events && (
- {
- mutate("/events").then();
+ mutate("/events/audit").then();
}}
/>
>
diff --git a/src/modules/activity/ActivityTypeIcon.tsx b/src/modules/activity/ActivityTypeIcon.tsx
index b21ab4c1..2ae40794 100644
--- a/src/modules/activity/ActivityTypeIcon.tsx
+++ b/src/modules/activity/ActivityTypeIcon.tsx
@@ -10,6 +10,7 @@ import {
KeyRound,
Layers3Icon,
LogIn,
+ type LucideIcon,
MonitorSmartphoneIcon,
NetworkIcon,
RefreshCcw,
@@ -28,79 +29,44 @@ type Props = {
const DEFAULT_CLASSES = "shrink-0";
+type ActivityTypeKey = keyof typeof ActivityTypeMappings;
+
+const ActivityTypeMappings = {
+ peer: MonitorSmartphoneIcon,
+ user: User,
+ account: Cog,
+ rule: ArrowLeftRight,
+ policy: Shield,
+ setupkey: KeyRound,
+ group: FolderGit2,
+ route: NetworkIcon,
+ dns: Globe,
+ nameserver: Server,
+ dashboard: LogIn,
+ integration: Blocks,
+ personal: User,
+ service: Cog,
+ billing: CreditCardIcon,
+ integrated: ShieldCheck,
+ posture: ShieldCheck,
+ transferred: RefreshCcw,
+ resource: Layers3Icon,
+ network: NetworkIcon,
+} as const satisfies Record;
+
export default function ActivityTypeIcon({
code,
size = 18,
className,
}: Props) {
- if (code.startsWith("peer")) {
- return (
-
- );
- } else if (code.startsWith("user")) {
- return ;
- } else if (code.startsWith("account")) {
- return ;
- } else if (code.startsWith("rule")) {
- return (
-
- );
- } else if (code.startsWith("policy")) {
- return ;
- } else if (code.startsWith("setupkey")) {
- return ;
- } else if (code.startsWith("group")) {
- return (
-
- );
- } else if (code.startsWith("route")) {
- return (
-
- );
- } else if (code.startsWith("dns")) {
- return ;
- } else if (code.startsWith("nameserver")) {
- return ;
- } else if (code.startsWith("dashboard")) {
- return ;
- } else if (code.startsWith("integration")) {
- return ;
- } else if (code.startsWith("account")) {
- return ;
- } else if (code.startsWith("personal")) {
- return ;
- } else if (code.startsWith("service")) {
- return ;
- } else if (code.startsWith("billing")) {
- return (
-
- );
- } else if (code.startsWith("integrated")) {
- return (
-
- );
- } else if (code.startsWith("posture")) {
- return (
-
- );
- } else if (code.startsWith("transferred")) {
- return (
-
- );
- } else if (code.startsWith("resource")) {
- return (
-
- );
- } else if (code.startsWith("network")) {
- return (
-
- );
- } else {
- return (
-
- );
- }
+ const prefixParts = code?.split(".") || [];
+ const prefix = (prefixParts[0] || "").toLowerCase();
+
+ // Check if prefix is a valid key, otherwise use fallback
+ const Icon =
+ prefix in ActivityTypeMappings
+ ? ActivityTypeMappings[prefix as ActivityTypeKey]
+ : HelpCircleIcon;
+
+ return ;
}
diff --git a/src/modules/activity/ActivityUserSelector.tsx b/src/modules/activity/UsersDropdownSelector.tsx
similarity index 70%
rename from src/modules/activity/ActivityUserSelector.tsx
rename to src/modules/activity/UsersDropdownSelector.tsx
index 1805782f..34f24ca9 100644
--- a/src/modules/activity/ActivityUserSelector.tsx
+++ b/src/modules/activity/UsersDropdownSelector.tsx
@@ -2,31 +2,39 @@ import Button from "@components/Button";
import { CommandItem } from "@components/Command";
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 { IconArrowBack } from "@tabler/icons-react";
import { cn, generateColorFromString } from "@utils/helpers";
import { Command, CommandGroup, CommandInput, CommandList } from "cmdk";
-import { trim, uniqBy } from "lodash";
+import { sortBy, trim, uniqBy } from "lodash";
import { ChevronsUpDown, Cog, SearchIcon, UserCircle2 } from "lucide-react";
import * as React from "react";
import { useMemo, useState } from "react";
import { useElementSize } from "@/hooks/useElementSize";
-import { ActivityEvent } from "@/interfaces/ActivityEvent";
+import { SmallUserAvatar } from "@/modules/users/SmallUserAvatar";
-interface MultiSelectProps {
+interface Props {
value?: string;
onChange: (item: string | undefined) => void;
disabled?: boolean;
popoverWidth?: "auto" | number;
- events: ActivityEvent[];
+ options: UserSelectOption[];
}
-export function ActivityUserSelector({
+
+export type UserSelectOption = {
+ id: string;
+ name: string;
+ email: string;
+ external?: boolean;
+};
+
+export function UsersDropdownSelector({
onChange,
value,
disabled = false,
popoverWidth = 250,
- events,
-}: MultiSelectProps) {
+ options,
+}: Props) {
const searchRef = React.useRef(null);
const [inputRef, { width }] = useElementSize();
const [search, setSearch] = useState("");
@@ -44,20 +52,16 @@ export function ActivityUserSelector({
const [open, setOpen] = useState(false);
- const users = useMemo(() => {
- const uniqueUsers = uniqBy(events, (event) => event.initiator_email);
- return uniqueUsers.map((event) => {
- return {
- name: event.initiator_name,
- id: event.initiator_id,
- email: event.initiator_email || "NetBird",
- };
- });
- }, [events]);
+ const sortedOptions = useMemo(() => {
+ return sortBy(
+ uniqBy(options, (o) => o.email),
+ ["external", "name"],
+ );
+ }, [options]);
const selectedUser = useMemo(() => {
- return users.find((user) => user.email == value);
- }, [value, users]);
+ return options.find((user) => user.email == value);
+ }, [value, options]);
return (
{selectedUser?.email === "NetBird" ? (
@@ -103,8 +108,13 @@ export function ActivityUserSelector({
@@ -117,7 +127,7 @@ export function ActivityUserSelector({
-
- {users.map((user) => {
- const searchValue =
- user.email === "NetBird"
- ? "NetBird System"
- : user.name + " " + user.id + " " + user.email;
+ {sortedOptions.map((user) => {
+ const isSystemUser = user.email === "NetBird";
+ const searchValue = isSystemUser
+ ? "NetBird System"
+ : user.name + " " + user.id + " " + user.email;
+
return (
e.preventDefault()}
>
-
-
- {user?.email === "NetBird" ? (
-
- ) : (
- user?.name?.charAt(0) || user?.id?.charAt(0)
- )}
-
+
+
-
-
+
+
-
+
+ {user.external && (
+
+
+
+ )}
);
diff --git a/src/modules/activity/utils.ts b/src/modules/activity/utils.ts
index 86bec4d7..2e291208 100644
--- a/src/modules/activity/utils.ts
+++ b/src/modules/activity/utils.ts
@@ -1,36 +1,48 @@
-export function getColorFromCode(code: string) {
- if (code.includes("add")) {
- return "green";
- } else if (code.includes("join")) {
- return "green";
- } else if (code.includes("invite")) {
- return "green";
- } else if (code.includes("create")) {
- return "green";
- } else if (code.includes("delete")) {
- return "red";
- } else if (code.includes("update")) {
- return "blue-darker";
- } else if (code.includes("revoke")) {
- return "red";
- } else if (code.includes("overuse")) {
- return "netbird";
- } else if (code.includes("overuse")) {
- return "netbird";
- } else if (code.includes("enable")) {
- return "blue-darker";
- } else if (code.includes("disable")) {
- return "blue-darker";
- } else if (code.includes("rename")) {
- return "blue-darker";
- } else if (code.includes("block")) {
- return "red";
- } else if (code.includes("unblock")) {
- return "blue-darker";
- } else if (code.includes("login")) {
- return "blue-darker";
- } else if (code.includes("expire")) {
- return "netbird";
+enum ActionStatus {
+ SUCCESS = "green",
+ ERROR = "red",
+ INFO = "blue-darker",
+ WARNING = "netbird",
+}
+
+const ACTION_COLOR_MAPPING: Record
= {
+ // Success actions
+ add: ActionStatus.SUCCESS,
+ join: ActionStatus.SUCCESS,
+ invite: ActionStatus.SUCCESS,
+ create: ActionStatus.SUCCESS,
+ approve: ActionStatus.SUCCESS,
+ complete: ActionStatus.SUCCESS,
+ activate: ActionStatus.SUCCESS,
+
+ // Error actions
+ delete: ActionStatus.ERROR,
+ revoke: ActionStatus.ERROR,
+ block: ActionStatus.ERROR,
+
+ // Warning actions
+ overuse: ActionStatus.WARNING,
+ expire: ActionStatus.WARNING,
+
+ // Info actions
+ update: ActionStatus.INFO,
+ enable: ActionStatus.INFO,
+ disable: ActionStatus.INFO,
+ rename: ActionStatus.INFO,
+ unblock: ActionStatus.INFO,
+ login: ActionStatus.INFO,
+};
+
+export function getColorFromCode(code: string): string {
+ try {
+ const matchingAction = Object.keys(ACTION_COLOR_MAPPING).find((action) =>
+ code.includes(action),
+ );
+
+ return matchingAction
+ ? ACTION_COLOR_MAPPING[matchingAction]
+ : ActionStatus.INFO;
+ } catch (error) {
+ return ActionStatus.INFO;
}
- return "blue-darker";
}
diff --git a/src/modules/common-table-rows/ActiveInactiveRow.tsx b/src/modules/common-table-rows/ActiveInactiveRow.tsx
index 5c58c5a0..b391b726 100644
--- a/src/modules/common-table-rows/ActiveInactiveRow.tsx
+++ b/src/modules/common-table-rows/ActiveInactiveRow.tsx
@@ -38,7 +38,7 @@ export default function ActiveInactiveRow({
) {
const { groups: allGroups } = useGroups();
- const { isUser } = useLoggedInUser();
+ const { permission } = usePermissions();
// Get the group by the id
const foundGroups = useMemo(() => {
@@ -62,7 +64,7 @@ export default function GroupsRow({
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
- setModal && !isUser && setModal(true);
+ setModal && permission.groups.update && setModal(true);
}}
>
{foundGroups?.length == 0 && showAddGroupButton ? (
@@ -81,6 +83,7 @@ export default function GroupsRow({
description={description}
peer={peer}
hideAllGroup={hideAllGroup}
+ disabled={disabled}
/>
);
@@ -93,6 +96,7 @@ type EditGroupsModalProps = {
description?: string;
peer?: Peer;
hideAllGroup?: boolean;
+ disabled: boolean;
};
export function EditGroupsModal({
@@ -102,7 +106,8 @@ export function EditGroupsModal({
description,
peer,
hideAllGroup = false,
-}: EditGroupsModalProps) {
+ disabled,
+}: Readonly) {
const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] =
useGroupHelper({
initial: groups,
@@ -141,7 +146,7 @@ export function EditGroupsModal({
Cancel
-
+
Save Groups
diff --git a/src/modules/dns-nameservers/NameserverModal.tsx b/src/modules/dns-nameservers/NameserverModal.tsx
index 844e4cf0..3ddaa9d9 100644
--- a/src/modules/dns-nameservers/NameserverModal.tsx
+++ b/src/modules/dns-nameservers/NameserverModal.tsx
@@ -36,6 +36,7 @@ import {
import React, { useEffect, useMemo, useReducer, useState } from "react";
import { useSWRConfig } from "swr";
import DNSIcon from "@/assets/icons/DNSIcon";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { Nameserver, NameserverGroup } from "@/interfaces/Nameserver";
import useGroupHelper from "@/modules/groups/useGroupHelper";
@@ -53,20 +54,18 @@ export default function NameserverModal({
open,
onOpenChange,
cell,
-}: Props) {
+}: Readonly
) {
return (
- <>
-
- {children && {children}}
- {open && (
- onOpenChange(false)}
- preset={preset}
- cell={cell}
- />
- )}
-
- >
+
+ {children && {children}}
+ {open && (
+ onOpenChange(false)}
+ preset={preset}
+ cell={cell}
+ />
+ )}
+
);
}
@@ -102,7 +101,8 @@ export function NameserverModalContent({
onSuccess,
preset,
cell,
-}: ModalProps) {
+}: Readonly) {
+ const { permission } = usePermissions();
const nsRequest = useApiCall("/dns/nameservers", true);
const { mutate } = useSWRConfig();
@@ -243,6 +243,12 @@ export function NameserverModalContent({
return !(!canContinueToGeneral || nameLengthError !== "" || name == "");
}, [canContinueToGeneral, nameLengthError, name]);
+ const canAction = useMemo(() => {
+ return isUpdate
+ ? permission.nameservers.update
+ : permission.nameservers.create;
+ }, [isUpdate, permission]);
+
return (
setNameservers({ type: "UPDATE", index: i, ns })
}
@@ -311,7 +318,7 @@ export function NameserverModalContent({
)}
= 3}
+ disabled={nameservers.length >= 3 || !canAction}
variant={"dotted"}
className={"w-full"}
size={"sm"}
@@ -328,7 +335,11 @@ export function NameserverModalContent({
Advertise this nameserver to peers that belong to the following
groups
-
+
}
helpText={"Use this switch to enable or disable the nameserver."}
+ disabled={!canAction}
/>
@@ -370,6 +382,7 @@ export function NameserverModalContent({
onRemove={() =>
setDomains({ type: "REMOVE", index: i })
}
+ disabled={!canAction}
/>
);
})}
@@ -382,6 +395,7 @@ export function NameserverModalContent({
className={"w-full"}
size={"sm"}
onClick={() => setDomains({ type: "ADD" })}
+ disabled={!canAction}
>
Add Domain
@@ -405,6 +419,7 @@ export function NameserverModalContent({
helpText={
"E.g., 'peer.example.com' will be accessible with 'peer'"
}
+ disabled={!canAction}
/>
@@ -421,6 +436,7 @@ export function NameserverModalContent({
placeholder={"e.g., Public DNS"}
value={name}
onChange={(e) => setName(e.target.value)}
+ disabled={!canAction}
/>
@@ -435,6 +451,7 @@ export function NameserverModalContent({
}
value={description}
rows={3}
+ disabled={!canAction}
onChange={(e) => setDescription(e.target.value)}
/>
@@ -504,7 +521,7 @@ export function NameserverModalContent({
@@ -520,7 +537,7 @@ export function NameserverModalContent({
Save Changes
@@ -538,12 +555,14 @@ function NameserverInput({
onChange,
onRemove,
onError,
-}: {
+ disabled,
+}: Readonly<{
value: Nameserver;
onChange: (ns: Nameserver) => void;
onRemove: () => void;
onError?: (error: boolean) => void;
-}) {
+ disabled?: boolean;
+}>) {
const [ip, setIP] = useState(value.ip);
const [port, setPort] = useState(value.port.toString());
@@ -586,6 +605,7 @@ function NameserverInput({
className={"font-mono !text-[13px]"}
error={cidrError}
onChange={handleIPChange}
+ disabled={disabled}
/>
@@ -596,11 +616,13 @@ function NameserverInput({
value={port}
type={"number"}
onChange={handlePortChange}
+ disabled={disabled}
/>
diff --git a/src/modules/dns-nameservers/table/NameserverActionCell.tsx b/src/modules/dns-nameservers/table/NameserverActionCell.tsx
index 633efecc..b5d1777a 100644
--- a/src/modules/dns-nameservers/table/NameserverActionCell.tsx
+++ b/src/modules/dns-nameservers/table/NameserverActionCell.tsx
@@ -5,15 +5,17 @@ import { Trash2 } from "lucide-react";
import * as React from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { NameserverGroup } from "@/interfaces/Nameserver";
type Props = {
ns: NameserverGroup;
};
-export default function NameserverActionCell({ ns }: Props) {
+export default function NameserverActionCell({ ns }: Readonly
) {
const { confirm } = useDialog();
const nsRequest = useApiCall("/dns/nameservers");
const { mutate } = useSWRConfig();
+ const { permission } = usePermissions();
const deleteRule = async () => {
notify({
@@ -41,7 +43,12 @@ export default function NameserverActionCell({ ns }: Props) {
return (
-
+
Delete
diff --git a/src/modules/dns-nameservers/table/NameserverActiveCell.tsx b/src/modules/dns-nameservers/table/NameserverActiveCell.tsx
index 14fa4e0b..d4ab380e 100644
--- a/src/modules/dns-nameservers/table/NameserverActiveCell.tsx
+++ b/src/modules/dns-nameservers/table/NameserverActiveCell.tsx
@@ -3,14 +3,16 @@ import { ToggleSwitch } from "@components/ToggleSwitch";
import { useApiCall } from "@utils/api";
import React, { useMemo } from "react";
import { useSWRConfig } from "swr";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { NameserverGroup } from "@/interfaces/Nameserver";
type Props = {
ns: NameserverGroup;
};
-export default function NameserverActiveCell({ ns }: Props) {
+export default function NameserverActiveCell({ ns }: Readonly) {
const nsRequest = useApiCall("/dns/nameservers");
const { mutate } = useSWRConfig();
+ const { permission } = usePermissions();
const update = async (enabled: boolean) => {
notify({
@@ -47,6 +49,7 @@ export default function NameserverActiveCell({ ns }: Props) {
return (
update(!isChecked)}
diff --git a/src/modules/dns-nameservers/table/NameserverGroupTable.tsx b/src/modules/dns-nameservers/table/NameserverGroupTable.tsx
index 17687477..7ea41551 100644
--- a/src/modules/dns-nameservers/table/NameserverGroupTable.tsx
+++ b/src/modules/dns-nameservers/table/NameserverGroupTable.tsx
@@ -13,6 +13,7 @@ import { usePathname } from "next/navigation";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import DNSIcon from "@/assets/icons/DNSIcon";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { NameserverGroup } from "@/interfaces/Nameserver";
import NameserverModal from "@/modules/dns-nameservers/NameserverModal";
@@ -96,9 +97,10 @@ export default function NameserverGroupTable({
nameserverGroups,
isLoading,
headingTarget,
-}: Props) {
+}: Readonly) {
const { mutate } = useSWRConfig();
const path = usePathname();
+ const { permission } = usePermissions();
// Default sorting state of the table
const [sorting, setSorting] = useLocalStorage(
@@ -161,7 +163,11 @@ export default function NameserverGroupTable({
-
+
Add Nameserver
@@ -189,7 +195,11 @@ export default function NameserverGroupTable({
<>
{nameserverGroups && nameserverGroups?.length > 0 && (
-
+
Add Nameserver
diff --git a/src/modules/exit-node/AddExitNodeButton.tsx b/src/modules/exit-node/AddExitNodeButton.tsx
index 96287da9..9d900873 100644
--- a/src/modules/exit-node/AddExitNodeButton.tsx
+++ b/src/modules/exit-node/AddExitNodeButton.tsx
@@ -3,6 +3,7 @@ import { Modal } from "@components/modal/Modal";
import { IconCirclePlus, IconDirectionSign } from "@tabler/icons-react";
import * as React from "react";
import { useState } from "react";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { Peer } from "@/interfaces/Peer";
import { ExitNodeHelpTooltip } from "@/modules/exit-node/ExitNodeHelpTooltip";
import { RouteModalContent } from "@/modules/routes/RouteModal";
@@ -13,11 +14,16 @@ type Props = {
};
export const AddExitNodeButton = ({ peer, firstTime = false }: Props) => {
const [modal, setModal] = useState(false);
+ const { permission } = usePermissions();
return (
<>
- setModal(true)}>
+ setModal(true)}
+ disabled={!permission.routes.create}
+ >
{!firstTime ? (
<>
diff --git a/src/modules/exit-node/ExitNodeDropdownButton.tsx b/src/modules/exit-node/ExitNodeDropdownButton.tsx
index a8e4bfa0..e259fa3f 100644
--- a/src/modules/exit-node/ExitNodeDropdownButton.tsx
+++ b/src/modules/exit-node/ExitNodeDropdownButton.tsx
@@ -3,6 +3,7 @@ import { Modal } from "@components/modal/Modal";
import { IconCirclePlus, IconDirectionSign } from "@tabler/icons-react";
import * as React from "react";
import { useState } from "react";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import RoutesProvider from "@/contexts/RoutesProvider";
import { Peer } from "@/interfaces/Peer";
import { useHasExitNodes } from "@/modules/exit-node/useHasExitNodes";
@@ -15,10 +16,14 @@ type Props = {
export const ExitNodeDropdownButton = ({ peer }: Props) => {
const [modal, setModal] = useState(false);
const hasExitNodes = useHasExitNodes(peer);
+ const { permission } = usePermissions();
return (
<>
- setModal(true)}>
+ setModal(true)}
+ disabled={!permission.routes.create}
+ >
{hasExitNodes ? (
<>
diff --git a/src/modules/groups/AssignPeerToGroupModal.tsx b/src/modules/groups/AssignPeerToGroupModal.tsx
index fe663744..6d72179e 100644
--- a/src/modules/groups/AssignPeerToGroupModal.tsx
+++ b/src/modules/groups/AssignPeerToGroupModal.tsx
@@ -370,6 +370,8 @@ const PeersTableColumns: ColumnDef
[] = [
header: ({ column }) => {
return OS;
},
- cell: ({ row }) => ,
+ cell: ({ row }) => (
+
+ ),
},
];
diff --git a/src/modules/groups/GroupSelector.tsx b/src/modules/groups/GroupFilterSelector.tsx
similarity index 91%
rename from src/modules/groups/GroupSelector.tsx
rename to src/modules/groups/GroupFilterSelector.tsx
index d46a1909..8b219f37 100644
--- a/src/modules/groups/GroupSelector.tsx
+++ b/src/modules/groups/GroupFilterSelector.tsx
@@ -5,7 +5,6 @@ import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
import { ScrollArea } from "@components/ScrollArea";
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
import TextWithTooltip from "@components/ui/TextWithTooltip";
-import { IconArrowBack } from "@tabler/icons-react";
import { cn } from "@utils/helpers";
import { Command, CommandGroup, CommandInput, CommandList } from "cmdk";
import { orderBy, trim } from "lodash";
@@ -27,7 +26,7 @@ interface MultiSelectProps {
popoverWidth?: "auto" | number;
groups: Group[] | undefined;
}
-export function GroupSelector({
+export function GroupFilterSelector({
onChange,
values,
disabled = false,
@@ -103,7 +102,7 @@ export function GroupSelector({
"min-h-[42px] w-full relative",
"border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center",
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
- "dark:placeholder:text-neutral-500 font-light placeholder:text-neutral-500 pl-10",
+ "dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10",
)}
ref={searchRef}
value={search}
@@ -119,19 +118,6 @@ export function GroupSelector({
-
void;
+ values: Group[];
+ popoverWidth?: number;
+ align?: "start" | "end";
+ side?: "top" | "bottom";
+};
+
+const searchPredicate = (item: Group, query: string) => {
+ const lowerCaseQuery = query.toLowerCase();
+ return item.name.toLowerCase().includes(lowerCaseQuery);
+};
+
+export const SingleGroupSelector = ({
+ trigger,
+ onSelect,
+ values,
+ popoverWidth = 370,
+ align = "end",
+ side = "top",
+}: Props) => {
+ const [inputRef, { width }] = useElementSize();
+ const [open, setOpen] = useState(false);
+ const [filteredItems, search, setSearch] = useSearch(
+ values,
+ searchPredicate,
+ { filter: true, debounce: 200 },
+ );
+ return (
+ {
+ if (!isOpen) {
+ setTimeout(() => {
+ setSearch("");
+ }, 200);
+ }
+ setOpen(isOpen);
+ }}
+ >
+
+
+
+
+
+ {values.length == 0 && !search && (
+
+
+ {"Seems like you don't have any groups."}
+
+
+ )}
+
+ {filteredItems.length == 0 && search != "" && (
+
+
+ There are no groups matching your search. Try another search
+ term.
+
+
+ )}
+
+ {filteredItems.length > 0 && (
+
{
+ onSelect(group);
+ setOpen(false);
+ }}
+ estimatedItemHeight={46}
+ maxHeight={300}
+ renderItem={(option, selected) => {
+ return ;
+ }}
+ />
+ )}
+
+
+
+ );
+};
+
+type ItemProps = {
+ group: Group;
+ selected?: boolean;
+};
+
+const Item = ({ group, selected }: ItemProps) => {
+ const { users } = useUsers();
+ const usersOfGroup =
+ users?.filter((user) => user.auto_groups.includes(group.id as string)) ||
+ [];
+
+ return (
+
+
+
+
+ );
+};
diff --git a/src/modules/networks/misc/NetworkNavigation.tsx b/src/modules/networks/misc/NetworkNavigation.tsx
index e625b7be..7345eeef 100644
--- a/src/modules/networks/misc/NetworkNavigation.tsx
+++ b/src/modules/networks/misc/NetworkNavigation.tsx
@@ -1,25 +1,23 @@
import SidebarItem from "@components/SidebarItem";
-import { SmallBadge } from "@components/ui/SmallBadge";
import * as React from "react";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
+import { usePermissions } from "@/contexts/PermissionsProvider";
export const NetworkNavigation = () => {
+ const { permission } = usePermissions();
return (
<>
}
- label={
-
- Networks
-
-
- }
+ label={"Networks"}
href={"/networks"}
+ visible={permission.networks.read}
/>
}
href={"/network-routes"}
label={"Network Routes"}
+ visible={permission.routes.read}
/>
>
);
diff --git a/src/modules/networks/resources/ResourceActionCell.tsx b/src/modules/networks/resources/ResourceActionCell.tsx
index aa1f93cf..eab88e70 100644
--- a/src/modules/networks/resources/ResourceActionCell.tsx
+++ b/src/modules/networks/resources/ResourceActionCell.tsx
@@ -7,6 +7,7 @@ import {
} from "@components/DropdownMenu";
import { MoreVertical, SquarePenIcon, Trash2 } from "lucide-react";
import * as React from "react";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { NetworkResource } from "@/interfaces/Network";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
@@ -14,6 +15,7 @@ type Props = {
resource: NetworkResource;
};
export const ResourceActionCell = ({ resource }: Props) => {
+ const { permission } = usePermissions();
const { deleteResource, network, openResourceModal } = useNetworksContext();
return (
@@ -25,8 +27,15 @@ export const ResourceActionCell = ({ resource }: Props) => {
e.stopPropagation();
e.preventDefault();
}}
+ disabled={!permission.networks.update && !permission.networks.delete}
>
-
+
@@ -36,6 +45,7 @@ export const ResourceActionCell = ({ resource }: Props) => {
if (!network) return;
openResourceModal(network, resource);
}}
+ disabled={!permission.networks.update}
>
@@ -48,6 +58,7 @@ export const ResourceActionCell = ({ resource }: Props) => {
deleteResource(network, resource);
}}
variant={"danger"}
+ disabled={!permission.networks.delete}
>
diff --git a/src/modules/networks/resources/ResourceEnabledCell.tsx b/src/modules/networks/resources/ResourceEnabledCell.tsx
index dd24c119..716a60e3 100644
--- a/src/modules/networks/resources/ResourceEnabledCell.tsx
+++ b/src/modules/networks/resources/ResourceEnabledCell.tsx
@@ -4,6 +4,7 @@ import { useApiCall } from "@utils/api";
import * as React from "react";
import { useMemo } from "react";
import { useSWRConfig } from "swr";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import { NetworkResource } from "@/interfaces/Network";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
@@ -12,6 +13,8 @@ type Props = {
resource: NetworkResource;
};
export const ResourceEnabledCell = ({ resource }: Props) => {
+ const { permission } = usePermissions();
+
const { mutate } = useSWRConfig();
const { network } = useNetworksContext();
@@ -52,6 +55,7 @@ export const ResourceEnabledCell = ({ resource }: Props) => {
checked={isChecked}
size={"small"}
onClick={() => toggle(!isChecked)}
+ disabled={!permission.networks.update}
/>
);
diff --git a/src/modules/networks/resources/ResourceGroupCell.tsx b/src/modules/networks/resources/ResourceGroupCell.tsx
index 752cbc24..91f3c643 100644
--- a/src/modules/networks/resources/ResourceGroupCell.tsx
+++ b/src/modules/networks/resources/ResourceGroupCell.tsx
@@ -1,5 +1,6 @@
import MultipleGroups from "@components/ui/MultipleGroups";
import * as React from "react";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import { NetworkResource } from "@/interfaces/Network";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
@@ -8,13 +9,15 @@ type Props = {
resource?: NetworkResource;
};
export const ResourceGroupCell = ({ resource }: Props) => {
+ const { permission } = usePermissions();
+
const { network, openResourceGroupModal } = useNetworksContext();
return (
{
- if (!network) return;
+ if (!network || !permission.networks.update) return;
openResourceGroupModal(network, resource);
}}
>
diff --git a/src/modules/networks/resources/ResourceNameCell.tsx b/src/modules/networks/resources/ResourceNameCell.tsx
index af37fa85..ec61b51b 100644
--- a/src/modules/networks/resources/ResourceNameCell.tsx
+++ b/src/modules/networks/resources/ResourceNameCell.tsx
@@ -3,6 +3,7 @@ import TextWithTooltip from "@components/ui/TextWithTooltip";
import { cn } from "@utils/helpers";
import { GlobeIcon, NetworkIcon, WorkflowIcon } from "lucide-react";
import React from "react";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { NetworkResource } from "@/interfaces/Network";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
@@ -11,13 +12,14 @@ type Props = {
};
export default function ResourceNameCell({ resource }: Readonly) {
+ const { permission } = usePermissions();
const { network, openResourceModal } = useNetworksContext();
return (
{
- if (!network) return;
+ if (!network || !permission.networks.update) return;
openResourceModal(network, resource);
}}
>
diff --git a/src/modules/networks/resources/ResourcePolicyCell.tsx b/src/modules/networks/resources/ResourcePolicyCell.tsx
index fd55a8f0..1482b4ab 100644
--- a/src/modules/networks/resources/ResourcePolicyCell.tsx
+++ b/src/modules/networks/resources/ResourcePolicyCell.tsx
@@ -6,6 +6,7 @@ import { PlusCircle, ShieldIcon } from "lucide-react";
import * as React from "react";
import { useMemo } from "react";
import Skeleton from "react-loading-skeleton";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import { NetworkResource } from "@/interfaces/Network";
import { Policy } from "@/interfaces/Policy";
@@ -15,6 +16,7 @@ type Props = {
resource?: NetworkResource;
};
export const ResourcePolicyCell = ({ resource }: Props) => {
+ const { permission } = usePermissions();
const { openPolicyModal, network } = useNetworksContext();
const { data: policies, isLoading } = useFetchApi("/policies");
@@ -30,7 +32,7 @@ export const ResourcePolicyCell = ({ resource }: Props) => {
?.map((rule) => rule?.destinations)
.flat() as Group[];
const policyGroups = [...destinationPolicyGroups];
- return resourceGroups.some((resourceGroup) =>
+ return resourceGroups?.some((resourceGroup) =>
policyGroups.some(
(policyGroup) => policyGroup?.id === resourceGroup.id,
),
@@ -93,6 +95,7 @@ export const ResourcePolicyCell = ({ resource }: Props) => {
size={"xs"}
variant={"secondary"}
className={"min-w-[100px]"}
+ disabled={!permission.networks.update}
onClick={() => openPolicyModal(network, resource)}
>
diff --git a/src/modules/networks/resources/ResourcesTable.tsx b/src/modules/networks/resources/ResourcesTable.tsx
index f03f5e5a..ecc4598e 100644
--- a/src/modules/networks/resources/ResourcesTable.tsx
+++ b/src/modules/networks/resources/ResourcesTable.tsx
@@ -10,6 +10,7 @@ import { removeAllSpaces } from "@utils/helpers";
import { Layers3Icon } from "lucide-react";
import * as React from "react";
import { useState } from "react";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import { NetworkResource } from "@/interfaces/Network";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
@@ -99,6 +100,8 @@ export default function ResourcesTable({
isLoading,
headingTarget,
}: Readonly) {
+ const { permission } = usePermissions();
+
const [sorting, setSorting] = useState([]);
const { openResourceModal, network } = useNetworksContext();
@@ -138,6 +141,7 @@ export default function ResourcesTable({
variant={"primary"}
className={"ml-auto"}
onClick={() => network && openResourceModal(network)}
+ disabled={!permission.networks.update}
>
Add Resource
diff --git a/src/modules/networks/routing-peers/NetworkRoutingPeersSection.tsx b/src/modules/networks/routing-peers/NetworkRoutingPeersSection.tsx
index 86a33627..06aaf3dd 100644
--- a/src/modules/networks/routing-peers/NetworkRoutingPeersSection.tsx
+++ b/src/modules/networks/routing-peers/NetworkRoutingPeersSection.tsx
@@ -8,6 +8,7 @@ import { IconCirclePlus } from "@tabler/icons-react";
import useFetchApi from "@utils/api";
import * as React from "react";
import { Suspense } from "react";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { Network, NetworkRouter } from "@/interfaces/Network";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
import NetworkRoutingPeersTable from "@/modules/networks/routing-peers/NetworkRoutingPeersTable";
@@ -17,6 +18,7 @@ export const NetworkRoutingPeersSection = ({
}: {
network: Network;
}) => {
+ const { permission } = usePermissions();
const { data: routers, isLoading } = useFetchApi(
`/networks/${network.id}/routers`,
);
@@ -40,6 +42,7 @@ export const NetworkRoutingPeersSection = ({
openAddRoutingPeerModal(network)}
+ disabled={!permission.networks.update}
>
Add Routing Peer
diff --git a/src/modules/networks/routing-peers/NetworkRoutingPeersTable.tsx b/src/modules/networks/routing-peers/NetworkRoutingPeersTable.tsx
index 9e801ad9..6045bc5c 100644
--- a/src/modules/networks/routing-peers/NetworkRoutingPeersTable.tsx
+++ b/src/modules/networks/routing-peers/NetworkRoutingPeersTable.tsx
@@ -9,6 +9,7 @@ import { ColumnDef, SortingState } from "@tanstack/react-table";
import * as React from "react";
import { useState } from "react";
import PeerIcon from "@/assets/icons/PeerIcon";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { NetworkRouter } from "@/interfaces/Network";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
import { NetworkRoutingPeerName } from "@/modules/networks/routing-peers/NetworkRoutingPeerName";
@@ -74,6 +75,7 @@ export default function NetworkRoutingPeersTable({
isLoading,
headingTarget,
}: Readonly) {
+ const { permission } = usePermissions();
const { openAddRoutingPeerModal, network } = useNetworksContext();
const [sorting, setSorting] = useState([
@@ -117,6 +119,7 @@ export default function NetworkRoutingPeersTable({
variant={"primary"}
className={"ml-auto"}
onClick={() => network && openAddRoutingPeerModal(network)}
+ disabled={!permission.networks.update}
>
Add Routing Peer
diff --git a/src/modules/networks/routing-peers/RoutingPeersActionCell.tsx b/src/modules/networks/routing-peers/RoutingPeersActionCell.tsx
index 3c64415c..08308677 100644
--- a/src/modules/networks/routing-peers/RoutingPeersActionCell.tsx
+++ b/src/modules/networks/routing-peers/RoutingPeersActionCell.tsx
@@ -7,6 +7,7 @@ import {
} from "@components/DropdownMenu";
import { MoreVertical, SquarePenIcon, Trash2 } from "lucide-react";
import * as React from "react";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { NetworkRouter } from "@/interfaces/Network";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
@@ -14,6 +15,7 @@ type Props = {
router: NetworkRouter;
};
export const RoutingPeersActionCell = ({ router }: Props) => {
+ const { permission } = usePermissions();
const { deleteRouter, network, openAddRoutingPeerModal } =
useNetworksContext();
@@ -26,8 +28,15 @@ export const RoutingPeersActionCell = ({ router }: Props) => {
e.stopPropagation();
e.preventDefault();
}}
+ disabled={!permission.networks.update && !permission.networks.delete}
>
-
+
@@ -37,6 +46,7 @@ export const RoutingPeersActionCell = ({ router }: Props) => {
if (!network) return;
openAddRoutingPeerModal(network, router);
}}
+ disabled={!permission.networks.update}
>
@@ -49,6 +59,7 @@ export const RoutingPeersActionCell = ({ router }: Props) => {
deleteRouter(network, router);
}}
variant={"danger"}
+ disabled={!permission.networks.delete}
>
diff --git a/src/modules/networks/routing-peers/RoutingPeersEnabledCell.tsx b/src/modules/networks/routing-peers/RoutingPeersEnabledCell.tsx
index debd5925..23f15826 100644
--- a/src/modules/networks/routing-peers/RoutingPeersEnabledCell.tsx
+++ b/src/modules/networks/routing-peers/RoutingPeersEnabledCell.tsx
@@ -4,6 +4,7 @@ import { useApiCall } from "@utils/api";
import * as React from "react";
import { useMemo } from "react";
import { useSWRConfig } from "swr";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { NetworkRouter } from "@/interfaces/Network";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
@@ -11,6 +12,7 @@ type Props = {
router: NetworkRouter;
};
export const RoutingPeersEnabledCell = ({ router }: Props) => {
+ const { permission } = usePermissions();
const { mutate } = useSWRConfig();
const { network } = useNetworksContext();
@@ -42,6 +44,7 @@ export const RoutingPeersEnabledCell = ({ router }: Props) => {
checked={isChecked}
size={"small"}
onClick={() => toggle(!isChecked)}
+ disabled={!permission.networks.update}
/>
);
diff --git a/src/modules/networks/routing-peers/RoutingPeersMasqueradeCell.tsx b/src/modules/networks/routing-peers/RoutingPeersMasqueradeCell.tsx
index 40de45d9..632c5b28 100644
--- a/src/modules/networks/routing-peers/RoutingPeersMasqueradeCell.tsx
+++ b/src/modules/networks/routing-peers/RoutingPeersMasqueradeCell.tsx
@@ -5,6 +5,7 @@ import useFetchApi, { useApiCall } from "@utils/api";
import * as React from "react";
import { useMemo } from "react";
import { useSWRConfig } from "swr";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { NetworkRouter } from "@/interfaces/Network";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import type { Peer } from "@/interfaces/Peer";
@@ -15,6 +16,7 @@ type Props = {
router: NetworkRouter;
};
export const RoutingPeersMasqueradeCell = ({ router }: Props) => {
+ const { permission } = usePermissions();
const { mutate } = useSWRConfig();
const { network } = useNetworksContext();
@@ -55,7 +57,9 @@ export const RoutingPeersMasqueradeCell = ({ router }: Props) => {
}, [router]);
const isToggleDisabled =
- isLoading || (isRoutingPeer && isNonLinuxRoutingPeer);
+ isLoading ||
+ (isRoutingPeer && isNonLinuxRoutingPeer) ||
+ !permission.networks.update;
return (
diff --git a/src/modules/networks/table/NetworkActionCell.tsx b/src/modules/networks/table/NetworkActionCell.tsx
index fb706479..5bac5d3d 100644
--- a/src/modules/networks/table/NetworkActionCell.tsx
+++ b/src/modules/networks/table/NetworkActionCell.tsx
@@ -9,6 +9,7 @@ import {
import { EyeIcon, MoreVertical, PencilLineIcon, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import * as React from "react";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { Network } from "@/interfaces/Network";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
@@ -16,6 +17,7 @@ type Props = {
network: Network;
};
export default function NetworkActionCell({ network }: Props) {
+ const { permission } = usePermissions();
const { deleteNetwork, openEditNetworkModal } = useNetworksContext();
const router = useRouter();
@@ -42,7 +44,10 @@ export default function NetworkActionCell({ network }: Props) {
View Details
-
openEditNetworkModal(network)}>
+ openEditNetworkModal(network)}
+ disabled={!permission.networks.update}
+ >
Rename
@@ -54,6 +59,7 @@ export default function NetworkActionCell({ network }: Props) {
deleteNetwork(network)}
variant={"danger"}
+ disabled={!permission.networks.delete}
>
diff --git a/src/modules/networks/table/NetworkPolicyCell.tsx b/src/modules/networks/table/NetworkPolicyCell.tsx
index 92d6c643..c910165a 100644
--- a/src/modules/networks/table/NetworkPolicyCell.tsx
+++ b/src/modules/networks/table/NetworkPolicyCell.tsx
@@ -3,6 +3,7 @@ import Button from "@components/Button";
import { PlusCircle, ShieldIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import * as React from "react";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { Network } from "@/interfaces/Network";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
@@ -11,6 +12,8 @@ type Props = {
};
export const NetworkPolicyCell = ({ network }: Props) => {
+ const { permission } = usePermissions();
+
const { openPolicyModal } = useNetworksContext();
const router = useRouter();
@@ -35,6 +38,7 @@ export const NetworkPolicyCell = ({ network }: Props) => {
variant={"secondary"}
className={"min-w-[130px]"}
onClick={() => openPolicyModal(network)}
+ disabled={!permission.networks.update}
>
Add Policy
diff --git a/src/modules/networks/table/NetworkResourceCell.tsx b/src/modules/networks/table/NetworkResourceCell.tsx
index 2d3cea89..ffb28ceb 100644
--- a/src/modules/networks/table/NetworkResourceCell.tsx
+++ b/src/modules/networks/table/NetworkResourceCell.tsx
@@ -3,6 +3,7 @@ import Button from "@components/Button";
import { LayersIcon, PlusCircle } from "lucide-react";
import { useRouter } from "next/navigation";
import * as React from "react";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { Network } from "@/interfaces/Network";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
@@ -11,6 +12,8 @@ type Props = {
};
export const NetworkResourceCell = ({ network }: Props) => {
+ const { permission } = usePermissions();
+
const { openResourceModal } = useNetworksContext();
const router = useRouter();
@@ -35,6 +38,7 @@ export const NetworkResourceCell = ({ network }: Props) => {
variant={"secondary"}
className={"min-w-[130px]"}
onClick={() => openResourceModal(network)}
+ disabled={!permission.networks.update}
>
Add Resource
diff --git a/src/modules/networks/table/NetworkRoutingPeerCell.tsx b/src/modules/networks/table/NetworkRoutingPeerCell.tsx
index c2ab7b13..ef87fb29 100644
--- a/src/modules/networks/table/NetworkRoutingPeerCell.tsx
+++ b/src/modules/networks/table/NetworkRoutingPeerCell.tsx
@@ -6,6 +6,7 @@ import { HelpCircle, PlusCircle } from "lucide-react";
import { useRouter } from "next/navigation";
import * as React from "react";
import { useMemo } from "react";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { Network } from "@/interfaces/Network";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
@@ -13,6 +14,7 @@ type Props = {
network: Network;
};
export default function NetworkRoutingPeerCell({ network }: Props) {
+ const { permission } = usePermissions();
const router = useRouter();
const disabledText = useMemo(
() => (
@@ -99,6 +101,7 @@ export default function NetworkRoutingPeerCell({ network }: Props) {
variant={"secondary"}
className={"min-w-[130px]"}
onClick={() => openAddRoutingPeerModal(network)}
+ disabled={!permission.networks.update}
>
Add Routing Peer
diff --git a/src/modules/networks/table/NetworksTable.tsx b/src/modules/networks/table/NetworksTable.tsx
index 86425bed..2c523dce 100644
--- a/src/modules/networks/table/NetworksTable.tsx
+++ b/src/modules/networks/table/NetworksTable.tsx
@@ -13,6 +13,7 @@ import { usePathname } from "next/navigation";
import React from "react";
import { useSWRConfig } from "swr";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { Network } from "@/interfaces/Network";
import {
@@ -167,9 +168,15 @@ export default function NetworksTable({
}
const AddNetworkButton = () => {
+ const { permission } = usePermissions();
+
const { openCreateNetworkModal } = useNetworksContext();
return (
-
+
Add Network
diff --git a/src/modules/peer/AccessiblePeersSection.tsx b/src/modules/peer/AccessiblePeersSection.tsx
index 91795d95..8083681e 100644
--- a/src/modules/peer/AccessiblePeersSection.tsx
+++ b/src/modules/peer/AccessiblePeersSection.tsx
@@ -33,7 +33,7 @@ export const AccessiblePeersSection = ({ peerID }: Props) => {
});
return (
-
+
diff --git a/src/modules/peer/AddRouteDropdownButton.tsx b/src/modules/peer/AddRouteDropdownButton.tsx
index 459f0ca4..7fedb5bd 100644
--- a/src/modules/peer/AddRouteDropdownButton.tsx
+++ b/src/modules/peer/AddRouteDropdownButton.tsx
@@ -11,6 +11,7 @@ import { ChevronDown, PlusCircle } from "lucide-react";
import React, { useState } from "react";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import { usePeer } from "@/contexts/PeerProvider";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import RouteAddRoutingPeerModal from "@/modules/routes/RouteAddRoutingPeerModal";
import { RouteModalContent } from "@/modules/routes/RouteModal";
@@ -18,6 +19,7 @@ export default function AddRouteDropdownButton() {
const [modal, setModal] = useState(false);
const [existingNetworkModal, setExistingNetworkModal] = useState(false);
const { peer } = usePeer();
+ const { permission } = usePermissions();
return (
<>
@@ -47,7 +49,10 @@ export default function AddRouteDropdownButton() {
- setModal(true)}>
+ setModal(true)}
+ disabled={!permission.routes.create}
+ >
}
@@ -63,7 +68,10 @@ export default function AddRouteDropdownButton() {
- setExistingNetworkModal(true)}>
+ setExistingNetworkModal(true)}
+ disabled={!permission.routes.update || !permission.peers.update}
+ >
{
- const { isUser } = useLoggedInUser();
+ const { permission } = usePermissions();
return (
}
className={"w-full block"}
- disabled={!!peer.user_id && !isUser}
+ disabled={!!peer.user_id && permission.peers.update}
>
{
const { peerRoutes, isLoading } = usePeerRoutes({ peer });
const hasExitNodes = useHasExitNodes(peer);
- const { isUser } = useLoggedInUser();
const { ref: headingRef, portalTarget } =
usePortalElement();
return (
-
+
diff --git a/src/modules/peer/PeerRoutesTable.tsx b/src/modules/peer/PeerRoutesTable.tsx
index 2ebcc6d3..336526f1 100644
--- a/src/modules/peer/PeerRoutesTable.tsx
+++ b/src/modules/peer/PeerRoutesTable.tsx
@@ -72,6 +72,7 @@ export default function PeerRoutesTable({
peerRoutes,
isLoading,
peer,
+ headingTarget,
}: Props) {
// Default sorting state of the table
const [sorting, setSorting] = useState
([
@@ -88,6 +89,7 @@ export default function PeerRoutesTable({
wrapperProps={{
className: cn("w-full"),
}}
+ headingTarget={headingTarget}
text={"Network Routes"}
tableClassName={"mt-0"}
getStartedCard={
diff --git a/src/modules/peers/PeerActionCell.tsx b/src/modules/peers/PeerActionCell.tsx
index ff50967f..58559edc 100644
--- a/src/modules/peers/PeerActionCell.tsx
+++ b/src/modules/peers/PeerActionCell.tsx
@@ -20,12 +20,14 @@ import { useRouter } from "next/navigation";
import React from "react";
import { useSWRConfig } from "swr";
import { usePeer } from "@/contexts/PeerProvider";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { ExitNodeDropdownButton } from "@/modules/exit-node/ExitNodeDropdownButton";
export default function PeerActionCell() {
const { peer, deletePeer, update, openSSHDialog } = usePeer();
const router = useRouter();
const { mutate } = useSWRConfig();
+ const { permission } = usePermissions();
const toggleLoginExpiration = async () => {
const text = peer.login_expiration_enabled ? "disabled" : "enabled";
@@ -76,7 +78,10 @@ export default function PeerActionCell() {
- router.push("/peer?id=" + peer.id)}>
+ router.push("/peer?id=" + peer.id)}
+ disabled={!permission.peers.read}
+ >
View Details
@@ -100,7 +105,7 @@ export default function PeerActionCell() {
>
@@ -118,6 +123,7 @@ export default function PeerActionCell() {
enable ? toggleSSH() : null,
)
}
+ disabled={!permission.peers.update}
>
@@ -131,7 +137,11 @@ export default function PeerActionCell() {
-
+
Delete
diff --git a/src/modules/peers/PeerGroupCell.tsx b/src/modules/peers/PeerGroupCell.tsx
index faf1b3f0..2e7a2f86 100644
--- a/src/modules/peers/PeerGroupCell.tsx
+++ b/src/modules/peers/PeerGroupCell.tsx
@@ -2,6 +2,7 @@ import { notify } from "@components/Notification";
import { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import { usePeer } from "@/contexts/PeerProvider";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import GroupsRow from "@/modules/common-table-rows/GroupsRow";
@@ -9,6 +10,7 @@ export default function PeerGroupCell() {
const { peer, peerGroups } = usePeer();
const [modal, setModal] = useState(false);
const { mutate } = useSWRConfig();
+ const { permission } = usePermissions();
const handleSave = async (promises: Promise
[]) => {
notify({
@@ -35,6 +37,7 @@ export default function PeerGroupCell() {
description={"Use groups to control what this peer can access"}
groups={groupIDs || []}
hideAllGroup={true}
+ disabled={!permission.groups.update}
onSave={handleSave}
modal={modal}
peer={peer}
diff --git a/src/modules/peers/PeerMultiSelect.tsx b/src/modules/peers/PeerMultiSelect.tsx
index b090c196..dd181052 100644
--- a/src/modules/peers/PeerMultiSelect.tsx
+++ b/src/modules/peers/PeerMultiSelect.tsx
@@ -25,6 +25,7 @@ import { useEffect, useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
import { usePeers } from "@/contexts/PeersProvider";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group, GroupPeer } from "@/interfaces/Group";
import { Peer } from "@/interfaces/Peer";
import useGroupHelper from "@/modules/groups/useGroupHelper";
@@ -52,6 +53,7 @@ const PeerGroupMassAssignmentContent = ({
}: Props) => {
const { mutate } = useSWRConfig();
const { confirm } = useDialog();
+ const { permission } = usePermissions();
const { peers } = usePeers();
@@ -388,6 +390,9 @@ const PeerGroupMassAssignmentContent = ({
variant={"default-outline"}
size={"xs"}
className={"!h-9 !w-9"}
+ disabled={
+ !permission.peers.read || !permission.groups.update
+ }
>
@@ -400,6 +405,7 @@ const PeerGroupMassAssignmentContent = ({
size={"xs"}
className={"!h-9 !w-9"}
onClick={deleteAllPeers}
+ disabled={!permission.peers.delete}
>
diff --git a/src/modules/peers/PeerNameCell.tsx b/src/modules/peers/PeerNameCell.tsx
index 04412319..b7aa3edd 100644
--- a/src/modules/peers/PeerNameCell.tsx
+++ b/src/modules/peers/PeerNameCell.tsx
@@ -43,7 +43,8 @@ export default function PeerNameCell({ peer, linkToPeer = true }: Props) {
}
>
- {displayUserEmailOrName || (displayUserId && `user: ${displayUserId}`)}
+ {displayUserEmailOrName ||
+ (displayUserId && `user: ${displayUserId}`)}
diff --git a/src/modules/peers/PeerStatusCell.tsx b/src/modules/peers/PeerStatusCell.tsx
index adab46ac..7c24b2f1 100644
--- a/src/modules/peers/PeerStatusCell.tsx
+++ b/src/modules/peers/PeerStatusCell.tsx
@@ -8,7 +8,7 @@ import * as React from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
import { usePeer } from "@/contexts/PeerProvider";
-import { useLoggedInUser } from "@/contexts/UsersProvider";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { Peer } from "@/interfaces/Peer";
type Props = {
@@ -20,8 +20,8 @@ export default function PeerStatusCell({ peer }: Props) {
const { confirm } = useDialog();
const { mutate } = useSWRConfig();
const needsApproval = peer.approval_required;
- const { isOwnerOrAdmin } = useLoggedInUser();
- const canApprove = isOwnerOrAdmin;
+ const { permission } = usePermissions();
+ const canApprove = permission.peers.update;
const approvePeer = async () => {
const choice = await confirm({
diff --git a/src/modules/peers/PeersTable.tsx b/src/modules/peers/PeersTable.tsx
index c9ba7185..fb1435ee 100644
--- a/src/modules/peers/PeersTable.tsx
+++ b/src/modules/peers/PeersTable.tsx
@@ -22,11 +22,12 @@ import React, { useState } from "react";
import { useSWRConfig } from "swr";
import PeerIcon from "@/assets/icons/PeerIcon";
import PeerProvider from "@/contexts/PeerProvider";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { Group } from "@/interfaces/Group";
import { Peer } from "@/interfaces/Peer";
-import { GroupSelector } from "@/modules/groups/GroupSelector";
+import { GroupFilterSelector } from "@/modules/groups/GroupFilterSelector";
import PeerActionCell from "@/modules/peers/PeerActionCell";
import PeerAddressCell from "@/modules/peers/PeerAddressCell";
import PeerGroupCell from "@/modules/peers/PeerGroupCell";
@@ -134,12 +135,13 @@ const PeersTableColumns: ColumnDef[] = [
cell: ({ row }) => ,
},
{
- id: "os",
accessorKey: "os",
header: ({ column }) => {
return OS;
},
- cell: ({ row }) => ,
+ cell: ({ row }) => (
+
+ ),
},
{
id: "serial",
@@ -194,8 +196,13 @@ type Props = {
headingTarget?: HTMLHeadingElement | null;
};
-export default function PeersTable({ peers, isLoading, headingTarget }: Props) {
+export default function PeersTable({
+ peers,
+ isLoading,
+ headingTarget,
+}: Readonly) {
const { mutate } = useSWRConfig();
+ const { permission } = usePermissions();
const path = usePathname();
// Default sorting state of the table
@@ -252,9 +259,9 @@ export default function PeersTable({ peers, isLoading, headingTarget }: Props) {
setSorting={setSorting}
columns={PeersTableColumns}
data={peers}
- searchPlaceholder={"Search by name, IP, Serial, owner or group..."}
+ searchPlaceholder={"Search by name, IP, owner or group..."}
columnVisibility={{
- select: !isUser,
+ select: permission.groups.read,
connected: false,
approval_required: false,
group_name_strings: false,
@@ -263,8 +270,8 @@ export default function PeersTable({ peers, isLoading, headingTarget }: Props) {
serial: false,
user_name: false,
user_email: false,
- actions: !isUser,
- groups: !isUser,
+ actions: permission.peers.update,
+ groups: permission.groups.read,
}}
isLoading={isLoading}
getStartedCard={
@@ -443,7 +450,7 @@ export default function PeersTable({ peers, isLoading, headingTarget }: Props) {
{!isUser && (
- void;
+ disabled?: boolean;
};
-export const PostureCheckGeoLocation = ({ value, onChange }: Props) => {
+export const PostureCheckGeoLocation = ({
+ value,
+ onChange,
+ disabled,
+}: Props) => {
const [open, setOpen] = useState(false);
return (
@@ -57,12 +62,13 @@ export const PostureCheckGeoLocation = ({ value, onChange }: Props) => {
onChange(v);
setOpen(false);
}}
+ disabled={disabled}
/>
);
};
-const CheckContent = ({ value, onChange }: Props) => {
+const CheckContent = ({ value, onChange, disabled }: Props) => {
const [allowDenyLocation, setAllowDenyLocation] = useState(
value?.action ? value.action : "allow",
);
@@ -159,7 +165,7 @@ const CheckContent = ({ value, onChange }: Props) => {
@@ -171,7 +177,12 @@ const CheckContent = ({ value, onChange }: Props) => {
Learn more about
-
+
Country & Region Check
@@ -193,6 +204,7 @@ const CheckContent = ({ value, onChange }: Props) => {
});
}
}}
+ disabled={disabled}
>
Save
diff --git a/src/modules/posture-checks/checks/PostureCheckNetBirdVersion.tsx b/src/modules/posture-checks/checks/PostureCheckNetBirdVersion.tsx
index 5434d9be..41456945 100644
--- a/src/modules/posture-checks/checks/PostureCheckNetBirdVersion.tsx
+++ b/src/modules/posture-checks/checks/PostureCheckNetBirdVersion.tsx
@@ -17,9 +17,14 @@ import { PostureCheckCard } from "@/modules/posture-checks/ui/PostureCheckCard";
type Props = {
value?: NetBirdVersionCheck;
onChange: (value: NetBirdVersionCheck | undefined) => void;
+ disabled?: boolean;
};
-export const PostureCheckNetBirdVersion = ({ value, onChange }: Props) => {
+export const PostureCheckNetBirdVersion = ({
+ value,
+ onChange,
+ disabled,
+}: Props) => {
const [open, setOpen] = useState(false);
return (
@@ -42,12 +47,13 @@ export const PostureCheckNetBirdVersion = ({ value, onChange }: Props) => {
onChange(v);
setOpen(false);
}}
+ disabled={disabled}
/>
);
};
-const CheckContent = ({ value, onChange }: Props) => {
+const CheckContent = ({ value, onChange, disabled }: Props) => {
const [version, setVersion] = useState(value?.min_version || "");
const versionError = useMemo(() => {
@@ -58,8 +64,13 @@ const CheckContent = ({ value, onChange }: Props) => {
}, [version]);
const canSave = useMemo(() => {
- return !versionError && version !== value?.min_version && !isEmpty(version);
- }, [version, versionError, value]);
+ return (
+ !versionError &&
+ version !== value?.min_version &&
+ !isEmpty(version) &&
+ !disabled
+ );
+ }, [version, versionError, value, disabled]);
return (
<>
@@ -78,6 +89,7 @@ const CheckContent = ({ value, onChange }: Props) => {
placeholder={"e.g., 0.25.0"}
error={versionError}
customPrefix={"Version"}
+ disabled={disabled}
/>
@@ -86,7 +98,12 @@ const CheckContent = ({ value, onChange }: Props) => {
Learn more about
-
+
Client Version Check
diff --git a/src/modules/posture-checks/checks/PostureCheckOperatingSystem.tsx b/src/modules/posture-checks/checks/PostureCheckOperatingSystem.tsx
index 160234d0..92bad5fe 100644
--- a/src/modules/posture-checks/checks/PostureCheckOperatingSystem.tsx
+++ b/src/modules/posture-checks/checks/PostureCheckOperatingSystem.tsx
@@ -43,9 +43,14 @@ import { PostureCheckCard } from "@/modules/posture-checks/ui/PostureCheckCard";
type Props = {
value?: OperatingSystemVersionCheck;
onChange: (value: OperatingSystemVersionCheck | undefined) => void;
+ disabled?: boolean;
};
-export const PostureCheckOperatingSystem = ({ value, onChange }: Props) => {
+export const PostureCheckOperatingSystem = ({
+ value,
+ onChange,
+ disabled,
+}: Props) => {
const [open, setOpen] = useState(false);
return (
@@ -69,12 +74,13 @@ export const PostureCheckOperatingSystem = ({ value, onChange }: Props) => {
onChange(v);
setOpen(false);
}}
+ disabled={disabled}
/>
);
};
-const CheckContent = ({ value, onChange }: Props) => {
+const CheckContent = ({ value, onChange, disabled }: Props) => {
const [tab] = useState(String(OperatingSystem.LINUX));
const firstTimeCheck = value === undefined;
@@ -118,7 +124,12 @@ const CheckContent = ({ value, onChange }: Props) => {
const [androidError, setAndroidError] = useState("");
const versionError =
- linuxError || windowsError || macOSError || iOSError || androidError;
+ linuxError ||
+ windowsError ||
+ macOSError ||
+ iOSError ||
+ androidError ||
+ disabled;
return (
<>
@@ -171,6 +182,7 @@ const CheckContent = ({ value, onChange }: Props) => {
onChange={setLinuxVersion}
os={OperatingSystem.LINUX}
onError={setLinuxError}
+ disabled={disabled}
/>
@@ -180,6 +192,7 @@ const CheckContent = ({ value, onChange }: Props) => {
onChange={setWindowsVersion}
os={OperatingSystem.WINDOWS}
onError={setWindowsError}
+ disabled={disabled}
/>
@@ -189,6 +202,7 @@ const CheckContent = ({ value, onChange }: Props) => {
onChange={setMacOSVersion}
os={OperatingSystem.APPLE}
onError={setMacOSError}
+ disabled={disabled}
/>
@@ -198,6 +212,7 @@ const CheckContent = ({ value, onChange }: Props) => {
onChange={setIOSVersion}
os={OperatingSystem.IOS}
onError={setIOSError}
+ disabled={disabled}
/>
@@ -207,6 +222,7 @@ const CheckContent = ({ value, onChange }: Props) => {
onChange={setAndroidVersion}
os={OperatingSystem.ANDROID}
onError={setAndroidError}
+ disabled={disabled}
/>
@@ -215,7 +231,12 @@ const CheckContent = ({ value, onChange }: Props) => {
Learn more about
-
+
Operating System Check
@@ -268,6 +289,7 @@ type OperatingSystemTabProps = {
versionList?: SelectOption[];
os: OperatingSystem;
onError: (error: string) => void;
+ disabled?: boolean;
};
const allOrMinOptions = [
@@ -289,6 +311,7 @@ export const OperatingSystemTab = ({
versionList,
os,
onError,
+ disabled,
}: OperatingSystemTabProps) => {
const [allow, setAllow] = useState(value == "-" ? "block" : "allow");
const [allOrMin, setAllOrMin] = useState(
@@ -370,7 +393,7 @@ export const OperatingSystemTab = ({
value={allOrMin}
onChange={changeAllOrMin}
options={allOrMinOptions}
- disabled={allow === "block"}
+ disabled={allow === "block" || disabled}
/>
{versionList && !useCustomVersion ? (
) : (
{
onChange(v.target.value);
}}
@@ -398,7 +421,7 @@ export const OperatingSystemTab = ({
{os !== OperatingSystem.LINUX && (
void;
+ disabled?: boolean;
};
-export const PostureCheckPeerNetworkRange = ({ value, onChange }: Props) => {
+export const PostureCheckPeerNetworkRange = ({
+ value,
+ onChange,
+ disabled,
+}: Props) => {
const [open, setOpen] = useState(false);
return (
@@ -50,6 +55,7 @@ export const PostureCheckPeerNetworkRange = ({ value, onChange }: Props) => {
onChange(v);
setOpen(false);
}}
+ disabled={disabled}
/>
);
@@ -60,7 +66,7 @@ interface NetworkRange {
value: string;
}
-const CheckContent = ({ value, onChange }: Props) => {
+const CheckContent = ({ value, onChange, disabled }: Props) => {
const [allowOrDeny, setAllowOrDeny] = useState(
value?.action ? value.action : "allow",
);
@@ -155,6 +161,7 @@ const CheckContent = ({ value, onChange }: Props) => {
onChange={(e) =>
handleNetworkRangeChange(ipRange.id, e.target.value)
}
+ disabled={disabled}
/>
@@ -162,6 +169,7 @@ const CheckContent = ({ value, onChange }: Props) => {
className={"h-[42px]"}
variant={"default-outline"}
onClick={() => removeNetworkRange(ipRange.id)}
+ disabled={disabled}
>
@@ -170,7 +178,12 @@ const CheckContent = ({ value, onChange }: Props) => {
})}
)}
-
+
Add Network Range
@@ -196,7 +209,7 @@ const CheckContent = ({ value, onChange }: Props) => {
{
if (isEmpty(networkRanges)) {
onChange(undefined);
diff --git a/src/modules/posture-checks/checks/PostureCheckProcess.tsx b/src/modules/posture-checks/checks/PostureCheckProcess.tsx
index 34fee5b0..f592c13c 100644
--- a/src/modules/posture-checks/checks/PostureCheckProcess.tsx
+++ b/src/modules/posture-checks/checks/PostureCheckProcess.tsx
@@ -24,9 +24,10 @@ import { PostureCheckCard } from "@/modules/posture-checks/ui/PostureCheckCard";
type Props = {
value?: ProcessCheck;
onChange: (value: ProcessCheck | undefined) => void;
+ disabled?: boolean;
};
-export const PostureCheckProcess = ({ value, onChange }: Props) => {
+export const PostureCheckProcess = ({ value, onChange, disabled }: Props) => {
const [open, setOpen] = useState(false);
return (
@@ -50,12 +51,13 @@ export const PostureCheckProcess = ({ value, onChange }: Props) => {
onChange(v);
setOpen(false);
}}
+ disabled={disabled}
/>
);
};
-const CheckContent = ({ value, onChange }: Props) => {
+const CheckContent = ({ value, onChange, disabled }: Props) => {
const [processes, setProcesses] = useState(
value?.processes
? value.processes.map((p) => {
@@ -183,6 +185,7 @@ const CheckContent = ({ value, onChange }: Props) => {
p?.windows_path || "",
)
}
+ disabled={disabled}
/>
{
p?.windows_path || "",
)
}
+ disabled={disabled}
/>
{
e.target.value,
)
}
+ disabled={disabled}
/>
@@ -246,6 +251,7 @@ const CheckContent = ({ value, onChange }: Props) => {
className={"h-[42px]"}
variant={"default-outline"}
onClick={() => removeProcess(p.id)}
+ disabled={disabled}
>
@@ -259,6 +265,7 @@ const CheckContent = ({ value, onChange }: Props) => {
size={"sm"}
onClick={addProcess}
className={"mt-1"}
+ disabled={disabled}
>
Add Process
@@ -285,7 +292,7 @@ const CheckContent = ({ value, onChange }: Props) => {
{
if (isEmpty(processes)) {
onChange(undefined);
diff --git a/src/modules/posture-checks/modal/PostureCheckModal.tsx b/src/modules/posture-checks/modal/PostureCheckModal.tsx
index ecf45d5e..51918bc0 100644
--- a/src/modules/posture-checks/modal/PostureCheckModal.tsx
+++ b/src/modules/posture-checks/modal/PostureCheckModal.tsx
@@ -12,6 +12,7 @@ import { cn } from "@utils/helpers";
import { isEmpty } from "lodash";
import { ExternalLinkIcon, LayoutList, ShieldCheck, Text } from "lucide-react";
import React, { useState } from "react";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { PostureCheck } from "@/interfaces/PostureCheck";
import { PostureCheckGeoLocation } from "@/modules/posture-checks/checks/PostureCheckGeoLocation";
import { PostureCheckNetBirdVersion } from "@/modules/posture-checks/checks/PostureCheckNetBirdVersion";
@@ -35,6 +36,8 @@ export default function PostureCheckModal({
postureCheck,
useSave = true,
}: Props) {
+ const { permission } = usePermissions();
+
const {
state: check,
dispatch: setCheck,
@@ -54,7 +57,10 @@ export default function PostureCheckModal({
!!check?.checks?.os_version_check ||
!!check?.checks?.peer_network_range_check ||
!!check?.checks.process_check;
- const canCreate = !isEmpty(check?.name) && isAtLeastOneCheckEnabled;
+ const canCreate =
+ !isEmpty(check?.name) &&
+ isAtLeastOneCheckEnabled &&
+ (permission.policies.create || permission.policies.update);
const [tab, setTab] = useState("checks");
@@ -107,6 +113,9 @@ export default function PostureCheckModal({
payload: v,
})
}
+ disabled={
+ !permission.policies.create || !permission.policies.update
+ }
/>
>
@@ -164,6 +185,9 @@ export default function PostureCheckModal({
})
}
placeholder={"e.g., NetBird Version > 0.25.0"}
+ disabled={
+ !permission.policies.create || !permission.policies.update
+ }
/>
@@ -184,6 +208,9 @@ export default function PostureCheckModal({
"e.g., Check if the NetBird version is bigger than 0.25.0"
}
rows={3}
+ disabled={
+ !permission.policies.create || !permission.policies.update
+ }
/>
diff --git a/src/modules/posture-checks/table/PostureCheckMinimalTable.tsx b/src/modules/posture-checks/table/PostureCheckMinimalTable.tsx
index 760a1fbc..86519ae6 100644
--- a/src/modules/posture-checks/table/PostureCheckMinimalTable.tsx
+++ b/src/modules/posture-checks/table/PostureCheckMinimalTable.tsx
@@ -15,6 +15,7 @@ import {
PlusCircle,
} from "lucide-react";
import React from "react";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { PostureCheck } from "@/interfaces/PostureCheck";
import { PostureCheckChecksCell } from "@/modules/posture-checks/table/cells/PostureCheckChecksCell";
import { PostureCheckNameCell } from "@/modules/posture-checks/table/cells/PostureCheckNameCell";
@@ -35,6 +36,8 @@ export default function PostureCheckMinimalTable({
onRemoveClick,
onEditClick,
}: Props) {
+ const { permission } = usePermissions();
+
return data && data.length > 0 ? (
@@ -48,11 +51,25 @@ export default function PostureCheckMinimalTable({
-
+
Browse Checks
-
+
New Posture Check
@@ -71,41 +88,52 @@ export default function PostureCheckMinimalTable({
className={
"flex justify-between py-2 items-center hover:bg-nb-gray-900/30 rounded-md cursor-pointer px-4 transition-all"
}
- onClick={() => onEditClick(check)}
+ onClick={() =>
+ (permission.policies.update || permission.policies.create) &&
+ onEditClick(check)
+ }
>
-
- {
- e.stopPropagation();
- e.preventDefault();
- }}
- >
-
-
-
-
-
- onEditClick(check)}>
-
-
- Edit Posture Check
-
-
- onRemoveClick(check)}>
-
-
- Remove Posture Check
-
-
-
-
+ {(permission.policies.update || permission.policies.create) && (
+
+ {
+ e.stopPropagation();
+ e.preventDefault();
+ }}
+ >
+
+
+
+
+
+ onEditClick(check)}
+ disabled={!permission.policies.update}
+ >
+
+
+ Edit Posture Check
+
+
+ onRemoveClick(check)}
+ disabled={!permission.policies.delete}
+ >
+
+
+ Remove Posture Check
+
+
+
+
+ )}
);
diff --git a/src/modules/posture-checks/table/PostureCheckTable.tsx b/src/modules/posture-checks/table/PostureCheckTable.tsx
index 7edf4bff..b04c0b54 100644
--- a/src/modules/posture-checks/table/PostureCheckTable.tsx
+++ b/src/modules/posture-checks/table/PostureCheckTable.tsx
@@ -15,6 +15,7 @@ import { ExternalLinkIcon, ShieldCheck } from "lucide-react";
import { usePathname } from "next/navigation";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { Policy } from "@/interfaces/Policy";
import { PostureCheck } from "@/interfaces/PostureCheck";
import PostureCheckModal from "@/modules/posture-checks/modal/PostureCheckModal";
@@ -70,6 +71,7 @@ export default function PostureCheckTable({
isLoading,
headingTarget,
}: Props) {
+ const { permission } = usePermissions();
const { data: policies } = useFetchApi
("/policies");
const { mutate } = useSWRConfig();
const path = usePathname();
@@ -145,6 +147,7 @@ export default function PostureCheckTable({
{
setCurrentRow(undefined);
setPostureCheckModal(true);
diff --git a/src/modules/posture-checks/table/cells/PostureCheckActionCell.tsx b/src/modules/posture-checks/table/cells/PostureCheckActionCell.tsx
index 8b214498..4fd7208c 100644
--- a/src/modules/posture-checks/table/cells/PostureCheckActionCell.tsx
+++ b/src/modules/posture-checks/table/cells/PostureCheckActionCell.tsx
@@ -6,12 +6,15 @@ import { Trash2 } from "lucide-react";
import * as React from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { PostureCheck } from "@/interfaces/PostureCheck";
type Props = {
check: PostureCheck;
};
export const PostureCheckActionCell = ({ check }: Props) => {
+ const { permission } = usePermissions();
+
const deleteRequest = useApiCall("/posture-checks");
const { confirm } = useDialog();
const { mutate } = useSWRConfig();
@@ -56,7 +59,7 @@ export const PostureCheckActionCell = ({ check }: Props) => {
variant={"danger-outline"}
size={"sm"}
onClick={handleDelete}
- disabled={hasPolicies}
+ disabled={hasPolicies || !permission.policies.delete}
>
Delete
diff --git a/src/modules/posture-checks/ui/PostureCheckNoChecksInfo.tsx b/src/modules/posture-checks/ui/PostureCheckNoChecksInfo.tsx
index b2e83a3b..2eec300c 100644
--- a/src/modules/posture-checks/ui/PostureCheckNoChecksInfo.tsx
+++ b/src/modules/posture-checks/ui/PostureCheckNoChecksInfo.tsx
@@ -5,6 +5,7 @@ import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import { FolderSearch } from "lucide-react";
import * as React from "react";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { PostureCheck } from "@/interfaces/PostureCheck";
export function PostureCheckNoChecksInfo({
@@ -14,6 +15,8 @@ export function PostureCheckNoChecksInfo({
onAddClick: () => void;
onBrowseClick: () => void;
}) {
+ const { permission } = usePermissions();
+
const { data: postureChecks } =
useFetchApi("/posture-checks");
@@ -37,13 +40,22 @@ export function PostureCheckNoChecksInfo({
Browse Checks
-
+
New Posture Check
diff --git a/src/modules/route-group/GroupedRouteActionCell.tsx b/src/modules/route-group/GroupedRouteActionCell.tsx
index 8606178c..8b5e0412 100644
--- a/src/modules/route-group/GroupedRouteActionCell.tsx
+++ b/src/modules/route-group/GroupedRouteActionCell.tsx
@@ -5,12 +5,15 @@ import { Trash2 } from "lucide-react";
import * as React from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { GroupedRoute, Route } from "@/interfaces/Route";
type Props = {
groupedRoute: GroupedRoute;
};
export default function GroupedRouteActionCell({ groupedRoute }: Props) {
+ const { permission } = usePermissions();
+
const { confirm } = useDialog();
const routeRequest = useApiCall("/routes");
const { mutate } = useSWRConfig();
@@ -47,7 +50,12 @@ export default function GroupedRouteActionCell({ groupedRoute }: Props) {
return (
-
+
Delete
diff --git a/src/modules/route-group/NetworkRoutesTable.tsx b/src/modules/route-group/NetworkRoutesTable.tsx
index 6b95a9f5..e3b4e940 100644
--- a/src/modules/route-group/NetworkRoutesTable.tsx
+++ b/src/modules/route-group/NetworkRoutesTable.tsx
@@ -15,6 +15,7 @@ import React, { useState } from "react";
import { useSWRConfig } from "swr";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import GroupRouteProvider from "@/contexts/GroupRouteProvider";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { GroupedRoute, Route } from "@/interfaces/Route";
import { AddExitNodeButton } from "@/modules/exit-node/AddExitNodeButton";
@@ -122,6 +123,7 @@ export default function NetworkRoutesTable({
routes,
headingTarget,
}: Props) {
+ const { permission } = usePermissions();
const { mutate } = useSWRConfig();
const path = usePathname();
@@ -197,6 +199,7 @@ export default function NetworkRoutesTable({
variant={"primary"}
className={""}
onClick={() => setRouteModal(true)}
+ disabled={!permission.routes.create}
>
Add Route
@@ -228,6 +231,7 @@ export default function NetworkRoutesTable({
variant={"primary"}
className={""}
onClick={() => setRouteModal(true)}
+ disabled={!permission.routes.create}
>
Add Route
diff --git a/src/modules/routes/RouteActionCell.tsx b/src/modules/routes/RouteActionCell.tsx
index 34aa041f..7bbbd0b3 100644
--- a/src/modules/routes/RouteActionCell.tsx
+++ b/src/modules/routes/RouteActionCell.tsx
@@ -6,6 +6,7 @@ import * as React from "react";
import { useState } from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { Route } from "@/interfaces/Route";
import RouteUpdateModal from "@/modules/routes/RouteUpdateModal";
@@ -13,6 +14,7 @@ type Props = {
route: Route;
};
export default function RouteActionCell({ route }: Props) {
+ const { permission } = usePermissions();
const { confirm } = useDialog();
const routeRequest = useApiCall("/routes");
const { mutate } = useSWRConfig();
@@ -55,11 +57,17 @@ export default function RouteActionCell({ route }: Props) {
variant={"default-outline"}
size={"sm"}
onClick={() => setEditModal(true)}
+ disabled={!permission.routes.update}
>
Edit
-
+
Delete
diff --git a/src/modules/routes/RouteActiveCell.tsx b/src/modules/routes/RouteActiveCell.tsx
index 387822d0..63de1a39 100644
--- a/src/modules/routes/RouteActiveCell.tsx
+++ b/src/modules/routes/RouteActiveCell.tsx
@@ -1,6 +1,7 @@
import { ToggleSwitch } from "@components/ToggleSwitch";
import React, { useMemo } from "react";
import { useSWRConfig } from "swr";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { useRoutes } from "@/contexts/RoutesProvider";
import { Route } from "@/interfaces/Route";
@@ -8,6 +9,8 @@ type Props = {
route: Route;
};
export default function RouteActiveCell({ route }: Readonly) {
+ const { permission } = usePermissions();
+
const { updateRoute } = useRoutes();
const { mutate } = useSWRConfig();
@@ -34,6 +37,7 @@ export default function RouteActiveCell({ route }: Readonly) {
checked={isChecked}
size={"small"}
onClick={() => update(!isChecked)}
+ disabled={!permission.routes.update}
/>
);
diff --git a/src/modules/routes/RouteModal.tsx b/src/modules/routes/RouteModal.tsx
index b81e31d4..2985f0ee 100644
--- a/src/modules/routes/RouteModal.tsx
+++ b/src/modules/routes/RouteModal.tsx
@@ -774,13 +774,13 @@ export function RouteModalContent({
- {tab == "network" && (
+ {(tab == "network" || (tab == "access-control" && exitNode)) && (
Cancel
)}
- {tab == "access-control" && (
+ {tab == "access-control" && !exitNode && (
setTab("network")}>
Back
diff --git a/src/modules/settings/AuthenticationTab.tsx b/src/modules/settings/AuthenticationTab.tsx
index c32fb854..3052edd1 100644
--- a/src/modules/settings/AuthenticationTab.tsx
+++ b/src/modules/settings/AuthenticationTab.tsx
@@ -28,6 +28,7 @@ import {
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import SettingsIcon from "@/assets/icons/SettingsIcon";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { useHasChanges } from "@/hooks/useHasChanges";
import { Account } from "@/interfaces/Account";
@@ -36,6 +37,8 @@ type Props = {
};
export default function AuthenticationTab({ account }: Readonly
) {
+ const { permission } = usePermissions();
+
const { mutate } = useSWRConfig();
/**
@@ -113,6 +116,7 @@ export default function AuthenticationTab({ account }: Readonly) {
: false,
peer_inactivity_expiration: 600,
extra: {
+ ...account.settings?.extra,
peer_approval_enabled: peerApproval,
},
},
@@ -168,7 +172,7 @@ export default function AuthenticationTab({ account }: Readonly) {
@@ -197,12 +201,13 @@ export default function AuthenticationTab({ account }: Readonly) {
registered with SSO.
>
}
+ disabled={!permission.settings.update}
/>
) {
placeholder={"7"}
maxWidthClass={"min-w-[100px]"}
min={1}
- disabled={!loginExpiration}
+ disabled={!loginExpiration || !permission.settings.update}
data-cy={"peer-login-expiration-input"}
max={180}
className={"w-full"}
@@ -229,7 +234,7 @@ export default function AuthenticationTab({ account }: Readonly
) {
onChange={(e) => setExpiresIn(e.target.value)}
/>
diff --git a/src/modules/settings/NetworkSettingsTab.tsx b/src/modules/settings/NetworkSettingsTab.tsx
index 25d4c1d6..e4c1aca4 100644
--- a/src/modules/settings/NetworkSettingsTab.tsx
+++ b/src/modules/settings/NetworkSettingsTab.tsx
@@ -15,6 +15,7 @@ import { ExternalLinkIcon, GlobeIcon, NetworkIcon } from "lucide-react";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import SettingsIcon from "@/assets/icons/SettingsIcon";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { Account } from "@/interfaces/Account";
type Props = {
@@ -22,6 +23,8 @@ type Props = {
};
export default function NetworkSettingsTab({ account }: Readonly) {
+ const { permission } = usePermissions();
+
const { mutate } = useSWRConfig();
const saveRequest = useApiCall("/accounts/" + account.id, true);
@@ -109,7 +112,7 @@ export default function NetworkSettingsTab({ account }: Readonly) {
Save Changes
@@ -139,6 +142,7 @@ export default function NetworkSettingsTab({ account }: Readonly) {
errorTooltipPosition={"top"}
error={domainError}
value={customDNSDomain}
+ disabled={!permission.settings.update}
onChange={(e) => setCustomDNSDomain(e.target.value)}
/>
@@ -170,6 +174,7 @@ export default function NetworkSettingsTab({ account }: Readonly
) {
>
}
+ disabled={!permission.settings.update}
/>
diff --git a/src/modules/settings/PermissionsTab.tsx b/src/modules/settings/PermissionsTab.tsx
index f30512e6..cbbba950 100644
--- a/src/modules/settings/PermissionsTab.tsx
+++ b/src/modules/settings/PermissionsTab.tsx
@@ -8,6 +8,7 @@ import { GaugeIcon, LockIcon } from "lucide-react";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import SettingsIcon from "@/assets/icons/SettingsIcon";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { useHasChanges } from "@/hooks/useHasChanges";
import { Account } from "@/interfaces/Account";
@@ -16,6 +17,8 @@ type Props = {
};
export default function PermissionsTab({ account }: Props) {
+ const { permission } = usePermissions();
+
const { mutate } = useSWRConfig();
const saveRequest = useApiCall
("/accounts/" + account.id);
@@ -65,7 +68,7 @@ export default function PermissionsTab({ account }: Props) {
Permissions
Save Changes
@@ -85,6 +88,7 @@ export default function PermissionsTab({ account }: Props) {
helpText={
"Access to the dashboard will be limited and regular users will not be able to view any peers."
}
+ disabled={!permission.settings.update}
/>
diff --git a/src/modules/setup-keys/SetupKeyActionCell.tsx b/src/modules/setup-keys/SetupKeyActionCell.tsx
index dde9f189..1968b4d6 100644
--- a/src/modules/setup-keys/SetupKeyActionCell.tsx
+++ b/src/modules/setup-keys/SetupKeyActionCell.tsx
@@ -5,6 +5,7 @@ import { Trash2, Undo2Icon } from "lucide-react";
import * as React from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { SetupKey } from "@/interfaces/SetupKey";
type Props = {
@@ -14,6 +15,7 @@ export default function SetupKeyActionCell({ setupKey }: Readonly) {
const { confirm } = useDialog();
const request = useApiCall("/setup-keys/" + setupKey.id);
const { mutate } = useSWRConfig();
+ const { permission } = usePermissions();
const handleRevoke = async () => {
const choice = await confirm({
@@ -76,12 +78,19 @@ export default function SetupKeyActionCell({ setupKey }: Readonly) {
variant={"danger-outline"}
size={"sm"}
onClick={handleRevoke}
- disabled={setupKey.revoked || !setupKey.valid}
+ disabled={
+ setupKey.revoked || !setupKey.valid || !permission.setup_keys.update
+ }
>
Revoke
-
+
Delete
diff --git a/src/modules/setup-keys/SetupKeyGroupsCell.tsx b/src/modules/setup-keys/SetupKeyGroupsCell.tsx
index 684283cf..e9a75d09 100644
--- a/src/modules/setup-keys/SetupKeyGroupsCell.tsx
+++ b/src/modules/setup-keys/SetupKeyGroupsCell.tsx
@@ -2,6 +2,7 @@ import { notify } from "@components/Notification";
import { useApiCall } from "@utils/api";
import { useState } from "react";
import { useSWRConfig } from "swr";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import { SetupKey } from "@/interfaces/SetupKey";
import GroupsRow from "@/modules/common-table-rows/GroupsRow";
@@ -11,6 +12,7 @@ type Props = {
};
export default function SetupKeyGroupsCell({ setupKey }: Readonly) {
const [modal, setModal] = useState(false);
+ const { permission } = usePermissions();
const request = useApiCall("/setup-keys/" + setupKey.id);
const { mutate } = useSWRConfig();
const handleSave = async (promises: Promise[]) => {
@@ -40,17 +42,20 @@ export default function SetupKeyGroupsCell({ setupKey }: Readonly) {
};
return (
-
+ permission.groups.read && (
+
+ )
);
}
diff --git a/src/modules/setup-keys/SetupKeysTable.tsx b/src/modules/setup-keys/SetupKeysTable.tsx
index f5524c70..cba32926 100644
--- a/src/modules/setup-keys/SetupKeysTable.tsx
+++ b/src/modules/setup-keys/SetupKeysTable.tsx
@@ -14,6 +14,7 @@ import { usePathname } from "next/navigation";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { SetupKey } from "@/interfaces/SetupKey";
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
@@ -126,6 +127,7 @@ export default function SetupKeysTable({
}: Readonly) {
const { mutate } = useSWRConfig();
const path = usePathname();
+ const { permission } = usePermissions();
// Default sorting state of the table
const [sorting, setSorting] = useLocalStorage(
@@ -184,6 +186,7 @@ export default function SetupKeysTable({
variant={"primary"}
className={""}
onClick={() => setOpen(true)}
+ disabled={!permission.setup_keys.create}
>
Create Setup Key
@@ -212,6 +215,7 @@ export default function SetupKeysTable({
variant={"primary"}
className={"ml-auto"}
onClick={() => setOpen(true)}
+ disabled={!permission.setup_keys.create}
>
Create Setup Key
diff --git a/src/modules/users/HorizontalUsersStack.tsx b/src/modules/users/HorizontalUsersStack.tsx
new file mode 100644
index 00000000..2e50082b
--- /dev/null
+++ b/src/modules/users/HorizontalUsersStack.tsx
@@ -0,0 +1,130 @@
+import FullTooltip from "@components/FullTooltip";
+import { ScrollArea } from "@components/ScrollArea";
+import TextWithTooltip from "@components/ui/TextWithTooltip";
+import { cn, generateColorFromString } from "@utils/helpers";
+import * as React from "react";
+import { User } from "@/interfaces/User";
+import { SmallUserAvatar } from "@/modules/users/SmallUserAvatar";
+
+type Props = {
+ users: User[];
+ max?: number;
+ avatarClassName?: string;
+ side?: "left" | "right" | "top" | "bottom";
+};
+
+export const HorizontalUsersStack = ({
+ users,
+ max = 3,
+ avatarClassName,
+ side = "top",
+}: Props) => {
+ let usersToDisplay = users?.slice(0, max) || [];
+
+ return (
+
+ e.stopPropagation()}
+ >
+ {users?.map((user, index) => (
+
+ ))}
+
+
+ }
+ disabled={users?.length === 0}
+ skipDelayDuration={200}
+ delayDuration={300}
+ >
+
+ {usersToDisplay.map((user, index) => (
+
+
+
+ ))}
+
+
0 && "group-hover/user-stack:text-nb-gray-200 ",
+ )}
+ >
+ {users?.length || 0} User(s)
+
+
+
+ );
+};
+
+const UserAvatarCircle = ({
+ name,
+ className,
+ hoverEffect = false,
+}: {
+ name: string;
+ className?: string;
+ hoverEffect?: boolean;
+}) => {
+ return (
+
+ {name.charAt(0)}
+
+ );
+};
diff --git a/src/modules/users/ServiceUserModal.tsx b/src/modules/users/ServiceUserModal.tsx
index e232534e..13d0b45d 100644
--- a/src/modules/users/ServiceUserModal.tsx
+++ b/src/modules/users/ServiceUserModal.tsx
@@ -26,16 +26,14 @@ type Props = {
children: React.ReactNode;
};
-export default function ServiceUserModal({ children }: Props) {
+export default function ServiceUserModal({ children }: Readonly) {
const [modal, setModal] = useState(false);
return (
- <>
-
- {children}
- setModal(false)} />
-
- >
+
+ {children}
+ setModal(false)} />
+
);
}
@@ -43,7 +41,7 @@ type ModalProps = {
onSuccess?: () => void;
};
-export function ServiceUserModalContent({ onSuccess }: ModalProps) {
+export function ServiceUserModalContent({ onSuccess }: Readonly) {
const userRequest = useApiCall("/users");
const { mutate } = useSWRConfig();
const [name, setName] = useState("");
@@ -86,8 +84,8 @@ export function ServiceUserModalContent({ onSuccess }: ModalProps) {
-
-
+
+
@@ -100,12 +98,13 @@ export function ServiceUserModalContent({ onSuccess }: ModalProps) {
onChange={(e) => setName(e.target.value)}
/>
-
-
+
+
+
diff --git a/src/modules/users/ServiceUsersTable.tsx b/src/modules/users/ServiceUsersTable.tsx
index b2d39832..db3eb2bb 100644
--- a/src/modules/users/ServiceUsersTable.tsx
+++ b/src/modules/users/ServiceUsersTable.tsx
@@ -13,6 +13,7 @@ import { ExternalLinkIcon, PlusCircle } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import React from "react";
import { useSWRConfig } from "swr";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { User } from "@/interfaces/User";
import ServiceUserModal from "@/modules/users/ServiceUserModal";
@@ -70,11 +71,12 @@ export default function ServiceUsersTable({
users,
isLoading,
headingTarget,
-}: Props) {
+}: Readonly
) {
useFetchApi("/groups");
const { mutate } = useSWRConfig();
const router = useRouter();
const path = usePathname();
+ const { permission } = usePermissions();
// Default sorting state of the table
const [sorting, setSorting] = useLocalStorage(
@@ -92,98 +94,98 @@ export default function ServiceUsersTable({
);
return (
- <>
- {
- router.push(`/team/user?id=${row.original.id}&service_user=true`);
- }}
- rowClassName={"cursor-pointer"}
- columnVisibility={{
- is_current: false,
- }}
- searchPlaceholder={"Search by name or role..."}
- getStartedCard={
- }
- color={"gray"}
- size={"large"}
- />
- }
- title={"Create Service User"}
- description={
- "It looks like you don't have any service users. Get started by creating a service user."
- }
- button={
-
-
-
-
-
- Create Service User
-
-
-
+
{
+ router.push(`/team/user?id=${row.original.id}&service_user=true`);
+ }}
+ rowClassName={"cursor-pointer"}
+ columnVisibility={{
+ is_current: false,
+ }}
+ searchPlaceholder={"Search by name or role..."}
+ getStartedCard={
+ }
+ color={"gray"}
+ size={"large"}
+ />
+ }
+ title={"Create Service User"}
+ description={
+ "It looks like you don't have any service users. Get started by creating a service user."
+ }
+ button={
+
+
+
+
+
+ Create Service User
+
+
- }
- learnMore={
- <>
- Learn more about
-
- Service Users
-
-
- >
- }
+
+ }
+ learnMore={
+ <>
+ Learn more about
+
+ Service Users
+
+
+ >
+ }
+ />
+ }
+ rightSide={() => (
+ <>
+ {users && users?.length > 0 && (
+
+
+
+ Create Service User
+
+
+ )}
+ >
+ )}
+ >
+ {(table) => (
+ <>
+
+ {
+ mutate("/users?service_user=true");
+ mutate("/groups");
+ }}
/>
- }
- rightSide={() => (
- <>
- {users && users?.length > 0 && (
-
-
-
- Create Service User
-
-
- )}
- >
- )}
- >
- {(table) => (
- <>
-
- {
- mutate("/users?service_user=true");
- mutate("/groups");
- }}
- />
- >
- )}
-
- >
+ >
+ )}
+
);
}
diff --git a/src/modules/users/SmallUserAvatar.tsx b/src/modules/users/SmallUserAvatar.tsx
new file mode 100644
index 00000000..85bcf4fb
--- /dev/null
+++ b/src/modules/users/SmallUserAvatar.tsx
@@ -0,0 +1,32 @@
+import { cn, generateColorFromString } from "@utils/helpers";
+import { Cog } from "lucide-react";
+import * as React from "react";
+
+type Props = {
+ name?: string;
+ email?: string;
+ id?: string;
+ className?: string;
+};
+export const SmallUserAvatar = ({ name, id, email, className }: Props) => {
+ return (
+
+ {email === "NetBird" ? (
+
+ ) : (
+ name?.charAt(0) || id?.charAt(0)
+ )}
+
+ );
+};
diff --git a/src/modules/users/UserInviteModal.tsx b/src/modules/users/UserInviteModal.tsx
index 2d1574c9..4f4872e9 100644
--- a/src/modules/users/UserInviteModal.tsx
+++ b/src/modules/users/UserInviteModal.tsx
@@ -30,16 +30,22 @@ type Props = {
children: React.ReactNode;
};
-export default function UserInviteModal({ children }: Props) {
+export default function UserInviteModal({ children }: Readonly
) {
const [open, setOpen] = useState(false);
+ const { mutate } = useSWRConfig();
+
+ const handleOnSuccess = () => {
+ setOpen(false);
+ setTimeout(() => {
+ mutate("/users?service_user=false");
+ }, 1000);
+ };
return (
- <>
-
- {children}
- setOpen(false)} />
-
- >
+
+ {children}
+
+
);
}
@@ -47,7 +53,7 @@ type ModalProps = {
onSuccess: () => void;
};
-export function UserInviteModalContent({ onSuccess }: ModalProps) {
+export function UserInviteModalContent({ onSuccess }: Readonly) {
const userRequest = useApiCall("/users");
const { mutate } = useSWRConfig();
@@ -74,8 +80,8 @@ export function UserInviteModalContent({ onSuccess }: ModalProps) {
is_service_user: false,
})
.then(() => {
- onSuccess && onSuccess();
mutate("/users?service_user=false");
+ onSuccess && onSuccess();
}),
loadingMessage: "Sending invite...",
});
diff --git a/src/modules/users/UserResendInviteButton.tsx b/src/modules/users/UserResendInviteButton.tsx
new file mode 100644
index 00000000..e80dd87c
--- /dev/null
+++ b/src/modules/users/UserResendInviteButton.tsx
@@ -0,0 +1,58 @@
+import Button from "@components/Button";
+import { notify } from "@components/Notification";
+import { useApiCall } from "@utils/api";
+import { cn } from "@utils/helpers";
+import { Loader2, MailIcon } from "lucide-react";
+import * as React from "react";
+import { useState } from "react";
+import { usePermissions } from "@/contexts/PermissionsProvider";
+import { User } from "@/interfaces/User";
+
+type Props = {
+ user: User;
+};
+export const UserResendInviteButton = ({ user }: Props) => {
+ const userRequest = useApiCall("/users", true);
+ const [isLoading, setIsLoading] = useState(false);
+ const { permission } = usePermissions();
+
+ const inviteUser = async () => {
+ setIsLoading(true);
+ notify({
+ title: "Resend Invite",
+ description: `The invitation is being sent to ${user.email}`,
+ promise: userRequest
+ .post("", `/${user.id}/invite`)
+ .finally(() => setIsLoading(false)),
+ loadingMessage: "Sending invitation...",
+ });
+ };
+
+ const LoadingMessage = () => (
+ <>
+
+ Sending...
+ >
+ );
+
+ const DefaultMessage = () => (
+ <>
+
+ Resend Invite
+ >
+ );
+
+ return (
+ user.status == "invited" && (
+
+ {isLoading ? : }
+
+ )
+ );
+};
diff --git a/src/modules/users/UserRoleSelector.tsx b/src/modules/users/UserRoleSelector.tsx
index 206453ac..ac0712ae 100644
--- a/src/modules/users/UserRoleSelector.tsx
+++ b/src/modules/users/UserRoleSelector.tsx
@@ -2,9 +2,17 @@ import Button from "@components/Button";
import { CommandItem } from "@components/Command";
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
import { ScrollArea } from "@components/ScrollArea";
+import { isNetBirdHosted } from "@utils/netbird";
import { Command, CommandGroup, CommandList } from "cmdk";
import { trim } from "lodash";
-import { ChevronsUpDown, Cog, User2 } from "lucide-react";
+import {
+ ChevronsUpDown,
+ Cog,
+ CreditCard,
+ EyeIcon,
+ NetworkIcon,
+ User2,
+} from "lucide-react";
import * as React from "react";
import { useState } from "react";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
@@ -20,9 +28,10 @@ interface MultiSelectProps {
popoverWidth?: "auto" | number;
hideOwner?: boolean;
currentUser?: User;
+ customTrigger?: React.ReactNode;
}
-const UserRoles = [
+export const UserRoles = [
{
name: "Owner",
value: Role.Owner,
@@ -33,6 +42,21 @@ const UserRoles = [
value: Role.Admin,
icon: Cog,
},
+ {
+ name: "Network Admin",
+ value: Role.NetworkAdmin,
+ icon: NetworkIcon,
+ },
+ {
+ name: "Billing Admin",
+ value: Role.BillingAdmin,
+ icon: CreditCard,
+ },
+ {
+ name: "Auditor",
+ value: Role.Auditor,
+ icon: EyeIcon,
+ },
{
name: "User",
value: Role.User,
@@ -47,8 +71,11 @@ export function UserRoleSelector({
popoverWidth = "auto",
hideOwner = false,
currentUser,
-}: MultiSelectProps) {
- const [inputRef, { width }] = useElementSize();
+ customTrigger,
+}: Readonly) {
+ const [inputRef, { width }] = useElementSize<
+ HTMLButtonElement | HTMLDivElement
+ >();
const { isOwner } = useLoggedInUser();
const { confirm } = useDialog();
@@ -80,10 +107,7 @@ export function UserRoleSelector({
}
const isSelected = value == item;
- if (isSelected) {
- } else {
- onChange && onChange(item);
- }
+ if (!isSelected) onChange && onChange(item);
setOpen(false);
};
@@ -99,35 +123,37 @@ export function UserRoleSelector({
}}
>
-
-
- {selectedRole && (
-
+ {customTrigger ? (
+ {customTrigger}
+ ) : (
+
+
+ {selectedRole && (
-
- )}
+ )}
-
-
-
+
+ )}
-
-
- {item.name}
-
+
+ {item.name}
diff --git a/src/modules/users/UsersTable.tsx b/src/modules/users/UsersTable.tsx
index 62775d10..1823763d 100644
--- a/src/modules/users/UsersTable.tsx
+++ b/src/modules/users/UsersTable.tsx
@@ -10,11 +10,12 @@ import { ColumnDef, SortingState } from "@tanstack/react-table";
import useFetchApi from "@utils/api";
import { isLocalDev, isNetBirdHosted } from "@utils/netbird";
import dayjs from "dayjs";
-import { ExternalLinkIcon, MailPlus, PlusCircle } from "lucide-react";
+import { ExternalLinkIcon, MailPlus } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import React from "react";
import { useSWRConfig } from "swr";
import TeamIcon from "@/assets/icons/TeamIcon";
+import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { User } from "@/interfaces/User";
import LastTimeRow from "@/modules/common-table-rows/LastTimeRow";
@@ -101,7 +102,11 @@ type Props = {
headingTarget?: HTMLHeadingElement | null;
};
-export default function UsersTable({ users, isLoading, headingTarget }: Props) {
+export default function UsersTable({
+ users,
+ isLoading,
+ headingTarget,
+}: Readonly
) {
useFetchApi("/groups");
const { mutate } = useSWRConfig();
const path = usePathname();
@@ -124,89 +129,102 @@ export default function UsersTable({ users, isLoading, headingTarget }: Props) {
const router = useRouter();
return (
- <>
- {
- router.push(`/team/user?id=${row.original.id}`);
- }}
- searchPlaceholder={"Search by name, email or role..."}
- getStartedCard={
- }
- color={"gray"}
- size={"large"}
- />
- }
- title={"Create Nameserver"}
- description={
- "It looks like you don't have any nameservers. Get started by adding one to your network. Select a predefined or add your custom nameservers."
- }
- button={
-
- }
- learnMore={
- <>
- Learn more about
-
- DNS
-
-
- >
- }
- />
- }
- rightSide={() => (
- <>
- {(isLocalDev() || isNetBirdHosted()) &&
- users &&
- users?.length > 0 && (
-
-
-
- Invite User
-
-
- )}
- >
- )}
- >
- {(table) => (
- <>
-
- {
- mutate("/users?service_user=false");
- mutate("/groups");
- }}
+ {
+ router.push(`/team/user?id=${row.original.id}`);
+ }}
+ searchPlaceholder={"Search by name, email or role..."}
+ getStartedCard={
+ }
+ color={"gray"}
+ size={"large"}
/>
- >
- )}
-
- >
+ }
+ title={"Add New Users"}
+ description={
+ "It looks like you don't have any users yet. Get started by inviting users to your account."
+ }
+ button={
+
+
+
+ }
+ learnMore={
+ <>
+ Learn more about
+
+ Users
+
+
+ >
+ }
+ />
+ }
+ rightSide={() => (
+ 0}
+ className={"ml-auto"}
+ />
+ )}
+ >
+ {(table) => (
+ <>
+
+ {
+ mutate("/users?service_user=false");
+ mutate("/groups");
+ }}
+ />
+ >
+ )}
+
);
}
+
+type InviteUserButtonProps = {
+ show?: boolean;
+ className?: string;
+};
+
+const InviteUserButton = ({
+ show = false,
+ className,
+}: InviteUserButtonProps) => {
+ const { permission } = usePermissions();
+ if (!show) return null;
+
+ return (
+ (isLocalDev() || isNetBirdHosted()) && (
+
+
+
+ Invite User
+
+
+ )
+ );
+};
diff --git a/src/modules/users/table-cells/ServiceUserNameCell.tsx b/src/modules/users/table-cells/ServiceUserNameCell.tsx
index 4d0ae1b6..1a3ffe21 100644
--- a/src/modules/users/table-cells/ServiceUserNameCell.tsx
+++ b/src/modules/users/table-cells/ServiceUserNameCell.tsx
@@ -7,7 +7,7 @@ type Props = {
user: User;
};
-export default function ServiceUserNameCell({ user }: Props) {
+export default function ServiceUserNameCell({ user }: Readonly) {
return (
) {
const { confirm } = useDialog();
+ const { permission } = usePermissions();
const userRequest = useApiCall
("/users");
const { mutate } = useSWRConfig();
@@ -44,11 +51,15 @@ export default function UserActionCell({ user, serviceUser = false }: Props) {
};
const disabled = useMemo(() => {
- return user.is_current || user.role === "owner";
- }, [user]);
+ if (!permission.users.delete) return true;
+ return user.is_current;
+ }, [permission.users.delete, user.is_current]);
return (
-
+
+ {!serviceUser && isNetBirdHosted() && (
+
+ )}
("/users");
const { mutate } = useSWRConfig();
const { confirm } = useDialog();
+ const { permission } = usePermissions();
const isChecked = useMemo(() => {
return user.is_blocked;
@@ -62,6 +64,7 @@ export default function UserBlockCell({ user, isUserPage = false }: Props) {
return !disabled ? (
) {
const status = user.status;
const isCurrent = user.is_current;
@@ -20,9 +20,7 @@ export default function UserNameCell({ user }: Props) {
"w-10 h-10 rounded-full relative flex items-center justify-center text-white uppercase text-md font-medium bg-nb-gray-900"
}
style={{
- color: user?.name
- ? generateColorFromString(user?.name || user?.id || "System User")
- : "#808080",
+ color: generateColorFromUser(user),
}}
>
{!user?.name && !user?.id && }
diff --git a/src/modules/users/table-cells/UserRoleCell.tsx b/src/modules/users/table-cells/UserRoleCell.tsx
index a6780dc6..08fb7d8e 100644
--- a/src/modules/users/table-cells/UserRoleCell.tsx
+++ b/src/modules/users/table-cells/UserRoleCell.tsx
@@ -1,6 +1,6 @@
import Badge from "@components/Badge";
import { cn } from "@utils/helpers";
-import { Cog, User2 } from "lucide-react";
+import { Cog, CreditCardIcon, EyeIcon, NetworkIcon, User2 } from "lucide-react";
import React from "react";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import { Role, User } from "@/interfaces/User";
@@ -9,7 +9,7 @@ type Props = {
user: User;
};
-export default function UserRoleCell({ user }: Props) {
+export default function UserRoleCell({ user }: Readonly) {
const role = user.role;
return (
@@ -33,6 +33,24 @@ export default function UserRoleCell({ user }: Props) {
Owner
>
)}
+ {role === Role.BillingAdmin && (
+ <>
+
+ Billing Admin
+ >
+ )}
+ {role === Role.Auditor && (
+ <>
+
+ Auditor
+ >
+ )}
+ {role === Role.NetworkAdmin && (
+ <>
+
+ Network Admin
+ >
+ )}
);
diff --git a/src/modules/users/table-cells/UserStatusCell.tsx b/src/modules/users/table-cells/UserStatusCell.tsx
index 1f338ba3..19d52923 100644
--- a/src/modules/users/table-cells/UserStatusCell.tsx
+++ b/src/modules/users/table-cells/UserStatusCell.tsx
@@ -5,7 +5,8 @@ import { User } from "@/interfaces/User";
type Props = {
user: User;
};
-export default function UserStatusCell({ user }: Props) {
+
+export default function UserStatusCell({ user }: Readonly) {
const status = user.status;
return (
diff --git a/src/utils/api.tsx b/src/utils/api.tsx
index 6e8dcdb0..1ca915e7 100644
--- a/src/utils/api.tsx
+++ b/src/utils/api.tsx
@@ -8,6 +8,7 @@ import { sleep } from "@utils/helpers";
import { usePathname } from "next/navigation";
import { isExpired } from "react-jwt";
import useSWR from "swr";
+import { useApplicationContext } from "@/contexts/ApplicationProvider";
import { useErrorBoundary } from "@/contexts/ErrorBoundary";
type Method = "GET" | "POST" | "PUT" | "DELETE";
@@ -20,10 +21,17 @@ export type ErrorResponse = {
const config = loadConfig();
type RequestOptions = {
+ key?: string;
signal?: AbortSignal;
origin?: string;
+ globalParams?: Params;
+ ignoreGlobalParams?: boolean;
+ blob?: boolean;
+ shouldRetryOnError?: boolean;
};
+export type Params = Record;
+
async function apiRequest(
oidcFetch: (input: RequestInfo, init?: RequestInit) => Promise,
method: Method,
@@ -32,8 +40,12 @@ async function apiRequest(
options?: RequestOptions,
) {
const origin = options?.origin ? options?.origin : config.apiOrigin + "/api";
+ let newUrl = mergeUrlParams(
+ url,
+ options?.ignoreGlobalParams ? undefined : options?.globalParams,
+ );
- const res = await oidcFetch(`${origin}${url}`, {
+ const res = await oidcFetch(`${origin}${newUrl}`, {
method,
body: JSON.stringify(data),
signal: options?.signal,
@@ -44,6 +56,7 @@ async function apiRequest(
const error = (await res.json()) as ErrorResponse;
return Promise.reject(error);
}
+ if (options?.blob) return (await res.blob()) as T;
return (await res.json()) as T;
} catch (e) {
if (!res.ok) {
@@ -113,20 +126,34 @@ export default function useFetchApi(
) {
const { fetch } = useNetBirdFetch(ignoreError);
const handleErrors = useApiErrorHandling(ignoreError);
+ const { globalApiParams } = useApplicationContext();
+
+ const cacheKey = options?.key ? [url, options?.key] : url;
+ const fetchFn = options?.key
+ ? async ([url]: [url: string]) => {
+ if (!allowFetch) return;
+ return apiRequest(fetch, "GET", url, undefined, {
+ ...options,
+ globalParams: globalApiParams,
+ }).catch((err) => handleErrors(err as ErrorResponse));
+ }
+ : async (url: string) => {
+ if (!allowFetch) return;
+ return apiRequest(fetch, "GET", url, undefined, {
+ ...options,
+ globalParams: globalApiParams,
+ }).catch((err) => handleErrors(err as ErrorResponse));
+ };
const { data, error, isLoading, isValidating, mutate } = useSWR(
- url,
- async (url) => {
- if (!allowFetch) return;
- return apiRequest(fetch, "GET", url, undefined, options).catch((err) =>
- handleErrors(err as ErrorResponse),
- );
- },
+ cacheKey,
+ fetchFn,
{
keepPreviousData: true,
revalidateOnFocus: revalidate,
revalidateIfStale: revalidate,
revalidateOnReconnect: revalidate,
+ shouldRetryOnError: options?.shouldRetryOnError ?? true,
},
);
@@ -146,49 +173,38 @@ export function useApiCall(
) {
const { fetch } = useNetBirdFetch(ignoreError);
const handleErrors = useApiErrorHandling(ignoreError);
+ const { globalApiParams } = useApplicationContext();
return {
post: async (data: any, suffix = "", options?: RequestOptions) => {
- return apiRequest(
- fetch,
- "POST",
- url + suffix,
- data,
- options || requestOptions,
- )
+ return apiRequest(fetch, "POST", url + suffix, data, {
+ ...(options || requestOptions),
+ globalParams: globalApiParams,
+ })
.then((res) => Promise.resolve(res as T))
.catch((err) => handleErrors(err as ErrorResponse)) as Promise;
},
put: async (data: any, suffix = "", options?: RequestOptions) => {
- return apiRequest(
- fetch,
- "PUT",
- url + suffix,
- data,
- options || requestOptions,
- )
+ return apiRequest(fetch, "PUT", url + suffix, data, {
+ ...(options || requestOptions),
+ globalParams: globalApiParams,
+ })
.then((res) => Promise.resolve(res as T))
.catch((err) => handleErrors(err as ErrorResponse)) as Promise;
},
del: async (data: any = "", suffix = "", options?: RequestOptions) => {
- return apiRequest(
- fetch,
- "DELETE",
- url + suffix,
- data,
- options || requestOptions,
- )
+ return apiRequest(fetch, "DELETE", url + suffix, data, {
+ ...(options || requestOptions),
+ globalParams: globalApiParams,
+ })
.then((res) => Promise.resolve(res as T))
.catch((err) => handleErrors(err as ErrorResponse)) as Promise;
},
get: async (suffix = "", options?: RequestOptions) => {
- return apiRequest(
- fetch,
- "GET",
- url + suffix,
- undefined,
- options || requestOptions,
- )
+ return apiRequest(fetch, "GET", url + suffix, undefined, {
+ ...(options || requestOptions),
+ globalParams: globalApiParams,
+ })
.then((res) => Promise.resolve(res as T))
.catch((err) => handleErrors(err as ErrorResponse)) as Promise;
},
@@ -225,3 +241,28 @@ export function useApiErrorHandling(ignoreError = false) {
return Promise.reject(err);
};
}
+
+function mergeUrlParams(url: string, params?: Params): string {
+ try {
+ // Split the URL and query parts
+ const [basePath, existingQuery] = url.split("?");
+
+ // Create a search params object with existing query params
+ const searchParams = new URLSearchParams(existingQuery || "");
+
+ // Add new params if provided
+ if (params && typeof params === "object") {
+ Object.entries(params).forEach(([key, value]) => {
+ if (value !== undefined && value !== null) {
+ searchParams.set(key, String(value));
+ }
+ });
+ }
+
+ // Build the final URL
+ const queryString = searchParams.toString();
+ return queryString ? `${basePath}?${queryString}` : basePath;
+ } catch (error) {
+ return url;
+ }
+}
diff --git a/src/utils/config.ts b/src/utils/config.ts
index b64a15b7..aeaac1eb 100644
--- a/src/utils/config.ts
+++ b/src/utils/config.ts
@@ -14,8 +14,9 @@ interface Config {
silentRedirectURI: string;
tokenSource: string;
dragQueryParams: boolean;
- hotjarTrackID: number;
- googleAnalyticsID: string;
+ hotjarTrackID?: number;
+ googleAnalyticsID?: string;
+ googleTagManagerID?: string;
}
/**
@@ -62,8 +63,9 @@ const loadConfig = (): Config => {
silentRedirectURI: silentRedirectURI,
tokenSource: tokenSource,
dragQueryParams: configJson.dragQueryParams == "true", // Drags all the query params to the auth layer specified in the URL when accessing dashboard.
- hotjarTrackID: configJson.hotjarTrackID,
- googleAnalyticsID: configJson.googleAnalyticsID,
+ hotjarTrackID: configJson?.hotjarTrackID || undefined,
+ googleAnalyticsID: configJson?.googleAnalyticsID || undefined,
+ googleTagManagerID: configJson?.googleTagManagerID || undefined,
} as Config;
};
diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts
index e5d48d75..bf61af6c 100644
--- a/src/utils/helpers.ts
+++ b/src/utils/helpers.ts
@@ -1,3 +1,4 @@
+import chroma from "chroma-js";
import { type ClassValue, clsx } from "clsx";
import deepClone from "lodash/cloneDeep";
import { twMerge } from "tailwind-merge";
@@ -24,6 +25,7 @@ export function removeAllSpaces(str: string) {
}
export const generateColorFromString = (str: string) => {
+ if (str.includes("System")) return "#808080";
let hash = 0;
str.split("").forEach((char) => {
hash = char.charCodeAt(0) + ((hash << 5) - hash);
@@ -33,7 +35,21 @@ export const generateColorFromString = (str: string) => {
const value = (hash >> (i * 8)) & 0xff;
colour += value.toString(16).padStart(2, "0");
}
- return colour;
+ return chroma(colour).saturate(2).luminance(0.4).hex();
+};
+
+export const generateColorFromUser = (user?: {
+ id?: string;
+ name?: string;
+ email?: string;
+}) => {
+ if (user?.email === "NetBird") return "#9c9c9c";
+ return user?.name
+ ? chroma(generateColorFromString(user?.name || user?.id || "System User"))
+ .saturate(2)
+ .luminance(0.4)
+ .hex()
+ : "#9c9c9c";
};
export const sleep = (ms: number) => {
@@ -144,3 +160,25 @@ export function cloneDeep(obj: T): T {
}
}
}
+
+/**
+ * Converts bytes to human-readable format (B, KB, MB, GB, TB)
+ * @param bytes Number of bytes to convert
+ * @param decimals Number of decimal places to show
+ * @returns Formatted string with appropriate unit
+ */
+export const formatBytes = (bytes: number, decimals: number = 2): string => {
+ try {
+ if (bytes === 0) return "0 B";
+
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB", "TB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return (
+ parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + " " + sizes[i]
+ );
+ } catch (e) {
+ return "0 B";
+ }
+};
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 3ddd6877..902eff1c 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -15,11 +15,13 @@ const config: Config = {
"100": "#e4e7e9",
"200": "#cbd2d6",
"300": "#a7b1b9",
+ "350": "#8f9ca8",
"400": "#7c8994",
"500": "#616e79",
"600": "#535d67",
"700": "#474e57",
"800": "#3f444b",
+ "850": "#363b40",
"900": "#32363D",
"910": "#2b2f33",
"920": "#25282d",