Compare commits

...

13 Commits

Author SHA1 Message Date
Eduard Gert
8e2cbe1d2a Add support for port ranges (#475)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-06-20 10:26:53 +02:00
Eduard Gert
8a08583225 Do not redirect on same page (#471)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-06-05 19:30:25 +02:00
Eduard Gert
1defac4e34 Update wording for dns domain, macOS and Windows install steps (#470)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Update wording for dns domain, macOS and Windows install steps

* Update src/modules/settings/NetworkSettingsTab.tsx

Co-authored-by: Viktor Liu <17948409+lixmal@users.noreply.github.com>

---------

Co-authored-by: Viktor Liu <17948409+lixmal@users.noreply.github.com>
2025-06-05 13:16:42 +02:00
Eduard Gert
fa68f98cd0 Remove permission for add peer button (#469) 2025-06-05 13:12:38 +02:00
Eduard Gert
3f6e4c4e4f Add lazy connection setting (#465) 2025-06-04 11:54:18 +02:00
Eduard Gert
0e2661caea Merge cloud changes to public (#462)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Add changes from dashboard cloud

* Add changes from dashboard cloud

* Update next.js version

* Small formatting changes

* remove unknown permission check

---------

Co-authored-by: Maycon Santos <mlsmaycon@gmail.com>
2025-05-05 15:30:28 +02:00
Eduard Gert
d7c5f7e183 Hide update available for mobile devices (#106) (#460)
Some checks failed
build and push / build_n_push (push) Has been cancelled
(cherry picked from commit 7f248ae060385acb1245591bd46e2bb6d53ed908)
2025-04-28 11:58:26 +02:00
Eduard Gert
ebbe865ce0 Add custom dns domain (#458)
* Update domain validator

* Add custom dns domain
2025-04-28 11:58:16 +02:00
Eduard Gert
6c0ab88488 Update domain validator (#459) 2025-04-28 11:53:44 +02:00
Eduard Gert
a50576d851 Fix nameserver port input for Safari (#456) 2025-04-28 11:21:25 +02:00
Eduard Gert
676250266c Fix browse posture checks table filters (#448) 2025-04-07 10:23:07 +02:00
Vladislav Tropnikov
042c65a652 Add display of ID if user does not have email (#450)
* Add display of ID if user does not have email

* Update PeerNameCell.tsx

* Add more possible id parameters

* Hide user if there is nothing

* change id order

* Keep default behavior
2025-03-27 17:30:26 +01:00
Misha Bragin
96f2d39e54 Add CLA 2025-03-18 15:58:05 +01:00
190 changed files with 5997 additions and 2169 deletions

View File

@@ -0,0 +1,64 @@
## Contributor License Agreement
This Contributor License Agreement (referred to as the "Agreement") is entered into by the individual
submitting this Agreement and NetBird GmbH, c/o Max-Beer-Straße 2-4 Münzstraße 12 10178 Berlin, Germany,
referred to as "NetBird" (collectively, the "Parties"). The Agreement outlines the terms and conditions
under which NetBird may utilize software contributions provided by the Contributor for inclusion in
its software development projects. By submitting this Agreement, the Contributor confirms their acceptance
of the terms and conditions outlined below. The Contributor further represents that they are authorized to
complete this process as described herein.
## 1 Preamble
In order to clarify the IP Rights situation with regard to Contributions from any person or entity, NetBird
must have a contributor license agreement on file to be signed by each Contributor, containing the license
terms below. This license serves as protection for both the Contributor as well as NetBird and its software users;
it does not change Contributors rights to use his/her own Contributions for any other purpose.
## 2 Definitions
2.1 “IP Rights” shall mean all industrial and intellectual property rights, whether registered or not registered, whether created by Contributor or acquired by Contributor from third parties, and similar rights, including (but not limited to) semiconductor property rights, design rights, copyrights (including in the form of database rights and rights to software), all neighbouring rights (Leistungsschutzrechte), trademarks, service marks, titles, internet domain names, trade names and other labelling rights, rights deriving from corresponding applications and registrations of such rights as well as any licenses (Nutzungsrechte) under and entitlements to any such intellectual and industrial property rights.
2.2 "Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is or previously has been intentionally Submitted by Contributor to NetBird for inclusion in, or documentation of any Work.
2.3 "Contributor" shall mean the copyright owner or legal entity authorized by the copyright owner that is concluding this Agreement with NetBird. For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
2.4 "Submitted" shall mean any form of electronic, verbal, or written communication sent to NetBird or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, NetBird for the purpose of discussing and improving the Work, but excluding communication that is marked or otherwise designated in writing by Contributor as "Not a Contribution".
2.5 "Work" means any of the products owned or managed by NetBird, in particular, but not exclusively, software.
## 3 Licenses
3.1 Subject to the terms and conditions of this agreement, Contributor hereby grants to NetBird and to recipients of software distributed by NetBird a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable license to reproduce by any means and in any form, in whole or in part, permanently or temporarily, the Contributions (including loading, displaying, executing, transmitting or storing works for the purpose of executing and processing data or transferring them to video, audio and other data carriers), including the right to distribute, display and present such Contributions and make them available to the public (e.g. via the internet) and to transmit and display such Contributions by any means. The license also includes the right to modify, translate, adapt, edit and otherwise alter the Contributions and to use these results in the same manner as the original Contributions and derivative works. Except for licenses in patents acc. to Sec. 3, such license refers to any IP Rights in the Contributions and derivative works. The Contributor acknowledges that NetBird is not required to credit them by name for their Contribution and agrees to waive any moral rights associated with their Contribution in relation to NetBird or its sublicensees.
3.2 Subject to the terms and conditions of this agreement, Contributor hereby grants to NetBird and to recipients of software distributed by NetBird a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license in the Contributions to make, have made, use, sell, offer to sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by the Contributor which are necessarily infringed by Contributors Contribution(s) alone or by combination of Contributors Contribution(s) with the Work to which such Contribution(s) was Submitted.
3.3 NetBird hereby accepts such licenses.
## 4 Contributors Representations
4.1 Contributor represents that Contributor is legally entitled to grant the above license. If Contributors employer has IP Rights to Contributors Contributions, Contributor represent that he/she has received permission to make Contributions on behalf of such employer, that such employer has waived such IP Rights to the Contributions of Contributor to NetBird, or that such employer has executed a separate contributor license agreement with NetBird.
4.2 Contributor represents that any Contribution is his/her original creation.
4.3 Contributor represents to his/her best knowledge that any Contribution does not violate any third party IP Rights.
4.4 Contributor represents that any Contribution submission includes complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which Contributor is personally aware and which are associated with any part of the Contribution.
4.5 The Contributor represents that their Contribution does not include any work distributed under a copyleft license.
## 5 Information obligation
Contributor agrees to notify NetBird of any facts or circumstances of which Contributor become aware that would make these representations inaccurate in any respect.
## 6 Submission of Third-Party works
Should Contributor wish to submit work that is not Contributors original creation, Contributor may submit it to NetBird separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which Contributor are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]".
## 7 No Consideration
Unless compensation is mandatory under statutory law, no compensation for any license under this agreement shall be payable.
## 8 Final Provisions
8.1 Laws. This Agreement is governed by the laws of the Federal Republic of Germany.
8.2 Venue. Place of jurisdiction shall, to the extent legally permissible, be Berlin, Germany.
8.3 Severability. If any provision in this agreement is unlawful, invalid or ineffective, it shall not affect the enforceability or effectiveness of the remainder of this agreement. The parties agree to replace any unlawful, invalid or ineffective provision with a provision that comes as close as possible to the commercial intent and purpose of the original provision. This section also applies accordingly to any gaps in the contract.
8.4 Variations. Any variations, amendments or supplements to this Agreement must be in writing. This also applies to any variation of this Section 8.4.

View File

@@ -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"
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View 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"} />;
}

View File

@@ -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

View File

@@ -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}

View File

@@ -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

View 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;

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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

View File

@@ -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>
)
);
};

View File

@@ -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}

View File

@@ -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}

View File

@@ -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"),

View File

@@ -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}

View File

@@ -73,4 +73,48 @@ p {
.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; }
}

View File

@@ -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 />;
};

View File

@@ -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 = () => {

View File

@@ -8,6 +8,7 @@ interface Props extends React.HTMLAttributes<HTMLDivElement>, BadgeVariants {
children: React.ReactNode;
className?: string;
useHover?: boolean;
disabled?: boolean;
}
const variants = cva("", {
@@ -53,6 +54,7 @@ export default function Badge({
className,
variant = "blue",
useHover = false,
disabled = false,
...props
}: Readonly<Props>) {
return (
@@ -61,6 +63,7 @@ export default function Badge({
"relative z-10 cursor-inherit whitespace-nowrap rounded-md text-[12px] py-1.5 px-3 font-normal flex gap-1.5 items-center justify-center transition-all",
className,
variants({ variant, hover: useHover ? variant : "none" }),
disabled && "cursor-not-allowed opacity-50 select-none",
)}
{...props}
>

View File

@@ -10,6 +10,7 @@ export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
ButtonVariants {
disabled?: boolean;
stopPropagation?: boolean;
}
export const buttonVariants = cva(
@@ -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-800 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);
}}
>

View 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>
);
};

View File

@@ -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}

View File

@@ -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,10 @@ 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(),
},
lastMonth: {
from: dayjs().subtract(1, "month").startOf("day").toDate(),
to: dayjs().endOf("day").toDate(),
@@ -47,12 +53,17 @@ 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),
lastMonth: isEqualDateRange(value, defaultRanges.lastMonth),
allTime: isEqualDateRange(value, defaultRanges.allTime),
};
@@ -64,6 +75,7 @@ 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.yesterday) return "Yesterday";
if (isActive.today) return "Today";
@@ -80,6 +92,22 @@ export function DatePickerWithRange({ className, value, onChange }: Props) {
onChange?.(range);
};
const debouncedOnChange = useMemo(() => {
return onChange ? debounce(onChange, 300) : 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 +121,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 +173,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 +189,11 @@ type CalendarButtonProps = {
active?: boolean;
};
function CalendarButton({ label, onClick, active }: CalendarButtonProps) {
function CalendarButton({
label,
onClick,
active,
}: Readonly<CalendarButtonProps>) {
return (
<button
className={cn(

View File

@@ -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>
);
},

View File

@@ -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}
/>
));

View File

@@ -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>
);
}

View File

@@ -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",

View File

@@ -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"}

View File

@@ -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

View File

@@ -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 };

View File

@@ -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>
)}
</>
);
}

View File

@@ -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>

View 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>
);
};

View File

@@ -10,15 +10,25 @@ 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;
renderBeforeItem?: (item: T) => React.ReactNode;
itemClassName?: string;
itemWrapperClassName?: string;
scrollAreaClassName?: string;
maxHeight?: number;
estimatedItemHeight?: number;
};
export function VirtualScrollAreaList<T extends { id?: string }>({
items,
onSelect,
renderItem,
renderBeforeItem,
itemClassName,
itemWrapperClassName,
scrollAreaClassName,
maxHeight,
estimatedItemHeight = 36,
}: Readonly<Props<T>>) {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const [selected, setSelected] = useState(0);
@@ -67,31 +77,47 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
const renderMemoizedItem = useMemo(() => renderItem, [renderItem]);
const scrollAreaHeight = { maxHeight: maxHeight ?? 195 };
const virtuosoHeight = {
height: Math.min(items.length * estimatedItemHeight + 8, maxHeight ?? 195),
};
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}
totalCount={items.length}
fixedItemHeight={estimatedItemHeight}
computeItemKey={(index) => items[index].id as string}
context={{ selected, setSelected, onClick: onSelect }}
itemContent={(index, option, { selected, setSelected, onClick }) => {
return (
<VirtualScrollListItemWrapper
onMouseEnter={() => setSelected(index)}
id={option.id}
onClick={() => onClick(option)}
ariaSelected={selected === index}
className={itemClassName}
>
{renderMemoizedItem ? renderMemoizedItem(option) : option.id}
</VirtualScrollListItemWrapper>
<div>
{renderBeforeItem?.(option)}
<VirtualScrollListItemWrapper
onMouseEnter={() => 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 +133,8 @@ type ItemWrapperProps = {
onClick?: () => void;
ariaSelected?: boolean;
className?: string;
itemClassName?: string;
isLast?: boolean;
};
export const VirtualScrollListItemWrapper = memo(
@@ -117,11 +145,17 @@ export const VirtualScrollListItemWrapper = memo(
onMouseEnter,
ariaSelected,
className,
itemClassName,
isLast,
}: ItemWrapperProps) => {
return (
<div
key={id ?? undefined}
className={"pr-3 pl-2 webkit-scroll"}
className={cn(
"pr-3 pl-2 webkit-scroll group/list-item",
isLast && "pb-2",
className,
)}
onMouseEnter={onMouseEnter}
onClick={onClick}
>
@@ -129,7 +163,7 @@ export const VirtualScrollListItemWrapper = memo(
className={cn(
"text-xs flex justify-between py-2 px-3 cursor-pointer items-center rounded-md",
"bg-transparent dark:aria-selected:bg-nb-gray-800/50",
className,
itemClassName,
)}
aria-selected={ariaSelected}
role={"listitem"}

View File

@@ -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,
};

View File

@@ -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")}>

View File

@@ -101,7 +101,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);
};
@@ -302,8 +302,11 @@ export function DataTableContent<TData, TValue>({
setGlobalSearch("");
setRowSelection?.({});
onFilterReset?.();
setSearchKey((prev) => (prev === 0 ? 1 : 0));
};
const [searchKey, setSearchKey] = useState(0);
return (
<div className={cn("relative table-fixed-scroll", className)}>
{showSearchAndFilters && (
@@ -316,6 +319,7 @@ export function DataTableContent<TData, TValue>({
<DataTableGlobalSearch
className={searchClassName}
disabled={!hasInitialData}
key={searchKey}
globalSearch={globalSearch}
setGlobalSearch={(val) => {
table.setPageIndex(0);

View 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>
);
};

View File

@@ -1,7 +1,8 @@
import { Input } from "@components/Input";
import Kbd from "@components/Kbd";
import { useDebounce } from "@hooks/useDebounce";
import { Search } from "lucide-react";
import React from "react";
import React, { useEffect, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
interface Props extends React.InputHTMLAttributes<HTMLInputElement> {
@@ -17,9 +18,16 @@ export default function DataTableGlobalSearch({
...props
}: Props) {
const ref = React.useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useState(globalSearch || "");
const debouncedValue = useDebounce(inputValue, 300);
// Call setGlobalSearch when debounced value changes
useEffect(() => {
setGlobalSearch(debouncedValue);
}, [debouncedValue]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setGlobalSearch(e.target.value);
setInputValue(e.target.value);
};
useHotkeys("mod+k", () => ref.current?.focus(), []);
@@ -29,7 +37,7 @@ export default function DataTableGlobalSearch({
{...props}
ref={ref}
icon={<Search size={15} />}
value={globalSearch}
value={inputValue} // Shows immediate updates
onChange={handleChange}
maxWidthClass={className}
customSuffix={<Kbd> K</Kbd>}

View File

@@ -7,6 +7,7 @@ import {
ChevronsLeft,
ChevronsRight,
} from "lucide-react";
import { useEffect } from "react";
interface DataTablePaginationProps<TData> {
table: Table<TData>;
@@ -27,6 +28,13 @@ export function DataTablePagination<TData>({
const showingTo = isLastPage ? allRows : showingFrom + rowsPerPage - 1;
const pageCount = table.getPageCount();
// Reset page index if it's greater than the page count
useEffect(() => {
if (currentPage > pageCount) {
table.setPageIndex(0);
}
}, []);
return pageCount > 1 ? (
<div className={cn("flex items-center justify-between", paginationPadding)}>
<div className="text-nb-gray-400">
@@ -53,7 +61,7 @@ export function DataTablePagination<TData>({
</ButtonGroup.Button>
<ButtonGroup.Button>
<div>
{table.getState().pagination.pageIndex + 1} of {pageCount}
{currentPage} of {pageCount}
</div>
</ButtonGroup.Button>
<ButtonGroup.Button

View 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>
);
};

View File

@@ -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>
</>
);
}

View File

@@ -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>

View File

@@ -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 && (

View File

@@ -13,6 +13,9 @@ type Props = {
onRemove: () => void;
onError?: (error: boolean) => void;
error?: string;
disabled?: boolean;
preventLeadingAndTrailingDots?: boolean;
allowWildcard?: boolean;
};
enum ActionType {
ADD = "ADD",
@@ -38,6 +41,9 @@ export default function InputDomain({
onChange,
onRemove,
onError,
disabled,
preventLeadingAndTrailingDots,
allowWildcard = true,
}: Readonly<Props>) {
const [name, setName] = useState(value?.name || "");
@@ -50,7 +56,11 @@ export default function InputDomain({
if (name == "") {
return "";
}
const valid = validator.isValidDomain(name);
const valid = validator.isValidDomain(name, {
allowOnlyTld: true,
allowWildcard,
preventLeadingAndTrailingDots,
});
if (!valid) {
return "Please enter a valid domain, e.g. example.com or intra.example.com";
}
@@ -74,6 +84,7 @@ export default function InputDomain({
value={name}
error={domainError}
onChange={handleNameChange}
disabled={disabled}
/>
</div>
@@ -81,6 +92,7 @@ export default function InputDomain({
className={"h-[42px]"}
variant={"default-outline"}
onClick={onRemove}
disabled={disabled}
>
<MinusCircleIcon size={15} />
</Button>

View File

@@ -8,6 +8,7 @@ import {
} from "@components/Tooltip";
import GroupBadge from "@components/ui/GroupBadge";
import PeerBadge from "@components/ui/PeerBadge";
import { cn } from "@utils/helpers";
import { orderBy } from "lodash";
import { ArrowRightIcon } from "lucide-react";
import * as React from "react";
@@ -18,25 +19,34 @@ type Props = {
groups: Group[];
label?: string;
description?: string;
onClick?: () => void;
className?: string;
};
export default function MultipleGroups({
groups,
label = "Assigned Groups",
description = "Use groups to control what this peer can access",
}: Props) {
onClick,
className,
}: Readonly<Props>) {
if (!groups) return <EmptyRow />;
const orderedGroups = orderBy(groups, ["peers_count", "name"], ["desc"]);
const firstGroup = orderedGroups.length > 0 ? orderedGroups[0] : undefined;
const otherGroups = orderedGroups.length > 0 ? orderedGroups.slice(1) : [];
return (
<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} />}
{otherGroups && otherGroups.length > 0 && (
@@ -51,7 +61,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>

View 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>
);
};

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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`,
}}

View File

@@ -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>

View File

@@ -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)}

View File

@@ -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>
);
};

View File

@@ -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);

View File

@@ -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);

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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"}

View File

@@ -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);

View 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);

View File

@@ -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;
};

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;
@@ -14,5 +18,7 @@ export interface Account {
jwt_allow_groups: string[];
regular_users_view_blocked: boolean;
routing_peer_dns_resolution_enabled: boolean;
dns_domain: string;
lazy_connection_enabled: boolean;
};
}

View File

@@ -0,0 +1,7 @@
export interface Pagination<T> {
data: T;
page: number;
page_size: number;
total_pages: number;
total_records: number;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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",
}

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -2,10 +2,9 @@
import Button from "@components/Button";
import { AnnouncementBanner } from "@components/ui/AnnouncementBanner";
import DarkModeToggle from "@components/ui/DarkModeToggle";
import UserDropdown from "@components/ui/UserDropdown";
import { cn } from "@utils/helpers";
import { MenuIcon } from "lucide-react";
import { MenuIcon, PanelLeftCloseIcon, PanelLeftOpenIcon } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import React, { useMemo } from "react";
@@ -13,7 +12,7 @@ import NetBirdLogo from "@/assets/netbird.svg";
import NetBirdLogoFull from "@/assets/netbird-full.svg";
import { useAnnouncement } from "@/contexts/AnnouncementProvider";
import { useApplicationContext } from "@/contexts/ApplicationProvider";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
export const headerHeight = 75;
@@ -32,7 +31,7 @@ export default function NavbarWithDropdown() {
src={NetBirdLogo}
width={30}
alt={"NetBird Logo"}
className={"md:hidden"}
className={"md:hidden ml-4"}
/>
</>
);
@@ -40,7 +39,7 @@ export default function NavbarWithDropdown() {
const { toggleMobileNav } = useApplicationContext();
const { bannerHeight } = useAnnouncement();
const { permission } = useLoggedInUser();
const { isRestricted } = usePermissions();
return (
<>
@@ -62,8 +61,7 @@ export default function NavbarWithDropdown() {
<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 +71,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"
}
>
{Logo}
</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 +96,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>
)
);
};

View File

@@ -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>
);
}

View File

@@ -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}

View File

@@ -41,6 +41,7 @@ import {
} from "lucide-react";
import React, { useMemo, useState } from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import { Policy, Protocol } from "@/interfaces/Policy";
import { PostureCheck } from "@/interfaces/PostureCheck";
@@ -126,6 +127,8 @@ export function AccessControlModalContent({
initialName,
initialDescription,
}: Readonly<ModalProps>) {
const { permission } = usePermissions();
const {
portAndDirectionDisabled,
destinationGroups,
@@ -151,6 +154,8 @@ export function AccessControlModalContent({
getPolicyData,
destinationResource,
setDestinationResource,
portRanges,
setPortRanges,
} = useAccessControl({
policy,
postureCheckTemplates,
@@ -163,15 +168,13 @@ export function AccessControlModalContent({
const [tab, setTab] = useState(() => {
if (!cell) return "policy";
if (cell == "posture_checks") return "posture_checks";
if (cell == "name") return "general";
return "policy";
});
const continuePostureChecksDisabled = useMemo(() => {
if (direction != "bi" && ports.length == 0) return true;
if (sourceGroups.length > 0 && destinationResource) return false;
if (sourceGroups.length == 0 || destinationGroups.length == 0) return true;
}, [sourceGroups, destinationGroups, direction, ports, destinationResource]);
}, [sourceGroups, destinationGroups, destinationResource]);
const submitDisabled = useMemo(() => {
if (name.length == 0) return true;
@@ -182,9 +185,11 @@ export function AccessControlModalContent({
setProtocol(p);
if (p == "icmp") {
setPorts([]);
setPortRanges([]);
}
if (p == "all") {
setPorts([]);
setPortRanges([]);
}
if (p == "tcp" || p == "udp") {
setDirection("in");
@@ -250,6 +255,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,6 +293,9 @@ export function AccessControlModalContent({
values={sourceGroups}
saveGroupAssignments={useSave}
showResourceCounter={false}
disabled={
!permission.policies.update || !permission.policies.create
}
/>
</div>
<PolicyDirection
@@ -311,6 +322,9 @@ export function AccessControlModalContent({
onResourceChange={setDestinationResource}
showResources={true}
placeholder={"Select destination(s)..."}
disabled={
!permission.policies.update || !permission.policies.create
}
/>
</div>
</div>
@@ -328,14 +342,16 @@ export function AccessControlModalContent({
</Label>
<HelpText>
Allow network traffic and access only to specified ports.
Select ports between 1 and 65535.
Select ports or port ranges between 1 and 65535.
</HelpText>
</div>
<div className={""}>
<PortSelector
showAll={direction == "bi"}
values={ports}
onChange={setPorts}
showAll={true}
ports={ports}
onPortsChange={setPorts}
portRanges={portRanges}
onPortRangesChange={setPortRanges}
disabled={portAndDirectionDisabled}
/>
</div>
@@ -344,6 +360,9 @@ export function AccessControlModalContent({
<FancyToggleSwitch
value={enabled}
onChange={setEnabled}
disabled={
!permission.policies.update || !permission.policies.create
}
label={
<>
<Power size={15} />
@@ -373,6 +392,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 +410,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 +478,7 @@ export function AccessControlModalContent({
<Button
variant={"primary"}
disabled={submitDisabled}
disabled={submitDisabled || !permission.policies.create}
onClick={submit}
data-cy={"submit-policy"}
>
@@ -470,7 +495,7 @@ export function AccessControlModalContent({
</ModalClose>
<Button
variant={"primary"}
disabled={submitDisabled}
disabled={submitDisabled || !permission.policies.update}
onClick={() => {
if (useSave) {
submit();

View File

@@ -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>

View File

@@ -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)}

View File

@@ -5,29 +5,45 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@components/Tooltip";
import { orderBy } from "lodash";
import React, { useMemo } from "react";
import { Policy } from "@/interfaces/Policy";
type Props = {
policy: Policy;
};
export default function AccessControlPortsCell({ policy }: Props) {
const firstRule = useMemo(() => {
export default function AccessControlPortsCell({ policy }: Readonly<Props>) {
const rule = useMemo(() => {
if (policy.rules.length > 0) return policy.rules[0];
return undefined;
}, [policy]);
const hasPorts = firstRule?.ports && firstRule?.ports.length > 0;
const hasPorts = rule?.ports && rule?.ports?.length > 0;
const hasPortRanges = rule?.port_ranges && rule?.port_ranges?.length > 0;
const hasAnyPorts = hasPorts || hasPortRanges;
const allPorts = useMemo(() => {
const ports = rule?.ports ?? [];
const portRanges =
rule?.port_ranges?.map((r) => {
if (r.start === r.end) return `${r.start}`;
return `${r.start}-${r.end}`;
}) ?? [];
return orderBy(
[...portRanges, ...ports],
[(p) => Number(p.split("-")[0])],
["asc"],
);
}, [rule]);
const firstTwoPorts = useMemo(() => {
if (!hasPorts) return [];
return firstRule?.ports.slice(0, 2) ?? [];
}, [hasPorts, firstRule]);
return allPorts?.slice(0, 2) ?? [];
}, [allPorts]);
const otherPorts = useMemo(() => {
if (!hasPorts) return [];
return firstRule?.ports.slice(2) ?? [];
}, [hasPorts, firstRule]);
return allPorts?.slice(2) ?? [];
}, [allPorts]);
return (
<div className={"flex-1"}>
@@ -35,7 +51,7 @@ export default function AccessControlPortsCell({ policy }: Props) {
<Tooltip delayDuration={1}>
<TooltipTrigger asChild={true}>
<div className={"inline-flex items-center gap-2"}>
{!hasPorts && (
{!hasAnyPorts && (
<Badge
variant={"gray"}
className={"uppercase tracking-wider font-medium"}
@@ -44,20 +60,19 @@ export default function AccessControlPortsCell({ policy }: Props) {
</Badge>
)}
{firstTwoPorts &&
firstTwoPorts.map((port) => {
return (
<Badge
key={port}
variant={"gray"}
className={
"px-3 gap-2 whitespace-nowrap uppercase tracking-wider font-medium"
}
>
{port}
</Badge>
);
})}
{firstTwoPorts?.map((port) => {
return (
<Badge
key={port}
variant={"gray"}
className={
"px-3 gap-2 whitespace-nowrap uppercase tracking-wider font-medium"
}
>
{port}
</Badge>
);
})}
{otherPorts && otherPorts.length > 0 && (
<Badge
@@ -73,9 +88,17 @@ export default function AccessControlPortsCell({ policy }: Props) {
</TooltipTrigger>
{otherPorts && otherPorts.length > 0 && (
<TooltipContent>
<div className={"flex flex-col gap-2 items-start mt-3 mb-2"}>
<div
className={
"flex gap-2 items-start mt-3 mb-2 flex-wrap max-w-sm"
}
>
{otherPorts.map((port) => {
return <Badge key={port}>{port}</Badge>;
return (
<Badge key={port} variant={"gray"}>
{port}
</Badge>
);
})}
</div>
</TooltipContent>

View File

@@ -2,12 +2,17 @@ import Badge from "@components/Badge";
import { IconCirclePlus } from "@tabler/icons-react";
import { ShieldCheck } from "lucide-react";
import React from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Policy } from "@/interfaces/Policy";
type Props = {
policy: Policy;
};
export default function AccessControlPostureCheckCell({ policy }: Props) {
const { permission } = usePermissions();
const isDisabled = !permission.policies.create || !permission.policies.update;
return policy.source_posture_checks &&
policy.source_posture_checks.length > 0 ? (
<div className={"flex"}>
@@ -18,7 +23,14 @@ export default function AccessControlPostureCheckCell({ policy }: Props) {
</div>
) : (
<div className={"flex"}>
<Badge variant={"gray"} useHover={true}>
<Badge
variant={"gray"}
useHover={!isDisabled}
onClick={(e) => {
if (isDisabled) e.stopPropagation();
}}
disabled={isDisabled}
>
<IconCirclePlus size={14} />
Add Posture Check
</Badge>

View File

@@ -8,11 +8,13 @@ import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
import GetStartedTest from "@components/ui/GetStartedTest";
import type { ColumnDef, SortingState } from "@tanstack/react-table";
import { removeAllSpaces } from "@utils/helpers";
import { ExternalLinkIcon, PlusCircle } from "lucide-react";
import { usePathname } from "next/navigation";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import type { Policy } from "@/interfaces/Policy";
import AccessControlModal, {
@@ -36,16 +38,20 @@ type Props = {
export const AccessControlTableColumns: ColumnDef<Policy>[] = [
{
accessorKey: "name",
id: "name",
accessorFn: (row) => removeAllSpaces(row?.name),
header: ({ column }) => {
return <DataTableHeader column={column}>Name</DataTableHeader>;
},
sortingFn: "text",
filterFn: "fuzzy",
cell: ({ cell }) => <AccessControlNameCell policy={cell.row.original} />,
},
{
accessorKey: "description",
id: "description",
accessorFn: (row) => removeAllSpaces(row?.description),
sortingFn: "text",
filterFn: "fuzzy",
},
{
id: "enabled",
@@ -166,9 +172,10 @@ export default function AccessControlTable({
policies,
isLoading,
headingTarget,
}: Props) {
}: Readonly<Props>) {
const { mutate } = useSWRConfig();
const path = usePathname();
const { permission } = usePermissions();
// Default sorting state of the table
const [sorting, setSorting] = useLocalStorage<SortingState>(
@@ -230,7 +237,10 @@ export default function AccessControlTable({
button={
<div className={"flex gap-4 items-center justify-center"}>
<AccessControlModal>
<Button variant={"primary"} className={""}>
<Button
variant={"primary"}
disabled={!permission.policies.create}
>
<PlusCircle size={16} />
Add Policy
</Button>
@@ -256,7 +266,11 @@ export default function AccessControlTable({
{policies && policies?.length > 0 && (
<div className={"flex ml-auto gap-4"}>
<AccessControlModal>
<Button variant={"primary"} className={"ml-auto"}>
<Button
variant={"primary"}
className={"ml-auto"}
disabled={!permission.policies.create}
>
<PlusCircle size={16} />
Add Policy
</Button>

View File

@@ -6,7 +6,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useSWRConfig } from "swr";
import { usePolicies } from "@/contexts/PoliciesProvider";
import { Group } from "@/interfaces/Group";
import { Policy, Protocol } from "@/interfaces/Policy";
import { Policy, PortRange, Protocol } from "@/interfaces/Policy";
import { PostureCheck } from "@/interfaces/PostureCheck";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { usePostureCheck } from "@/modules/posture-checks/usePostureCheck";
@@ -83,6 +83,15 @@ export const useAccessControl = ({
return [];
});
const [portRanges, setPortRanges] = useState<PortRange[]>(() => {
if (!firstRule) return [];
if (firstRule.port_ranges == undefined) return [];
if (firstRule.port_ranges.length > 0) {
return firstRule.port_ranges;
}
return [];
});
const [protocol, setProtocol] = useState<Protocol>(
firstRule ? firstRule.protocol : "all",
);
@@ -139,6 +148,11 @@ export const useAccessControl = ({
destinations = tmp;
}
const [newPorts, newPortRanges] = parseAccessControlPorts(
ports,
portRanges,
);
return {
name,
description,
@@ -155,7 +169,8 @@ export const useAccessControl = ({
action: "accept",
protocol,
enabled,
ports: ports.length > 0 ? ports.map((p) => p.toString()) : undefined,
ports: newPorts,
port_ranges: newPortRanges,
},
],
} as Policy;
@@ -169,7 +184,10 @@ export const useAccessControl = ({
);
const groups = await Promise.all(
createOrUpdateGroups.map((call) => call()),
);
).then((groups) => {
mutate("/groups");
return groups;
});
// Create posture checks if they don't have an ID
let hasError = false;
@@ -203,6 +221,11 @@ export const useAccessControl = ({
destinations = tmp;
}
const [newPorts, newPortRanges] = parseAccessControlPorts(
ports,
portRanges,
);
const policyObj = {
name,
description,
@@ -221,7 +244,8 @@ export const useAccessControl = ({
sources,
destinations: destinationResource ? undefined : destinations,
destinationResource: destinationResource || undefined,
ports: ports.length > 0 ? ports.map((p) => p.toString()) : undefined,
ports: newPorts,
port_ranges: newPortRanges,
},
],
} as Policy;
@@ -264,6 +288,8 @@ export const useAccessControl = ({
setEnabled,
ports,
setPorts,
portRanges,
setPortRanges,
sourceGroups,
setSourceGroups,
destinationGroups,
@@ -278,3 +304,19 @@ export const useAccessControl = ({
setDestinationResource,
} as const;
};
const parseAccessControlPorts = (ports: number[], portRanges: PortRange[]) => {
const hasRanges = portRanges.length > 0;
const hasPorts = ports.length > 0;
if (!hasPorts && !hasRanges) return [undefined, undefined];
if (!hasRanges) return [ports.map(String), undefined];
if (!hasPorts) return [undefined, portRanges];
const portRangesFromPorts = ports.map((port) => ({
start: port,
end: port,
})) as PortRange[];
const allRanges = [...portRanges, ...portRangesFromPorts];
return [undefined, allRanges];
};

View File

@@ -5,6 +5,7 @@ import { Trash2 } from "lucide-react";
import * as React from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useUserContext } from "@/contexts/UserProvider";
import { AccessToken } from "@/interfaces/AccessToken";
import { SetupKey } from "@/interfaces/SetupKey";
@@ -12,8 +13,11 @@ import { SetupKey } from "@/interfaces/SetupKey";
type Props = {
access_token: AccessToken;
};
export default function AccessTokenActionCell({ access_token }: Props) {
export default function AccessTokenActionCell({
access_token,
}: Readonly<Props>) {
const { user } = useUserContext();
const { permission } = usePermissions();
const { confirm } = useDialog();
const { mutate } = useSWRConfig();
const deleteRequest = useApiCall<SetupKey>(
@@ -47,6 +51,7 @@ export default function AccessTokenActionCell({ access_token }: Props) {
return (
<div className={"flex justify-end pr-4"}>
<Button
disabled={!permission.pats.delete}
variant={"danger-outline"}
size={"sm"}
onClick={handleConfirm}

View File

@@ -64,9 +64,10 @@ export const AccessTokensTableColumns: ColumnDef<AccessToken>[] = [
},
];
export default function AccessTokensTable({ user }: Props) {
export default function AccessTokensTable({ user }: Readonly<Props>) {
const { data: tokens } = useFetchApi<AccessToken[]>(
`/users/${user.id}/tokens`,
true,
);
const path = usePathname();
@@ -83,35 +84,33 @@ export default function AccessTokensTable({ user }: Props) {
);
return (
<>
<UserProvider user={user}>
<Card className={"mt-5 w-full"}>
{tokens && tokens.length > 0 ? (
<DataTable
text={"Access Tokens"}
tableClassName={"mt-0"}
minimal={true}
showSearchAndFilters={false}
inset={false}
sorting={sorting}
setSorting={setSorting}
columns={AccessTokensTableColumns}
data={tokens}
<UserProvider user={user}>
<Card className={"mt-5 w-full"}>
{tokens && tokens.length > 0 ? (
<DataTable
text={"Access Tokens"}
tableClassName={"mt-0"}
minimal={true}
showSearchAndFilters={false}
inset={false}
sorting={sorting}
setSorting={setSorting}
columns={AccessTokensTableColumns}
data={tokens}
/>
) : (
<div className={"bg-nb-gray-950 overflow-hidden"}>
<NoResults
className={"py-3"}
title={"No access tokens"}
description={
"You don't have any access tokens yet. You can add a token to access the NetBird API."
}
icon={<IconApi size={20} className={"fill-nb-gray-300"} />}
/>
) : (
<div className={"bg-nb-gray-950 overflow-hidden"}>
<NoResults
className={"py-3"}
title={"No access tokens"}
description={
"You don't have any access tokens yet. You can add a token to access the NetBird API."
}
icon={<IconApi size={20} className={"fill-nb-gray-300"} />}
/>
</div>
)}
</Card>
</UserProvider>
</>
</div>
)}
</Card>
</UserProvider>
);
}

View File

@@ -37,7 +37,10 @@ type Props = {
user: User;
};
const copyMessage = "Access token was copied to your clipboard!";
export default function CreateAccessTokenModal({ children, user }: Props) {
export default function CreateAccessTokenModal({
children,
user,
}: Readonly<Props>) {
const [modal, setModal] = useState(false);
const [successModal, setSuccessModal] = useState(false);
const [token, setToken] = useState<string>("");
@@ -125,7 +128,10 @@ type ModalProps = {
user: User;
};
export function AccessTokenModalContent({ onSuccess, user }: ModalProps) {
export function AccessTokenModalContent({
onSuccess,
user,
}: Readonly<ModalProps>) {
const tokenRequest = useApiCall<AccessToken>(`/users/${user.id}/tokens`);
const { mutate } = useSWRConfig();

View File

@@ -1,9 +1,17 @@
import useFetchApi from "@utils/api";
import { useMemo } from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Account } from "@/interfaces/Account";
export const useAccount = () => {
const { data: accounts } = useFetchApi<Account[]>("/accounts", true, true);
const { permission } = usePermissions();
const { data: accounts } = useFetchApi<Account[]>(
"/accounts",
true,
true,
permission.accounts.read,
);
return useMemo(() => {
if (!accounts) return;

View File

@@ -1,33 +1,59 @@
import Card from "@components/Card";
import { SmallBadge } from "@components/ui/SmallBadge";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import { cn, generateColorFromString } from "@utils/helpers";
import { cn, generateColorFromUser } from "@utils/helpers";
import dayjs from "dayjs";
import { AlertCircle, ArrowUpRight, Cog, PlusIcon, XIcon } from "lucide-react";
import React, { useMemo } from "react";
import { useUsers } from "@/contexts/UsersProvider";
import { ActivityEvent } from "@/interfaces/ActivityEvent";
import { User } from "@/interfaces/User";
import ActivityDescription from "@/modules/activity/ActivityDescription";
import ActivityTypeIcon from "@/modules/activity/ActivityTypeIcon";
import { getColorFromCode } from "@/modules/activity/utils";
export type ActionColor = "green" | "red" | "blue-darker" | "netbird";
const ActionIcons: Record<ActionColor, React.ReactNode> = {
green: <PlusIcon size={12} />,
red: <XIcon size={12} />,
"blue-darker": <ArrowUpRight size={12} />,
netbird: <AlertCircle size={12} />,
};
export const ActivityEntryRow = ({ event }: { event: ActivityEvent }) => {
const { users } = useUsers();
const user = users
? users.find((user) => user.id === event.initiator_id)
: undefined;
const getActivityUser = () => {
let user;
const findFromCurrentUsers = users?.find(
(user) => user.id === event.initiator_id,
);
if (findFromCurrentUsers) {
user = findFromCurrentUsers;
return user;
}
const icons = {
green: <PlusIcon size={12} />,
"blue-darker": <ArrowUpRight size={12} />,
red: <XIcon size={12} />,
netbird: <AlertCircle size={12} />,
// Check if user has an email & name
if (event?.initiator_email && event?.initiator_name) {
return {
id: event.initiator_id,
email: event.initiator_email,
name: event.initiator_name,
} as User;
}
return undefined;
};
const user = getActivityUser();
const color = useMemo(() => {
return getColorFromCode(event.activity_code);
}, [event.activity_code]);
const isExternal = !!event?.meta?.external;
return (
<div className={"flex items-start gap-6 relative max-w-[735px] pb-10"}>
<VerticalLine />
@@ -47,7 +73,7 @@ export const ActivityEntryRow = ({ event }: { event: ActivityEvent }) => {
color == "netbird" && "bg-netbird-950 text-netbird-500",
)}
>
{color && icons[color]}
{color && ActionIcons[color as ActionColor]}
</div>
</div>
@@ -60,11 +86,7 @@ export const ActivityEntryRow = ({ event }: { event: ActivityEvent }) => {
"w-4 h-4 rounded-full flex items-center justify-center text-white uppercase text-[9px] font-medium bg-nb-gray-900"
}
style={{
color: user?.name
? generateColorFromString(
user?.name || user?.id || "System User",
)
: "#808080",
color: generateColorFromUser(user),
}}
>
{!user?.name && !user?.id && <Cog size={12} />}
@@ -80,6 +102,17 @@ export const ActivityEntryRow = ({ event }: { event: ActivityEvent }) => {
<span className={"text-sm text-nb-gray-400 font-light"}>
<TextWithTooltip text={user?.email || ""} maxChars={20} />
</span>
{isExternal && (
<span className={"flex items-center"}>
<SmallBadge
text={"External"}
variant={"sky"}
className={
"text-[10px] py-[0.2rem] px-1.5 rounded-full leading-none -top-0"
}
/>
</span>
)}
</div>
</div>

View File

@@ -3,7 +3,6 @@ import { Checkbox } from "@components/Checkbox";
import { CommandItem } from "@components/Command";
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
import { ScrollArea } from "@components/ScrollArea";
import { IconArrowBack } from "@tabler/icons-react";
import { cn } from "@utils/helpers";
import { Command, CommandGroup, CommandInput, CommandList } from "cmdk";
import { trim, uniqBy } from "lodash";
@@ -116,7 +115,7 @@ export function ActivityEventCodeSelector({
"min-h-[42px] w-full relative",
"border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center",
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
"dark:placeholder:text-neutral-500 font-light placeholder:text-neutral-500 pl-10",
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-nb-gray-500 pl-10",
)}
ref={searchRef}
value={search}
@@ -132,19 +131,6 @@ export function ActivityEventCodeSelector({
<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>
<ScrollArea

View File

@@ -9,9 +9,10 @@ import AddPeerButton from "@components/ui/AddPeerButton";
import GetStartedTest from "@components/ui/GetStartedTest";
import { ColumnDef, SortingState } from "@tanstack/react-table";
import dayjs from "dayjs";
import { uniqBy } from "lodash";
import { ExternalLinkIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import { DateRange } from "react-day-picker";
import { useSWRConfig } from "swr";
import PeerIcon from "@/assets/icons/PeerIcon";
@@ -19,7 +20,10 @@ import { useLocalStorage } from "@/hooks/useLocalStorage";
import { ActivityEvent } from "@/interfaces/ActivityEvent";
import { ActivityEntryRow } from "@/modules/activity/ActivityEntryRow";
import { ActivityEventCodeSelector } from "@/modules/activity/ActivityEventCodeSelector";
import { ActivityUserSelector } from "@/modules/activity/ActivityUserSelector";
import {
UsersDropdownSelector,
UserSelectOption,
} from "@/modules/activity/UsersDropdownSelector";
type Props = {
events?: ActivityEvent[];
@@ -102,6 +106,18 @@ export default function ActivityTable({
to: dayjs(initialDateRange?.to).toDate(),
});
const userSelectOptions = useMemo(() => {
const uniqueUsers = uniqBy(events, (event) => event.initiator_email);
return uniqueUsers.map((event) => {
return {
name: event.initiator_name,
id: event.initiator_id,
email: event.initiator_email || "NetBird",
external: !!event?.meta?.external,
} as UserSelectOption;
});
}, [events]);
return (
<DataTable
headingTarget={headingTarget}
@@ -109,12 +125,12 @@ export default function ActivityTable({
tableClassName={"px-8 mt-10"}
paginationClassName={"max-w-[800px]"}
as={"div"}
text={"Activity Events"}
text={"Audit Events"}
sorting={sorting}
setSorting={setSorting}
columns={ActivityFeedColumnsTable}
data={events}
searchPlaceholder={"Search by activity name..."}
searchPlaceholder={"Search by audit name, user, peer, meta..."}
isLoading={isLoading}
columnVisibility={{
timestamp: false,
@@ -191,8 +207,8 @@ export default function ActivityTable({
/>
)}
{events && (
<ActivityUserSelector
events={events}
<UsersDropdownSelector
options={userSelectOptions}
value={
(table
.getColumn("initiator_email")
@@ -211,7 +227,7 @@ export default function ActivityTable({
<DataTableRefreshButton
isDisabled={events?.length == 0}
onClick={() => {
mutate("/events").then();
mutate("/events/audit").then();
}}
/>
</>

View File

@@ -10,6 +10,7 @@ import {
KeyRound,
Layers3Icon,
LogIn,
type LucideIcon,
MonitorSmartphoneIcon,
NetworkIcon,
RefreshCcw,
@@ -28,79 +29,44 @@ type Props = {
const DEFAULT_CLASSES = "shrink-0";
type ActivityTypeKey = keyof typeof ActivityTypeMappings;
const ActivityTypeMappings = {
peer: MonitorSmartphoneIcon,
user: User,
account: Cog,
rule: ArrowLeftRight,
policy: Shield,
setupkey: KeyRound,
group: FolderGit2,
route: NetworkIcon,
dns: Globe,
nameserver: Server,
dashboard: LogIn,
integration: Blocks,
personal: User,
service: Cog,
billing: CreditCardIcon,
integrated: ShieldCheck,
posture: ShieldCheck,
transferred: RefreshCcw,
resource: Layers3Icon,
network: NetworkIcon,
} as const satisfies Record<string, LucideIcon>;
export default function ActivityTypeIcon({
code,
size = 18,
className,
}: Props) {
if (code.startsWith("peer")) {
return (
<MonitorSmartphoneIcon
size={size}
className={cn(DEFAULT_CLASSES, className)}
/>
);
} else if (code.startsWith("user")) {
return <User size={size} className={cn(DEFAULT_CLASSES, className)} />;
} else if (code.startsWith("account")) {
return <User size={size} className={cn(DEFAULT_CLASSES, className)} />;
} else if (code.startsWith("rule")) {
return (
<ArrowLeftRight size={size} className={cn(DEFAULT_CLASSES, className)} />
);
} else if (code.startsWith("policy")) {
return <Shield size={size} className={cn(DEFAULT_CLASSES, className)} />;
} else if (code.startsWith("setupkey")) {
return <KeyRound size={size} className={cn(DEFAULT_CLASSES, className)} />;
} else if (code.startsWith("group")) {
return (
<FolderGit2 size={size} className={cn(DEFAULT_CLASSES, className)} />
);
} else if (code.startsWith("route")) {
return (
<NetworkIcon size={size} className={cn(DEFAULT_CLASSES, className)} />
);
} else if (code.startsWith("dns")) {
return <Globe size={size} className={cn(DEFAULT_CLASSES, className)} />;
} else if (code.startsWith("nameserver")) {
return <Server size={size} className={cn(DEFAULT_CLASSES, className)} />;
} else if (code.startsWith("dashboard")) {
return <LogIn size={size} className={cn(DEFAULT_CLASSES, className)} />;
} else if (code.startsWith("integration")) {
return <Blocks size={size} className={cn(DEFAULT_CLASSES, className)} />;
} else if (code.startsWith("account")) {
return <User size={size} className={cn(DEFAULT_CLASSES, className)} />;
} else if (code.startsWith("personal")) {
return <User size={size} className={cn(DEFAULT_CLASSES, className)} />;
} else if (code.startsWith("service")) {
return <Cog size={size} className={cn(DEFAULT_CLASSES, className)} />;
} else if (code.startsWith("billing")) {
return (
<CreditCardIcon size={size} className={cn(DEFAULT_CLASSES, className)} />
);
} else if (code.startsWith("integrated")) {
return (
<ShieldCheck size={size} className={cn(DEFAULT_CLASSES, className)} />
);
} else if (code.startsWith("posture")) {
return (
<ShieldCheck size={size} className={cn(DEFAULT_CLASSES, className)} />
);
} else if (code.startsWith("transferred")) {
return (
<RefreshCcw size={size} className={cn(DEFAULT_CLASSES, className)} />
);
} else if (code.startsWith("resource")) {
return (
<Layers3Icon size={size} className={cn(DEFAULT_CLASSES, className)} />
);
} else if (code.startsWith("network")) {
return (
<NetworkIcon size={size} className={cn(DEFAULT_CLASSES, className)} />
);
} else {
return (
<HelpCircleIcon size={size} className={cn(DEFAULT_CLASSES, className)} />
);
}
const prefixParts = code?.split(".") || [];
const prefix = (prefixParts[0] || "").toLowerCase();
// Check if prefix is a valid key, otherwise use fallback
const Icon =
prefix in ActivityTypeMappings
? ActivityTypeMappings[prefix as ActivityTypeKey]
: HelpCircleIcon;
return <Icon size={size} className={cn(DEFAULT_CLASSES, className)} />;
}

View File

@@ -2,31 +2,39 @@ import Button from "@components/Button";
import { CommandItem } from "@components/Command";
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
import { ScrollArea } from "@components/ScrollArea";
import { SmallBadge } from "@components/ui/SmallBadge";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import { IconArrowBack } from "@tabler/icons-react";
import { cn, generateColorFromString } from "@utils/helpers";
import { Command, CommandGroup, CommandInput, CommandList } from "cmdk";
import { trim, uniqBy } from "lodash";
import { sortBy, trim, uniqBy } from "lodash";
import { ChevronsUpDown, Cog, SearchIcon, UserCircle2 } from "lucide-react";
import * as React from "react";
import { useMemo, useState } from "react";
import { useElementSize } from "@/hooks/useElementSize";
import { ActivityEvent } from "@/interfaces/ActivityEvent";
import { SmallUserAvatar } from "@/modules/users/SmallUserAvatar";
interface MultiSelectProps {
interface Props {
value?: string;
onChange: (item: string | undefined) => void;
disabled?: boolean;
popoverWidth?: "auto" | number;
events: ActivityEvent[];
options: UserSelectOption[];
}
export function ActivityUserSelector({
export type UserSelectOption = {
id: string;
name: string;
email: string;
external?: boolean;
};
export function UsersDropdownSelector({
onChange,
value,
disabled = false,
popoverWidth = 250,
events,
}: MultiSelectProps) {
options,
}: Props) {
const searchRef = React.useRef<HTMLInputElement>(null);
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
const [search, setSearch] = useState("");
@@ -44,20 +52,16 @@ export function ActivityUserSelector({
const [open, setOpen] = useState(false);
const users = useMemo(() => {
const uniqueUsers = uniqBy(events, (event) => event.initiator_email);
return uniqueUsers.map((event) => {
return {
name: event.initiator_name,
id: event.initiator_id,
email: event.initiator_email || "NetBird",
};
});
}, [events]);
const sortedOptions = useMemo(() => {
return sortBy(
uniqBy(options, (o) => o.email),
["external", "name"],
);
}, [options]);
const selectedUser = useMemo(() => {
return users.find((user) => user.email == value);
}, [value, users]);
return options.find((user) => user.email == value);
}, [value, options]);
return (
<Popover
@@ -86,13 +90,14 @@ export function ActivityUserSelector({
"w-5 h-5 rounded-full flex items-center justify-center text-white uppercase text-[9px] font-medium bg-nb-gray-900"
}
style={{
color: selectedUser?.name
? generateColorFromString(
selectedUser?.name ||
selectedUser?.id ||
"System User",
)
: "#808080",
color:
selectedUser?.email === "NetBird"
? "#808080"
: generateColorFromString(
selectedUser?.name ||
selectedUser?.id ||
"System User",
),
}}
>
{selectedUser?.email === "NetBird" ? (
@@ -103,8 +108,13 @@ export function ActivityUserSelector({
</div>
<div className={"flex items-center gap-2"}>
<TextWithTooltip
text={selectedUser?.name || "System"}
text={
selectedUser?.email === "NetBird"
? "System"
: selectedUser?.name
}
maxChars={20}
className={"leading-none"}
/>
</div>
</React.Fragment>
@@ -117,7 +127,7 @@ export function ActivityUserSelector({
</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 min-w-[300px]"
style={{
width: popoverWidth === "auto" ? width : popoverWidth,
}}
@@ -142,7 +152,7 @@ export function ActivityUserSelector({
"min-h-[42px] w-full relative",
"border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center",
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
"dark:placeholder:text-neutral-500 font-light placeholder:text-neutral-500 pl-10",
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10",
)}
ref={searchRef}
value={search}
@@ -158,19 +168,6 @@ export function ActivityUserSelector({
<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>
<ScrollArea
@@ -207,11 +204,12 @@ export function ActivityUserSelector({
</div>
</CommandItem>
{users.map((user) => {
const searchValue =
user.email === "NetBird"
? "NetBird System"
: user.name + " " + user.id + " " + user.email;
{sortedOptions.map((user) => {
const isSystemUser = user.email === "NetBird";
const searchValue = isSystemUser
? "NetBird System"
: user.name + " " + user.id + " " + user.email;
return (
<CommandItem
key={user.id}
@@ -223,44 +221,52 @@ export function ActivityUserSelector({
}}
onClick={(e) => e.preventDefault()}
>
<div className={"flex items-center gap-2"}>
<div
className={
"w-7 h-7 rounded-full shrink-0 flex items-center justify-center text-white uppercase text-[12px] font-medium bg-nb-gray-800"
}
style={{
color: user?.name
? generateColorFromString(
user?.name || user?.id || "System User",
)
: "#808080",
}}
>
{user?.email === "NetBird" ? (
<Cog size={14} />
) : (
user?.name?.charAt(0) || user?.id?.charAt(0)
)}
</div>
<div className={"flex items-center gap-2 w-full"}>
<SmallUserAvatar
name={user?.name}
email={user?.email}
id={user?.id}
/>
<div className={"flex flex-col text-xs"}>
<span className={" text-nb-gray-200"}>
<div className={"flex flex-col text-xs w-full"}>
<span
className={
"text-nb-gray-200 flex items-center gap-1.5 w-full"
}
>
<TextWithTooltip
text={
user?.email === "NetBird"
isSystemUser
? "System"
: user?.name || user?.id
}
maxChars={20}
/>
</span>
<span className={"text-nb-gray-400 font-light"}>
<span
className={
"text-nb-gray-400 font-light flex items-center gap-1"
}
>
<TextWithTooltip
text={user?.email || "NetBird"}
maxChars={20}
/>
</span>
</div>
{user.external && (
<span
className={"flex items-center ml-auto relative"}
>
<SmallBadge
text={"External"}
variant={"sky"}
className={
"text-[8.5px] py-[0.15rem] px-[.32rem] leading-none rounded-full -top-0"
}
/>
</span>
)}
</div>
</CommandItem>
);

View File

@@ -1,36 +1,48 @@
export function getColorFromCode(code: string) {
if (code.includes("add")) {
return "green";
} else if (code.includes("join")) {
return "green";
} else if (code.includes("invite")) {
return "green";
} else if (code.includes("create")) {
return "green";
} else if (code.includes("delete")) {
return "red";
} else if (code.includes("update")) {
return "blue-darker";
} else if (code.includes("revoke")) {
return "red";
} else if (code.includes("overuse")) {
return "netbird";
} else if (code.includes("overuse")) {
return "netbird";
} else if (code.includes("enable")) {
return "blue-darker";
} else if (code.includes("disable")) {
return "blue-darker";
} else if (code.includes("rename")) {
return "blue-darker";
} else if (code.includes("block")) {
return "red";
} else if (code.includes("unblock")) {
return "blue-darker";
} else if (code.includes("login")) {
return "blue-darker";
} else if (code.includes("expire")) {
return "netbird";
}
return "blue-darker";
enum ActionStatus {
SUCCESS = "green",
ERROR = "red",
INFO = "blue-darker",
WARNING = "netbird",
}
const ACTION_COLOR_MAPPING: Record<string, ActionStatus> = {
// Success actions
add: ActionStatus.SUCCESS,
join: ActionStatus.SUCCESS,
invite: ActionStatus.SUCCESS,
create: ActionStatus.SUCCESS,
approve: ActionStatus.SUCCESS,
complete: ActionStatus.SUCCESS,
activate: ActionStatus.SUCCESS,
// Error actions
delete: ActionStatus.ERROR,
revoke: ActionStatus.ERROR,
block: ActionStatus.ERROR,
// Warning actions
overuse: ActionStatus.WARNING,
expire: ActionStatus.WARNING,
// Info actions
update: ActionStatus.INFO,
enable: ActionStatus.INFO,
disable: ActionStatus.INFO,
rename: ActionStatus.INFO,
unblock: ActionStatus.INFO,
login: ActionStatus.INFO,
};
export function getColorFromCode(code: string): string {
try {
const matchingAction = Object.keys(ACTION_COLOR_MAPPING).find((action) =>
code.includes(action),
);
return matchingAction
? ACTION_COLOR_MAPPING[matchingAction]
: ActionStatus.INFO;
} catch (error) {
return ActionStatus.INFO;
}
}

View File

@@ -38,7 +38,7 @@ export default function ActiveInactiveRow({
<CircleIcon
active={active}
inactiveDot={inactiveDot}
className={"mt-1 shrink-0"}
className={"mt-[0.34rem] shrink-0"}
/>
<div className={"flex flex-col min-w-0"}>
<div

View File

@@ -16,7 +16,7 @@ import { FolderGit2 } from "lucide-react";
import * as React from "react";
import { useMemo } from "react";
import { useGroups } from "@/contexts/GroupsProvider";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import { Peer } from "@/interfaces/Peer";
import useGroupHelper from "@/modules/groups/useGroupHelper";
@@ -31,6 +31,7 @@ type Props = {
peer?: Peer;
showAddGroupButton?: boolean;
hideAllGroup?: boolean;
disabled: boolean;
};
export default function GroupsRow({
@@ -43,9 +44,10 @@ export default function GroupsRow({
peer,
showAddGroupButton = false,
hideAllGroup = false,
}: Props) {
disabled = false,
}: Readonly<Props>) {
const { groups: allGroups } = useGroups();
const { isUser } = useLoggedInUser();
const { permission } = usePermissions();
// Get the group by the id
const foundGroups = useMemo(() => {
@@ -62,7 +64,7 @@ export default function GroupsRow({
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setModal && !isUser && setModal(true);
setModal && permission.groups.update && setModal(true);
}}
>
{foundGroups?.length == 0 && showAddGroupButton ? (
@@ -81,6 +83,7 @@ export default function GroupsRow({
description={description}
peer={peer}
hideAllGroup={hideAllGroup}
disabled={disabled}
/>
</Modal>
);
@@ -93,6 +96,7 @@ type EditGroupsModalProps = {
description?: string;
peer?: Peer;
hideAllGroup?: boolean;
disabled: boolean;
};
export function EditGroupsModal({
@@ -102,7 +106,8 @@ export function EditGroupsModal({
description,
peer,
hideAllGroup = false,
}: EditGroupsModalProps) {
disabled,
}: Readonly<EditGroupsModalProps>) {
const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] =
useGroupHelper({
initial: groups,
@@ -141,7 +146,7 @@ export function EditGroupsModal({
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button variant={"primary"} onClick={handleSave}>
<Button variant={"primary"} onClick={handleSave} disabled={disabled}>
Save Groups
</Button>
</div>

Some files were not shown because too many files have changed in this diff Show More