Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2046fee21 | ||
|
|
8e2cbe1d2a | ||
|
|
8a08583225 | ||
|
|
1defac4e34 | ||
|
|
fa68f98cd0 | ||
|
|
3f6e4c4e4f | ||
|
|
0e2661caea |
@@ -12,5 +12,6 @@
|
||||
"tokenSource": "$NETBIRD_TOKEN_SOURCE",
|
||||
"dragQueryParams": "$NETBIRD_DRAG_QUERY_PARAMS",
|
||||
"hotjarTrackID": "$NETBIRD_HOTJAR_TRACK_ID",
|
||||
"googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID"
|
||||
"googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID",
|
||||
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID"
|
||||
}
|
||||
@@ -58,13 +58,14 @@ export NETBIRD_MGMT_API_ENDPOINT=$(echo $NETBIRD_MGMT_API_ENDPOINT | sed -E 's/(
|
||||
export NETBIRD_MGMT_GRPC_API_ENDPOINT=${NETBIRD_MGMT_GRPC_API_ENDPOINT}
|
||||
export NETBIRD_HOTJAR_TRACK_ID=${NETBIRD_HOTJAR_TRACK_ID}
|
||||
export NETBIRD_GOOGLE_ANALYTICS_ID=${NETBIRD_GOOGLE_ANALYTICS_ID}
|
||||
export NETBIRD_GOOGLE_TAG_MANAGER_ID=${NETBIRD_GOOGLE_TAG_MANAGER_ID}
|
||||
export NETBIRD_TOKEN_SOURCE=${NETBIRD_TOKEN_SOURCE:-accessToken}
|
||||
export NETBIRD_DRAG_QUERY_PARAMS=${NETBIRD_DRAG_QUERY_PARAMS:-false}
|
||||
|
||||
echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}"
|
||||
|
||||
# replace ENVs in the config
|
||||
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS"
|
||||
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS"
|
||||
|
||||
OIDC_TRUSTED_DOMAINS="/usr/share/nginx/html/OidcTrustedDomains.js"
|
||||
envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS"
|
||||
|
||||
1205
package-lock.json
generated
1205
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -41,6 +41,8 @@
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"autoprefixer": "^10",
|
||||
"chart.js": "^4.4.8",
|
||||
"chroma-js": "^3.1.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"cmdk": "^0.2.0",
|
||||
@@ -48,21 +50,21 @@
|
||||
"date-fns": "^2.30.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "13.5.5",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"flowbite": "^1.8.1",
|
||||
"flowbite-react": "^0.6.4",
|
||||
"framer-motion": "^10.16.4",
|
||||
"ip-cidr": "^3.1.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.460.0",
|
||||
"next": "13.5.7",
|
||||
"lucide-react": "^0.481.0",
|
||||
"next": "^14.2.28",
|
||||
"next-themes": "^0.2.1",
|
||||
"punycode": "^2.3.1",
|
||||
"react": "^18",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^8.9.1",
|
||||
"react-dom": "^18",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-ga4": "^2.1.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-hotjar": "^6.2.0",
|
||||
@@ -74,9 +76,14 @@
|
||||
"swr": "^2.2.4",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"timescape": "^0.7.1",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^9.5.1",
|
||||
"@types/chroma-js": "^3.1.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"eslint-config-next": "^14.2.28",
|
||||
"cypress": "^13.13.0",
|
||||
"postcss": "^8",
|
||||
"prettier": "3.0.3",
|
||||
|
||||
10
src/app/(dashboard)/(deprecated)/activity/page.tsx
Normal file
10
src/app/(dashboard)/(deprecated)/activity/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { useRedirect } from "@hooks/useRedirect";
|
||||
import React from "react";
|
||||
|
||||
export default function Redirect() {
|
||||
useRedirect("/events/audit");
|
||||
return <FullScreenLoading height={"auto"} />;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
import GroupsProvider from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import PoliciesProvider from "@/contexts/PoliciesProvider";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
@@ -19,6 +20,8 @@ const AccessControlTable = lazy(
|
||||
() => import("@/modules/access-control/table/AccessControlTable"),
|
||||
);
|
||||
export default function AccessControlPage() {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { data: policies, isLoading } = useFetchApi<Policy[]>("/policies");
|
||||
|
||||
const { ref: headingRef, portalTarget } =
|
||||
@@ -53,7 +56,10 @@ export default function AccessControlPage() {
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<RestrictedAccess page={"Access Control"}>
|
||||
<RestrictedAccess
|
||||
page={"Access Control"}
|
||||
hasAccess={permission.policies.read}
|
||||
>
|
||||
<PoliciesProvider>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<AccessControlTable
|
||||
|
||||
@@ -10,6 +10,7 @@ import useFetchApi from "@utils/api";
|
||||
import { ExternalLinkIcon, ServerIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import DNSIcon from "@/assets/icons/DNSIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { NameserverGroup } from "@/interfaces/Nameserver";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
@@ -18,6 +19,8 @@ const NameserverGroupTable = lazy(
|
||||
);
|
||||
|
||||
export default function NameServers() {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { data: nameserverGroups, isLoading } =
|
||||
useFetchApi<NameserverGroup[]>("/dns/nameservers");
|
||||
|
||||
@@ -57,7 +60,10 @@ export default function NameServers() {
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<RestrictedAccess page={"Nameservers"}>
|
||||
<RestrictedAccess
|
||||
page={"Nameservers"}
|
||||
hasAccess={permission.nameservers.read}
|
||||
>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<NameserverGroupTable
|
||||
nameserverGroups={nameserverGroups}
|
||||
|
||||
@@ -17,6 +17,7 @@ import React from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { useSWRConfig } from "swr";
|
||||
import DNSIcon from "@/assets/icons/DNSIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useHasChanges } from "@/hooks/useHasChanges";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NameserverSettings } from "@/interfaces/NameserverSettings";
|
||||
@@ -25,6 +26,8 @@ import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import { useGroupIdsToGroups } from "@/modules/groups/useGroupIdsToGroups";
|
||||
|
||||
export default function NameServerSettings() {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { data: settings, isLoading } =
|
||||
useFetchApi<NameserverSettings>("/dns/settings");
|
||||
|
||||
@@ -61,7 +64,7 @@ export default function NameServerSettings() {
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
<RestrictedAccess page={"DNS Settings"}>
|
||||
<RestrictedAccess page={"DNS Settings"} hasAccess={permission.dns.read}>
|
||||
{!isLoading && initialDNSGroups !== undefined ? (
|
||||
<SettingDisabledManagementGroups initialGroups={initialDNSGroups} />
|
||||
) : (
|
||||
@@ -86,6 +89,7 @@ const SettingDisabledManagementGroups = ({
|
||||
}) => {
|
||||
const settingRequest = useApiCall<NameserverSettings>("/dns/settings");
|
||||
const { mutate } = useSWRConfig();
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const [selectedGroups, setSelectedGroups, { save: saveGroups }] =
|
||||
useGroupHelper({
|
||||
@@ -124,6 +128,7 @@ const SettingDisabledManagementGroups = ({
|
||||
dataCy={"dns-groups-selector"}
|
||||
onChange={setSelectedGroups}
|
||||
values={selectedGroups}
|
||||
disabled={!permission.dns.update}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -135,7 +140,7 @@ const SettingDisabledManagementGroups = ({
|
||||
variant={"primary"}
|
||||
size={"sm"}
|
||||
onClick={saveSettings}
|
||||
disabled={!hasChanges}
|
||||
disabled={!hasChanges || !permission.dns.update}
|
||||
data-cy={"save-changes"}
|
||||
>
|
||||
Save Changes
|
||||
|
||||
8
src/app/(dashboard)/events/audit/layout.tsx
Normal file
8
src/app/(dashboard)/events/audit/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Audit Events - Activity - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
@@ -6,15 +6,19 @@ import Paragraph from "@components/Paragraph";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { ExternalLinkIcon, LogsIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import ActivityIcon from "@/assets/icons/ActivityIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { ActivityEvent } from "@/interfaces/ActivityEvent";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import ActivityTable from "@/modules/activity/ActivityTable";
|
||||
|
||||
export default function Activity() {
|
||||
const { data: events, isLoading } = useFetchApi<ActivityEvent[]>("/events");
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { data: events, isLoading } =
|
||||
useFetchApi<ActivityEvent[]>("/events/audit");
|
||||
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
@@ -24,30 +28,31 @@ export default function Activity() {
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/activity"}
|
||||
label={"Activity"}
|
||||
disabled={true}
|
||||
icon={<ActivityIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/events/audit"}
|
||||
label={"Audit Events"}
|
||||
icon={<LogsIcon size={18} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Activity Events</h1>
|
||||
<Paragraph>
|
||||
Here you can see all the account and network activity events.
|
||||
</Paragraph>
|
||||
<h1 ref={headingRef}>Audit Events</h1>
|
||||
<Paragraph>Here you can see all the audit activity events.</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about{" "}
|
||||
<InlineLink
|
||||
href={
|
||||
"https://docs.netbird.io/how-to/monitor-system-and-network-activity"
|
||||
}
|
||||
href={"https://docs.netbird.io/how-to/audit-events-logging"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Activity Events
|
||||
Audit Events
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess page={"Activity"}>
|
||||
<RestrictedAccess page={"Activity"} hasAccess={permission.events.read}>
|
||||
<ActivityTable
|
||||
events={events}
|
||||
isLoading={isLoading}
|
||||
@@ -11,6 +11,7 @@ import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import PeersProvider from "@/contexts/PeersProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import RoutesProvider from "@/contexts/RoutesProvider";
|
||||
import { Route } from "@/interfaces/Route";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
@@ -21,6 +22,7 @@ const NetworkRoutesTable = lazy(
|
||||
);
|
||||
|
||||
export default function NetworkRoutes() {
|
||||
const { permission } = usePermissions();
|
||||
const { data: routes, isLoading } = useFetchApi<Route[]>("/routes");
|
||||
const groupedRoutes = useGroupedRoutes({ routes });
|
||||
|
||||
@@ -59,7 +61,7 @@ export default function NetworkRoutes() {
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<RestrictedAccess>
|
||||
<RestrictedAccess hasAccess={permission.routes.read}>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<NetworkRoutesTable
|
||||
isLoading={isLoading}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { useSearchParams } from "next/navigation";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Network } from "@/interfaces/Network";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { NetworkInformationSquare } from "@/modules/networks/misc/NetworkInformationSquare";
|
||||
@@ -48,7 +48,8 @@ export default function NetworkDetailPage() {
|
||||
}
|
||||
|
||||
function NetworkOverview({ network }: Readonly<{ network: Network }>) {
|
||||
const { isUser } = useLoggedInUser();
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const [networkModal, setNetworkModal] = useState(false);
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
@@ -64,7 +65,7 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
|
||||
<Breadcrumbs.Item
|
||||
href={"/networks"}
|
||||
label={"Networks"}
|
||||
disabled={isUser}
|
||||
disabled={!permission.networks.read}
|
||||
icon={<NetworkRoutesIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
@@ -87,14 +88,16 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
|
||||
size={"lg"}
|
||||
description={network.description}
|
||||
/>
|
||||
<button
|
||||
className={
|
||||
"flex items-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md cursor-pointer"
|
||||
}
|
||||
onClick={() => setNetworkModal(true)}
|
||||
>
|
||||
<PencilLineIcon size={18} />
|
||||
</button>
|
||||
{permission.networks.update && (
|
||||
<button
|
||||
className={
|
||||
"flex items-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md cursor-pointer"
|
||||
}
|
||||
onClick={() => setNetworkModal(true)}
|
||||
>
|
||||
<PencilLineIcon size={18} />
|
||||
</button>
|
||||
)}
|
||||
<NetworkModal
|
||||
open={networkModal}
|
||||
setOpen={setNetworkModal}
|
||||
|
||||
@@ -10,12 +10,14 @@ import useFetchApi from "@utils/api";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { Suspense } from "react";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Network } from "@/interfaces/Network";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import NetworksTable from "@/modules/networks/table/NetworksTable";
|
||||
|
||||
export default function Networks() {
|
||||
const { data: networks, isLoading } = useFetchApi<Network[]>("/networks");
|
||||
const { permission } = usePermissions();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
@@ -31,8 +33,8 @@ export default function Networks() {
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Networks</h1>
|
||||
<Paragraph>
|
||||
Networks allow you to access internal resources in LANs and VPCs without
|
||||
installing NetBird on every machine.
|
||||
Networks allow you to access internal resources in LANs and VPCs
|
||||
without installing NetBird on every machine.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
@@ -47,7 +49,7 @@ export default function Networks() {
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<RestrictedAccess>
|
||||
<RestrictedAccess hasAccess={permission.networks.read}>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<NetworksTable
|
||||
data={networks}
|
||||
|
||||
@@ -19,9 +19,11 @@ import ModalHeader from "@components/modal/ModalHeader";
|
||||
import { notify } from "@components/Notification";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import Separator from "@components/Separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import LoginExpiredBadge from "@components/ui/LoginExpiredBadge";
|
||||
import { PageNotFound } from "@components/ui/PageNotFound";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import useRedirect from "@hooks/useRedirect";
|
||||
import useFetchApi from "@utils/api";
|
||||
@@ -53,8 +55,8 @@ import NetBirdIcon from "@/assets/icons/NetBirdIcon";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import { useCountries } from "@/contexts/CountryProvider";
|
||||
import PeerProvider, { usePeer } from "@/contexts/PeerProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import RoutesProvider from "@/contexts/RoutesProvider";
|
||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
import { useHasChanges } from "@/hooks/useHasChanges";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
@@ -65,10 +67,15 @@ import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSectio
|
||||
|
||||
export default function PeerPage() {
|
||||
const queryParameter = useSearchParams();
|
||||
const { isRestricted } = usePermissions();
|
||||
const peerId = queryParameter.get("id");
|
||||
const { data: peer, isLoading } = useFetchApi<Peer>("/peers/" + peerId, true);
|
||||
const {
|
||||
data: peer,
|
||||
isLoading,
|
||||
error,
|
||||
} = useFetchApi<Peer>("/peers/" + peerId, true);
|
||||
|
||||
useRedirect("/peers", false, !peerId);
|
||||
useRedirect("/peers", false, !peerId || isRestricted);
|
||||
|
||||
const peerKey = useMemo(() => {
|
||||
let id = peer?.id ?? "";
|
||||
@@ -77,6 +84,24 @@ export default function PeerPage() {
|
||||
return `${id}-${ssh}-${expiration}`;
|
||||
}, [peer]);
|
||||
|
||||
if (isRestricted) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<RestrictedAccess page={"Peer Information"} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<PageNotFound
|
||||
title={error?.message}
|
||||
description={
|
||||
"The peer you are attempting to access cannot be found. It may have been deleted, or you may not have permission to view it. Please verify the URL or return to the dashboard."
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return peer && !isLoading ? (
|
||||
<PeerProvider peer={peer} key={peerId}>
|
||||
<PeerOverview key={peerKey} />
|
||||
@@ -87,6 +112,29 @@ export default function PeerPage() {
|
||||
}
|
||||
|
||||
function PeerOverview() {
|
||||
const { peer } = usePeer();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<RoutesProvider>
|
||||
<div className={"p-default py-6 pb-0"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/peers"}
|
||||
label={"Peers"}
|
||||
icon={<PeerIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item label={peer.ip} active />
|
||||
</Breadcrumbs>
|
||||
<PeerGeneralInformation />
|
||||
</div>
|
||||
<PeerOverviewTabs />
|
||||
</RoutesProvider>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const PeerGeneralInformation = () => {
|
||||
const router = useRouter();
|
||||
const { mutate } = useSWRConfig();
|
||||
const { peer, user, peerGroups, openSSHDialog, update } = usePeer();
|
||||
@@ -109,24 +157,28 @@ function PeerOverview() {
|
||||
* Detect if there are changes in the peer information, if there are changes, then enable the save button.
|
||||
*/
|
||||
const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([
|
||||
name,
|
||||
ssh,
|
||||
selectedGroups,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
]);
|
||||
|
||||
const updatePeer = async () => {
|
||||
const updateRequest = update({
|
||||
name,
|
||||
ssh,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
});
|
||||
const updatePeer = async (newName?: string) => {
|
||||
let batchCall: Promise<any>[] = [];
|
||||
const groupCalls = getAllGroupCalls();
|
||||
const batchCall = groupCalls
|
||||
? [...groupCalls, updateRequest]
|
||||
: [updateRequest];
|
||||
|
||||
if (permission.peers.update) {
|
||||
const updateRequest = update({
|
||||
name: newName ?? name,
|
||||
ssh,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
});
|
||||
batchCall = groupCalls ? [...groupCalls, updateRequest] : [updateRequest];
|
||||
} else {
|
||||
batchCall = [...groupCalls];
|
||||
}
|
||||
|
||||
notify({
|
||||
title: name,
|
||||
description: "Peer was successfully saved",
|
||||
@@ -134,7 +186,6 @@ function PeerOverview() {
|
||||
mutate("/peers/" + peer.id);
|
||||
mutate("/groups");
|
||||
updateHasChangedRef([
|
||||
name,
|
||||
ssh,
|
||||
selectedGroups,
|
||||
loginExpiration,
|
||||
@@ -145,199 +196,222 @@ function PeerOverview() {
|
||||
});
|
||||
};
|
||||
|
||||
const { isUser, isOwnerOrAdmin } = useLoggedInUser();
|
||||
const { permission } = usePermissions();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<RoutesProvider>
|
||||
<div className={"p-default py-6 mb-4"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/peers"}
|
||||
label={"Peers"}
|
||||
icon={<PeerIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item label={peer.ip} active />
|
||||
</Breadcrumbs>
|
||||
<>
|
||||
<div className={"flex justify-between max-w-6xl items-start"}>
|
||||
<div>
|
||||
<div className={"flex items-center gap-3"}>
|
||||
<h1 className={"flex items-center gap-3"}>
|
||||
<CircleIcon
|
||||
active={peer.connected}
|
||||
size={12}
|
||||
className={"mb-[3px] shrink-0"}
|
||||
/>
|
||||
<TextWithTooltip text={name} maxChars={30} />
|
||||
|
||||
<div className={"flex justify-between max-w-6xl items-start"}>
|
||||
<div>
|
||||
<div className={"flex items-center gap-3"}>
|
||||
<h1 className={"flex items-center gap-3"}>
|
||||
<CircleIcon
|
||||
active={peer.connected}
|
||||
size={12}
|
||||
className={"mb-[3px] shrink-0"}
|
||||
/>
|
||||
<TextWithTooltip text={name} maxChars={30} />
|
||||
|
||||
{!isUser && (
|
||||
<Modal
|
||||
open={showEditNameModal}
|
||||
onOpenChange={setShowEditNameModal}
|
||||
>
|
||||
<ModalTrigger>
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md cursor-pointer"
|
||||
}
|
||||
>
|
||||
<PencilIcon size={16} />
|
||||
</div>
|
||||
</ModalTrigger>
|
||||
<EditNameModal
|
||||
onSuccess={(newName) => {
|
||||
setName(newName);
|
||||
setShowEditNameModal(false);
|
||||
}}
|
||||
peer={peer}
|
||||
initialName={name}
|
||||
key={showEditNameModal ? 1 : 0}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</h1>
|
||||
<LoginExpiredBadge loginExpired={peer.login_expired} />
|
||||
</div>
|
||||
<div className={"flex items-center gap-8"}>
|
||||
<Paragraph className={"flex items-center"}>
|
||||
{user?.email}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
<div className={"flex gap-4"}>
|
||||
<Button
|
||||
variant={"default"}
|
||||
className={"w-full"}
|
||||
onClick={() => router.push("/peers")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"w-full"}
|
||||
onClick={() => updatePeer()}
|
||||
disabled={!hasChanges || isUser}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"flex gap-10 w-full mt-5 max-w-6xl"}>
|
||||
<PeerInformationCard peer={peer} />
|
||||
|
||||
<div className={"flex flex-col gap-6 w-1/2 transition-all"}>
|
||||
<div>
|
||||
<PeerExpirationToggle
|
||||
peer={peer}
|
||||
value={loginExpiration}
|
||||
icon={<TimerResetIcon size={16} />}
|
||||
onChange={(state) => {
|
||||
setLoginExpiration(state);
|
||||
!state && setInactivityExpiration(false);
|
||||
}}
|
||||
/>
|
||||
{isOwnerOrAdmin && !!peer?.user_id && (
|
||||
<div
|
||||
className={cn(
|
||||
"border border-nb-gray-900 border-t-0 rounded-b-md bg-nb-gray-940 px-[1.28rem] pt-3 pb-5 flex flex-col gap-4 mx-[0.25rem]",
|
||||
!loginExpiration
|
||||
? "opacity-50 pointer-events-none"
|
||||
: "bg-nb-gray-930/80",
|
||||
)}
|
||||
>
|
||||
<PeerExpirationToggle
|
||||
peer={peer}
|
||||
variant={"blank"}
|
||||
value={inactivityExpiration}
|
||||
onChange={setInactivityExpiration}
|
||||
title={"Require login after disconnect"}
|
||||
description={
|
||||
"Enable to require authentication after users disconnect from management for 10 minutes."
|
||||
}
|
||||
{permission.peers.update && (
|
||||
<Modal
|
||||
open={showEditNameModal}
|
||||
onOpenChange={setShowEditNameModal}
|
||||
>
|
||||
<ModalTrigger>
|
||||
<div
|
||||
className={
|
||||
!loginExpiration ? "opacity-40 pointer-events-none" : ""
|
||||
"flex items-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md cursor-pointer"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FullTooltip
|
||||
content={
|
||||
<div
|
||||
className={
|
||||
"flex gap-2 items-center !text-nb-gray-300 text-xs"
|
||||
}
|
||||
>
|
||||
<LockIcon size={14} />
|
||||
<span>
|
||||
{`You don't have the required permissions to update this
|
||||
setting.`}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
interactive={false}
|
||||
className={"w-full block"}
|
||||
disabled={!isUser}
|
||||
>
|
||||
<FancyToggleSwitch
|
||||
value={ssh}
|
||||
disabled={isUser}
|
||||
onChange={(set) =>
|
||||
!set
|
||||
? setSsh(false)
|
||||
: openSSHDialog().then((confirm) => setSsh(confirm))
|
||||
}
|
||||
label={
|
||||
<>
|
||||
<TerminalSquare size={16} />
|
||||
SSH Access
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
"Enable the SSH server on this peer to access the machine via an secure shell."
|
||||
}
|
||||
/>
|
||||
</FullTooltip>
|
||||
|
||||
{!isUser && (
|
||||
<div>
|
||||
<Label>Assigned Groups</Label>
|
||||
<HelpText>
|
||||
Use groups to control what this peer can access.
|
||||
</HelpText>
|
||||
<PeerGroupSelector
|
||||
disabled={isUser}
|
||||
onChange={setSelectedGroups}
|
||||
values={selectedGroups}
|
||||
hideAllGroup={true}
|
||||
>
|
||||
<PencilIcon size={16} />
|
||||
</div>
|
||||
</ModalTrigger>
|
||||
<EditNameModal
|
||||
onSuccess={(newName) => {
|
||||
updatePeer(newName).then(() => {
|
||||
setName(newName);
|
||||
setShowEditNameModal(false);
|
||||
});
|
||||
}}
|
||||
peer={peer}
|
||||
initialName={name}
|
||||
key={showEditNameModal ? 1 : 0}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
</h1>
|
||||
<LoginExpiredBadge loginExpired={peer.login_expired} />
|
||||
</div>
|
||||
<div className={"flex items-center gap-8"}>
|
||||
<Paragraph className={"flex items-center"}>{user?.email}</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
<div className={"flex gap-4"}>
|
||||
<Button
|
||||
variant={"default"}
|
||||
className={"w-full"}
|
||||
onClick={() => router.push("/peers")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"w-full"}
|
||||
onClick={() => updatePeer()}
|
||||
disabled={
|
||||
!hasChanges || !permission.peers.read || !permission.groups.update
|
||||
}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isUser ? (
|
||||
<>
|
||||
<Separator />
|
||||
<PeerNetworkRoutesSection peer={peer} />
|
||||
</>
|
||||
) : null}
|
||||
<div
|
||||
className={
|
||||
"flex-wrap xl:flex-nowrap flex gap-10 w-full mt-5 max-w-6xl items-start"
|
||||
}
|
||||
>
|
||||
<PeerInformationCard peer={peer} />
|
||||
|
||||
{peer?.id && (
|
||||
<>
|
||||
<Separator />
|
||||
<AccessiblePeersSection peerID={peer.id} />
|
||||
</>
|
||||
)}
|
||||
</RoutesProvider>
|
||||
</PageContainer>
|
||||
<div className={"flex flex-col gap-6 lg:w-1/2 transition-all"}>
|
||||
<div>
|
||||
<PeerExpirationToggle
|
||||
peer={peer}
|
||||
value={loginExpiration}
|
||||
icon={<TimerResetIcon size={16} />}
|
||||
onChange={(state) => {
|
||||
setLoginExpiration(state);
|
||||
!state && setInactivityExpiration(false);
|
||||
}}
|
||||
/>
|
||||
{permission.peers.update && !!peer?.user_id && (
|
||||
<div
|
||||
className={cn(
|
||||
"border border-nb-gray-900 border-t-0 rounded-b-md bg-nb-gray-940 px-[1.28rem] pt-3 pb-5 flex flex-col gap-4 mx-[0.25rem]",
|
||||
!loginExpiration
|
||||
? "opacity-50 pointer-events-none"
|
||||
: "bg-nb-gray-930/80",
|
||||
)}
|
||||
>
|
||||
<PeerExpirationToggle
|
||||
peer={peer}
|
||||
variant={"blank"}
|
||||
value={inactivityExpiration}
|
||||
onChange={setInactivityExpiration}
|
||||
title={"Require login after disconnect"}
|
||||
description={
|
||||
"Enable to require authentication after users disconnect from management for 10 minutes."
|
||||
}
|
||||
className={
|
||||
!loginExpiration ? "opacity-40 pointer-events-none" : ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FullTooltip
|
||||
content={
|
||||
<div
|
||||
className={"flex gap-2 items-center !text-nb-gray-300 text-xs"}
|
||||
>
|
||||
<LockIcon size={14} />
|
||||
<span>
|
||||
{`You don't have the required permissions to update this
|
||||
setting.`}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
interactive={false}
|
||||
className={"w-full block"}
|
||||
disabled={!permission.peers.update}
|
||||
>
|
||||
<FancyToggleSwitch
|
||||
value={ssh}
|
||||
disabled={!permission.peers.update}
|
||||
onChange={(set) =>
|
||||
!set
|
||||
? setSsh(false)
|
||||
: openSSHDialog().then((confirm) => setSsh(confirm))
|
||||
}
|
||||
label={
|
||||
<>
|
||||
<TerminalSquare size={16} />
|
||||
SSH Access
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
"Enable the SSH server on this peer to access the machine via an secure shell."
|
||||
}
|
||||
/>
|
||||
</FullTooltip>
|
||||
|
||||
{permission.groups.read && (
|
||||
<div>
|
||||
<Label>Assigned Groups</Label>
|
||||
<HelpText>
|
||||
Use groups to control what this peer can access.
|
||||
</HelpText>
|
||||
<PeerGroupSelector
|
||||
disabled={!permission.groups.update}
|
||||
onChange={setSelectedGroups}
|
||||
values={selectedGroups}
|
||||
hideAllGroup={true}
|
||||
peer={peer}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const PeerOverviewTabs = () => {
|
||||
const { peer } = usePeer();
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const [tab, setTab] = useState(
|
||||
permission.routes.read ? "network-routes" : "accessible-peers",
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
defaultValue={tab}
|
||||
onValueChange={(v) => setTab(v)}
|
||||
value={tab}
|
||||
className={"pt-10 pb-0 mb-0"}
|
||||
>
|
||||
<TabsList justify={"start"} className={"px-8"}>
|
||||
{permission.routes.read && (
|
||||
<TabsTrigger value={"network-routes"}>
|
||||
<NetworkIcon size={16} />
|
||||
Network Routes
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
{peer?.id && permission.peers.read && (
|
||||
<TabsTrigger value={"accessible-peers"}>
|
||||
<MonitorSmartphoneIcon size={16} />
|
||||
Accessible Peers
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
{permission.routes.read && (
|
||||
<TabsContent value={"network-routes"} className={"pb-8"}>
|
||||
<PeerNetworkRoutesSection peer={peer} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{peer?.id && permission.peers.read && (
|
||||
<TabsContent value={"accessible-peers"} className={"pb-8"}>
|
||||
<AccessiblePeersSection peerID={peer.id} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
const { isLoading, getRegionByPeer } = useCountries();
|
||||
@@ -347,7 +421,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
}, [getRegionByPeer, peer]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className={"w-full xl:w-1/2"}>
|
||||
<Card.List>
|
||||
<Card.ListItem
|
||||
copy
|
||||
@@ -480,15 +554,17 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
|
||||
value={peer.version}
|
||||
/>
|
||||
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<NetBirdIcon size={16} />
|
||||
UI Version
|
||||
</>
|
||||
}
|
||||
value={peer.ui_version?.replace("netbird-desktop-ui/", "")}
|
||||
/>
|
||||
{peer.ui_version && (
|
||||
<Card.ListItem
|
||||
label={
|
||||
<>
|
||||
<NetBirdIcon size={16} />
|
||||
UI Version
|
||||
</>
|
||||
}
|
||||
value={peer.ui_version?.replace("netbird-desktop-ui/", "")}
|
||||
/>
|
||||
)}
|
||||
</Card.List>
|
||||
</Card>
|
||||
);
|
||||
@@ -499,6 +575,7 @@ interface ModalProps {
|
||||
peer: Peer;
|
||||
initialName: string;
|
||||
}
|
||||
|
||||
function EditNameModal({ onSuccess, peer, initialName }: Readonly<ModalProps>) {
|
||||
const [name, setName] = useState(initialName);
|
||||
|
||||
|
||||
@@ -9,18 +9,19 @@ import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import PeersProvider, { usePeers } from "@/contexts/PeersProvider";
|
||||
import { useLoggedInUser, useUsers } from "@/contexts/UsersProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useUsers } from "@/contexts/UsersProvider";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
const PeersTable = lazy(() => import("@/modules/peers/PeersTable"));
|
||||
|
||||
export default function Peers() {
|
||||
const { permission } = useLoggedInUser();
|
||||
const { isRestricted } = usePermissions();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
{permission.dashboard_view === "blocked" ? (
|
||||
{isRestricted ? (
|
||||
<PeersBlockedView />
|
||||
) : (
|
||||
<PeersProvider>
|
||||
@@ -104,7 +105,7 @@ function PeersBlockedView() {
|
||||
<div className={"px-3 pt-1 pb-8 max-w-3xl w-full"}>
|
||||
<div
|
||||
className={
|
||||
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40 stepper-bg-variant"
|
||||
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40"
|
||||
}
|
||||
>
|
||||
<SetupModalContent header={false} footer={false} />
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ExternalLinkIcon, ShieldCheck } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
import GroupsProvider from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import PoliciesProvider from "@/contexts/PoliciesProvider";
|
||||
import { PostureCheck } from "@/interfaces/PostureCheck";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
@@ -19,6 +20,7 @@ const PostureCheckTable = lazy(
|
||||
() => import("@/modules/posture-checks/table/PostureCheckTable"),
|
||||
);
|
||||
export default function PostureChecksPage() {
|
||||
const { permission } = usePermissions();
|
||||
const { data: postureChecks, isLoading } =
|
||||
useFetchApi<PostureCheck[]>("/posture-checks");
|
||||
|
||||
@@ -59,7 +61,10 @@ export default function PostureChecksPage() {
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<RestrictedAccess page={"Posture Checks"}>
|
||||
<RestrictedAccess
|
||||
page={"Posture Checks"}
|
||||
hasAccess={permission.policies.read}
|
||||
>
|
||||
<PoliciesProvider>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<PostureCheckTable
|
||||
|
||||
@@ -6,15 +6,18 @@ import {
|
||||
AlertOctagonIcon,
|
||||
FolderGit2Icon,
|
||||
LockIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
NetworkIcon,
|
||||
ShieldIcon,
|
||||
} from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { useAccount } from "@/modules/account/useAccount";
|
||||
import AuthenticationTab from "@/modules/settings/AuthenticationTab";
|
||||
import ClientSettingsTab from "@/modules/settings/ClientSettingsTab";
|
||||
import DangerZoneTab from "@/modules/settings/DangerZoneTab";
|
||||
import GroupsTab from "@/modules/settings/GroupsTab";
|
||||
import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab";
|
||||
@@ -23,8 +26,15 @@ import PermissionsTab from "@/modules/settings/PermissionsTab";
|
||||
export default function NetBirdSettings() {
|
||||
const queryParams = useSearchParams();
|
||||
const queryTab = queryParams.get("tab");
|
||||
const [tab, setTab] = useState(queryTab || "authentication");
|
||||
const { isOwner } = useLoggedInUser();
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const initialTab = useMemo(() => {
|
||||
if (permission.settings.read) return "authentication";
|
||||
return "authentication";
|
||||
}, [permission]);
|
||||
|
||||
const [tab, setTab] = useState(queryTab ?? initialTab);
|
||||
|
||||
const account = useAccount();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -37,33 +47,43 @@ export default function NetBirdSettings() {
|
||||
<PageContainer>
|
||||
<VerticalTabs value={tab} onChange={setTab}>
|
||||
<VerticalTabs.List>
|
||||
<VerticalTabs.Trigger value="authentication">
|
||||
<ShieldIcon size={14} />
|
||||
Authentication
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value="groups">
|
||||
<FolderGit2Icon size={14} />
|
||||
Groups
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value="permissions">
|
||||
<LockIcon size={14} />
|
||||
Permissions
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value="networks">
|
||||
<NetworkIcon size={14} />
|
||||
Networks
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value="danger-zone" disabled={!isOwner}>
|
||||
<AlertOctagonIcon size={14} />
|
||||
Danger zone
|
||||
</VerticalTabs.Trigger>
|
||||
{permission.settings.read && (
|
||||
<>
|
||||
<VerticalTabs.Trigger value="authentication">
|
||||
<ShieldIcon size={14} />
|
||||
Authentication
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value="groups">
|
||||
<FolderGit2Icon size={14} />
|
||||
Groups
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value="permissions">
|
||||
<LockIcon size={14} />
|
||||
Permissions
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value="networks">
|
||||
<NetworkIcon size={14} />
|
||||
Networks
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value="clients">
|
||||
<MonitorSmartphoneIcon size={14} />
|
||||
Clients
|
||||
</VerticalTabs.Trigger>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DangerZoneTabTrigger />
|
||||
</VerticalTabs.List>
|
||||
<RestrictedAccess page={"Settings"}>
|
||||
<RestrictedAccess
|
||||
page={"Settings"}
|
||||
hasAccess={permission.settings.read}
|
||||
>
|
||||
<div className={"border-l border-nb-gray-930 w-full"}>
|
||||
{account && <AuthenticationTab account={account} />}
|
||||
{account && <PermissionsTab account={account} />}
|
||||
{account && <GroupsTab account={account} />}
|
||||
{account && <NetworkSettingsTab account={account} />}
|
||||
{account && <ClientSettingsTab account={account} />}
|
||||
{account && <DangerZoneTab account={account} />}
|
||||
</div>
|
||||
</RestrictedAccess>
|
||||
@@ -71,3 +91,16 @@ export default function NetBirdSettings() {
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const DangerZoneTabTrigger = () => {
|
||||
const { isOwner } = useLoggedInUser();
|
||||
|
||||
return (
|
||||
isOwner && (
|
||||
<VerticalTabs.Trigger value="danger-zone" disabled={!isOwner}>
|
||||
<AlertOctagonIcon size={14} />
|
||||
Danger zone
|
||||
</VerticalTabs.Trigger>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense, useMemo } from "react";
|
||||
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { SetupKey } from "@/interfaces/SetupKey";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
@@ -21,6 +22,7 @@ const SetupKeysTable = lazy(
|
||||
|
||||
export default function SetupKeys() {
|
||||
const { data: setupKeys, isLoading } = useFetchApi<SetupKey[]>("/setup-keys");
|
||||
const { permission } = usePermissions();
|
||||
const { groups } = useGroups();
|
||||
|
||||
const setupKeysWithGroups = useMemo(() => {
|
||||
@@ -71,7 +73,10 @@ export default function SetupKeys() {
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess page={"Setup Keys"}>
|
||||
<RestrictedAccess
|
||||
page={"Setup Keys"}
|
||||
hasAccess={permission.setup_keys.read}
|
||||
>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<SetupKeysTable
|
||||
headingTarget={portalTarget}
|
||||
|
||||
@@ -11,6 +11,7 @@ import useFetchApi from "@utils/api";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import TeamIcon from "@/assets/icons/TeamIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { User } from "@/interfaces/User";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
@@ -19,6 +20,7 @@ const ServiceUsersTable = lazy(
|
||||
);
|
||||
|
||||
export default function ServiceUsers() {
|
||||
const { permission } = usePermissions();
|
||||
const { data: users, isLoading } = useFetchApi<User[]>(
|
||||
"/users?service_user=true",
|
||||
);
|
||||
@@ -59,7 +61,10 @@ export default function ServiceUsers() {
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess page={"Service Users"}>
|
||||
<RestrictedAccess
|
||||
page={"Service Users"}
|
||||
hasAccess={permission.users.read}
|
||||
>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<ServiceUsersTable
|
||||
users={users}
|
||||
|
||||
@@ -10,6 +10,7 @@ import Paragraph from "@components/Paragraph";
|
||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import Separator from "@components/Separator";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import useRedirect from "@hooks/useRedirect";
|
||||
import { IconCirclePlus, IconSettings2 } from "@tabler/icons-react";
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
@@ -20,6 +21,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import TeamIcon from "@/assets/icons/TeamIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
import { useHasChanges } from "@/hooks/useHasChanges";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
@@ -36,6 +38,7 @@ import { UserRoleSelector } from "@/modules/users/UserRoleSelector";
|
||||
export default function UserPage() {
|
||||
const queryParameter = useSearchParams();
|
||||
const userId = queryParameter.get("id");
|
||||
const { permission } = usePermissions();
|
||||
const isServiceUser = queryParameter.get("service_user") === "true";
|
||||
const { data: users, isLoading } = useFetchApi<User[]>(
|
||||
`/users?service_user=${isServiceUser}`,
|
||||
@@ -50,6 +53,14 @@ export default function UserPage() {
|
||||
|
||||
const userGroups = useGroupIdsToGroups(user?.auto_groups);
|
||||
|
||||
if (!permission.users.read) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<RestrictedAccess page={"User Information"} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isOwnerOrAdmin && user && !isLoading) {
|
||||
return <UserOverview user={user} initialGroups={[]} />;
|
||||
}
|
||||
@@ -72,6 +83,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
const { mutate } = useSWRConfig();
|
||||
const { loggedInUser, isOwnerOrAdmin, isUser } = useLoggedInUser();
|
||||
const isLoggedInUser = loggedInUser ? loggedInUser?.id === user.id : false;
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const [selectedGroups, setSelectedGroups, { save: saveGroups }] =
|
||||
useGroupHelper({
|
||||
@@ -116,7 +128,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
<Breadcrumbs.Item
|
||||
href={"/team"}
|
||||
label={"Team"}
|
||||
disabled={isUser}
|
||||
disabled={!permission.users.read}
|
||||
icon={<TeamIcon size={13} />}
|
||||
/>
|
||||
|
||||
@@ -130,7 +142,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
<Breadcrumbs.Item
|
||||
href={"/team/users"}
|
||||
label={"Users"}
|
||||
disabled={isUser}
|
||||
disabled={!permission.users.read}
|
||||
icon={<User2 size={16} />}
|
||||
/>
|
||||
)}
|
||||
@@ -187,7 +199,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"w-full"}
|
||||
disabled={!hasChanges}
|
||||
disabled={!hasChanges || !permission.users.update}
|
||||
onClick={save}
|
||||
data-cy={"save-changes"}
|
||||
>
|
||||
@@ -228,11 +240,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
onChange={setRole}
|
||||
hideOwner={user.is_service_user}
|
||||
currentUser={user}
|
||||
disabled={
|
||||
isLoggedInUser ||
|
||||
!isOwnerOrAdmin ||
|
||||
user.role === Role.Owner
|
||||
}
|
||||
disabled={isLoggedInUser || !permission.users.update}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -240,7 +248,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(user.is_current || user.is_service_user) && (
|
||||
{(user.is_current || user.is_service_user) && permission.pats.read && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className={"px-8 py-6"}>
|
||||
@@ -258,6 +266,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
<Button
|
||||
variant={"primary"}
|
||||
data-cy={"access-token-open-modal"}
|
||||
disabled={!permission.pats.create}
|
||||
>
|
||||
<IconCirclePlus size={16} />
|
||||
Create Access Token
|
||||
@@ -275,7 +284,7 @@ function UserOverview({ user, initialGroups }: Readonly<Props>) {
|
||||
);
|
||||
}
|
||||
|
||||
function UserInformationCard({ user }: { user: User }) {
|
||||
function UserInformationCard({ user }: Readonly<{ user: User }>) {
|
||||
const isServiceUser = user.is_service_user || false;
|
||||
const neverLoggedIn = dayjs(user.last_login).isBefore(
|
||||
dayjs().subtract(1000, "years"),
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ExternalLinkIcon, User2 } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import TeamIcon from "@/assets/icons/TeamIcon";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { User } from "@/interfaces/User";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
@@ -18,6 +19,7 @@ const UsersTable = lazy(() => import("@/modules/users/UsersTable"));
|
||||
|
||||
export default function TeamUsers() {
|
||||
const { isLoading: isGroupsLoading } = useGroups();
|
||||
const { permission } = usePermissions();
|
||||
const { data: users, isLoading } = useFetchApi<User[]>(
|
||||
"/users?service_user=false",
|
||||
);
|
||||
@@ -58,7 +60,7 @@ export default function TeamUsers() {
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess page={"Users"}>
|
||||
<RestrictedAccess page={"Users"} hasAccess={permission.users.read}>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<UsersTable
|
||||
users={users}
|
||||
|
||||
@@ -67,10 +67,93 @@ p {
|
||||
}
|
||||
|
||||
.stepper-bg-variant .step-circle {
|
||||
@apply !border-[#1d2024];
|
||||
@apply !border-nb-gray-940;
|
||||
}
|
||||
|
||||
.webkit-scroll{
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-webkit-transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Timescape Root element
|
||||
*/
|
||||
.timescape {
|
||||
@apply flex items-center gap-[1px] rounded-md py-2 px-3 select-none w-fit cursor-text bg-nb-gray-900;
|
||||
}
|
||||
|
||||
/**
|
||||
* Date and time input elements
|
||||
*/
|
||||
.timescape input {
|
||||
@apply cursor-text px-0.5 py-1 bg-transparent h-fit border-0 outline-0 select-none box-content caret-transparent text-nb-gray-200 text-sm placeholder-nb-gray-300;
|
||||
font-variant-numeric: tabular-nums;
|
||||
/* For the calculation of the input width these are important */
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.timescape input:focus {
|
||||
@apply bg-nb-gray-700 text-white rounded py-1 px-0.5 border-0 outline-0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Separator elements
|
||||
*/
|
||||
.timescape .separator {
|
||||
@apply text-gray-400 m-0 text-[80%] -top-[1px] relative;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fade in animation
|
||||
*/
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.4s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
|
||||
.animate-slow-ping {
|
||||
animation: ping 1.6s cubic-bezier(0, 0, 0.2, 1) infinite
|
||||
}
|
||||
|
||||
@keyframes ping {
|
||||
75%, 100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slow-pulse {
|
||||
animation: pulse 2.5s cubic-bezier(0.4, 0, 0.6, 1) infinite
|
||||
}
|
||||
|
||||
|
||||
@keyframes pulse {
|
||||
60% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bg-scroll {
|
||||
0% {
|
||||
background-position: 0% 100%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-bg-scroll {
|
||||
animation: bg-scroll 4s linear infinite;
|
||||
}
|
||||
.animate-bg-scroll-faster {
|
||||
animation: bg-scroll 1.8s linear infinite;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ export default function Home() {
|
||||
}
|
||||
|
||||
const Redirect = ({ url, queryParams }: Props) => {
|
||||
useRedirect(url == "/" ? "/peers" : url + (queryParams && `?${queryParams}`));
|
||||
const params = queryParams && `?${queryParams}`;
|
||||
useRedirect(url == "/" ? `/peers${params}` : `${url}${params}`);
|
||||
return <FullScreenLoading />;
|
||||
};
|
||||
|
||||
12
src/assets/nameservers/dns0-zero.svg
Normal file
12
src/assets/nameservers/dns0-zero.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg width="573" height="148" viewBox="0 0 573 148" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4_4)">
|
||||
<path d="M0.739014 125.602V33.09H28.239C34.491 33.09 39.919 34.274 44.524 36.638C49.128 39.004 52.698 42.341 55.233 46.65C57.767 50.958 59.034 56.071 59.034 61.984V96.58C59.034 102.41 57.767 107.5 55.233 111.85C52.698 116.203 49.128 119.581 44.523 121.99C39.918 124.397 34.491 125.601 28.239 125.601L0.739014 125.602ZM16.58 111.028H28.24C32.801 111.028 36.433 109.741 39.138 107.163C41.841 104.587 43.193 101.06 43.193 96.581V61.984C43.193 57.592 41.841 54.107 39.138 51.529C36.433 48.952 32.801 47.663 28.239 47.663H16.58V111.028ZM76.396 125.602V33.09H95.786L121.512 107.86C121.216 104.673 120.942 101.483 120.688 98.292C120.386 94.5366 120.154 90.776 119.991 87.012C119.821 83.169 119.738 79.812 119.738 76.938V33.09H134.185V125.602H114.795L89.323 50.832C89.491 53.283 89.703 56.239 89.956 59.702C90.2137 63.2478 90.4251 66.7967 90.59 70.348C90.758 73.982 90.844 77.318 90.844 80.36V125.602H76.396ZM181.582 126.869C175.245 126.869 169.752 125.812 165.107 123.701C160.46 121.591 156.889 118.569 154.398 114.64C151.905 110.711 150.616 106.086 150.533 100.763H166.374C166.374 104.565 167.747 107.543 170.494 109.698C173.238 111.852 176.976 112.929 181.709 112.929C186.271 112.929 189.839 111.874 192.417 109.761C194.993 107.65 196.282 104.735 196.282 101.017C196.282 97.892 195.374 95.167 193.558 92.842C191.74 90.52 189.142 88.936 185.764 88.09L175.119 85.175C167.852 83.318 162.256 79.979 158.327 75.164C154.398 70.348 152.434 64.518 152.434 57.675C152.434 52.438 153.616 47.875 155.982 43.988C158.347 40.103 161.705 37.103 166.058 34.99C170.408 32.88 175.54 31.822 181.455 31.822C190.409 31.822 197.506 34.125 202.745 38.729C207.983 43.335 210.645 49.523 210.73 57.295H194.888C194.888 53.663 193.704 50.812 191.34 48.741C188.974 46.671 185.637 45.636 181.328 45.636C177.188 45.636 173.978 46.608 171.697 48.551C169.416 50.495 168.275 53.24 168.275 56.788C168.275 60 169.141 62.724 170.873 64.962C172.603 67.202 175.119 68.786 178.413 69.714L189.439 72.756C196.789 74.616 202.407 77.932 206.294 82.704C210.179 87.478 212.124 93.371 212.124 100.383C212.124 105.623 210.856 110.248 208.322 114.26C205.787 118.273 202.239 121.378 197.677 123.574C193.114 125.77 187.748 126.869 181.582 126.869ZM257.366 126.869C251.366 126.869 246.17 125.729 241.778 123.448C237.384 121.167 233.985 117.957 231.577 113.816C229.169 109.678 227.965 104.818 227.965 99.242V59.45C227.965 53.874 229.169 49.017 231.577 44.876C233.984 40.738 237.384 37.526 241.778 35.245C246.17 32.964 251.366 31.823 257.366 31.823C263.449 31.823 268.665 32.963 273.017 35.245C277.367 37.526 280.747 40.738 283.156 44.876C285.563 49.016 286.767 53.874 286.767 59.45V99.243C286.767 104.819 285.563 109.679 283.156 113.817C280.748 117.957 277.346 121.167 272.954 123.449C268.56 125.729 263.364 126.869 257.366 126.869ZM257.366 113.183C261.758 113.183 265.265 111.915 267.885 109.381C270.502 106.846 271.813 103.468 271.813 99.242V59.45C271.813 55.227 270.503 51.846 267.885 49.312C265.265 46.777 261.758 45.51 257.366 45.51C252.972 45.51 249.466 46.777 246.848 49.312C244.228 51.846 242.918 55.227 242.918 59.45V99.243C242.918 103.469 244.228 106.847 246.848 109.382C249.465 111.916 252.972 113.183 257.366 113.183ZM257.366 87.583C254.915 87.583 252.909 86.781 251.346 85.175C249.782 83.571 249.002 81.5 249.002 78.965C249.002 76.431 249.762 74.403 251.283 72.883C252.803 71.362 254.831 70.601 257.366 70.601C259.901 70.601 261.928 71.361 263.449 72.883C264.969 74.403 265.73 76.431 265.73 78.966C265.73 81.5 264.97 83.571 263.45 85.176C261.928 86.781 259.9 87.583 257.366 87.583Z" fill="black"/>
|
||||
<path d="M332.69 126.999C329.057 126.999 326.164 125.941 324.01 123.831C321.855 121.72 320.778 118.847 320.778 115.213C320.778 111.581 321.855 108.686 324.01 106.532C326.164 104.378 329.057 103.3 332.69 103.3C336.322 103.3 339.217 104.378 341.372 106.532C343.526 108.686 344.603 111.582 344.603 115.213C344.603 118.847 343.526 121.72 341.372 123.831C339.217 125.941 336.322 126.999 332.691 126.999H332.69ZM381.862 125.732V33.219H437.369V47.032H397.449V71.112H432.934V84.798H397.45V111.918H437.37V125.732H381.862ZM484.766 126.999C475.725 126.999 468.65 124.528 463.539 119.585C458.426 114.643 455.872 107.906 455.872 99.372V33.219H471.84V99.245C471.84 103.639 472.937 107.061 475.135 109.51C477.331 111.962 480.54 113.185 484.766 113.185C488.905 113.185 492.095 111.962 494.334 109.51C496.572 107.06 497.693 103.639 497.693 99.245V33.219H513.66V99.372C513.66 107.906 511.126 114.642 506.057 119.585C500.987 124.528 493.891 126.999 484.767 126.999H484.766Z" fill="#686868"/>
|
||||
<path d="M549 0H573V148H549V0Z" fill="#FFCC03"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4_4">
|
||||
<rect width="573" height="148" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
12
src/assets/nameservers/dns0.svg
Normal file
12
src/assets/nameservers/dns0.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg width="573" height="148" viewBox="0 0 573 148" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4_4)">
|
||||
<path d="M0.739014 125.602V33.09H28.239C34.491 33.09 39.919 34.274 44.524 36.638C49.128 39.004 52.698 42.341 55.233 46.65C57.767 50.958 59.034 56.071 59.034 61.984V96.58C59.034 102.41 57.767 107.5 55.233 111.85C52.698 116.203 49.128 119.581 44.523 121.99C39.918 124.397 34.491 125.601 28.239 125.601L0.739014 125.602ZM16.58 111.028H28.24C32.801 111.028 36.433 109.741 39.138 107.163C41.841 104.587 43.193 101.06 43.193 96.581V61.984C43.193 57.592 41.841 54.107 39.138 51.529C36.433 48.952 32.801 47.663 28.239 47.663H16.58V111.028ZM76.396 125.602V33.09H95.786L121.512 107.86C121.216 104.673 120.942 101.483 120.688 98.292C120.386 94.5366 120.154 90.776 119.991 87.012C119.821 83.169 119.738 79.812 119.738 76.938V33.09H134.185V125.602H114.795L89.323 50.832C89.491 53.283 89.703 56.239 89.956 59.702C90.2137 63.2478 90.4251 66.7967 90.59 70.348C90.758 73.982 90.844 77.318 90.844 80.36V125.602H76.396ZM181.582 126.869C175.245 126.869 169.752 125.812 165.107 123.701C160.46 121.591 156.889 118.569 154.398 114.64C151.905 110.711 150.616 106.086 150.533 100.763H166.374C166.374 104.565 167.747 107.543 170.494 109.698C173.238 111.852 176.976 112.929 181.709 112.929C186.271 112.929 189.839 111.874 192.417 109.761C194.993 107.65 196.282 104.735 196.282 101.017C196.282 97.892 195.374 95.167 193.558 92.842C191.74 90.52 189.142 88.936 185.764 88.09L175.119 85.175C167.852 83.318 162.256 79.979 158.327 75.164C154.398 70.348 152.434 64.518 152.434 57.675C152.434 52.438 153.616 47.875 155.982 43.988C158.347 40.103 161.705 37.103 166.058 34.99C170.408 32.88 175.54 31.822 181.455 31.822C190.409 31.822 197.506 34.125 202.745 38.729C207.983 43.335 210.645 49.523 210.73 57.295H194.888C194.888 53.663 193.704 50.812 191.34 48.741C188.974 46.671 185.637 45.636 181.328 45.636C177.188 45.636 173.978 46.608 171.697 48.551C169.416 50.495 168.275 53.24 168.275 56.788C168.275 60 169.141 62.724 170.873 64.962C172.603 67.202 175.119 68.786 178.413 69.714L189.439 72.756C196.789 74.616 202.407 77.932 206.294 82.704C210.179 87.478 212.124 93.371 212.124 100.383C212.124 105.623 210.856 110.248 208.322 114.26C205.787 118.273 202.239 121.378 197.677 123.574C193.114 125.77 187.748 126.869 181.582 126.869ZM257.366 126.869C251.366 126.869 246.17 125.729 241.778 123.448C237.384 121.167 233.985 117.957 231.577 113.816C229.169 109.678 227.965 104.818 227.965 99.242V59.45C227.965 53.874 229.169 49.017 231.577 44.876C233.984 40.738 237.384 37.526 241.778 35.245C246.17 32.964 251.366 31.823 257.366 31.823C263.449 31.823 268.665 32.963 273.017 35.245C277.367 37.526 280.747 40.738 283.156 44.876C285.563 49.016 286.767 53.874 286.767 59.45V99.243C286.767 104.819 285.563 109.679 283.156 113.817C280.748 117.957 277.346 121.167 272.954 123.449C268.56 125.729 263.364 126.869 257.366 126.869ZM257.366 113.183C261.758 113.183 265.265 111.915 267.885 109.381C270.502 106.846 271.813 103.468 271.813 99.242V59.45C271.813 55.227 270.503 51.846 267.885 49.312C265.265 46.777 261.758 45.51 257.366 45.51C252.972 45.51 249.466 46.777 246.848 49.312C244.228 51.846 242.918 55.227 242.918 59.45V99.243C242.918 103.469 244.228 106.847 246.848 109.382C249.465 111.916 252.972 113.183 257.366 113.183ZM257.366 87.583C254.915 87.583 252.909 86.781 251.346 85.175C249.782 83.571 249.002 81.5 249.002 78.965C249.002 76.431 249.762 74.403 251.283 72.883C252.803 71.362 254.831 70.601 257.366 70.601C259.901 70.601 261.928 71.361 263.449 72.883C264.969 74.403 265.73 76.431 265.73 78.966C265.73 81.5 264.97 83.571 263.45 85.176C261.928 86.781 259.9 87.583 257.366 87.583Z" fill="black"/>
|
||||
<path d="M332.69 126.999C329.057 126.999 326.164 125.941 324.01 123.831C321.855 121.72 320.778 118.847 320.778 115.213C320.778 111.581 321.855 108.686 324.01 106.532C326.164 104.378 329.057 103.3 332.69 103.3C336.322 103.3 339.217 104.378 341.372 106.532C343.526 108.686 344.603 111.582 344.603 115.213C344.603 118.847 343.526 121.72 341.372 123.831C339.217 125.941 336.322 126.999 332.691 126.999H332.69ZM381.862 125.732V33.219H437.369V47.032H397.449V71.112H432.934V84.798H397.45V111.918H437.37V125.732H381.862ZM484.766 126.999C475.725 126.999 468.65 124.528 463.539 119.585C458.426 114.643 455.872 107.906 455.872 99.372V33.219H471.84V99.245C471.84 103.639 472.937 107.061 475.135 109.51C477.331 111.962 480.54 113.185 484.766 113.185C488.905 113.185 492.095 111.962 494.334 109.51C496.572 107.06 497.693 103.639 497.693 99.245V33.219H513.66V99.372C513.66 107.906 511.126 114.642 506.057 119.585C500.987 124.528 493.891 126.999 484.767 126.999H484.766Z" fill="#359CEF"/>
|
||||
<path d="M549 0H573V148H549V0Z" fill="#FFCC03"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4_4">
|
||||
<rect width="573" height="148" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -48,14 +48,24 @@ export default function OIDCProvider({ children }: Props) {
|
||||
const [, setQueryParams] = useLocalStorage("netbird-query-params", params);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
params?.includes("tab") ||
|
||||
params?.includes("search") ||
|
||||
params?.includes("id")
|
||||
) {
|
||||
setQueryParams(params);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const validParams = [
|
||||
"tab",
|
||||
"search",
|
||||
"id",
|
||||
"invite",
|
||||
"utm_source",
|
||||
"utm_medium",
|
||||
"utm_content",
|
||||
"utm_campaign",
|
||||
"hs_id",
|
||||
];
|
||||
|
||||
try {
|
||||
const urlParams = new URLSearchParams(params);
|
||||
if (validParams.some((param) => urlParams.has(param))) {
|
||||
setQueryParams(params);
|
||||
}
|
||||
} catch (e) {}
|
||||
}, []);
|
||||
|
||||
const withCustomHistory = () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ interface Props extends React.HTMLAttributes<HTMLDivElement>, BadgeVariants {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
useHover?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const variants = cva("", {
|
||||
@@ -53,14 +54,16 @@ export default function Badge({
|
||||
className,
|
||||
variant = "blue",
|
||||
useHover = false,
|
||||
disabled = false,
|
||||
...props
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 cursor-inherit whitespace-nowrap rounded-md text-[12px] py-1.5 px-3 font-normal flex gap-1.5 items-center justify-center transition-all",
|
||||
className,
|
||||
variants({ variant, hover: useHover ? variant : "none" }),
|
||||
disabled && "cursor-not-allowed opacity-50 select-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -4,12 +4,13 @@ import { cva, VariantProps } from "class-variance-authority";
|
||||
import classNames from "classnames";
|
||||
import React, { forwardRef } from "react";
|
||||
|
||||
type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
ButtonVariants {
|
||||
disabled?: boolean;
|
||||
stopPropagation?: boolean;
|
||||
}
|
||||
|
||||
export const buttonVariants = cva(
|
||||
@@ -27,7 +28,7 @@ export const buttonVariants = cva(
|
||||
"dark:focus:ring-zinc-800/50 dark:bg-nb-gray dark:text-gray-400 dark:border-gray-700/30 dark:hover:text-white dark:hover:bg-zinc-800/50",
|
||||
],
|
||||
primary: [
|
||||
"dark:focus:ring-netbird-600/50 dark:ring-offset-neutral-950/50 enabled:dark:bg-netbird disabled:dark:bg-nb-gray-920 dark:text-gray-100 enabled:dark:hover:text-white enabled:dark:hover:bg-netbird-500/80",
|
||||
"dark:focus:ring-netbird-600/50 dark:ring-offset-neutral-950/50 enabled:dark:bg-netbird disabled:dark:bg-nb-gray-910 dark:text-gray-100 enabled:dark:hover:text-white enabled:dark:hover:bg-netbird-500/80",
|
||||
"enabled:bg-netbird enabled:text-white enabled:focus:ring-netbird-400/50 enabled:hover:bg-netbird-500",
|
||||
],
|
||||
secondary: [
|
||||
@@ -45,6 +46,11 @@ export const buttonVariants = cva(
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 ",
|
||||
"dark:bg-nb-gray-900 dark:text-gray-400 dark:border-nb-gray-700 dark:hover:bg-nb-gray-900/80",
|
||||
],
|
||||
dropdown: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-neutral-200 text-gray-900",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 ",
|
||||
"dark:bg-nb-gray-900/40 dark:text-gray-400 dark:border-nb-gray-900 dark:hover:bg-nb-gray-900/50",
|
||||
],
|
||||
dotted: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900 border-dashed",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 ",
|
||||
@@ -68,8 +74,8 @@ export const buttonVariants = cva(
|
||||
"",
|
||||
],
|
||||
"default-outline": [
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:bg-transparent dark:text-nb-gray-400 dark:border-transparent dark:hover:text-white dark:hover:bg-zinc-800/50 dark:hover:border-nb-gray-800/50",
|
||||
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
|
||||
"dark:bg-transparent dark:text-nb-gray-400 dark:border-transparent dark:hover:text-white dark:hover:bg-nb-gray-900/30 dark:hover:border-nb-gray-800/50",
|
||||
],
|
||||
danger: [
|
||||
"", // TODO - add danger button styles for light mode
|
||||
@@ -103,6 +109,7 @@ const Button = forwardRef(
|
||||
rounded = true,
|
||||
border = 1,
|
||||
size = "md",
|
||||
stopPropagation = true,
|
||||
...props
|
||||
}: ButtonProps,
|
||||
ref: React.ForwardedRef<HTMLButtonElement>,
|
||||
@@ -122,7 +129,7 @@ const Button = forwardRef(
|
||||
props.className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
stopPropagation && e.stopPropagation();
|
||||
props.onClick && props.onClick(e);
|
||||
}}
|
||||
>
|
||||
|
||||
39
src/components/Callout.tsx
Normal file
39
src/components/Callout.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { cn } from "@utils/helpers";
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
type CalloutVariants = VariantProps<typeof calloutVariants>;
|
||||
|
||||
type Props = {
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & CalloutVariants;
|
||||
|
||||
export const calloutVariants = cva(
|
||||
["px-4 py-3.5 rounded-md border text-sm font-normal flex gap-3 font-light"],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-nb-gray-900/60 border-nb-gray-800/80 text-nb-gray-300",
|
||||
warning: "bg-netbird-500/10 border-netbird-400/20 text-netbird-150",
|
||||
info: "bg-sky-400/10 border-sky-400/20 text-sky-100",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const Callout = ({
|
||||
children,
|
||||
icon = <InfoIcon size={14} className={"shrink-0 relative top-[2px]"} />,
|
||||
className,
|
||||
variant = "default",
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className={cn(calloutVariants({ variant }), className)}>
|
||||
{icon}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -117,7 +117,7 @@ const CommandItem = React.forwardRef<
|
||||
ref={ref}
|
||||
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/20",
|
||||
"bg-transparent dark:aria-selected:bg-nb-gray-910 group/command-item",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import Button from "@components/Button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
|
||||
import { AbsoluteDateTimeInput } from "@components/ui/AbsoluteDateTimeInput";
|
||||
import { Calendar } from "@components/ui/Calendar";
|
||||
import { cn } from "@utils/helpers";
|
||||
import dayjs from "dayjs";
|
||||
import { debounce } from "lodash";
|
||||
import { Calendar as CalendarIcon } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { DateRange } from "react-day-picker";
|
||||
@@ -28,6 +30,14 @@ const defaultRanges = {
|
||||
from: dayjs().subtract(14, "day").startOf("day").toDate(),
|
||||
to: dayjs().endOf("day").toDate(),
|
||||
},
|
||||
last2Days: {
|
||||
from: dayjs().subtract(2, "day").startOf("day").toDate(),
|
||||
to: dayjs().endOf("day").toDate(),
|
||||
},
|
||||
last7Days: {
|
||||
from: dayjs().subtract(7, "day").startOf("day").toDate(),
|
||||
to: dayjs().endOf("day").toDate(),
|
||||
},
|
||||
lastMonth: {
|
||||
from: dayjs().subtract(1, "month").startOf("day").toDate(),
|
||||
to: dayjs().endOf("day").toDate(),
|
||||
@@ -47,12 +57,18 @@ const isEqualDateRange = (a: DateRange | undefined, b: DateRange) => {
|
||||
return aFromDay === bFromDay && aToDay === bToDay;
|
||||
};
|
||||
|
||||
export function DatePickerWithRange({ className, value, onChange }: Props) {
|
||||
export function DatePickerWithRange({
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
}: Readonly<Props>) {
|
||||
const isActive = useMemo(() => {
|
||||
return {
|
||||
today: isEqualDateRange(value, defaultRanges.today),
|
||||
yesterday: isEqualDateRange(value, defaultRanges.yesterday),
|
||||
last14Days: isEqualDateRange(value, defaultRanges.last14Days),
|
||||
last2Days: isEqualDateRange(value, defaultRanges.last2Days),
|
||||
last7Days: isEqualDateRange(value, defaultRanges.last7Days),
|
||||
lastMonth: isEqualDateRange(value, defaultRanges.lastMonth),
|
||||
allTime: isEqualDateRange(value, defaultRanges.allTime),
|
||||
};
|
||||
@@ -64,6 +80,8 @@ export function DatePickerWithRange({ className, value, onChange }: Props) {
|
||||
if (isActive.allTime) return "All Time";
|
||||
if (isActive.lastMonth) return "Last Month";
|
||||
if (isActive.last14Days) return "Last 14 Days";
|
||||
if (isActive.last2Days) return "Last 2 Days";
|
||||
if (isActive.last7Days) return "Last 7 Days";
|
||||
if (isActive.yesterday) return "Yesterday";
|
||||
if (isActive.today) return "Today";
|
||||
|
||||
@@ -76,10 +94,25 @@ export function DatePickerWithRange({ className, value, onChange }: Props) {
|
||||
const [calendarOpen, setCalendarOpen] = useState(false);
|
||||
|
||||
const updateRangeAndClose = (range: DateRange) => {
|
||||
setCalendarOpen(false);
|
||||
onChange?.(range);
|
||||
};
|
||||
|
||||
const debouncedOnChange = useMemo(() => {
|
||||
return onChange ? debounce(onChange, 500) : undefined;
|
||||
}, [onChange]);
|
||||
|
||||
const handleOnSelect = (range?: DateRange) => {
|
||||
let from = range?.from
|
||||
? dayjs(range.from).startOf("day").toDate()
|
||||
: undefined;
|
||||
let to = range?.to ? dayjs(range.to).endOf("day").toDate() : undefined;
|
||||
if (!from && !to) {
|
||||
onChange?.(undefined);
|
||||
return;
|
||||
}
|
||||
onChange?.({ from, to });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("grid gap-2", className)}>
|
||||
<Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
|
||||
@@ -93,10 +126,16 @@ export function DatePickerWithRange({ className, value, onChange }: Props) {
|
||||
{displayDateValue}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start" sideOffset={10}>
|
||||
<PopoverContent
|
||||
className="w-auto p-0"
|
||||
align="start"
|
||||
side={"right"}
|
||||
sideOffset={10}
|
||||
alignOffset={-100}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"px-4 py-3 flex flex-wrap gap-2 max-w-[280px] sm:max-w-none border-b border-nb-gray-800 items-center justify-between w-full"
|
||||
"px-3 py-2 flex flex-wrap gap-2 max-w-[280px] sm:max-w-none border-b border-nb-gray-800 items-center justify-between w-full"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
@@ -139,23 +178,10 @@ export function DatePickerWithRange({ className, value, onChange }: Props) {
|
||||
mode="range"
|
||||
defaultMonth={value?.from}
|
||||
selected={value}
|
||||
onSelect={(range) => {
|
||||
let from =
|
||||
range && range.from
|
||||
? dayjs(range.from).startOf("day").toDate()
|
||||
: undefined;
|
||||
let to =
|
||||
range && range.to
|
||||
? dayjs(range.to).endOf("day").toDate()
|
||||
: undefined;
|
||||
if (!from && !to) {
|
||||
onChange?.(undefined);
|
||||
return;
|
||||
}
|
||||
onChange?.({ from, to });
|
||||
}}
|
||||
onSelect={handleOnSelect}
|
||||
numberOfMonths={2}
|
||||
/>
|
||||
<AbsoluteDateTimeInput value={value} onChange={debouncedOnChange} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
@@ -168,7 +194,11 @@ type CalendarButtonProps = {
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
function CalendarButton({ label, onClick, active }: CalendarButtonProps) {
|
||||
function CalendarButton({
|
||||
label,
|
||||
onClick,
|
||||
active,
|
||||
}: Readonly<CalendarButtonProps>) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
|
||||
@@ -8,10 +8,22 @@ type Props = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
};
|
||||
hideEnterIcon?: boolean;
|
||||
className?: string;
|
||||
} & React.InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
export const DropdownInput = forwardRef<HTMLInputElement, Props>(
|
||||
({ value, onChange, placeholder = "Search..." }, ref) => {
|
||||
(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Search...",
|
||||
className,
|
||||
hideEnterIcon = false,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<div className={"relative w-full"}>
|
||||
<input
|
||||
@@ -21,25 +33,31 @@ export const DropdownInput = forwardRef<HTMLInputElement, Props>(
|
||||
"border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center",
|
||||
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
|
||||
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10",
|
||||
className,
|
||||
)}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
{...props}
|
||||
/>
|
||||
<div className={"absolute left-0 top-0 h-full flex items-center pl-4"}>
|
||||
<div className={"flex items-center"}>
|
||||
<SearchIcon size={14} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={"absolute right-0 top-0 h-full flex items-center pr-4"}>
|
||||
{!hideEnterIcon && (
|
||||
<div
|
||||
className={
|
||||
"flex items-center bg-nb-gray-800 py-1 px-1.5 rounded-[4px] border border-nb-gray-500"
|
||||
}
|
||||
className={"absolute right-0 top-0 h-full flex items-center pr-4"}
|
||||
>
|
||||
<IconArrowBack size={10} />
|
||||
<div
|
||||
className={
|
||||
"flex items-center bg-nb-gray-800 py-1 px-1.5 rounded-[4px] border border-nb-gray-500"
|
||||
}
|
||||
>
|
||||
<IconArrowBack size={10} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -22,13 +22,16 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "danger";
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
>(({ className, inset, children, variant = "default", ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-gray-100 data-[state=open]:bg-gray-100 dark:focus:bg-gray-800 dark:data-[state=open]:bg-gray-800",
|
||||
"relative flex select-none items-center rounded-md pr-2 pl-3 py-1.5 text-sm outline-none",
|
||||
"transition-colors focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer ",
|
||||
inset && "pl-8",
|
||||
menuItemVariants({ variant }),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -47,7 +50,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-200 bg-white p-1 text-gray-950 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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-gray-800 dark:bg-gray-950 dark:text-gray-50",
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-200 bg-white p-1 text-gray-950 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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-900 dark:bg-nb-gray-940 dark:text-gray-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -78,7 +81,7 @@ const menuItemVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"dark:focus:bg-nb-gray-900 dark:focus:text-gray-50 dark:text-gray-400",
|
||||
"dark:focus:bg-nb-gray-900 dark:focus:text-gray-50 dark:text-gray-400 dark:data-[state=open]:bg-nb-gray-900 dark:data-[state=open]:text-gray-50",
|
||||
danger:
|
||||
"dark:focus:bg-red-900/20 dark:focus:text-red-500 dark:text-red-500",
|
||||
},
|
||||
@@ -181,7 +184,7 @@ const DropdownMenuSeparator = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-gray-100 dark:bg-gray-800", className)}
|
||||
className={cn("-mx-1 my-1 h-px bg-gray-100 dark:bg-nb-gray-910", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
TooltipVariants,
|
||||
} from "@components/Tooltip";
|
||||
import { TooltipProps } from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@utils/helpers";
|
||||
@@ -24,7 +25,9 @@ type Props = {
|
||||
customOnOpenChange?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
delayDuration?: number;
|
||||
skipDelayDuration?: number;
|
||||
} & TooltipProps;
|
||||
} & TooltipProps &
|
||||
TooltipVariants;
|
||||
|
||||
export default function FullTooltip({
|
||||
children,
|
||||
content,
|
||||
@@ -41,6 +44,7 @@ export default function FullTooltip({
|
||||
customOnOpenChange,
|
||||
delayDuration = 1,
|
||||
skipDelayDuration = 300,
|
||||
variant = "default",
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(!!keepOpen);
|
||||
|
||||
@@ -66,7 +70,7 @@ export default function FullTooltip({
|
||||
<div
|
||||
className={cn(
|
||||
isAction ? "cursor-pointer" : "cursor-default",
|
||||
"inline-flex items-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md",
|
||||
"inline-flex items-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -82,6 +86,7 @@ export default function FullTooltip({
|
||||
alignOffset={20}
|
||||
forceMount={true}
|
||||
className={contentClassName}
|
||||
variant={variant}
|
||||
align={align}
|
||||
side={side}
|
||||
>
|
||||
|
||||
@@ -11,26 +11,45 @@ interface Props extends LinkProps, InlineLinkProps {
|
||||
target?: "_blank" | "_self" | "_parent" | "_top";
|
||||
}
|
||||
|
||||
const linkVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "text-netbird hover:underline font-normal",
|
||||
faded: "text-nb-gray-400 hover:text-nb-gray-300 hover:underline",
|
||||
interface InlineButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
InlineLinkProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
target?: "_blank" | "_self" | "_parent" | "_top";
|
||||
}
|
||||
|
||||
export const linkVariants = cva(
|
||||
"underline-offset-4 items-center transition-all duration-200 inline-flex texts-inherit gap-1",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "text-netbird hover:underline font-normal",
|
||||
faded: "text-nb-gray-400 hover:text-nb-gray-300 hover:underline",
|
||||
white: "text-nb-gray-100 hover:text-white hover:underline",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
export default function InlineLink({ variant = "default", ...props }: Props) {
|
||||
return (
|
||||
<Link
|
||||
{...props}
|
||||
className={cn(
|
||||
"underline-offset-4 texts-inherit gap-1 items-center transition-all duration-200 inline-flex",
|
||||
props.className,
|
||||
linkVariants({ variant }),
|
||||
)}
|
||||
>
|
||||
<Link {...props} className={cn(props.className, linkVariants({ variant }))}>
|
||||
{props.children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function InlineButtonLink({
|
||||
variant = "default",
|
||||
...props
|
||||
}: InlineButtonProps) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={cn(props.className, linkVariants({ variant }))}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
}),
|
||||
"flex h-[42px] w-auto rounded-l-md bg-white px-3 py-2 text-sm ",
|
||||
"border items-center whitespace-nowrap",
|
||||
props.disabled && "opacity-20",
|
||||
props.disabled && "opacity-40",
|
||||
prefixClassName,
|
||||
)}
|
||||
>
|
||||
@@ -87,7 +87,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pl-3 leading-[0]",
|
||||
props.disabled && "opacity-30",
|
||||
props.disabled && "opacity-40",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
@@ -99,7 +99,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
{...props}
|
||||
className={cn(
|
||||
inputVariants({ variant: error ? "error" : variant }),
|
||||
"flex h-[42px] w-full rounded-md bg-white px-3 py-2 text-sm file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-20 ",
|
||||
"flex h-[42px] w-full rounded-md bg-white px-3 py-2 text-sm file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-40 ",
|
||||
"file:border-0",
|
||||
"focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
customPrefix && "!border-l-0 !rounded-l-none",
|
||||
|
||||
@@ -12,6 +12,7 @@ const variants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: ["bg-nb-gray-800 border-nb-gray-700 text-nb-gray-300 "],
|
||||
darker: ["bg-nb-gray-930 border-nb-gray-900 text-nb-gray-250 "],
|
||||
netbird: ["bg-netbird-100 text-netbird border-netbird "],
|
||||
},
|
||||
size: {
|
||||
@@ -30,7 +31,7 @@ export default function Kbd({
|
||||
size = "default",
|
||||
disabled = false,
|
||||
className,
|
||||
}: Props) {
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
42
src/components/NetBirdLogo.tsx
Normal file
42
src/components/NetBirdLogo.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { cn } from "@utils/helpers";
|
||||
import Image from "next/image";
|
||||
import * as React from "react";
|
||||
import NetBirdLogoMark from "@/assets/netbird.svg";
|
||||
import NetBirdLogoFull from "@/assets/netbird-full.svg";
|
||||
|
||||
type Props = {
|
||||
size?: "default" | "large";
|
||||
mobile?: boolean;
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
default: {
|
||||
desktop: 22,
|
||||
mobile: 30,
|
||||
},
|
||||
large: {
|
||||
desktop: 24,
|
||||
mobile: 40,
|
||||
},
|
||||
};
|
||||
|
||||
export const NetBirdLogo = ({ size = "default", mobile = true }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<Image
|
||||
src={NetBirdLogoFull}
|
||||
height={sizes[size].desktop}
|
||||
alt={"NetBird Logo"}
|
||||
className={cn(mobile && "hidden md:block")}
|
||||
/>
|
||||
{mobile && (
|
||||
<Image
|
||||
src={NetBirdLogoMark}
|
||||
width={sizes[size].mobile}
|
||||
alt={"NetBird Logo"}
|
||||
className={cn(mobile && "md:hidden ml-4")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -12,12 +12,15 @@ export interface NotifyProps<T> {
|
||||
title: string;
|
||||
description: string;
|
||||
promise?: Promise<T | ErrorResponse>;
|
||||
loadingTitle?: string;
|
||||
loadingMessage?: string;
|
||||
duration?: number;
|
||||
icon?: React.ReactNode;
|
||||
backgroundColor?: string;
|
||||
preventSuccessToast?: boolean;
|
||||
errorMessages?: ErrorResponse[];
|
||||
}
|
||||
|
||||
interface NotificationProps<T> extends NotifyProps<T> {
|
||||
t: Toast;
|
||||
}
|
||||
@@ -28,9 +31,11 @@ export default function Notification<T>({
|
||||
backgroundColor,
|
||||
t,
|
||||
promise,
|
||||
loadingTitle,
|
||||
loadingMessage,
|
||||
duration = 3500,
|
||||
preventSuccessToast = false,
|
||||
errorMessages,
|
||||
}: NotificationProps<T>) {
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(!!promise);
|
||||
@@ -51,15 +56,27 @@ export default function Notification<T>({
|
||||
if (promise) {
|
||||
promise
|
||||
.then(() => {
|
||||
if (preventSuccessToast) setPreventSuccess(true);
|
||||
setLoading(false);
|
||||
closeToast();
|
||||
if (preventSuccessToast) setPreventSuccess(true);
|
||||
})
|
||||
.catch((e) => {
|
||||
const err = e as ErrorResponse;
|
||||
const message = err.message || "Something went wrong...";
|
||||
let message = err.message || "Something went wrong...";
|
||||
message = message.charAt(0).toUpperCase() + message.slice(1);
|
||||
const code: number = err.code || 418;
|
||||
setError(`Code ${code}: ${message}`);
|
||||
|
||||
if (errorMessages) {
|
||||
const errorMessage = errorMessages.find(
|
||||
(error) => error.code === code,
|
||||
);
|
||||
if (errorMessage) {
|
||||
setError(errorMessage.message);
|
||||
}
|
||||
} else {
|
||||
setError(`Code ${code}: ${message}`);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
closeToast();
|
||||
});
|
||||
@@ -101,7 +118,9 @@ export default function Notification<T>({
|
||||
</div>
|
||||
<div className={"flex flex-col text-sm"}>
|
||||
<p>
|
||||
<span className={"font-semibold"}>{title}</span>
|
||||
<span className={"font-semibold"}>
|
||||
{loading ? loadingTitle || title : title}
|
||||
</span>
|
||||
</p>
|
||||
<p
|
||||
className={"text-xs dark:text-nb-gray-300 text-gray-600 mt-0.5"}
|
||||
|
||||
@@ -41,6 +41,8 @@ import type { Group, GroupPeer, GroupResource } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { PolicyRuleResource } from "@/interfaces/Policy";
|
||||
import { User } from "@/interfaces/User";
|
||||
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
|
||||
|
||||
interface MultiSelectProps {
|
||||
values: Group[];
|
||||
@@ -61,6 +63,10 @@ interface MultiSelectProps {
|
||||
resource?: PolicyRuleResource;
|
||||
onResourceChange?: (resource?: PolicyRuleResource) => 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<MultiSelectProps>) {
|
||||
const { groups, dropdownOptions, setDropdownOptions, addDropdownOptions } =
|
||||
useGroups();
|
||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
|
||||
const [inputRef, { width }] = useElementSize<
|
||||
HTMLButtonElement | HTMLSpanElement
|
||||
>();
|
||||
const [search, setSearch] = useState("");
|
||||
const { data: resources, isLoading } = useFetchApi<NetworkResource[]>(
|
||||
"/networks/resources",
|
||||
@@ -251,97 +263,105 @@ export function PeerGroupSelector({
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"min-h-[46px] w-full relative items-center group",
|
||||
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3",
|
||||
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer hover:dark:bg-nb-gray-900/50",
|
||||
"disabled:pointer-events-none disabled:opacity-30 transition-all",
|
||||
)}
|
||||
disabled={disabled}
|
||||
data-cy={dataCy}
|
||||
ref={inputRef}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-2 border-nb-gray-700 flex-wrap h-full"
|
||||
}
|
||||
{customTrigger ? (
|
||||
<div ref={inputRef} className={"w-full"}>
|
||||
{customTrigger}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className={cn(
|
||||
"min-h-[46px] w-full relative items-center group",
|
||||
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3",
|
||||
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer hover:dark:bg-nb-gray-900/50",
|
||||
"disabled:pointer-events-none disabled:opacity-30 transition-all",
|
||||
)}
|
||||
disabled={disabled}
|
||||
data-cy={dataCy}
|
||||
ref={inputRef}
|
||||
>
|
||||
{resource && showResources && (
|
||||
<ResourceBadge
|
||||
className={"py-[3px]"}
|
||||
resource={resources?.find((r) => r.id === resource.id)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectResource();
|
||||
}}
|
||||
showX={true}
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-2 border-nb-gray-700 flex-wrap h-full"
|
||||
}
|
||||
>
|
||||
{resource && showResources && (
|
||||
<ResourceBadge
|
||||
className={"py-[3px]"}
|
||||
resource={resources?.find((r) => r.id === resource.id)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectResource();
|
||||
}}
|
||||
showX={true}
|
||||
/>
|
||||
)}
|
||||
{values.map((group) => {
|
||||
return (
|
||||
<div
|
||||
key={group.name}
|
||||
className={cn(
|
||||
showPeerCount
|
||||
? "flex gap-x-1 gap-y-2 items-center justify-between w-full"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{showPeerCount ? (
|
||||
<GroupBadgeWithEditPeers
|
||||
className={"py-[3px]"}
|
||||
group={group}
|
||||
key={group.name}
|
||||
showNewBadge={true}
|
||||
onPeerAssignmentChange={onPeerAssignmentChange}
|
||||
useSave={saveGroupAssignments}
|
||||
/>
|
||||
) : (
|
||||
<GroupBadge
|
||||
className={"py-[3px]"}
|
||||
group={group}
|
||||
key={group.name}
|
||||
showNewBadge={true}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (disableInlineRemoveGroup) return;
|
||||
if (peer != undefined && group.name == "All") return; // Prevent removing the "All" group
|
||||
toggleGroupByName(group.name);
|
||||
}}
|
||||
showX={
|
||||
peer != undefined
|
||||
? group.name !== "All"
|
||||
: !disableInlineRemoveGroup
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{values.length == 0 && !resource && (
|
||||
<span className={"pl-1"}>{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={"pl-2"} data-cy={"group-selector-open-close"}>
|
||||
<ChevronsUpDown
|
||||
size={18}
|
||||
className={
|
||||
"shrink-0 group-hover:text-nb-gray-300 transition-all"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{values.map((group) => {
|
||||
return (
|
||||
<div
|
||||
key={group.name}
|
||||
className={cn(
|
||||
showPeerCount
|
||||
? "flex gap-x-1 gap-y-2 items-center justify-between w-full"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{showPeerCount ? (
|
||||
<GroupBadgeWithEditPeers
|
||||
className={"py-[3px]"}
|
||||
group={group}
|
||||
key={group.name}
|
||||
showNewBadge={true}
|
||||
onPeerAssignmentChange={onPeerAssignmentChange}
|
||||
useSave={saveGroupAssignments}
|
||||
/>
|
||||
) : (
|
||||
<GroupBadge
|
||||
className={"py-[3px]"}
|
||||
group={group}
|
||||
key={group.name}
|
||||
showNewBadge={true}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (disableInlineRemoveGroup) return;
|
||||
if (peer != undefined && group.name == "All") return; // Prevent removing the "All" group
|
||||
toggleGroupByName(group.name);
|
||||
}}
|
||||
showX={
|
||||
peer != undefined
|
||||
? group.name !== "All"
|
||||
: !disableInlineRemoveGroup
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{values.length == 0 && !resource && (
|
||||
<span className={"pl-1"}>{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={"pl-2"} data-cy={"group-selector-open-close"}>
|
||||
<ChevronsUpDown
|
||||
size={18}
|
||||
className={"shrink-0 group-hover:text-nb-gray-300 transition-all"}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950"
|
||||
style={{
|
||||
width: popoverWidth === "auto" ? width : popoverWidth,
|
||||
}}
|
||||
align="start"
|
||||
side={"top"}
|
||||
align={align}
|
||||
side={side}
|
||||
sideOffset={10}
|
||||
>
|
||||
<Command
|
||||
@@ -482,13 +502,24 @@ export function PeerGroupSelector({
|
||||
<ResourcesCounter group={option} />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
{peerIcon}
|
||||
{peerCount} Peer(s)
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
{!users ? (
|
||||
<div
|
||||
className={
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
{peerIcon}
|
||||
{peerCount} Peer(s)
|
||||
</div>
|
||||
) : (
|
||||
<UsersCounter
|
||||
group={option}
|
||||
users={users}
|
||||
selected={isSelected}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Checkbox checked={isSelected} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<HorizontalUsersStack
|
||||
users={usersOfGroup}
|
||||
max={3}
|
||||
avatarClassName={cn(
|
||||
"border-nb-gray-920",
|
||||
"bg-nb-gray-800 group-hover/user-stack:bg-nb-gray-700",
|
||||
"group-hover/command-item:border-nb-gray-910",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ResourcesCounter = ({ group }: { group: Group }) => {
|
||||
return group?.resources_count && group.resources_count > 0 ? (
|
||||
<div
|
||||
|
||||
@@ -172,6 +172,7 @@ export function PeerSelector({
|
||||
{filteredItems.length > 0 && (
|
||||
<VirtualScrollAreaList
|
||||
items={filteredItems}
|
||||
estimatedItemHeight={37}
|
||||
onSelect={(item) => {
|
||||
const isSupported = isRoutingPeerSupported(
|
||||
item.version,
|
||||
|
||||
@@ -2,33 +2,62 @@
|
||||
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
type PopoverVariants = VariantProps<typeof popoverVariants>;
|
||||
|
||||
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<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden",
|
||||
"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",
|
||||
"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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> &
|
||||
PopoverVariants
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
variant = "lighter",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden",
|
||||
"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",
|
||||
popoverVariants({ variant }),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
),
|
||||
);
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverContent, PopoverTrigger };
|
||||
|
||||
@@ -1,261 +1,328 @@
|
||||
import Badge from "@components/Badge";
|
||||
import { Callout } from "@components/Callout";
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import { CommandItem } from "@components/Command";
|
||||
import { DropdownInfoText } from "@components/DropdownInfoText";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
|
||||
import { IconArrowBack } from "@tabler/icons-react";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Command, CommandGroup, CommandInput, CommandList } from "cmdk";
|
||||
import { trim } from "lodash";
|
||||
import { orderBy, trim } from "lodash";
|
||||
import { ChevronsUpDown, SearchIcon, XIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useElementSize } from "@/hooks/useElementSize";
|
||||
import { PortRange } from "@/interfaces/Policy";
|
||||
|
||||
interface MultiSelectProps {
|
||||
values: number[];
|
||||
onChange: React.Dispatch<React.SetStateAction<number[]>>;
|
||||
ports: number[];
|
||||
onPortsChange: React.Dispatch<React.SetStateAction<number[]>>;
|
||||
portRanges?: PortRange[];
|
||||
onPortRangesChange?: React.Dispatch<React.SetStateAction<PortRange[]>>;
|
||||
max?: number;
|
||||
disabled?: boolean;
|
||||
popoverWidth?: "auto" | number;
|
||||
showAll?: boolean;
|
||||
}
|
||||
|
||||
const isValidPort = (p: number) => p >= 1 && p <= 65535;
|
||||
|
||||
const parseRange = (value: string): PortRange | undefined => {
|
||||
const parts = value.split("-").map((x) => Number(trim(x)));
|
||||
if (parts.length !== 2) return undefined;
|
||||
const [start, end] = parts;
|
||||
if (!isValidPort(start) || !isValidPort(end) || start >= end)
|
||||
return undefined;
|
||||
return { start, end };
|
||||
};
|
||||
|
||||
const parsePortInput = (value: string): number | PortRange | undefined => {
|
||||
const trimmed = trim(value);
|
||||
if (/^\d{1,5}-\d{1,5}$/.test(trimmed)) return parseRange(trimmed);
|
||||
const port = Number(trimmed);
|
||||
return isValidPort(port) ? port : undefined;
|
||||
};
|
||||
|
||||
export function PortSelector({
|
||||
onChange,
|
||||
values,
|
||||
max,
|
||||
onPortsChange,
|
||||
ports,
|
||||
portRanges = [],
|
||||
onPortRangesChange,
|
||||
disabled = false,
|
||||
popoverWidth = "auto",
|
||||
showAll = false,
|
||||
}: MultiSelectProps) {
|
||||
}: Readonly<MultiSelectProps>) {
|
||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const toggle = (x: number) => {
|
||||
if (isNaN(Number(x))) return;
|
||||
const port = Number(x);
|
||||
if (port < 1 || port > 65535) return;
|
||||
const [portsInput, setPortsInput] = useState<string[]>(() => {
|
||||
const p = ports.map(String);
|
||||
const pr = portRanges.map((r) => {
|
||||
if (r.start === r.end) return String(r.start);
|
||||
return `${r.start}-${r.end}`;
|
||||
});
|
||||
return orderBy([...p, ...pr], [(x) => Number(x.split("-")[0])], ["asc"]);
|
||||
});
|
||||
|
||||
const isSelected = values.includes(port);
|
||||
if (isSelected) {
|
||||
onChange((previous) => previous.filter((y) => y !== port));
|
||||
} else {
|
||||
onChange((previous) => [...previous, port]);
|
||||
setSearch("");
|
||||
}
|
||||
useEffect(() => {
|
||||
const parsed = portsInput.map(parsePortInput).filter(Boolean);
|
||||
const newPorts: number[] = [];
|
||||
const newRanges: PortRange[] = [];
|
||||
parsed.forEach((entry) => {
|
||||
if (typeof entry === "number") newPorts.push(entry);
|
||||
else if (entry !== undefined) newRanges.push(entry);
|
||||
});
|
||||
onPortsChange(newPorts);
|
||||
onPortRangesChange?.(newRanges);
|
||||
}, [portsInput]);
|
||||
|
||||
const toggle = (value: string) => {
|
||||
if (disabled) return;
|
||||
setPortsInput((prev) =>
|
||||
prev.includes(value) ? prev.filter((e) => e !== value) : [...prev, value],
|
||||
);
|
||||
setSearch("");
|
||||
};
|
||||
|
||||
const notFound = useMemo(() => {
|
||||
const isSearching = search.length > 0;
|
||||
const found =
|
||||
values.filter((item) => item == Number(trim(search))).length == 0;
|
||||
return isSearching && found;
|
||||
}, [search, values]);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const trimmed = trim(search);
|
||||
return (
|
||||
trimmed &&
|
||||
!portsInput.includes(trimmed) &&
|
||||
parsePortInput(trimmed) &&
|
||||
isSearching
|
||||
);
|
||||
}, [search, portsInput]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setTimeout(() => {
|
||||
setSearch("");
|
||||
}, 100);
|
||||
}
|
||||
setOpen(isOpen);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"min-h-[48px] w-full relative items-center",
|
||||
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3",
|
||||
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer hover:dark:bg-nb-gray-900/50",
|
||||
)}
|
||||
data-cy={"port-selector"}
|
||||
disabled={disabled}
|
||||
ref={inputRef}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-2 border-nb-gray-700 flex-wrap h-full"
|
||||
}
|
||||
>
|
||||
{values.length === 0 && showAll && (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
className={"uppercase tracking-wider font-medium py-1"}
|
||||
>
|
||||
All
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{values.map((x) => (
|
||||
<Badge
|
||||
key={x}
|
||||
variant={"gray"}
|
||||
onClick={() => toggle(x)}
|
||||
className={"uppercase tracking-wider font-medium py-1"}
|
||||
>
|
||||
{x}
|
||||
<XIcon
|
||||
size={12}
|
||||
className={"cursor-pointer group-hover:text-black"}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
{values.length == 0 && <span>Select ports...</span>}
|
||||
</div>
|
||||
|
||||
<ChevronsUpDown size={18} className={"shrink-0"} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950"
|
||||
style={{
|
||||
width: popoverWidth === "auto" ? width : popoverWidth,
|
||||
<>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setTimeout(() => {
|
||||
setSearch("");
|
||||
}, 100);
|
||||
}
|
||||
setOpen(isOpen);
|
||||
}}
|
||||
align="start"
|
||||
side={"top"}
|
||||
sideOffset={10}
|
||||
>
|
||||
<Command
|
||||
className={"w-full flex"}
|
||||
loop
|
||||
filter={(value, search) => {
|
||||
const formatValue = trim(value.toLowerCase());
|
||||
const formatSearch = trim(search.toLowerCase());
|
||||
if (formatValue.includes(formatSearch)) return 1;
|
||||
return 0;
|
||||
}}
|
||||
>
|
||||
<CommandList className={"w-full"}>
|
||||
<div className={"relative"}>
|
||||
<CommandInput
|
||||
className={cn(
|
||||
"min-h-[42px] w-full relative",
|
||||
"border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center",
|
||||
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
|
||||
"dark:placeholder:text-neutral-500 font-light placeholder:text-neutral-500 pl-10",
|
||||
)}
|
||||
data-cy={"port-input"}
|
||||
typeof={"number"}
|
||||
ref={searchRef}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder={'Add new ports by pressing "Enter"...'}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
"absolute left-0 top-0 h-full flex items-center pl-4"
|
||||
}
|
||||
>
|
||||
<div className={"flex items-center"}>
|
||||
<SearchIcon size={14} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"absolute right-0 top-0 h-full flex items-center pr-4"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex items-center bg-nb-gray-800 py-1 px-1.5 rounded-[4px] border border-nb-gray-500"
|
||||
}
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"min-h-[48px] w-full relative items-center",
|
||||
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3",
|
||||
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer hover:dark:bg-nb-gray-900/50",
|
||||
)}
|
||||
data-cy={"port-selector"}
|
||||
disabled={disabled}
|
||||
ref={inputRef}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-2 border-nb-gray-700 flex-wrap h-full"
|
||||
}
|
||||
>
|
||||
{portsInput.length === 0 && showAll && (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
className={"uppercase tracking-wider font-medium py-1"}
|
||||
>
|
||||
<IconArrowBack size={10} />
|
||||
</div>
|
||||
</div>
|
||||
All
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{portsInput.map((x) => (
|
||||
<Badge
|
||||
key={x}
|
||||
variant={"gray"}
|
||||
onClick={() => toggle(x)}
|
||||
className={"uppercase tracking-wider font-medium py-1"}
|
||||
>
|
||||
{x}
|
||||
<XIcon
|
||||
size={12}
|
||||
className={"cursor-pointer group-hover:text-black"}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
{ports.length == 0 && <span>Select ports...</span>}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2",
|
||||
values.length != 0 && "p-2",
|
||||
values.length != 0 && search && "p-2",
|
||||
values.length == 0 && search && "p-2",
|
||||
)}
|
||||
>
|
||||
{notFound && (
|
||||
<ChevronsUpDown size={18} className={"shrink-0"} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950"
|
||||
style={{
|
||||
width: popoverWidth === "auto" ? width : popoverWidth,
|
||||
}}
|
||||
align="start"
|
||||
side={"top"}
|
||||
sideOffset={10}
|
||||
>
|
||||
<Command
|
||||
className={"w-full flex"}
|
||||
loop
|
||||
filter={(value, search) => {
|
||||
const formatValue = trim(value.toLowerCase());
|
||||
const formatSearch = trim(search.toLowerCase());
|
||||
if (formatValue.includes(formatSearch)) return 1;
|
||||
return 0;
|
||||
}}
|
||||
>
|
||||
<CommandList className={"w-full"}>
|
||||
<div className={"relative"}>
|
||||
<CommandInput
|
||||
className={cn(
|
||||
"min-h-[42px] w-full relative",
|
||||
"border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center",
|
||||
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
|
||||
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10",
|
||||
)}
|
||||
data-cy={"port-input"}
|
||||
typeof={"number"}
|
||||
ref={searchRef}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder={
|
||||
'Add a port or a range e.g. 80 or 1-1023 and press "Enter" to add...'
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
"absolute left-0 top-0 h-full flex items-center pl-4"
|
||||
}
|
||||
>
|
||||
<div className={"flex items-center"}>
|
||||
<SearchIcon size={14} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"absolute right-0 top-0 h-full flex items-center pr-4"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex items-center bg-nb-gray-800 py-1 px-1.5 rounded-[4px] border border-nb-gray-500"
|
||||
}
|
||||
>
|
||||
<IconArrowBack size={10} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2",
|
||||
portsInput.length != 0 && "p-2",
|
||||
portsInput.length != 0 && search && "p-2",
|
||||
notFound && "p-2",
|
||||
)}
|
||||
>
|
||||
{!notFound && search && !portsInput.includes(search) && (
|
||||
<div className={"text-sm"}>
|
||||
<DropdownInfoText className={"mb-[18px] pt-[4px]"}>
|
||||
{
|
||||
"Please add a valid port or port range (e.g. 80, 443, 1-1023)"
|
||||
}
|
||||
</DropdownInfoText>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notFound && (
|
||||
<CommandGroup>
|
||||
<div
|
||||
className={cn(
|
||||
"max-h-[180px] overflow-y-auto flex flex-col gap-1",
|
||||
)}
|
||||
>
|
||||
<CommandItem
|
||||
key={search}
|
||||
onSelect={() => {
|
||||
toggle(search);
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
value={search}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
className={
|
||||
"uppercase tracking-wider font-medium py-1"
|
||||
}
|
||||
>
|
||||
{search}
|
||||
</Badge>
|
||||
<div
|
||||
className={"text-neutral-500 dark:text-nb-gray-300"}
|
||||
>
|
||||
Add this port or range by pressing{" "}
|
||||
<span className={"font-bold text-netbird"}>
|
||||
{"'Enter'"}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</div>
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
<CommandGroup>
|
||||
<div
|
||||
className={cn(
|
||||
"max-h-[180px] overflow-y-auto flex flex-col gap-1",
|
||||
)}
|
||||
>
|
||||
<CommandItem
|
||||
key={search}
|
||||
onSelect={() => {
|
||||
toggle(Number(search));
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
value={search}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
className={"uppercase tracking-wider font-medium py-1"}
|
||||
>
|
||||
{search}
|
||||
</Badge>
|
||||
<div className={"text-neutral-500 dark:text-nb-gray-300"}>
|
||||
Add this port by pressing{" "}
|
||||
<span className={"font-bold text-netbird"}>
|
||||
{"'Enter'"}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</div>
|
||||
</CommandGroup>
|
||||
)}
|
||||
{portsInput.map((option) => {
|
||||
const isSelected = portsInput.includes(option);
|
||||
return (
|
||||
<CommandItem
|
||||
key={option}
|
||||
value={option.toString()}
|
||||
onSelect={() => {
|
||||
toggle(option);
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
className={
|
||||
"uppercase tracking-wider font-medium py-1"
|
||||
}
|
||||
>
|
||||
{option}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<CommandGroup>
|
||||
<div
|
||||
className={cn(
|
||||
"max-h-[180px] overflow-y-auto flex flex-col gap-1",
|
||||
)}
|
||||
>
|
||||
{values.map((option) => {
|
||||
const isSelected = values.includes(option);
|
||||
return (
|
||||
<CommandItem
|
||||
key={option}
|
||||
value={option.toString()}
|
||||
onSelect={() => {
|
||||
toggle(option);
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
<div
|
||||
className={
|
||||
"uppercase tracking-wider font-medium py-1"
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
{option}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
<Checkbox checked={isSelected} />
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CommandGroup>
|
||||
</div>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Checkbox checked={isSelected} />
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CommandGroup>
|
||||
</div>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{portRanges?.length > 0 && (
|
||||
<Callout variant={"info"} className={"mt-4"}>
|
||||
Port ranges requires NetBird client{" "}
|
||||
<span className={"text-white font-normal"}>v0.48</span> or higher.
|
||||
</Callout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
67
src/components/RadioCard.tsx
Normal file
67
src/components/RadioCard.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { ReactNode } from "react"; // or replace with clsx or similar
|
||||
import { cn } from "@/utils/helpers";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
icon?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const RadioCard = ({
|
||||
value,
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
icon,
|
||||
}: Props) => {
|
||||
return (
|
||||
<RadioGroup.Item
|
||||
value={value}
|
||||
className={cn(
|
||||
"peer relative block cursor-pointer rounded-lg border border-nb-gray-900 bg-nb-gray-930/60 px-5 py-3 transition-all focus:outline-none",
|
||||
"data-[state=checked]:border-nb-gray-400 data-[state=checked]:bg-nb-gray-920",
|
||||
"outline-none focus:ring-0 focus:bg-nb-gray-930 focus:border-nb-gray-920",
|
||||
"hover:bg-nb-gray-930",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="text-nb-gray-100 font-normal text-sm text-left gap-2 flex items-center">
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
<div className="text-nb-gray-300 text-[0.8rem] text-left">
|
||||
{description}
|
||||
</div>
|
||||
</RadioGroup.Item>
|
||||
);
|
||||
};
|
||||
|
||||
type RadioCardGroupProps = {
|
||||
value: string;
|
||||
onValueChange: (val: string) => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
"aria-label"?: string;
|
||||
};
|
||||
|
||||
export const RadioCardGroup = ({
|
||||
value,
|
||||
onValueChange,
|
||||
children,
|
||||
className,
|
||||
"aria-label": ariaLabel = "Options",
|
||||
}: RadioCardGroupProps) => {
|
||||
return (
|
||||
<RadioGroup.Root
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</RadioGroup.Root>
|
||||
);
|
||||
};
|
||||
@@ -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<SidebarItemProps>) {
|
||||
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 (
|
||||
<Collapsible.Root open={open} onOpenChange={setOpen}>
|
||||
<Collapsible.Trigger asChild>
|
||||
<li className={"px-4 cursor-pointer"}>
|
||||
<li className={"px-4 cursor-pointer list-none"}>
|
||||
<button
|
||||
className={classNames(
|
||||
"rounded-lg text-[.87rem] w-full ",
|
||||
"font-normal ",
|
||||
"rounded-lg text-[.87rem] w-full relative font-normal",
|
||||
className,
|
||||
isChild
|
||||
? "pl-7 pr-2 py-[.45rem] mt-1 mb-0.5"
|
||||
@@ -72,22 +80,56 @@ export default function SidebarItem({
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{isChild && isNavigationCollapsed && !mobileNavOpen && (
|
||||
<div
|
||||
className={
|
||||
"absolute left-0 top-0 w-full h-full flex items-center justify-center group-hover/navigation:hidden text-[10px]"
|
||||
}
|
||||
>
|
||||
<DotIcon size={14} className={"shrink-0"} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
"flex w-full items-center shrink-0",
|
||||
"flex w-full items-center shrink-0 ",
|
||||
href == "" ? "disabled pointer-events-none" : "",
|
||||
)}
|
||||
>
|
||||
<span className="peer/icon" data-active={isActive} />
|
||||
{icon}
|
||||
<span className="px-4 whitespace-nowrap flex-1 w-full text-left">
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"px-4 whitespace-nowrap flex-1 w-full text-left",
|
||||
labelClassName,
|
||||
isNavigationCollapsed &&
|
||||
!mobileNavOpen &&
|
||||
"opacity-0 group-hover/navigation:opacity-100",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{collapsible &&
|
||||
(open ? (
|
||||
<ChevronUpIcon className={"shrink-0"} />
|
||||
<ChevronUpIcon
|
||||
size={18}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isNavigationCollapsed &&
|
||||
!mobileNavOpen &&
|
||||
"opacity-0 group-hover/navigation:opacity-100",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<ChevronDownIcon className={"shrink-0"} />
|
||||
<ChevronDownIcon
|
||||
size={18}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isNavigationCollapsed &&
|
||||
!mobileNavOpen &&
|
||||
"opacity-0 group-hover/navigation:opacity-100",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -24,6 +24,8 @@ type StepProps = {
|
||||
line?: boolean;
|
||||
center?: boolean;
|
||||
horizontal?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Step = ({
|
||||
@@ -32,6 +34,8 @@ const Step = ({
|
||||
line = true,
|
||||
center = false,
|
||||
horizontal,
|
||||
disabled = false,
|
||||
className,
|
||||
}: StepProps) => {
|
||||
return (
|
||||
<div
|
||||
@@ -39,6 +43,8 @@ const Step = ({
|
||||
"flex gap-4 items-start justify-start relative pb-6 -mx-1.5 group px-[2px]",
|
||||
center && "items-center",
|
||||
horizontal ? "flex-col items-center" : "min-w-full",
|
||||
disabled && "opacity-40 pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{line && (
|
||||
@@ -57,6 +63,7 @@ const Step = ({
|
||||
"h-[34px] w-[34px] shrink-0 rounded-full flex items-center justify-center font-medium text-xs relative z-0 border-4 transition-all",
|
||||
"dark:bg-nb-gray-900 dark:text-nb-gray-400 dark:border-nb-gray dark:group-hover:bg-nb-gray-800",
|
||||
"bg-nb-gray-100 text-nb-gray-400 border-white group-hover:bg-nb-gray-200 step-circle",
|
||||
"[.stepper-bg-variant]:border-nb-gray-940",
|
||||
)}
|
||||
>
|
||||
{step}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
@@ -10,25 +11,58 @@ const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
export const tooltipClasses =
|
||||
"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";
|
||||
export type TooltipVariants = VariantProps<typeof tooltipVariants>;
|
||||
|
||||
export const tooltipVariants = cva(
|
||||
[
|
||||
"z-[9999] overflow-hidden rounded-md border text-sm 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",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
"bg-white dark:bg-nb-gray-940",
|
||||
"text-neutral-950 dark:text-neutral-50",
|
||||
"border-neutral-200 dark:border-nb-gray-930",
|
||||
],
|
||||
lighter: [
|
||||
"bg-white dark:bg-nb-gray-920",
|
||||
"text-neutral-950 dark:text-neutral-50",
|
||||
"border-neutral-200 dark:border-nb-gray-900",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className = "px-4 py-2.5", sideOffset = 7, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
asChild={true}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(tooltipClasses, className)}
|
||||
{...props}
|
||||
>
|
||||
<div>{props.children}</div>
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> &
|
||||
TooltipVariants
|
||||
>(
|
||||
(
|
||||
{
|
||||
className = "px-4 py-2.5",
|
||||
sideOffset = 7,
|
||||
variant = "default",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
asChild={true}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(tooltipVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
<div>{props.children}</div>
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
),
|
||||
);
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|
||||
|
||||
219
src/components/UserSelector.tsx
Normal file
219
src/components/UserSelector.tsx
Normal file
@@ -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(() => <MapPin size={12} />);
|
||||
MapPinIcon.displayName = "MapPinIcon";
|
||||
|
||||
interface MultiSelectProps {
|
||||
value?: User;
|
||||
onChange: React.Dispatch<React.SetStateAction<User | undefined>>;
|
||||
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<HTMLButtonElement>();
|
||||
|
||||
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 (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setTimeout(() => {
|
||||
setSearch("");
|
||||
}, 100);
|
||||
}
|
||||
setOpen(isOpen);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"min-h-[46px] w-full relative items-center group",
|
||||
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3",
|
||||
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer enabled:hover:dark:bg-nb-gray-900/50",
|
||||
"disabled:opacity-40 disabled:cursor-default",
|
||||
)}
|
||||
disabled={disabled}
|
||||
ref={inputRef}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex items-center w-full gap-2 border-nb-gray-700 flex-wrap h-full"
|
||||
}
|
||||
>
|
||||
{value ? (
|
||||
<UserListItem
|
||||
user={value}
|
||||
className={"bg-nb-gray-800"}
|
||||
variant={"selected"}
|
||||
/>
|
||||
) : (
|
||||
<span>{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChevronsUpDown size={18} className={"shrink-0"} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
hideWhenDetached={false}
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950"
|
||||
style={{
|
||||
width: width,
|
||||
}}
|
||||
align="start"
|
||||
side={"top"}
|
||||
sideOffset={10}
|
||||
>
|
||||
<div className={"w-full"}>
|
||||
<DropdownInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
hideEnterIcon={true}
|
||||
placeholder={"Search for users by name or email..."}
|
||||
/>
|
||||
|
||||
{options.length == 0 && !search && (
|
||||
<div className={"max-w-xs mx-auto"}>
|
||||
<DropdownInfoText>
|
||||
{
|
||||
"There are no users to select. Invite some users for this tenant before unlinking."
|
||||
}
|
||||
</DropdownInfoText>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredItems.length == 0 && search != "" && (
|
||||
<DropdownInfoText>
|
||||
There are no users matching your search.
|
||||
</DropdownInfoText>
|
||||
)}
|
||||
|
||||
{filteredItems.length > 0 && (
|
||||
<VirtualScrollAreaList
|
||||
items={filteredItems}
|
||||
onSelect={toggleUser}
|
||||
estimatedItemHeight={52}
|
||||
scrollAreaClassName={"pt-0"}
|
||||
renderItem={(option) => {
|
||||
return (
|
||||
<div>
|
||||
<UserListItem user={option} className={"bg-nb-gray-800"} />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={"flex items-center gap-2 w-full text-left"}>
|
||||
<SmallUserAvatar
|
||||
name={user?.name}
|
||||
email={user?.email}
|
||||
id={user?.id}
|
||||
className={cn(
|
||||
variant === "selected" && "w-5 h-5 text-[9px]",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col w-full",
|
||||
variant === "selected" && "flex-row",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-nb-gray-200 flex items-center relative gap-1.5 w-full text-xs",
|
||||
variant === "selected" && "text-[0.85rem]",
|
||||
)}
|
||||
>
|
||||
<TextWithTooltip
|
||||
text={isSystemUser ? "System" : user?.name || user?.id}
|
||||
maxChars={maxChars}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"text-nb-gray-350 font-light flex items-center gap-1 text-xs",
|
||||
variant === "selected" && "text-xs pr-3 font-normal",
|
||||
)}
|
||||
>
|
||||
<TextWithTooltip
|
||||
text={user?.email || "NetBird"}
|
||||
maxChars={maxChars}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -10,17 +10,38 @@ import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
|
||||
type Props<T extends { id?: string }> = {
|
||||
items: T[];
|
||||
onSelect: (item: T) => void;
|
||||
renderItem?: (item: T) => React.ReactNode;
|
||||
renderItem?: (item: T, selected?: boolean) => React.ReactNode;
|
||||
renderHeading?: (item: T) => React.ReactNode;
|
||||
renderBeforeItem?: (item: T) => React.ReactNode;
|
||||
itemClassName?: string;
|
||||
itemWrapperClassName?: string;
|
||||
scrollAreaClassName?: string;
|
||||
maxHeight?: number;
|
||||
estimatedItemHeight?: number;
|
||||
estimatedHeadingHeight?: number;
|
||||
heightAdjustment?: number;
|
||||
groupKey?: (item: T) => string | undefined;
|
||||
};
|
||||
|
||||
export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
items,
|
||||
onSelect,
|
||||
renderItem,
|
||||
renderBeforeItem,
|
||||
renderHeading,
|
||||
itemClassName,
|
||||
itemWrapperClassName,
|
||||
scrollAreaClassName,
|
||||
maxHeight,
|
||||
estimatedItemHeight = 36,
|
||||
estimatedHeadingHeight = 16,
|
||||
heightAdjustment = 8,
|
||||
groupKey,
|
||||
}: Readonly<Props<T>>) {
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const [lastInputMethod, setLastInputMethod] = useState<"mouse" | "keyboard">(
|
||||
"mouse",
|
||||
);
|
||||
const [selected, setSelected] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -37,6 +58,7 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
|
||||
const navigation = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
setLastInputMethod("keyboard");
|
||||
if (items.length === 0) return;
|
||||
const length = items.length - 1;
|
||||
if (e.code === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) {
|
||||
@@ -59,39 +81,99 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouse = () => setLastInputMethod("mouse");
|
||||
|
||||
window.addEventListener("keydown", navigation);
|
||||
window.addEventListener("mousemove", handleMouse);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", navigation);
|
||||
window.removeEventListener("mousemove", handleMouse);
|
||||
};
|
||||
}, [navigation]);
|
||||
|
||||
const headingCount = useMemo(() => {
|
||||
if (!groupKey) return 0;
|
||||
|
||||
let count = 0;
|
||||
let prev: string | undefined;
|
||||
|
||||
for (const item of items) {
|
||||
const key = groupKey(item);
|
||||
if (key !== prev) {
|
||||
count++;
|
||||
prev = key;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}, [items, groupKey]);
|
||||
|
||||
const renderMemoizedItem = useMemo(() => renderItem, [renderItem]);
|
||||
|
||||
const scrollAreaHeight = { maxHeight: maxHeight ?? 195 };
|
||||
|
||||
const virtuosoHeight = {
|
||||
height: Math.min(
|
||||
items.length * estimatedItemHeight +
|
||||
headingCount * estimatedHeadingHeight +
|
||||
+(8 + heightAdjustment),
|
||||
maxHeight ?? 195,
|
||||
),
|
||||
};
|
||||
|
||||
const fixedItemHeight = useMemo(() => {
|
||||
if (!groupKey) return estimatedItemHeight;
|
||||
if (items.length === 0) return 0;
|
||||
const h = virtuosoHeight.height / items.length;
|
||||
if (isNaN(h)) return estimatedItemHeight;
|
||||
return h;
|
||||
}, [estimatedItemHeight, groupKey, items.length, virtuosoHeight.height]);
|
||||
|
||||
return (
|
||||
<MemoizedScrollArea
|
||||
withoutViewport={true}
|
||||
className={"max-h-[195px] flex flex-col gap-1 py-2"}
|
||||
className={cn("flex flex-col gap-1 pt-2", scrollAreaClassName)}
|
||||
style={scrollAreaHeight}
|
||||
>
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
overscan={50}
|
||||
data={items}
|
||||
defaultItemHeight={fixedItemHeight}
|
||||
totalCount={items.length}
|
||||
computeItemKey={(index) => items[index].id as string}
|
||||
context={{ selected, setSelected, onClick: onSelect }}
|
||||
itemContent={(index, option, { selected, setSelected, onClick }) => {
|
||||
const group = groupKey?.(option);
|
||||
const prevGroup =
|
||||
index > 0 ? groupKey?.(items[index - 1]) : undefined;
|
||||
const showHeading = group && group !== prevGroup;
|
||||
|
||||
return (
|
||||
<VirtualScrollListItemWrapper
|
||||
onMouseEnter={() => setSelected(index)}
|
||||
id={option.id}
|
||||
onClick={() => onClick(option)}
|
||||
ariaSelected={selected === index}
|
||||
className={itemClassName}
|
||||
>
|
||||
{renderMemoizedItem ? renderMemoizedItem(option) : option.id}
|
||||
</VirtualScrollListItemWrapper>
|
||||
<div>
|
||||
{showHeading && renderHeading?.(option)}
|
||||
{renderBeforeItem?.(option)}
|
||||
<VirtualScrollListItemWrapper
|
||||
onMouseEnter={() => {
|
||||
if (lastInputMethod === "mouse") {
|
||||
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}
|
||||
</VirtualScrollListItemWrapper>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
style={{ height: 195 }}
|
||||
style={virtuosoHeight}
|
||||
components={{
|
||||
Scroller: MemoizedScrollAreaViewport,
|
||||
}}
|
||||
@@ -107,6 +189,8 @@ type ItemWrapperProps = {
|
||||
onClick?: () => void;
|
||||
ariaSelected?: boolean;
|
||||
className?: string;
|
||||
itemClassName?: string;
|
||||
isLast?: boolean;
|
||||
};
|
||||
|
||||
export const VirtualScrollListItemWrapper = memo(
|
||||
@@ -117,19 +201,21 @@ export const VirtualScrollListItemWrapper = memo(
|
||||
onMouseEnter,
|
||||
ariaSelected,
|
||||
className,
|
||||
itemClassName,
|
||||
isLast,
|
||||
}: ItemWrapperProps) => {
|
||||
return (
|
||||
<div
|
||||
key={id ?? undefined}
|
||||
className={"pr-3 pl-2 webkit-scroll"}
|
||||
onMouseEnter={onMouseEnter}
|
||||
className={cn("pr-3 pl-2 webkit-scroll", isLast && "pb-2", className)}
|
||||
onMouseOver={onMouseEnter}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs flex justify-between py-2 px-3 cursor-pointer items-center rounded-md",
|
||||
"text-xs flex justify-between py-2 px-3 cursor-pointer items-center rounded-md group/list-item",
|
||||
"bg-transparent dark:aria-selected:bg-nb-gray-800/50",
|
||||
className,
|
||||
itemClassName,
|
||||
)}
|
||||
aria-selected={ariaSelected}
|
||||
role={"listitem"}
|
||||
|
||||
@@ -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<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mx-auto relative top-0 z-[52] grid w-full border border-neutral-200 bg-white py-6 dark:shadow-lg shadow-sm duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1 data-[state=open]:slide-in-from-left-1 sm:rounded-lg md:w-full dark:border-nb-gray-900 dark:bg-nb-gray",
|
||||
"mx-auto relative top-0 z-[52] grid w-full focus:outline-0 border border-neutral-200 bg-white py-6 dark:shadow-lg shadow-sm duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1 data-[state=open]:slide-in-from-left-1 sm:rounded-lg md:w-full dark:border-nb-gray-900 dark:bg-nb-gray",
|
||||
className,
|
||||
maxWidthClass,
|
||||
)}
|
||||
@@ -92,6 +93,62 @@ const ModalContent = React.forwardRef<
|
||||
);
|
||||
ModalContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const SidebarModalContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> &
|
||||
ModalContentProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
showClose = true,
|
||||
maxWidthClass = "max-w-3xl",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<ModalPortal>
|
||||
<div
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ml-auto mt-auto relative bottom-0 z-[52] grid w-full border border-zinc-700/40 bg-white py-6 dark:shadow-lg shadow-sm duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-out-to-left-1 data-[state=open]:slide-in-from-left-1 md:w-full dark:border-nb-gray-900 dark:bg-nb-gray",
|
||||
"border-t-0 border-r-0 border-b-0 shadow-2xl",
|
||||
className,
|
||||
maxWidthClass,
|
||||
)}
|
||||
{...props}
|
||||
style={{
|
||||
height: `calc(100vh - ${headerHeight + 100 - 2}px)`,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<>
|
||||
{children}
|
||||
{showClose && (
|
||||
<DialogPrimitive.Close
|
||||
data-cy={"modal-close"}
|
||||
className="absolute right-4 z-10 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</>
|
||||
</DialogPrimitive.Content>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
);
|
||||
},
|
||||
);
|
||||
SidebarModalContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
type ModalFooterProps = {
|
||||
variant?: "setup" | "default";
|
||||
separator?: boolean;
|
||||
@@ -158,4 +215,5 @@ export {
|
||||
ModalPortal,
|
||||
ModalTitle,
|
||||
ModalTrigger,
|
||||
SidebarModalContent,
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function ModalHeader({
|
||||
center,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className={cn(className, "min-w-0")}>
|
||||
<div className={cn(className, "min-w-0 relative z-[1]")}>
|
||||
<div className={"flex items-start gap-5 min-w-0"}>
|
||||
{icon && <SquareIcon color={color} icon={icon} />}
|
||||
<div className={cn("min-w-0", center && "text-center")}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Button from "@components/Button";
|
||||
import Button, { ButtonVariants } from "@components/Button";
|
||||
import { CommandItem } from "@components/Command";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
|
||||
@@ -36,6 +36,7 @@ interface SelectDropdownProps {
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
isLoading?: boolean;
|
||||
variant?: ButtonVariants["variant"];
|
||||
}
|
||||
|
||||
export function SelectDropdown({
|
||||
@@ -49,15 +50,13 @@ export function SelectDropdown({
|
||||
placeholder = "Select...",
|
||||
searchPlaceholder = "Search...",
|
||||
isLoading = false,
|
||||
}: SelectDropdownProps) {
|
||||
variant = "input",
|
||||
}: Readonly<SelectDropdownProps>) {
|
||||
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
|
||||
|
||||
const toggle = (selectedValue: string) => {
|
||||
const isSelected = value == selectedValue;
|
||||
if (isSelected) {
|
||||
} else {
|
||||
onChange && onChange(selectedValue);
|
||||
}
|
||||
if (!isSelected) onChange?.(selectedValue);
|
||||
setTimeout(() => {
|
||||
setSearch("");
|
||||
}, 100);
|
||||
@@ -66,18 +65,6 @@ export function SelectDropdown({
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const [slice, setSlice] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
setSlice(options.length);
|
||||
}, 100);
|
||||
} else {
|
||||
setSlice(10);
|
||||
}
|
||||
}, [open, options]);
|
||||
|
||||
const selected = options.find((o) => o.value === value);
|
||||
|
||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||
@@ -96,7 +83,6 @@ export function SelectDropdown({
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
setSlice(10);
|
||||
if (!isOpen) {
|
||||
setTimeout(() => {
|
||||
setSearch("");
|
||||
@@ -107,7 +93,7 @@ export function SelectDropdown({
|
||||
>
|
||||
<PopoverTrigger asChild={true} disabled={disabled || isLoading}>
|
||||
<Button
|
||||
variant={"input"}
|
||||
variant={variant}
|
||||
disabled={disabled || isLoading}
|
||||
ref={inputRef}
|
||||
className={"w-full"}
|
||||
@@ -146,7 +132,7 @@ export function SelectDropdown({
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950"
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950 focus:outline-none"
|
||||
style={{
|
||||
width: popoverWidth === "auto" ? width : popoverWidth,
|
||||
}}
|
||||
|
||||
@@ -6,16 +6,26 @@ type Props = {
|
||||
withHeader?: boolean;
|
||||
};
|
||||
|
||||
export default function SkeletonTable({ withHeader = true }: Props) {
|
||||
export default function SkeletonTable({ withHeader = true }: Readonly<Props>) {
|
||||
return (
|
||||
<div className={"w-full"}>
|
||||
{withHeader && <SkeletonTableHeader />}
|
||||
<Skeleton
|
||||
height={48}
|
||||
containerClassName={"flex"}
|
||||
className={cn(withHeader && "mt-8")}
|
||||
/>
|
||||
<div>
|
||||
<div className={"mt-6"}>
|
||||
<TableSkeletonRow />
|
||||
<TableSkeletonRow odd />
|
||||
<TableSkeletonRow />
|
||||
<TableSkeletonRow odd />
|
||||
<TableSkeletonRow />
|
||||
<TableSkeletonRow odd />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableContentSkeleton() {
|
||||
return (
|
||||
<div className={"w-full"}>
|
||||
<div className={"mt-6"}>
|
||||
<TableSkeletonRow />
|
||||
<TableSkeletonRow odd />
|
||||
<TableSkeletonRow />
|
||||
@@ -31,7 +41,7 @@ type RowProps = {
|
||||
odd?: boolean;
|
||||
};
|
||||
|
||||
export function TableSkeletonRow({ odd = false }: RowProps) {
|
||||
export function TableSkeletonRow({ odd = false }: Readonly<RowProps>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { TableContentSkeleton } from "@components/skeletons/SkeletonTable";
|
||||
import DataTableGlobalSearch from "@components/table/DataTableGlobalSearch";
|
||||
import { DataTableHeadingPortal } from "@components/table/DataTableHeadingPortal";
|
||||
import { DataTablePagination } from "@components/table/DataTablePagination";
|
||||
@@ -40,10 +40,10 @@ import {
|
||||
import { FilterFn } from "@tanstack/table-core";
|
||||
import { cn, removeAllSpaces } from "@utils/helpers";
|
||||
import dayjs from "dayjs";
|
||||
import { trim } from "lodash";
|
||||
import { isEqual, trim } from "lodash";
|
||||
import { usePathname } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
|
||||
declare module "@tanstack/table-core" {
|
||||
@@ -84,7 +84,6 @@ const isWithinRange: FilterFn<any> = (
|
||||
) => {
|
||||
const date = dayjs(row.getValue(columnId));
|
||||
const [start, end] = value;
|
||||
//If one filter defined and date is null filter it
|
||||
if ((start || end) && !date) return false;
|
||||
if (start && !end) {
|
||||
return date.isAfter(dayjs(start));
|
||||
@@ -101,7 +100,7 @@ const arrIncludesSomeExact: FilterFn<any> = (
|
||||
value: string[],
|
||||
) => {
|
||||
const rowValue = row.getValue(columnId);
|
||||
if (!rowValue) return false;
|
||||
if (!rowValue && rowValue !== 0) return false;
|
||||
return value.some((val) => val === rowValue);
|
||||
};
|
||||
|
||||
@@ -147,6 +146,8 @@ interface DataTableProps<TData, TValue> {
|
||||
showSearchAndFilters?: boolean;
|
||||
rightSide?: (table: TanStackTable<TData>) => React.ReactNode;
|
||||
manualPagination?: boolean;
|
||||
manualFiltering?: boolean;
|
||||
manualColumnFiltering?: boolean;
|
||||
showHeader?: boolean;
|
||||
rowSelection?: RowSelectionState;
|
||||
setRowSelection?: React.Dispatch<React.SetStateAction<RowSelectionState>>;
|
||||
@@ -163,14 +164,23 @@ interface DataTableProps<TData, TValue> {
|
||||
initialPageSize?: number;
|
||||
uniqueKey?: string;
|
||||
resetRowSelectionOnSearch?: boolean;
|
||||
pageCount?: number;
|
||||
pagination?: { pageIndex: number; pageSize: number };
|
||||
onPaginationChange?: (pagination: {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
}) => void;
|
||||
totalRecords?: number;
|
||||
globalFilter?: string;
|
||||
onGlobalFilterChange?: (value: string) => void;
|
||||
columnFilters?: ColumnFiltersState;
|
||||
onColumnFiltersChange?: (filters: ColumnFiltersState) => void;
|
||||
initialFilters?: ColumnFiltersState;
|
||||
initialSearch?: string;
|
||||
onSearchClick?: () => void;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>(props: DataTableProps<TData, TValue>) {
|
||||
if (props.isLoading) return <SkeletonTable withHeader={!props.minimal} />;
|
||||
return <DataTableContent {...props} />;
|
||||
}
|
||||
|
||||
export function DataTableContent<TData, TValue>({
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
children,
|
||||
@@ -196,6 +206,8 @@ export function DataTableContent<TData, TValue>({
|
||||
searchClassName,
|
||||
rightSide,
|
||||
manualPagination = false,
|
||||
manualFiltering = false,
|
||||
manualColumnFiltering = false,
|
||||
showHeader = true,
|
||||
rowSelection,
|
||||
setRowSelection,
|
||||
@@ -212,18 +224,33 @@ export function DataTableContent<TData, TValue>({
|
||||
initialPageSize = 10,
|
||||
uniqueKey,
|
||||
resetRowSelectionOnSearch = true,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
pageCount,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
totalRecords,
|
||||
globalFilter,
|
||||
onGlobalFilterChange,
|
||||
columnFilters: externalColumnFilters,
|
||||
onColumnFiltersChange: externalOnColumnFiltersChange,
|
||||
initialFilters,
|
||||
initialSearch,
|
||||
onSearchClick,
|
||||
}: Readonly<DataTableProps<TData, TValue>>) {
|
||||
const path = usePathname();
|
||||
const isInitialRender = useRef(true);
|
||||
|
||||
const [columnFilters, setColumnFilters] = useLocalStorage<ColumnFiltersState>(
|
||||
`netbird-table-columns${uniqueKey ? "/" + (uniqueKey as string) : path}`,
|
||||
[],
|
||||
keepStateInLocalStorage,
|
||||
);
|
||||
const [globalSearch, setGlobalSearch] = useLocalStorage(
|
||||
const [localColumnFilters, setLocalColumnFilters] =
|
||||
useLocalStorage<ColumnFiltersState>(
|
||||
`netbird-table-columns${uniqueKey ? "/" + (uniqueKey as string) : path}`,
|
||||
[],
|
||||
keepStateInLocalStorage && !manualColumnFiltering,
|
||||
initialFilters,
|
||||
);
|
||||
const [localGlobalSearch, setLocalGlobalSearch] = useLocalStorage(
|
||||
`netbird-table-search${uniqueKey ? "/" + (uniqueKey as string) : path}`,
|
||||
"",
|
||||
keepStateInLocalStorage,
|
||||
globalFilter || "",
|
||||
keepStateInLocalStorage && !manualFiltering,
|
||||
initialSearch,
|
||||
);
|
||||
|
||||
const [paginationState, setPaginationState] =
|
||||
@@ -232,10 +259,10 @@ export function DataTableContent<TData, TValue>({
|
||||
uniqueKey ? "/" + (uniqueKey as string) : path
|
||||
}`,
|
||||
{
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
pageIndex: pagination?.pageIndex ?? 0,
|
||||
pageSize: pagination?.pageSize ?? initialPageSize,
|
||||
},
|
||||
keepStateInLocalStorage,
|
||||
keepStateInLocalStorage && !manualPagination,
|
||||
);
|
||||
|
||||
const hasInitialData = !!(data && data.length > 0);
|
||||
@@ -253,19 +280,30 @@ export function DataTableContent<TData, TValue>({
|
||||
autoResetAll: false,
|
||||
autoResetExpanded: false,
|
||||
manualPagination: manualPagination,
|
||||
manualFiltering: manualFiltering || manualColumnFiltering,
|
||||
pageCount: pageCount,
|
||||
state: {
|
||||
sorting,
|
||||
rowSelection: rowSelection ?? {},
|
||||
columnFilters,
|
||||
columnFilters: manualColumnFiltering
|
||||
? externalColumnFilters || []
|
||||
: localColumnFilters,
|
||||
columnVisibility: columnVisibility,
|
||||
globalFilter: globalSearch,
|
||||
pagination: paginationState,
|
||||
globalFilter: manualFiltering ? globalFilter : localGlobalSearch,
|
||||
pagination: manualPagination
|
||||
? {
|
||||
pageIndex: pagination?.pageIndex ?? 0,
|
||||
pageSize: pagination?.pageSize ?? initialPageSize,
|
||||
}
|
||||
: paginationState,
|
||||
},
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageIndex: 0,
|
||||
pageSize: initialPageSize || 10,
|
||||
pageIndex: pagination?.pageIndex ?? 0,
|
||||
pageSize: pagination?.pageSize ?? initialPageSize,
|
||||
},
|
||||
columnFilters: initialFilters,
|
||||
globalFilter: initialSearch,
|
||||
},
|
||||
sortingFns: {
|
||||
checkbox: checkboxSort,
|
||||
@@ -273,8 +311,36 @@ export function DataTableContent<TData, TValue>({
|
||||
getRowId: useRowId ? (row) => row.id : undefined,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onPaginationChange: setPaginationState,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onPaginationChange: (updater) => {
|
||||
if (manualPagination) {
|
||||
if (isInitialRender.current) {
|
||||
isInitialRender.current = false;
|
||||
return;
|
||||
}
|
||||
if (typeof updater === "function") {
|
||||
const newState = updater(pagination!);
|
||||
onPaginationChange?.(newState);
|
||||
} else {
|
||||
onPaginationChange?.(updater);
|
||||
}
|
||||
} else {
|
||||
setPaginationState(updater);
|
||||
}
|
||||
},
|
||||
onColumnFiltersChange: (filters) => {
|
||||
if (manualColumnFiltering) {
|
||||
externalOnColumnFiltersChange?.(filters as ColumnFiltersState);
|
||||
} else {
|
||||
setLocalColumnFilters(filters as ColumnFiltersState);
|
||||
}
|
||||
},
|
||||
onGlobalFilterChange: (value) => {
|
||||
if (manualFiltering) {
|
||||
onGlobalFilterChange?.(value);
|
||||
} else {
|
||||
setLocalGlobalSearch(value);
|
||||
}
|
||||
},
|
||||
globalFilterFn: fuzzyFilter,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
@@ -298,12 +364,53 @@ export function DataTableContent<TData, TValue>({
|
||||
*/
|
||||
const resetFilters = () => {
|
||||
table.setPageIndex(0);
|
||||
setColumnFilters([]);
|
||||
setGlobalSearch("");
|
||||
if (manualColumnFiltering) {
|
||||
externalOnColumnFiltersChange?.([]);
|
||||
} else {
|
||||
setLocalColumnFilters([]);
|
||||
}
|
||||
if (manualFiltering) {
|
||||
onGlobalFilterChange?.("");
|
||||
} else {
|
||||
setLocalGlobalSearch("");
|
||||
}
|
||||
setRowSelection?.({});
|
||||
onFilterReset?.();
|
||||
setSearchKey((prev) => (prev === 0 ? 1 : 0));
|
||||
};
|
||||
|
||||
const [searchKey, setSearchKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (manualPagination && pagination) {
|
||||
const currentPagination = table.getState().pagination;
|
||||
if (isEqual(currentPagination, pagination)) return;
|
||||
|
||||
table.setPagination({
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
}
|
||||
}, [manualPagination, pagination, table]);
|
||||
|
||||
useEffect(() => {
|
||||
if (manualFiltering && globalFilter !== undefined) {
|
||||
const currentGlobalFilter = table.getState().globalFilter;
|
||||
if (currentGlobalFilter !== globalFilter) {
|
||||
table.setGlobalFilter(globalFilter);
|
||||
}
|
||||
}
|
||||
}, [manualFiltering, globalFilter, table]);
|
||||
|
||||
useEffect(() => {
|
||||
if (manualColumnFiltering && externalColumnFilters) {
|
||||
const currentFilters = table.getState().columnFilters;
|
||||
if (!isEqual(currentFilters, externalColumnFilters)) {
|
||||
table.setColumnFilters(externalColumnFilters);
|
||||
}
|
||||
}
|
||||
}, [manualColumnFiltering, externalColumnFilters, table]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative table-fixed-scroll", className)}>
|
||||
{showSearchAndFilters && (
|
||||
@@ -315,44 +422,48 @@ export function DataTableContent<TData, TValue>({
|
||||
>
|
||||
<DataTableGlobalSearch
|
||||
className={searchClassName}
|
||||
disabled={!hasInitialData}
|
||||
globalSearch={globalSearch}
|
||||
disabled={false} // Never disable the search input
|
||||
key={searchKey}
|
||||
onClick={onSearchClick}
|
||||
isLoading={isLoading}
|
||||
globalSearch={
|
||||
manualFiltering ? globalFilter || "" : localGlobalSearch
|
||||
}
|
||||
setGlobalSearch={(val) => {
|
||||
table.setPageIndex(0);
|
||||
setGlobalSearch(val);
|
||||
if (manualFiltering) {
|
||||
onGlobalFilterChange?.(val);
|
||||
} else {
|
||||
setLocalGlobalSearch(val);
|
||||
}
|
||||
resetRowSelectionOnSearch && setRowSelection?.({});
|
||||
}}
|
||||
placeholder={searchPlaceholder}
|
||||
/>
|
||||
{children && children(table)}
|
||||
{children?.(table)}
|
||||
{showResetFilterButton && (
|
||||
<DataTableResetFilterButton onClick={resetFilters} table={table} />
|
||||
)}
|
||||
<div className={"flex gap-4 flex-wrap grow"}>
|
||||
<div className={"flex gap-4 flex-wrap"}></div>
|
||||
{rightSide && rightSide(table)}
|
||||
{rightSide?.(table)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{aboveTable && aboveTable(table)}
|
||||
{aboveTable?.(table)}
|
||||
|
||||
{!hasInitialData && !isLoading && (
|
||||
<TableWrapper
|
||||
wrapperComponent={wrapperComponent}
|
||||
wrapperProps={wrapperProps}
|
||||
>
|
||||
{getStartedCard}
|
||||
</TableWrapper>
|
||||
)}
|
||||
|
||||
{hasInitialData && !isLoading && (
|
||||
<TableWrapper
|
||||
wrapperComponent={wrapperComponent}
|
||||
wrapperProps={wrapperProps}
|
||||
>
|
||||
<TableWrapper
|
||||
wrapperComponent={wrapperComponent}
|
||||
wrapperProps={wrapperProps}
|
||||
>
|
||||
{isLoading ? (
|
||||
<TableContentSkeleton />
|
||||
) : !hasInitialData ? (
|
||||
getStartedCard
|
||||
) : (
|
||||
<TableComponent
|
||||
className={cn("relative mt-8", tableClassName)}
|
||||
className={cn("relative mt-6", tableClassName)}
|
||||
minimal={minimal}
|
||||
>
|
||||
{showHeader && as == "table" && (
|
||||
@@ -394,94 +505,99 @@ export function DataTableContent<TData, TValue>({
|
||||
)}
|
||||
>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<AccordionItem
|
||||
value={row.original.id}
|
||||
asChild={true}
|
||||
key={row.original.id}
|
||||
>
|
||||
<>
|
||||
<TableRowComponent
|
||||
minimal={minimal}
|
||||
data-row-id={row.original.id}
|
||||
className={cn(
|
||||
(onRowClick || renderExpandedRow) &&
|
||||
"cursor-pointer relative group/accordion",
|
||||
rowClassName,
|
||||
)}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
data-accordion={
|
||||
accordion?.includes(row.original.id)
|
||||
? "opened"
|
||||
: "closed"
|
||||
}
|
||||
onClick={(e) => {
|
||||
if (renderExpandedRow) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setAccordion((prev) => {
|
||||
if (prev?.includes(row.original.id)) {
|
||||
return prev.filter(
|
||||
(item) => item !== row.original.id,
|
||||
);
|
||||
} else {
|
||||
return [...(prev ?? []), row.original.id];
|
||||
}
|
||||
});
|
||||
table.getRowModel().rows.map((row) => {
|
||||
const expandedRow = renderExpandedRow?.(row.original);
|
||||
return (
|
||||
<AccordionItem
|
||||
value={row.original.id}
|
||||
asChild={true}
|
||||
key={row.id}
|
||||
>
|
||||
<>
|
||||
<TableRowComponent
|
||||
minimal={minimal}
|
||||
data-row-id={row.original.id}
|
||||
className={cn(
|
||||
(onRowClick || renderExpandedRow) &&
|
||||
"relative group/accordion",
|
||||
(onRowClick || expandedRow) && "cursor-pointer",
|
||||
rowClassName,
|
||||
)}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
data-accordion={
|
||||
accordion?.includes(row.original.id)
|
||||
? "opened"
|
||||
: "closed"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCellComponent
|
||||
key={cell.id}
|
||||
className={cn("relative", tableCellClassName)}
|
||||
minimal={minimal}
|
||||
inset={inset}
|
||||
onClick={() => {
|
||||
onRowClick && onRowClick(row, cell.column.id);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"absolute left-0 top-0 w-full h-full z-0"
|
||||
onClick={(e) => {
|
||||
if (expandedRow) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setAccordion((prev) => {
|
||||
if (prev?.includes(row.original.id)) {
|
||||
return prev.filter(
|
||||
(item) => item !== row.original.id,
|
||||
);
|
||||
} else {
|
||||
return [...(prev ?? []), row.original.id];
|
||||
}
|
||||
></div>
|
||||
<div className={"relative z-[1]"}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</div>
|
||||
</TableCellComponent>
|
||||
))}
|
||||
</>
|
||||
</TableRowComponent>
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCellComponent
|
||||
key={cell.id}
|
||||
className={cn("relative", tableCellClassName)}
|
||||
minimal={minimal}
|
||||
inset={inset}
|
||||
onClick={() => {
|
||||
onRowClick &&
|
||||
onRowClick(row, cell.column.id);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"absolute left-0 top-0 w-full h-full z-0"
|
||||
}
|
||||
></div>
|
||||
<div className={"relative z-[1]"}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</div>
|
||||
</TableCellComponent>
|
||||
))}
|
||||
</>
|
||||
</TableRowComponent>
|
||||
|
||||
{renderExpandedRow && (
|
||||
<AccordionContent asChild={true}>
|
||||
<TableRowComponent
|
||||
data-row-id={row.id + "-expanded-row"}
|
||||
key={row.id + "-expanded-row"}
|
||||
minimal={minimal}
|
||||
className={cn(
|
||||
onRowClick && "cursor-pointer relative",
|
||||
rowClassName,
|
||||
)}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
<TableDataUnstyledComponent
|
||||
className={"w-full"}
|
||||
colSpan={row.getVisibleCells().length}
|
||||
{expandedRow && (
|
||||
<AccordionContent asChild={true}>
|
||||
<TableRowComponent
|
||||
data-row-id={row.id + "-expanded-row"}
|
||||
key={row.id + "-expanded-row"}
|
||||
minimal={minimal}
|
||||
className={cn(
|
||||
onRowClick && "cursor-pointer relative",
|
||||
rowClassName,
|
||||
)}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{renderExpandedRow(row.original)}
|
||||
</TableDataUnstyledComponent>
|
||||
</TableRowComponent>
|
||||
</AccordionContent>
|
||||
)}
|
||||
</>
|
||||
</AccordionItem>
|
||||
))
|
||||
<TableDataUnstyledComponent
|
||||
className={"w-full"}
|
||||
colSpan={row.getVisibleCells().length}
|
||||
>
|
||||
{expandedRow}
|
||||
</TableDataUnstyledComponent>
|
||||
</TableRowComponent>
|
||||
</AccordionContent>
|
||||
)}
|
||||
</>
|
||||
</AccordionItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRowUnstyledComponent>
|
||||
<TableCellComponent
|
||||
@@ -495,14 +611,15 @@ export function DataTableContent<TData, TValue>({
|
||||
</TableBodyComponent>
|
||||
</Accordion>
|
||||
</TableComponent>
|
||||
</TableWrapper>
|
||||
)}
|
||||
)}
|
||||
</TableWrapper>
|
||||
|
||||
<div className={paginationClassName}>
|
||||
<DataTablePagination
|
||||
table={table}
|
||||
text={text}
|
||||
paginationPadding={paginationPaddingClassName}
|
||||
totalRecords={totalRecords}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
276
src/components/table/DataTableFilter.tsx
Normal file
276
src/components/table/DataTableFilter.tsx
Normal file
@@ -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<TData> {
|
||||
table: Table<TData>;
|
||||
filters: Filter<TData>[];
|
||||
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<TData> {
|
||||
columnId: keyof TData | string;
|
||||
group?: string;
|
||||
item: (item: TData, value: string) => string | React.ReactNode;
|
||||
exclude?: string[];
|
||||
}
|
||||
|
||||
interface FilterItem<TData> {
|
||||
id: string;
|
||||
value: string;
|
||||
showGroupHeading: boolean;
|
||||
columnId: keyof TData | string;
|
||||
group?: string;
|
||||
original: TData;
|
||||
renderItem: () => React.ReactNode;
|
||||
}
|
||||
|
||||
type SearchPredicate<TData> = (
|
||||
item: FilterItem<TData>,
|
||||
query: string,
|
||||
) => boolean;
|
||||
|
||||
const searchPredicate: SearchPredicate<any> = (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
|
||||
* <DataTableFilter table={table} disabled={false}
|
||||
* filters={[{
|
||||
* columnId: "name",
|
||||
* group: "Users",
|
||||
* item: (item) => item.name,
|
||||
* }]}
|
||||
* />
|
||||
*/
|
||||
export function DataTableFilter<TData>({
|
||||
table,
|
||||
filters,
|
||||
disabled = false,
|
||||
}: Readonly<Props<TData>>) {
|
||||
const searchRef = React.useRef<HTMLInputElement>(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<string, number> = {};
|
||||
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<TData>;
|
||||
});
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const [filteredItems, search, setSearch] = useSearch<FilterItem<TData>>(
|
||||
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<TData>) => {
|
||||
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 (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild={true}>
|
||||
<Button variant={"secondary"} disabled={disabled}>
|
||||
<FilterIcon size={15} className={"shrink-0"} />
|
||||
<span>
|
||||
<span className={"text-white"}>
|
||||
{activeFiltersCount > 0 && activeFiltersCount}
|
||||
</span>
|
||||
{activeFiltersCount > 0 ? ` Filter(s)` : "Filter"}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
hideWhenDetached={false}
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950"
|
||||
style={{
|
||||
width: "400px",
|
||||
}}
|
||||
align="start"
|
||||
side={"bottom"}
|
||||
sideOffset={10}
|
||||
>
|
||||
<div className={"w-full"}>
|
||||
<DropdownInput
|
||||
ref={searchRef}
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={"Search filters..."}
|
||||
hideEnterIcon={true}
|
||||
/>
|
||||
|
||||
{filteredItems.length == 0 && search != "" && (
|
||||
<DropdownInfoText className={"mb-4"}>
|
||||
There are no filters matching your search.
|
||||
</DropdownInfoText>
|
||||
)}
|
||||
|
||||
<VirtualScrollAreaList
|
||||
items={filteredItems}
|
||||
maxHeight={270}
|
||||
scrollAreaClassName={"pt-0"}
|
||||
renderItem={(option) => {
|
||||
const currentTableFilters = getCurrentTableFilters(
|
||||
option.columnId as string,
|
||||
);
|
||||
const isActive = currentTableFilters?.includes(option.value);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2 justify-between w-full"
|
||||
}
|
||||
key={option.id}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-2 whitespace-nowrap text-xs font-normal tracking-wide"
|
||||
}
|
||||
>
|
||||
<div>{option?.renderItem()}</div>
|
||||
</div>
|
||||
<Checkbox checked={isActive} />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const ListItemHeading = ({
|
||||
children,
|
||||
show = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
show: boolean;
|
||||
}) => {
|
||||
if (!show) return null;
|
||||
return (
|
||||
<p
|
||||
className={
|
||||
"!text-nb-gray-400 text-xs uppercase font-medium tracking-wider pb-1 pl-5 mb-.5 mt-4"
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
@@ -1,38 +1,74 @@
|
||||
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<HTMLInputElement> {
|
||||
setGlobalSearch: (value: string) => void;
|
||||
globalSearch?: string;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function DataTableGlobalSearch({
|
||||
setGlobalSearch,
|
||||
globalSearch,
|
||||
className = "min-w-[300px] max-w-[400px] grow",
|
||||
isLoading,
|
||||
onClick,
|
||||
...props
|
||||
}: Props) {
|
||||
}: Readonly<Props>) {
|
||||
const ref = React.useRef<HTMLInputElement>(null);
|
||||
const [inputValue, setInputValue] = useState(globalSearch || "");
|
||||
const debouncedValue = useDebounce(inputValue, 800);
|
||||
|
||||
// Call setGlobalSearch when debounced value changes
|
||||
useEffect(() => {
|
||||
setGlobalSearch(debouncedValue);
|
||||
}, [debouncedValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (globalSearch !== undefined && globalSearch !== inputValue) {
|
||||
setInputValue(globalSearch);
|
||||
}
|
||||
}, [globalSearch]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setGlobalSearch(e.target.value);
|
||||
setInputValue(e.target.value);
|
||||
};
|
||||
|
||||
useHotkeys("mod+k", () => ref.current?.focus(), []);
|
||||
useHotkeys(
|
||||
"mod+k",
|
||||
() => {
|
||||
if (onClick) {
|
||||
onClick?.();
|
||||
} else {
|
||||
ref.current?.focus();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
ref={ref}
|
||||
onFocus={(e) => {
|
||||
if (onClick) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
}
|
||||
}}
|
||||
icon={<Search size={15} />}
|
||||
value={globalSearch}
|
||||
value={inputValue} // Shows immediate updates
|
||||
onChange={handleChange}
|
||||
maxWidthClass={className}
|
||||
customSuffix={<Kbd>⌘ K</Kbd>}
|
||||
disabled={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,65 +12,78 @@ interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>;
|
||||
text?: string;
|
||||
paginationPadding?: string;
|
||||
totalRecords?: number;
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
text = "rows",
|
||||
paginationPadding = "px-8 py-8",
|
||||
totalRecords,
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const allRows = table.getFilteredRowModel().rows.length;
|
||||
const rowsPerPage = table.getState().pagination.pageSize;
|
||||
const currentPage = table.getState().pagination.pageIndex + 1;
|
||||
const isLastPage = currentPage === table.getPageCount();
|
||||
const showingFrom = (currentPage - 1) * rowsPerPage + 1;
|
||||
const showingTo = isLastPage ? allRows : showingFrom + rowsPerPage - 1;
|
||||
const pageCount = table.getPageCount();
|
||||
|
||||
return pageCount > 1 ? (
|
||||
<div className={cn("flex items-center justify-between", paginationPadding)}>
|
||||
<div className="text-nb-gray-400">
|
||||
Showing{" "}
|
||||
<span className={"font-medium text-white"}>
|
||||
{showingFrom} to {showingTo}
|
||||
</span>{" "}
|
||||
of <span className={"font-medium text-white"}>{allRows}</span> {text}
|
||||
</div>
|
||||
<div className={"flex items-center gap-3"}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<ButtonGroup>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronsLeft size={16} />
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button>
|
||||
<div>
|
||||
{table.getState().pagination.pageIndex + 1} of {pageCount}
|
||||
</div>
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronsRight size={18} />
|
||||
</ButtonGroup.Button>
|
||||
</ButtonGroup>
|
||||
const totalRows =
|
||||
totalRecords !== undefined
|
||||
? totalRecords
|
||||
: table.getFilteredRowModel().rows.length;
|
||||
|
||||
const showingFrom = totalRows === 0 ? 0 : (currentPage - 1) * rowsPerPage + 1;
|
||||
const showingTo = Math.min(currentPage * rowsPerPage, totalRows);
|
||||
|
||||
return (
|
||||
pageCount > 1 && (
|
||||
<div
|
||||
className={cn("flex items-center justify-between", paginationPadding)}
|
||||
>
|
||||
<div className="text-nb-gray-400">
|
||||
Showing{" "}
|
||||
<span className={"font-medium text-white"}>
|
||||
{showingFrom} to {showingTo}
|
||||
</span>{" "}
|
||||
of <span className={"font-medium text-white"}>{totalRows}</span>{" "}
|
||||
{text}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<div className={"flex items-center gap-3"}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<ButtonGroup>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronsLeft size={16} />
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button>
|
||||
<div>
|
||||
{currentPage} of {pageCount}
|
||||
</div>
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronsRight size={18} />
|
||||
</ButtonGroup.Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,48 +8,54 @@ import { useState } from "react";
|
||||
interface Props<TData> {
|
||||
table: Table<TData>;
|
||||
onClick: () => void;
|
||||
hasServerSideFilters?: boolean;
|
||||
}
|
||||
|
||||
export default function DataTableResetFilterButton<TData>({
|
||||
table,
|
||||
onClick,
|
||||
hasServerSideFilters = undefined,
|
||||
}: Props<TData>) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const isDisabled =
|
||||
table.getState().columnFilters.length <= 0 &&
|
||||
table.getState().globalFilter === "";
|
||||
|
||||
return !isDisabled ? (
|
||||
<Tooltip delayDuration={1}>
|
||||
<TooltipTrigger
|
||||
asChild={true}
|
||||
onMouseOver={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
className={"h-[42px]"}
|
||||
variant={"secondary"}
|
||||
disabled={isDisabled}
|
||||
onClick={onClick}
|
||||
const hasClientSideFilters =
|
||||
table.getState().globalFilter !== "" ||
|
||||
table.getState().columnFilters.length > 0;
|
||||
|
||||
const showButton = hasServerSideFilters ?? hasClientSideFilters;
|
||||
|
||||
return (
|
||||
showButton && (
|
||||
<Tooltip delayDuration={1}>
|
||||
<TooltipTrigger
|
||||
asChild={true}
|
||||
onMouseOver={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<FilterX size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<Button
|
||||
className={"h-[42px]"}
|
||||
variant={"secondary"}
|
||||
onClick={onClick}
|
||||
>
|
||||
<FilterX size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent
|
||||
sideOffset={10}
|
||||
className={"px-3 py-2"}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (hovered) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<span className={"text-xs text-neutral-300"}>
|
||||
Reset Filters & Search
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null;
|
||||
<TooltipContent
|
||||
sideOffset={10}
|
||||
className={"px-3 py-2"}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (hovered) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<span className={"text-xs text-neutral-300"}>
|
||||
Reset Filters & Search
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
87
src/components/ui/AbsoluteDateTimeInput.tsx
Normal file
87
src/components/ui/AbsoluteDateTimeInput.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useEffect } from "react";
|
||||
import { DateRange } from "react-day-picker";
|
||||
import { useTimescape } from "timescape/react";
|
||||
|
||||
type Props = {
|
||||
value?: DateRange;
|
||||
onChange?: (range: DateRange | undefined) => void;
|
||||
};
|
||||
export const AbsoluteDateTimeInput = ({ value, onChange }: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"px-4 py-4 flex flex-wrap gap-2 sm:max-w-none border-t border-nb-gray-800"
|
||||
}
|
||||
>
|
||||
<div className={"flex items-center gap-2 w-full justify-between"}>
|
||||
<div className={"text-sm flex flex-col gap-1 text-nb-gray-300"}>
|
||||
<Time
|
||||
value={value?.from}
|
||||
onChange={(e) => {
|
||||
if (e?.getTime() === value?.from?.getTime()) return;
|
||||
onChange?.({ from: e, to: value?.to });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className={"text-nb-gray-300"}>-</span>
|
||||
<div className={"text-sm flex flex-col gap-1 text-nb-gray-300"}>
|
||||
<Time
|
||||
value={value?.to}
|
||||
onChange={(e) => {
|
||||
if (e?.getTime() === value?.to?.getTime()) return;
|
||||
onChange?.({ from: value?.from, to: e });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className={"timescape w-full"} {...getRootProps()}>
|
||||
<div>
|
||||
<input {...getInputProps("years")} />
|
||||
<span className={"separator"}>/</span>
|
||||
<input {...getInputProps("months")} />
|
||||
<span className={"separator"}>/</span>
|
||||
<input {...getInputProps("days")} />
|
||||
</div>
|
||||
<span className={"separator px-1"}>⋆</span>
|
||||
<div>
|
||||
<input {...getInputProps("hours")} />
|
||||
<span className={"separator"}>:</span>
|
||||
<input {...getInputProps("minutes")} />
|
||||
<span className={"separator"}>:</span>
|
||||
<input {...getInputProps("seconds")} />
|
||||
<input {...getInputProps("am/pm")} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,7 @@ 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 { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
|
||||
@@ -12,28 +12,41 @@ function AddPeerButton() {
|
||||
const { data: peers } = useFetchApi<Peer[]>("/peers");
|
||||
const { oidcUser: user } = useOidcUser();
|
||||
|
||||
const [hasOnboardingFormCompleted] = useLocalStorage(
|
||||
"netbird-onboarding-modal",
|
||||
false,
|
||||
);
|
||||
|
||||
const [isFirstRun, setIsFirstRun] = useLocalStorage<boolean>(
|
||||
"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 (
|
||||
<Modal open={setupModal} onOpenChange={handleOpenChange}>
|
||||
<ModalTrigger asChild>
|
||||
<Button variant={"primary"} size={"sm"} className={"ml-auto"}>
|
||||
<PlusCircle size={16} />
|
||||
Add Peer
|
||||
</Button>
|
||||
</ModalTrigger>
|
||||
<SetupModal user={user} />
|
||||
</Modal>
|
||||
<>
|
||||
<Modal open={installModal} onOpenChange={handleOpenChange}>
|
||||
<ModalTrigger asChild>
|
||||
<Button variant={"primary"} size={"sm"} className={"ml-auto"}>
|
||||
<PlusCircle size={16} />
|
||||
Add Peer
|
||||
</Button>
|
||||
</ModalTrigger>
|
||||
<SetupModal user={user} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
</h1>
|
||||
<Paragraph className={"justify-center my-3"}>
|
||||
<Paragraph
|
||||
className={cn("justify-center mt-3", button && "mb-3")}
|
||||
>
|
||||
{description}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
|
||||
export const GradientFadedBackground = () => {
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const GradientFadedBackground = ({ className }: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"h-full w-full absolute left-0 top-0 rounded-md overflow-hidden z-0 pointer-events-none"
|
||||
}
|
||||
className={cn(
|
||||
"h-full w-full absolute left-0 top-0 rounded-md overflow-hidden z-0 pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
|
||||
@@ -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<Props>) {
|
||||
const isNew = !group?.id;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={group.id || group.name}
|
||||
key={group.id ?? group.name}
|
||||
useHover={true}
|
||||
data-cy={"group-badge"}
|
||||
variant={"gray-ghost"}
|
||||
@@ -39,7 +45,12 @@ export default function GroupBadge({
|
||||
}}
|
||||
>
|
||||
<GroupBadgeIcon id={group?.id} issued={group?.issued} />
|
||||
<TruncatedText text={group?.name || ""} maxChars={20} />
|
||||
<TruncatedText
|
||||
text={group?.name || ""}
|
||||
maxChars={maxChars}
|
||||
maxWidth={maxWidth}
|
||||
hideTooltip={hideTooltip}
|
||||
/>
|
||||
{children}
|
||||
{isNew && showNewBadge && <SmallBadge />}
|
||||
{showX && (
|
||||
|
||||
@@ -13,6 +13,10 @@ type Props = {
|
||||
onRemove: () => void;
|
||||
onError?: (error: boolean) => void;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
showRemoveButton?: boolean;
|
||||
preventLeadingAndTrailingDots?: boolean;
|
||||
allowWildcard?: boolean;
|
||||
};
|
||||
enum ActionType {
|
||||
ADD = "ADD",
|
||||
@@ -38,6 +42,10 @@ export default function InputDomain({
|
||||
onChange,
|
||||
onRemove,
|
||||
onError,
|
||||
disabled,
|
||||
preventLeadingAndTrailingDots,
|
||||
allowWildcard = true,
|
||||
showRemoveButton = true,
|
||||
}: Readonly<Props>) {
|
||||
const [name, setName] = useState(value?.name || "");
|
||||
|
||||
@@ -50,7 +58,11 @@ export default function InputDomain({
|
||||
if (name == "") {
|
||||
return "";
|
||||
}
|
||||
const valid = validator.isValidDomain(name);
|
||||
const valid = validator.isValidDomain(name, {
|
||||
allowOnlyTld: true,
|
||||
allowWildcard,
|
||||
preventLeadingAndTrailingDots,
|
||||
});
|
||||
if (!valid) {
|
||||
return "Please enter a valid domain, e.g. example.com or intra.example.com";
|
||||
}
|
||||
@@ -74,16 +86,20 @@ export default function InputDomain({
|
||||
value={name}
|
||||
error={domainError}
|
||||
onChange={handleNameChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className={"h-[42px]"}
|
||||
variant={"default-outline"}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<MinusCircleIcon size={15} />
|
||||
</Button>
|
||||
{showRemoveButton && (
|
||||
<Button
|
||||
className={"h-[42px]"}
|
||||
variant={"default-outline"}
|
||||
onClick={onRemove}
|
||||
disabled={disabled}
|
||||
>
|
||||
<MinusCircleIcon size={15} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@ import {
|
||||
} from "@components/Tooltip";
|
||||
import GroupBadge from "@components/ui/GroupBadge";
|
||||
import PeerBadge from "@components/ui/PeerBadge";
|
||||
import { orderBy } from "lodash";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ArrowRightIcon, PencilLineIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
|
||||
@@ -18,32 +19,60 @@ 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) {
|
||||
if (!groups) return <EmptyRow />;
|
||||
const orderedGroups = orderBy(groups, ["peers_count", "name"], ["desc"]);
|
||||
onClick,
|
||||
className,
|
||||
}: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
if (!groups || groups?.length === 0) return <EmptyRow />;
|
||||
const orderedGroups = groups.sort((a, b) => {
|
||||
if (a.name === "All") return 1;
|
||||
if (b.name === "All") return -1;
|
||||
const aPeerCount = a.peers_count ?? 0;
|
||||
const bPeerCount = b.peers_count ?? 0;
|
||||
if (aPeerCount !== bPeerCount) return bPeerCount - aPeerCount;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
const firstGroup = orderedGroups.length > 0 ? orderedGroups[0] : undefined;
|
||||
const otherGroups = orderedGroups.length > 0 ? orderedGroups.slice(1) : [];
|
||||
|
||||
return (
|
||||
<TooltipProvider disableHoverableContent={false}>
|
||||
<Tooltip delayDuration={1}>
|
||||
<TooltipProvider
|
||||
disableHoverableContent={false}
|
||||
delayDuration={200}
|
||||
skipDelayDuration={200}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<div
|
||||
className={"inline-flex items-center gap-2 z-0"}
|
||||
className={cn("inline-flex items-center gap-2 z-0", className)}
|
||||
data-cy={"multiple-groups"}
|
||||
onClick={onClick}
|
||||
>
|
||||
{firstGroup && <GroupBadge group={firstGroup} />}
|
||||
{firstGroup && (
|
||||
<GroupBadge
|
||||
group={firstGroup}
|
||||
className={
|
||||
permission.groups.update ? "group-hover:bg-nb-gray-800" : ""
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{otherGroups && otherGroups.length > 0 && (
|
||||
<Badge
|
||||
variant={"gray-ghost"}
|
||||
useHover={true}
|
||||
className={"px-3 gap-2 whitespace-nowrap"}
|
||||
className={cn(
|
||||
"px-3 gap-2 whitespace-nowrap",
|
||||
permission.groups.update ? "group-hover:bg-nb-gray-800" : "",
|
||||
)}
|
||||
>
|
||||
+ {otherGroups.length}
|
||||
</Badge>
|
||||
@@ -51,7 +80,10 @@ export default function MultipleGroups({
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{orderedGroups && orderedGroups.length > 0 && (
|
||||
<TooltipContent className={"p-0"}>
|
||||
<TooltipContent
|
||||
className={"p-0"}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={"text-sm font-medium text-left px-5 pt-3"}>
|
||||
{label}
|
||||
</div>
|
||||
@@ -85,3 +117,15 @@ export default function MultipleGroups({
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const TransparentEditIconButton = () => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"h-[34px] w-[34px] !p-0 opacity-0 group-hover:opacity-100 flex items-center justify-center text-nb-gray-400 hover:text-nb-gray-100"
|
||||
}
|
||||
>
|
||||
<PencilLineIcon size={16} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import Button from "@components/Button";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { FilterX } from "lucide-react";
|
||||
import React from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useCallback } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
|
||||
type Props = {
|
||||
@@ -10,6 +12,8 @@ type Props = {
|
||||
description?: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
hasFiltersApplied?: boolean;
|
||||
onResetFilters?: () => void;
|
||||
};
|
||||
export default function NoResults({
|
||||
icon,
|
||||
@@ -17,7 +21,32 @@ export default function NoResults({
|
||||
description = "We couldn't find any results. Please try a different search term or change your filters.",
|
||||
children,
|
||||
className,
|
||||
hasFiltersApplied = false,
|
||||
onResetFilters,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const handleResetClick = useCallback(() => {
|
||||
if (onResetFilters) {
|
||||
onResetFilters();
|
||||
|
||||
const params = new URLSearchParams();
|
||||
|
||||
const page_size = searchParams.get("page_size");
|
||||
|
||||
params.set("page", "1");
|
||||
|
||||
if (page_size) {
|
||||
params.set("page_size", page_size);
|
||||
}
|
||||
|
||||
const newUrl = `${pathname}?${params.toString()}`;
|
||||
router.push(newUrl);
|
||||
}
|
||||
}, [onResetFilters, router, pathname, searchParams]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative overflow-hidden", className)}>
|
||||
<div
|
||||
@@ -49,6 +78,16 @@ export default function NoResults({
|
||||
<Paragraph className={"justify-center my-2 !text-nb-gray-400"}>
|
||||
{description}
|
||||
</Paragraph>
|
||||
{hasFiltersApplied && onResetFilters && (
|
||||
<Button
|
||||
onClick={handleResetClick}
|
||||
variant="secondary"
|
||||
className="mt-4"
|
||||
>
|
||||
<FilterX size={16} />
|
||||
Reset Filters & Search
|
||||
</Button>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
93
src/components/ui/PageNotFound.tsx
Normal file
93
src/components/ui/PageNotFound.tsx
Normal file
@@ -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 (
|
||||
<PageContainer>
|
||||
<div className={"px-8"}>
|
||||
<div
|
||||
className={
|
||||
"absolute left-0 top-0 h-full w-full p-10 flex items-center justify-center mx-auto backdrop-blur-sm"
|
||||
}
|
||||
>
|
||||
<Card className={"relative overflow-hidden max-w-4xl"}>
|
||||
<div
|
||||
className={
|
||||
"absolute z-20 bg-gradient-to-b dark:to-nb-gray-950 dark:from-nb-gray-950/40 w-full h-full"
|
||||
}
|
||||
></div>
|
||||
<div
|
||||
className={
|
||||
"absolute w-full h-full left-0 top-0 z-10 px-5 py-3 overflow-hidden"
|
||||
}
|
||||
>
|
||||
<div className={"flex flex-col gap-2"}>
|
||||
<Skeleton className={"w-full"} height={70} duration={4} />
|
||||
<Skeleton className={"w-full"} height={70} duration={4} />
|
||||
<Skeleton className={"w-full"} height={70} duration={4} />
|
||||
<Skeleton className={"w-full"} height={70} duration={4} />
|
||||
<Skeleton className={"w-full"} height={70} duration={4} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={"w-full h-full z-20 relative left-0 top-0 flex py-8"}
|
||||
>
|
||||
<div className={"inline-flex justify-center w-full"}>
|
||||
<div>
|
||||
<div className={"max-w-2xl relative z-50"}>
|
||||
<div className={"text-center flex flex-col gap-2 p-8"}>
|
||||
<div className={"mx-auto"}>
|
||||
{" "}
|
||||
<SquareIcon
|
||||
icon={<CircleAlertIcon size={20} />}
|
||||
color={"netbird"}
|
||||
size={"large"}
|
||||
/>
|
||||
</div>
|
||||
<div className={"text-center"}>
|
||||
<h1
|
||||
className={
|
||||
"text-3xl font-medium mx-auto mt-3 capitalize"
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
<Paragraph className={"justify-center my-3 max-w-xl"}>
|
||||
{description}
|
||||
</Paragraph>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
className={"mt-3"}
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<Undo2Icon size={15} className={"shrink-0"} />
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,15 @@
|
||||
import Badge from "@components/Badge";
|
||||
import { cn } from "@utils/helpers";
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import LongArrowLeftIcon from "@/assets/icons/LongArrowLeftIcon";
|
||||
import { PolicyRuleResource } from "@/interfaces/Policy";
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean;
|
||||
value: Direction;
|
||||
onChange: (value: Direction) => void;
|
||||
className?: string;
|
||||
destinationResource?: PolicyRuleResource;
|
||||
};
|
||||
|
||||
export type Direction = "bi" | "in" | "out";
|
||||
@@ -17,31 +19,8 @@ export default function PolicyDirection({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: Props) {
|
||||
const toggleIn = () => {
|
||||
if (value == "in") {
|
||||
onChange("out");
|
||||
return;
|
||||
}
|
||||
if (value == "bi") {
|
||||
onChange("out");
|
||||
} else {
|
||||
onChange("bi");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleOut = () => {
|
||||
if (value == "out") {
|
||||
onChange("in");
|
||||
return;
|
||||
}
|
||||
if (value == "bi") {
|
||||
onChange("in");
|
||||
} else {
|
||||
onChange("bi");
|
||||
}
|
||||
};
|
||||
|
||||
destinationResource,
|
||||
}: Readonly<Props>) {
|
||||
const toggleDirection = () => {
|
||||
if (value == "bi") {
|
||||
onChange("in");
|
||||
@@ -55,8 +34,34 @@ export default function PolicyDirection({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [disabled]);
|
||||
|
||||
const topBadgeClass = useMemo(() => {
|
||||
if (destinationResource) return "blueDark";
|
||||
if (value === "bi") return "green";
|
||||
if (value === "in") return "blueDark";
|
||||
return "gray";
|
||||
}, [value, destinationResource]);
|
||||
|
||||
const topArrowClass = useMemo(() => {
|
||||
if (destinationResource) return "fill-sky-500";
|
||||
if (value === "bi") return "fill-green-500";
|
||||
if (value === "in") return "fill-sky-500";
|
||||
return "fill-gray-500";
|
||||
}, [value, destinationResource]);
|
||||
|
||||
const bottomBadgeClass = useMemo(() => {
|
||||
if (destinationResource) return "gray";
|
||||
if (value === "bi") return "green";
|
||||
return "gray";
|
||||
}, [value, destinationResource]);
|
||||
|
||||
const bottomArrowClass = useMemo(() => {
|
||||
if (destinationResource) return "fill-gray-500";
|
||||
if (value === "bi") return "fill-green-500";
|
||||
return "fill-gray-500";
|
||||
}, [value, destinationResource]);
|
||||
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
className={cn(
|
||||
"flex flex-col gap-2 mt-[23px] cursor-pointer select-none",
|
||||
disabled && "opacity-50 pointer-events-none",
|
||||
@@ -66,39 +71,20 @@ export default function PolicyDirection({
|
||||
onClick={toggleDirection}
|
||||
data-cy={"policy-direction"}
|
||||
>
|
||||
<Badge
|
||||
variant={value == "bi" ? "green" : value == "in" ? "blueDark" : "gray"}
|
||||
className={"px-4 py-1"}
|
||||
>
|
||||
<Badge variant={topBadgeClass} className={"px-4 py-1"}>
|
||||
<LongArrowLeftIcon
|
||||
size={40}
|
||||
autoHeight={true}
|
||||
className={cn(
|
||||
value == "bi"
|
||||
? "fill-green-500"
|
||||
: value == "in"
|
||||
? "fill-sky-500"
|
||||
: "fill-gray-500",
|
||||
"rotate-180",
|
||||
)}
|
||||
className={cn(topArrowClass, "rotate-180")}
|
||||
/>
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={value == "bi" ? "green" : value == "out" ? "blueDark" : "gray"}
|
||||
className={"px-4 py-1"}
|
||||
>
|
||||
<Badge variant={bottomBadgeClass} className={"px-4 py-1"}>
|
||||
<LongArrowLeftIcon
|
||||
size={40}
|
||||
autoHeight={true}
|
||||
className={cn(
|
||||
value == "bi"
|
||||
? "fill-green-500"
|
||||
: value == "out"
|
||||
? "fill-sky-500"
|
||||
: "fill-gray-500",
|
||||
)}
|
||||
className={cn(bottomArrowClass)}
|
||||
/>
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
if (hasAccess) return children;
|
||||
|
||||
const isAllowed = loggedInUser
|
||||
? allow.includes(loggedInUser?.role as Role)
|
||||
: false;
|
||||
|
||||
return isAllowed ? (
|
||||
<>{children}</>
|
||||
) : (
|
||||
return (
|
||||
<div className={"px-8"}>
|
||||
<div
|
||||
className={
|
||||
@@ -54,7 +47,7 @@ export const RestrictedAccess = ({
|
||||
<div className={"w-full h-full z-20 relative left-0 top-0 flex py-8"}>
|
||||
<div className={"inline-flex justify-center w-full"}>
|
||||
<div>
|
||||
<div className={"max-w-lg relative z-50"}>
|
||||
<div className={"max-w-xl relative z-50"}>
|
||||
<div className={"text-center flex flex-col gap-2 p-8"}>
|
||||
<div className={"mx-auto"}>
|
||||
{" "}
|
||||
@@ -66,13 +59,13 @@ export const RestrictedAccess = ({
|
||||
</div>
|
||||
<div className={"text-center"}>
|
||||
<h1
|
||||
className={"text-3xl font-medium max-w-lg mx-auto mt-3"}
|
||||
className={"text-3xl font-medium max-w-xl mx-auto mt-3"}
|
||||
>
|
||||
{"You don't have access to"} <br /> {page}
|
||||
</h1>
|
||||
<Paragraph className={"justify-center my-3"}>
|
||||
{
|
||||
"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."
|
||||
}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
@@ -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<typeof smallBadgeVariants>;
|
||||
|
||||
export const SmallBadge = ({
|
||||
text = "NEW",
|
||||
className,
|
||||
textClassName,
|
||||
variant = "green",
|
||||
children,
|
||||
}: Props) => {
|
||||
@@ -33,7 +37,7 @@ export const SmallBadge = ({
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<span className={"relative top-[0.4px]"}>{text}</span>
|
||||
<span className={cn("relative top-[0.4px]", textClassName)}>{text}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function TextWithTooltip({
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={"w-full min-w-0 inline-block"}
|
||||
className={"w-full min-w-0 inline-block leading-normal"}
|
||||
style={{
|
||||
maxWidth: `${maxChars - 2}ch`,
|
||||
}}
|
||||
|
||||
@@ -6,6 +6,7 @@ type Props = {
|
||||
text?: string;
|
||||
className?: string;
|
||||
maxChars?: number;
|
||||
maxWidth?: string; // Optional CSS width value
|
||||
hideTooltip?: boolean;
|
||||
};
|
||||
|
||||
@@ -13,26 +14,42 @@ export default function TruncatedText({
|
||||
text,
|
||||
className,
|
||||
maxChars = 40,
|
||||
maxWidth,
|
||||
hideTooltip = false,
|
||||
}: Props) {
|
||||
}: Readonly<Props>) {
|
||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const contentRef = React.useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
className={"w-full min-w-0 inline-block"}
|
||||
style={{
|
||||
maxWidth: `${maxChars - 2}ch`,
|
||||
}}
|
||||
>
|
||||
<div className={cn(className, "truncate")}>{text}</div>
|
||||
<div className="w-full min-w-0 inline-block" style={containerStyle}>
|
||||
<div ref={contentRef} className={cn(className, "truncate")}>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -45,13 +62,10 @@ export default function TruncatedText({
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<HoverCard.Trigger asChild={true}>
|
||||
<div
|
||||
className={"w-full min-w-0 inline-block"}
|
||||
style={{
|
||||
maxWidth: `${maxChars - 2}ch`,
|
||||
}}
|
||||
>
|
||||
<div className={cn(className, "truncate")}>{text}</div>
|
||||
<div className="w-full min-w-0 inline-block" style={containerStyle}>
|
||||
<div ref={contentRef} className={cn(className, "truncate")}>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
</HoverCard.Trigger>
|
||||
<HoverCard.Portal>
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
<div className={"text-neutral-300 flex flex-col gap-1"}>
|
||||
<div className={"max-w-xs break-all whitespace-normal text-xs"}>
|
||||
<div className="text-neutral-300 flex flex-col gap-1">
|
||||
<div className="max-w-xs break-all whitespace-normal text-xs">
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 ? (
|
||||
<Avatar
|
||||
alt=""
|
||||
img={user?.picture}
|
||||
rounded
|
||||
onError={() => 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)}
|
||||
|
||||
@@ -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 (
|
||||
<DropdownMenu
|
||||
@@ -44,7 +37,7 @@ export default function UserDropdown() {
|
||||
onOpenChange={setDropdownOpen}
|
||||
>
|
||||
<DropdownMenuTrigger>
|
||||
<UserAvatar />
|
||||
<UserAvatar size={"medium"} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
@@ -68,23 +61,18 @@ export default function UserDropdown() {
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{permission.dashboard_view !== "blocked" && (
|
||||
<DropdownMenuItem
|
||||
{!isRestricted && (
|
||||
<ProfileSettingsDropdownItem
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
if (loggedInUser) {
|
||||
router.push(`/team/user?id=${loggedInUser.id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<User2 size={14} />
|
||||
Profile Settings
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
/>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem onClick={logoutSession}>
|
||||
<DropdownMenuItem onClick={logout}>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<LogOutIcon size={14} />
|
||||
Log out
|
||||
@@ -95,3 +83,14 @@ export default function UserDropdown() {
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
const ProfileSettingsDropdownItem = ({ onClick }: { onClick: () => void }) => {
|
||||
return (
|
||||
<DropdownMenuItem onClick={onClick}>
|
||||
<div className={"flex gap-3 items-center"}>
|
||||
<User2 size={14} />
|
||||
Profile Settings
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<Props>) {
|
||||
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 (
|
||||
<AnalyticsContext.Provider
|
||||
value={{ initialized, trackPageView, trackEvent }}
|
||||
value={{
|
||||
initialized,
|
||||
trackPageView,
|
||||
trackEvent,
|
||||
trackEventV2,
|
||||
trackGTMCustomEvent,
|
||||
}}
|
||||
>
|
||||
<GoogleTageManagerBodyScript />
|
||||
{children}
|
||||
</AnalyticsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const GoogleTagManagerHeadScript = () => {
|
||||
if (!config.googleTagManagerID) return null;
|
||||
return (
|
||||
isProduction() && (
|
||||
<Script id="gtm-script" strategy="afterInteractive">
|
||||
{`(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||
})(window,document,'script','dataLayer','${config.googleTagManagerID}');`}
|
||||
</Script>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const GoogleTageManagerBodyScript = () => {
|
||||
if (!config.googleTagManagerID) return null;
|
||||
return (
|
||||
isProduction() && (
|
||||
<noscript>
|
||||
<iframe
|
||||
title={"Google Tag Manager"}
|
||||
src={`https://www.googletagmanager.com/ns.html?id=${config.googleTagManagerID}`}
|
||||
height="0"
|
||||
width="0"
|
||||
style={{ display: "none", visibility: "hidden" }}
|
||||
/>
|
||||
</noscript>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const useAnalytics = () => React.useContext(AnalyticsContext);
|
||||
|
||||
@@ -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<Props>) {
|
||||
const [height, setHeight] = useState(0);
|
||||
const [closedAnnouncements, setClosedAnnouncements] = useLocalStorage<
|
||||
string[]
|
||||
>("netbird-closed-announcements", []);
|
||||
const [announcements, setAnnouncements] = useState<AnnouncementInfo[]>();
|
||||
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);
|
||||
|
||||
@@ -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<User[]>("/users", true);
|
||||
const userRequest = useApiCall<User[]>(`/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 ? (
|
||||
<ApplicationContext.Provider
|
||||
value={{ latestVersion, toggleMobileNav, latestUrl, mobileNavOpen, user }}
|
||||
value={{
|
||||
latestVersion,
|
||||
toggleMobileNav,
|
||||
latestUrl,
|
||||
mobileNavOpen,
|
||||
user,
|
||||
globalApiParams,
|
||||
setGlobalApiParams,
|
||||
isNavigationCollapsed,
|
||||
toggleNavigation,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ApplicationContext.Provider>
|
||||
|
||||
@@ -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 ? (
|
||||
<CountryContext.Provider
|
||||
value={{ countries: [], isLoading: false, getRegionByPeer }}
|
||||
value={{
|
||||
countries: [],
|
||||
isLoading: false,
|
||||
getRegionByPeer,
|
||||
getRegionText,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CountryContext.Provider>
|
||||
@@ -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 (
|
||||
<CountryContext.Provider value={{ countries, isLoading, getRegionByPeer }}>
|
||||
<CountryContext.Provider
|
||||
value={{ countries, isLoading, getRegionByPeer, getRegionText }}
|
||||
>
|
||||
{children}
|
||||
</CountryContext.Provider>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
<ModalContent maxWidthClass={"max-w-[400px]"} showClose={false}>
|
||||
<ModalContent
|
||||
maxWidthClass={dialogOptions.maxWidthClass || "max-w-[400px]"}
|
||||
showClose={false}
|
||||
>
|
||||
<ModalHeader
|
||||
center={dialogOptions.type == "center"}
|
||||
title={dialogOptions.title || "Confirmation"}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
import { merge, sortBy, unionBy } from "lodash";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
import {Group, GroupResource} from "@/interfaces/Group";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group, GroupResource } from "@/interfaces/Group";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
|
||||
type Props = {
|
||||
@@ -24,29 +24,29 @@ const GroupContext = React.createContext(
|
||||
);
|
||||
|
||||
export default function GroupsProvider({ children }: Props) {
|
||||
const { permission, isUser } = useLoggedInUser();
|
||||
const { isRestricted } = usePermissions();
|
||||
|
||||
return permission.dashboard_view == "blocked" ? (
|
||||
return isRestricted ? (
|
||||
<>{children}</>
|
||||
) : (
|
||||
<GroupsProviderContent isUser={isUser}>{children}</GroupsProviderContent>
|
||||
<GroupsProviderContent>{children}</GroupsProviderContent>
|
||||
);
|
||||
}
|
||||
|
||||
type ProviderContentProps = {
|
||||
children: React.ReactNode;
|
||||
isUser: boolean;
|
||||
};
|
||||
|
||||
export function GroupsProviderContent({
|
||||
children,
|
||||
isUser,
|
||||
}: Readonly<ProviderContentProps>) {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const {
|
||||
data: groups,
|
||||
mutate,
|
||||
isLoading,
|
||||
} = useFetchApi<Group[]>("/groups", false, true, !isUser);
|
||||
} = useFetchApi<Group[]>("/groups", false, true, permission.groups.read);
|
||||
const groupRequest = useApiCall<Group>("/groups", true);
|
||||
const [dropdownOptions, setDropdownOptions] = useState<Group[]>([]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
39
src/contexts/PermissionsProvider.tsx
Normal file
39
src/contexts/PermissionsProvider.tsx
Normal file
@@ -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<Props>) {
|
||||
const permissions = useMemo(() => {
|
||||
return user.permissions;
|
||||
}, [user]);
|
||||
|
||||
const data = useMemo(() => {
|
||||
return {
|
||||
isRestricted: permissions.is_restricted,
|
||||
permission: permissions.modules,
|
||||
};
|
||||
}, [permissions]);
|
||||
|
||||
return (
|
||||
<PermissionsContext.Provider value={data}>
|
||||
{children}
|
||||
</PermissionsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const usePermissions = () => React.useContext(PermissionsContext);
|
||||
@@ -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<Props>) {
|
||||
const { data: users, mutate, isLoading } = useFetchApi<User[]>("/users");
|
||||
|
||||
const refresh = () => {
|
||||
mutate().then();
|
||||
};
|
||||
|
||||
const loggedInUser = useMemo(() => {
|
||||
return users?.find((user) => user.is_current);
|
||||
}, [users]);
|
||||
|
||||
return !isLoading && loggedInUser ? (
|
||||
<UsersContext.Provider value={{ users, refresh, loggedInUser }}>
|
||||
{children}
|
||||
return (
|
||||
<UsersContext.Provider value={{ users, refresh, isLoading }}>
|
||||
<UserProfileProvider>{children}</UserProfileProvider>
|
||||
</UsersContext.Provider>
|
||||
) : (
|
||||
<FullScreenLoading />
|
||||
);
|
||||
}
|
||||
|
||||
export const useUsers = () => React.useContext(UsersContext);
|
||||
|
||||
const UserProfileProvider = ({ children }: Props) => {
|
||||
const { users, isLoading: isAllUsersLoading } = useUsers();
|
||||
const {
|
||||
data: user,
|
||||
error,
|
||||
isLoading,
|
||||
} = useFetchApi<User>("/users/current", true, true, true, {
|
||||
key: "user-profile",
|
||||
});
|
||||
|
||||
const loggedInUser = useMemo(() => {
|
||||
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 ? (
|
||||
<UserProfileContext.Provider value={data}>
|
||||
<PermissionsProvider user={loggedInUser}>{children}</PermissionsProvider>
|
||||
</UserProfileContext.Provider>
|
||||
) : (
|
||||
<FullScreenLoading />
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useEventCallback } from "@/hooks/useEventCallback";
|
||||
@@ -20,8 +21,10 @@ export function useLocalStorage<T>(
|
||||
key: string,
|
||||
initialValue: T,
|
||||
enabled: boolean = true,
|
||||
overrideValue?: T,
|
||||
): [T, SetValue<T>] {
|
||||
const [tempValue, setTempValue] = useState(initialValue);
|
||||
const [tempValue, setTempValue] = useState(overrideValue ?? initialValue);
|
||||
const isInitialRender = useRef(true);
|
||||
|
||||
// Get from local storage then
|
||||
// parse stored json or return initialValue
|
||||
@@ -31,6 +34,11 @@ export function useLocalStorage<T>(
|
||||
return initialValue;
|
||||
}
|
||||
|
||||
if (isInitialRender.current && overrideValue !== undefined) {
|
||||
isInitialRender.current = false;
|
||||
return overrideValue;
|
||||
}
|
||||
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? (parseJSON(item) as T) : initialValue;
|
||||
@@ -95,6 +103,13 @@ export function useLocalStorage<T>(
|
||||
[key, readValue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (overrideValue) {
|
||||
setValue(overrideValue);
|
||||
setStoredValue(overrideValue);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// this only works for other documents, not the current one
|
||||
useEventListener("storage", handleStorageChange);
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export const useRedirect = (
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// If redirect is disabled or the url is already in the callback urls or the url is the current path then do not redirect
|
||||
// If redirect is disabled or the url is already in the callback urls then do not redirect
|
||||
if (!enable || callBackUrls.current.includes(url) || url === currentPath)
|
||||
return;
|
||||
|
||||
|
||||
@@ -32,7 +32,9 @@ export function useSearch<T>(
|
||||
string,
|
||||
(event: ChangeEvent<HTMLInputElement> | string) => void,
|
||||
(querty: string) => void,
|
||||
boolean,
|
||||
] {
|
||||
const [isSearching, setIsSearching] = useState<boolean>(false);
|
||||
const isMounted = useRef<boolean>(false);
|
||||
const [query, setQuery] = useState<string>(initialQuery);
|
||||
const prevCollection = usePrevious(collection);
|
||||
@@ -62,6 +64,7 @@ export function useSearch<T>(
|
||||
setFilteredCollection(
|
||||
filterCollection(collection, predicate, query, filter),
|
||||
);
|
||||
setIsSearching(false);
|
||||
}
|
||||
},
|
||||
debounce,
|
||||
@@ -75,8 +78,10 @@ export function useSearch<T>(
|
||||
!isEqual(predicate, prevPredicate) ||
|
||||
!isEqual(query, prevQuery) ||
|
||||
!isEqual(filter, prevFilter)
|
||||
)
|
||||
) {
|
||||
if (!isEqual(query, prevQuery)) setIsSearching(true);
|
||||
debouncedFilterCollection(collection, predicate, query, filter);
|
||||
}
|
||||
}, [collection, predicate, query, filter]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -87,5 +92,5 @@ export function useSearch<T>(
|
||||
};
|
||||
}, []);
|
||||
|
||||
return [filteredCollection, query, handleChange, setQuery];
|
||||
return [filteredCollection, query, handleChange, setQuery, isSearching];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -15,5 +19,6 @@ export interface Account {
|
||||
regular_users_view_blocked: boolean;
|
||||
routing_peer_dns_resolution_enabled: boolean;
|
||||
dns_domain: string;
|
||||
lazy_connection_enabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,4 +104,50 @@ export const NameserverPresets: Record<string, NameserverGroup> = {
|
||||
enabled: true,
|
||||
search_domains_enabled: false,
|
||||
},
|
||||
DNS0: {
|
||||
name: "DNS0.EU",
|
||||
description: "DNS0.EU DNS Servers",
|
||||
primary: true,
|
||||
domains: [],
|
||||
nameservers: [
|
||||
{
|
||||
ip: "193.110.81.0",
|
||||
ns_type: "udp",
|
||||
port: 53,
|
||||
id: "1",
|
||||
},
|
||||
{
|
||||
ip: "185.253.5.0",
|
||||
ns_type: "udp",
|
||||
port: 53,
|
||||
id: "2",
|
||||
},
|
||||
],
|
||||
groups: [],
|
||||
enabled: true,
|
||||
search_domains_enabled: false,
|
||||
},
|
||||
DNS0Zero: {
|
||||
name: "DNS0.EU Zero",
|
||||
description: "DNS0.EU Zero DNS Servers",
|
||||
primary: true,
|
||||
domains: [],
|
||||
nameservers: [
|
||||
{
|
||||
ip: "193.110.81.9",
|
||||
ns_type: "udp",
|
||||
port: 53,
|
||||
id: "1",
|
||||
},
|
||||
{
|
||||
ip: "185.253.5.9",
|
||||
ns_type: "udp",
|
||||
port: 53,
|
||||
id: "2",
|
||||
},
|
||||
],
|
||||
groups: [],
|
||||
enabled: true,
|
||||
search_domains_enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
7
src/interfaces/Pagination.ts
Normal file
7
src/interfaces/Pagination.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface Pagination<T> {
|
||||
data: T;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
total_records: number;
|
||||
}
|
||||
@@ -1,3 +1,43 @@
|
||||
export interface Permission {
|
||||
dashboard_view: "limited" | "full" | "blocked";
|
||||
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 {
|
||||
create: boolean;
|
||||
read: boolean;
|
||||
update: boolean;
|
||||
delete: boolean;
|
||||
}
|
||||
|
||||
@@ -22,10 +22,16 @@ export interface PolicyRule {
|
||||
action: string;
|
||||
protocol: Protocol;
|
||||
ports: string[];
|
||||
port_ranges?: PortRange[];
|
||||
sourceResource?: PolicyRuleResource;
|
||||
destinationResource?: PolicyRuleResource;
|
||||
}
|
||||
|
||||
export interface PortRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface PolicyRuleResource {
|
||||
id: string;
|
||||
type: "domain" | "host" | "subnet" | undefined;
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<GoogleTagManagerHeadScript />
|
||||
</head>
|
||||
<body className={cn(inter.className, "dark:bg-nb-gray bg-gray-50")}>
|
||||
<AnalyticsProvider>
|
||||
<DialogProvider>
|
||||
<GlobalThemeProvider>
|
||||
<ErrorBoundaryProvider>
|
||||
<OIDCProvider>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</OIDCProvider>
|
||||
</ErrorBoundaryProvider>
|
||||
</GlobalThemeProvider>
|
||||
</DialogProvider>
|
||||
<Toaster
|
||||
position={"top-center"}
|
||||
toastOptions={{
|
||||
duration: 3000,
|
||||
}}
|
||||
/>
|
||||
<NavigationEvents />
|
||||
<DisableDarkReader />
|
||||
</AnalyticsProvider>
|
||||
<Suspense fallback={<FullScreenLoading />}>
|
||||
<AnalyticsProvider>
|
||||
<DialogProvider>
|
||||
<GlobalThemeProvider>
|
||||
<ErrorBoundaryProvider>
|
||||
<OIDCProvider>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</OIDCProvider>
|
||||
</ErrorBoundaryProvider>
|
||||
</GlobalThemeProvider>
|
||||
</DialogProvider>
|
||||
<Toaster
|
||||
position={"top-center"}
|
||||
toastOptions={{
|
||||
duration: 3000,
|
||||
}}
|
||||
/>
|
||||
<NavigationEvents />
|
||||
<DisableDarkReader />
|
||||
</AnalyticsProvider>
|
||||
</Suspense>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<ApplicationProvider>
|
||||
<UsersProvider>
|
||||
@@ -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 (
|
||||
<div className={cn("flex flex-col h-screen", mobileNavOpen && "flex")}>
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
<Navbar />
|
||||
|
||||
<Header />
|
||||
<div
|
||||
className={"flex flex-row flex-grow"}
|
||||
style={{
|
||||
height: `calc(100vh - ${headerHeight + bannerHeight}px)`,
|
||||
}}
|
||||
>
|
||||
{permission.dashboard_view !== "blocked" && (
|
||||
<Navigation hideOnMobile />
|
||||
)}
|
||||
{!isRestricted && <Navigation hideOnMobile />}
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,46 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@components/Button";
|
||||
import { NetBirdLogo } from "@components/NetBirdLogo";
|
||||
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 Image from "next/image";
|
||||
import { MenuIcon, PanelLeftCloseIcon, PanelLeftOpenIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useMemo } from "react";
|
||||
import NetBirdLogo from "@/assets/netbird.svg";
|
||||
import NetBirdLogoFull from "@/assets/netbird-full.svg";
|
||||
import React from "react";
|
||||
import { useAnnouncement } from "@/contexts/AnnouncementProvider";
|
||||
import { useApplicationContext } from "@/contexts/ApplicationProvider";
|
||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
|
||||
export const headerHeight = 75;
|
||||
|
||||
export default function NavbarWithDropdown() {
|
||||
const router = useRouter();
|
||||
const Logo = useMemo(() => {
|
||||
return (
|
||||
<>
|
||||
<Image
|
||||
src={NetBirdLogoFull}
|
||||
height={22}
|
||||
alt={"NetBird Logo"}
|
||||
className={"hidden md:block"}
|
||||
/>
|
||||
<Image
|
||||
src={NetBirdLogo}
|
||||
width={30}
|
||||
alt={"NetBird Logo"}
|
||||
className={"md:hidden"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}, []);
|
||||
|
||||
const { toggleMobileNav } = useApplicationContext();
|
||||
const { bannerHeight } = useAnnouncement();
|
||||
const { permission } = useLoggedInUser();
|
||||
const { isRestricted } = usePermissions();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -62,8 +40,7 @@ export default function NavbarWithDropdown() {
|
||||
<Button
|
||||
className={cn(
|
||||
"!px-3 md:hidden",
|
||||
permission.dashboard_view == "blocked" &&
|
||||
"opacity-0 pointer-events-none",
|
||||
isRestricted && "opacity-0 pointer-events-none",
|
||||
)}
|
||||
variant={"default-outline"}
|
||||
onClick={toggleMobileNav}
|
||||
@@ -73,18 +50,19 @@ export default function NavbarWithDropdown() {
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => router.push("/peers")}
|
||||
className={"cursor-pointer hover:opacity-70 transition-all"}
|
||||
>
|
||||
{Logo}
|
||||
<div className={"flex gap-4 mr-auto"}>
|
||||
<button
|
||||
onClick={() => router.push("/peers")}
|
||||
className={
|
||||
"cursor-pointer hover:opacity-70 transition-all mr-auto"
|
||||
}
|
||||
>
|
||||
<NetBirdLogo />
|
||||
</button>
|
||||
<ToggleCollapsableNavigationButton />
|
||||
</div>
|
||||
|
||||
<div className="flex md:order-2 gap-4">
|
||||
<div className={"hidden md:block"}>
|
||||
<DarkModeToggle />
|
||||
</div>
|
||||
|
||||
<div className="flex md:order-2 gap-4 items-center">
|
||||
<UserDropdown />
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,3 +75,26 @@ export default function NavbarWithDropdown() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const ToggleCollapsableNavigationButton = () => {
|
||||
const { isRestricted } = usePermissions();
|
||||
const { toggleNavigation, isNavigationCollapsed } = useApplicationContext();
|
||||
|
||||
return (
|
||||
!isRestricted && (
|
||||
<button
|
||||
onClick={toggleNavigation}
|
||||
className={cn(
|
||||
"h-10 w-10 hover:text-white flex items-center justify-center text-nb-gray-300 transition-all ml-2",
|
||||
"hidden md:block",
|
||||
)}
|
||||
>
|
||||
{isNavigationCollapsed ? (
|
||||
<PanelLeftOpenIcon size={16} />
|
||||
) : (
|
||||
<PanelLeftCloseIcon size={16} />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<Props>) {
|
||||
const { bannerHeight } = useAnnouncement();
|
||||
const { isNavigationCollapsed } = useApplicationContext();
|
||||
const { permission, isRestricted } = usePermissions();
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
<div
|
||||
className={cn(
|
||||
"whitespace-nowrap md:border-r dark:border-zinc-700/40",
|
||||
"whitespace-nowrap md:border-r dark:border-zinc-700/40 bg-gray-50 dark:bg-nb-gray relative group/navigation transition-all",
|
||||
hideOnMobile ? "hidden md:block" : "",
|
||||
fullWidth
|
||||
? "w-auto max-w-[22rem]"
|
||||
: "w-[15rem] max-w-[15rem] min-w-[15rem] overflow-y-auto",
|
||||
isNavigationCollapsed &&
|
||||
"md:w-[70px] md:min-w-[70px] md:fixed md:overflow-hidden md:hover:w-[15rem] md:hover:max-w-[15rem] md:hover:min-w-[15rem] md:z-50",
|
||||
)}
|
||||
theme={customTheme}
|
||||
style={{
|
||||
height: fullWidth
|
||||
? `calc(100vh - ${headerHeight + bannerHeight}px)`
|
||||
: "100%",
|
||||
height: `calc(100vh - ${headerHeight + bannerHeight}px)`,
|
||||
}}
|
||||
>
|
||||
<Sidebar.Items className={cn(fullWidth ? "w-10/12" : "fixed h-full")}>
|
||||
<div className={cn(fullWidth ? "w-10/12" : "fixed z-0")}>
|
||||
<ScrollArea
|
||||
style={{
|
||||
height: !fullWidth
|
||||
@@ -62,9 +54,11 @@ export default function Navigation({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex flex-col justify-between pt-4 w-[15rem] max-w-[15rem] min-w-[15rem]"
|
||||
}
|
||||
className={cn(
|
||||
"flex flex-col pt-3 justify-between w-[15rem] max-w-[15rem] min-w-[15rem] transition-all",
|
||||
isNavigationCollapsed &&
|
||||
"md:w-[70px] md:min-w-[70px] md:group-hover/navigation:w-[15rem] md:group-hover/navigation:max-w-[15rem] md:group-hover/navigation:min-w-[15rem] md:overflow-x-clip",
|
||||
)}
|
||||
style={{
|
||||
height: !fullWidth
|
||||
? `calc(100vh - ${headerHeight + bannerHeight}px)`
|
||||
@@ -77,101 +71,123 @@ export default function Navigation({
|
||||
icon={<PeerIcon />}
|
||||
label="Peers"
|
||||
href={"/peers"}
|
||||
visible={!isRestricted}
|
||||
/>
|
||||
|
||||
{!isUser && (
|
||||
<>
|
||||
<SidebarItem
|
||||
icon={<SetupKeysIcon />}
|
||||
label="Setup Keys"
|
||||
href={"/setup-keys"}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={<AccessControlIcon />}
|
||||
label="Access Control"
|
||||
collapsible
|
||||
>
|
||||
<SidebarItem
|
||||
label="Policies"
|
||||
href={"/access-control"}
|
||||
isChild
|
||||
exactPathMatch={true}
|
||||
/>
|
||||
<SidebarItem
|
||||
label="Posture Checks"
|
||||
isChild
|
||||
href={"/posture-checks"}
|
||||
exactPathMatch={true}
|
||||
/>
|
||||
</SidebarItem>
|
||||
<SidebarItem
|
||||
icon={<SetupKeysIcon />}
|
||||
label="Setup Keys"
|
||||
href={"/setup-keys"}
|
||||
visible={permission.setup_keys.read}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={<AccessControlIcon />}
|
||||
label="Access Control"
|
||||
collapsible
|
||||
visible={permission.policies.read}
|
||||
>
|
||||
<SidebarItem
|
||||
label="Policies"
|
||||
href={"/access-control"}
|
||||
isChild
|
||||
exactPathMatch={true}
|
||||
visible={permission.policies.read}
|
||||
/>
|
||||
<SidebarItem
|
||||
label="Posture Checks"
|
||||
isChild
|
||||
href={"/posture-checks"}
|
||||
exactPathMatch={true}
|
||||
visible={permission.policies.read}
|
||||
/>
|
||||
</SidebarItem>
|
||||
|
||||
<NetworkNavigation />
|
||||
<NetworkNavigation />
|
||||
|
||||
<SidebarItem
|
||||
icon={<DNSIcon />}
|
||||
label="DNS"
|
||||
collapsible
|
||||
exactPathMatch={true}
|
||||
>
|
||||
<SidebarItem
|
||||
label="Nameservers"
|
||||
isChild
|
||||
href={"/dns/nameservers"}
|
||||
/>
|
||||
<SidebarItem
|
||||
label="DNS Settings"
|
||||
isChild
|
||||
href={"/dns/settings"}
|
||||
/>
|
||||
</SidebarItem>
|
||||
<SidebarItem icon={<TeamIcon />} label="Team" collapsible>
|
||||
<SidebarItem label="Users" isChild href={"/team/users"} />
|
||||
<SidebarItem
|
||||
label="Service Users"
|
||||
isChild
|
||||
href={"/team/service-users"}
|
||||
/>
|
||||
</SidebarItem>
|
||||
<SidebarItem
|
||||
icon={<ActivityIcon />}
|
||||
label="Activity"
|
||||
href={"/activity"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<SidebarItem
|
||||
icon={<DNSIcon />}
|
||||
label="DNS"
|
||||
collapsible
|
||||
exactPathMatch={true}
|
||||
visible={permission.dns.read || permission.nameservers.read}
|
||||
>
|
||||
<SidebarItem
|
||||
label="Nameservers"
|
||||
isChild
|
||||
href={"/dns/nameservers"}
|
||||
visible={permission.nameservers.read}
|
||||
/>
|
||||
<SidebarItem
|
||||
label="DNS Settings"
|
||||
isChild
|
||||
href={"/dns/settings"}
|
||||
visible={permission.dns.read}
|
||||
/>
|
||||
</SidebarItem>
|
||||
<SidebarItem
|
||||
icon={<TeamIcon />}
|
||||
label="Team"
|
||||
collapsible
|
||||
visible={permission.users.read}
|
||||
>
|
||||
<SidebarItem
|
||||
label="Users"
|
||||
isChild
|
||||
href={"/team/users"}
|
||||
visible={permission.users.read}
|
||||
/>
|
||||
<SidebarItem
|
||||
label="Service Users"
|
||||
isChild
|
||||
href={"/team/service-users"}
|
||||
visible={permission.users.read}
|
||||
/>
|
||||
</SidebarItem>
|
||||
<SidebarItem
|
||||
icon={<ActivityIcon />}
|
||||
label="Activity"
|
||||
href={"/events/audit"}
|
||||
exactPathMatch={true}
|
||||
visible={permission.events.read}
|
||||
/>
|
||||
</SidebarItemGroup>
|
||||
|
||||
<SidebarItemGroup>
|
||||
{isOwnerOrAdmin && (
|
||||
<SidebarItem
|
||||
icon={<SettingsIcon />}
|
||||
label="Settings"
|
||||
href={"/settings"}
|
||||
exactPathMatch={true}
|
||||
/>
|
||||
)}
|
||||
<SidebarItem
|
||||
icon={<SettingsIcon />}
|
||||
label="Settings"
|
||||
href={"/settings"}
|
||||
exactPathMatch={true}
|
||||
visible={permission.settings.read}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={<DocsIcon />}
|
||||
href={"https://docs.netbird.io/"}
|
||||
target={"_blank"}
|
||||
label="Documentation"
|
||||
visible={true}
|
||||
/>
|
||||
</SidebarItemGroup>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Sidebar.Items>
|
||||
</Sidebar>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarItemGroup(props: SidebarItemGroupProps) {
|
||||
type SidebarItemGroupProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function SidebarItemGroup({ children }: SidebarItemGroupProps) {
|
||||
return (
|
||||
<Sidebar.ItemGroup
|
||||
className={"dark:border-zinc-700/40 space-y-1.5"}
|
||||
{...props}
|
||||
<div
|
||||
className={
|
||||
"mt-4 border-t border-gray-200 pt-4 first:mt-0 first:border-t-0 first:pt-0 dark:border-zinc-700/40 space-y-[3px]"
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</Sidebar.ItemGroup>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Props>) {
|
||||
const { isNavigationCollapsed } = useApplicationContext();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
className,
|
||||
"relative flex-auto overflow-auto bg-nb-gray z-1",
|
||||
"focus:outline-none",
|
||||
"relative flex-auto overflow-auto bg-nb-gray z-1 focus:outline-none",
|
||||
isNavigationCollapsed && "md:pl-[70px]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@components/Button";
|
||||
import { Callout } from "@components/Callout";
|
||||
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
||||
import HelpText from "@components/HelpText";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
@@ -29,6 +30,7 @@ import { Textarea } from "@components/Textarea";
|
||||
import PolicyDirection from "@components/ui/PolicyDirection";
|
||||
import { cn } from "@utils/helpers";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
ArrowRightLeft,
|
||||
ExternalLinkIcon,
|
||||
FolderDown,
|
||||
@@ -41,6 +43,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,12 +129,16 @@ export function AccessControlModalContent({
|
||||
initialName,
|
||||
initialDescription,
|
||||
}: Readonly<ModalProps>) {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const {
|
||||
portAndDirectionDisabled,
|
||||
portDisabled,
|
||||
destinationGroups,
|
||||
direction,
|
||||
ports,
|
||||
sourceGroups,
|
||||
destinationHasResources,
|
||||
destinationOnlyResources,
|
||||
setSourceGroups,
|
||||
setDestinationGroups,
|
||||
setPorts,
|
||||
@@ -151,6 +158,9 @@ export function AccessControlModalContent({
|
||||
getPolicyData,
|
||||
destinationResource,
|
||||
setDestinationResource,
|
||||
portRanges,
|
||||
setPortRanges,
|
||||
hasPortSupport,
|
||||
} = useAccessControl({
|
||||
policy,
|
||||
postureCheckTemplates,
|
||||
@@ -163,15 +173,13 @@ export function AccessControlModalContent({
|
||||
const [tab, setTab] = useState(() => {
|
||||
if (!cell) return "policy";
|
||||
if (cell == "posture_checks") return "posture_checks";
|
||||
if (cell == "name") return "general";
|
||||
return "policy";
|
||||
});
|
||||
|
||||
const continuePostureChecksDisabled = useMemo(() => {
|
||||
if (direction != "bi" && ports.length == 0) return true;
|
||||
if (sourceGroups.length > 0 && destinationResource) return false;
|
||||
if (sourceGroups.length == 0 || destinationGroups.length == 0) return true;
|
||||
}, [sourceGroups, destinationGroups, direction, ports, destinationResource]);
|
||||
}, [sourceGroups, destinationGroups, destinationResource]);
|
||||
|
||||
const submitDisabled = useMemo(() => {
|
||||
if (name.length == 0) return true;
|
||||
@@ -180,14 +188,9 @@ export function AccessControlModalContent({
|
||||
|
||||
const handleProtocolChange = (p: Protocol) => {
|
||||
setProtocol(p);
|
||||
if (p == "icmp") {
|
||||
if (!hasPortSupport(p)) {
|
||||
setPorts([]);
|
||||
}
|
||||
if (p == "all") {
|
||||
setPorts([]);
|
||||
}
|
||||
if (p == "tcp" || p == "udp") {
|
||||
setDirection("in");
|
||||
setPortRanges([]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -250,6 +253,9 @@ export function AccessControlModalContent({
|
||||
<Select
|
||||
value={protocol}
|
||||
onValueChange={(v) => handleProtocolChange(v as Protocol)}
|
||||
disabled={
|
||||
!permission.policies.update || !permission.policies.create
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<div
|
||||
@@ -285,12 +291,16 @@ export function AccessControlModalContent({
|
||||
values={sourceGroups}
|
||||
saveGroupAssignments={useSave}
|
||||
showResourceCounter={false}
|
||||
disabled={
|
||||
!permission.policies.update || !permission.policies.create
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<PolicyDirection
|
||||
value={direction}
|
||||
onChange={setDirection}
|
||||
disabled={portAndDirectionDisabled}
|
||||
disabled={destinationOnlyResources}
|
||||
destinationResource={destinationResource}
|
||||
/>
|
||||
|
||||
<div className={"w-full self-start"}>
|
||||
@@ -311,14 +321,35 @@ export function AccessControlModalContent({
|
||||
onResourceChange={setDestinationResource}
|
||||
showResources={true}
|
||||
placeholder={"Select destination(s)..."}
|
||||
disabled={
|
||||
!permission.policies.update || !permission.policies.create
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{destinationHasResources &&
|
||||
!destinationOnlyResources &&
|
||||
direction === "bi" && (
|
||||
<Callout
|
||||
variant={"warning"}
|
||||
icon={
|
||||
<AlertCircleIcon
|
||||
size={14}
|
||||
className={"shrink-0 relative top-[3px] text-netbird"}
|
||||
/>
|
||||
}
|
||||
className="mb-4"
|
||||
>
|
||||
Some destination groups contain resources. Resources only
|
||||
support incoming traffic and cannot initiate connections.
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mb-2",
|
||||
portAndDirectionDisabled && "opacity-30 pointer-events-none",
|
||||
portDisabled && "opacity-30 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
@@ -328,15 +359,17 @@ export function AccessControlModalContent({
|
||||
</Label>
|
||||
<HelpText>
|
||||
Allow network traffic and access only to specified ports.
|
||||
Select ports between 1 and 65535.
|
||||
Select ports or port ranges between 1 and 65535.
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={""}>
|
||||
<PortSelector
|
||||
showAll={direction == "bi"}
|
||||
values={ports}
|
||||
onChange={setPorts}
|
||||
disabled={portAndDirectionDisabled}
|
||||
showAll={true}
|
||||
ports={ports}
|
||||
onPortsChange={setPorts}
|
||||
portRanges={portRanges}
|
||||
onPortRangesChange={setPortRanges}
|
||||
disabled={portDisabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -344,6 +377,9 @@ export function AccessControlModalContent({
|
||||
<FancyToggleSwitch
|
||||
value={enabled}
|
||||
onChange={setEnabled}
|
||||
disabled={
|
||||
!permission.policies.update || !permission.policies.create
|
||||
}
|
||||
label={
|
||||
<>
|
||||
<Power size={15} />
|
||||
@@ -373,6 +409,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
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -388,6 +427,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
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -453,7 +495,7 @@ export function AccessControlModalContent({
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
disabled={submitDisabled}
|
||||
disabled={submitDisabled || !permission.policies.create}
|
||||
onClick={submit}
|
||||
data-cy={"submit-policy"}
|
||||
>
|
||||
@@ -470,7 +512,7 @@ export function AccessControlModalContent({
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
disabled={submitDisabled}
|
||||
disabled={submitDisabled || !permission.policies.update}
|
||||
onClick={() => {
|
||||
if (useSave) {
|
||||
submit();
|
||||
|
||||
@@ -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<Props>) {
|
||||
const { confirm } = useDialog();
|
||||
const policyRequest = useApiCall<Route>("/policies");
|
||||
const { mutate } = useSWRConfig();
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const deleteRule = async () => {
|
||||
notify({
|
||||
@@ -42,7 +44,12 @@ export default function AccessControlActionCell({ policy }: Props) {
|
||||
|
||||
return (
|
||||
<div className={"flex justify-end pr-4"}>
|
||||
<Button variant={"danger-outline"} size={"sm"} onClick={openConfirm}>
|
||||
<Button
|
||||
variant={"danger-outline"}
|
||||
size={"sm"}
|
||||
onClick={openConfirm}
|
||||
disabled={!permission.policies.delete}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
@@ -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<Props>) {
|
||||
const { updatePolicy } = usePolicies();
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const isChecked = useMemo(() => {
|
||||
return policy.enabled;
|
||||
@@ -52,6 +54,7 @@ export default function AccessControlActiveCell({ policy }: Readonly<Props>) {
|
||||
return (
|
||||
<div className={"flex min-w-[0px]"}>
|
||||
<ToggleSwitch
|
||||
disabled={!permission.policies.update}
|
||||
checked={isChecked}
|
||||
size={"small"}
|
||||
onClick={() => update(!isChecked)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user