Compare commits

...

21 Commits

Author SHA1 Message Date
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
Eduard Gert
61e11d3740 Apply recent cloud changes (#447)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Add resource description, add single resource for acl, add icons for group badges, add inactivity expiration

* Add extra dns labels, remove routing restriction
2025-02-21 15:53:40 +01:00
Edouard Vanbelle
c8e3b50f1b Display serial number on peer information and on peers table (#444)
* display serial number on peer information and on peers table

  * add serial on peers list (included in OS information to minimize informations)
  * permit a lookup via serial number
  * add serial on peer information

* Update os icon to match existing one and hide serial if it does not exist

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2025-01-30 13:14:38 +01:00
Eduard Gert
25be69e7bb Add improvements to new networks features (#439)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Fix wrong ui state for routing peer modal in networks

* Add confirmation dialog when blocking users

* Keep peer sort order when switching pages

* Update sidebar navigation order and remove deprecation notice

* Fix issue when hovering over truncated text in a group badge closes the multiple groups popover

* Update group text in network resource modal

* Update networks page text

* Fix line height

* Add search to resource table

* Switch networks flow to create first resources and then add routers

* Add enabled toggle to routing peers

* Add enabled toggle to network resources

* Add resource group modal and adjust tables

* Clarify networks

* Fix not properly aligned horizontal scroll bar

* Add option to install netbird after creating a setup key

* Fix text for install netbird modal

* Show resources count in group settings

* Fix "no results" and "no routing peers" text showing at the same time

* Fix wording

* Fix resource policy count

* Hide resource count when selection source groups

* Extend networks routing peer modal with option to create a setup key and install netbird

* Add option for horizontal stepper

* Generate setup key when installing netbird from routing peer modal

* Add confirm dialog to let the user know a one-off setup-key will be created. This avoids accidental clicking and later confusion on the setup keys page

---------

Co-authored-by: Misha Bragin <bangvalo@gmail.com>
2025-01-20 16:18:21 +01:00
Eduard Gert
43e5d5cf53 Fix activity search and allow searching for meta fields (#440) 2025-01-15 16:41:55 +01:00
Eduard Gert
18819d6fdf Add confirmation dialog when blocking users (#437) 2025-01-15 16:29:05 +01:00
Eduard Gert
158804c1ac Fix wrong ui state for routing peer modal in networks (#436) 2025-01-15 16:28:50 +01:00
Misha Bragin
14d2d68819 Update links to networks doc (#435)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-12-27 21:48:36 +01:00
Pascal Fischer
40902b3629 add resources to groups update operation (#434)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-12-27 14:20:11 +01:00
Pascal Fischer
fa9bcea4ab Update links for networks concept (#433)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-12-23 18:08:06 +01:00
Eduard Gert
3ba7acdecf Add new networks feature (#427) 2024-12-23 13:20:01 +03:00
Eduard Gert
c7775ade8c Hide groups for regular users (#423)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-11-20 17:50:28 +01:00
Maycon Santos
cd3e75b640 Add setup-key improvements (#420)
Some checks failed
build and push / build_n_push (push) Has been cancelled
- Add support to key deletion
- Add custom and unlimited expiration
2024-11-01 16:04:43 +01:00
Jon "The Nice Guy" Spriggs
f8281c8057 Typo (#418)
Some checks failed
build and push / build_n_push (push) Has been cancelled
Protocol appears to include the : delimiter
2024-10-22 11:19:16 +02:00
260 changed files with 12379 additions and 2684 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"

2025
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,7 @@
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.1.4",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-radio-group": "^1.1.3",
@@ -40,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",
@@ -47,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.383.0",
"next": "13.5.5",
"lucide-react": "^0.479.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",
@@ -73,10 +76,15 @@
"swr": "^2.2.4",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"timescape": "^0.7.1",
"typescript": "^5"
},
"devDependencies": {
"cypress": "^13.3.3",
"@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",
"tailwindcss": "^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

@@ -14,17 +14,27 @@ import { IconSettings2 } from "@tabler/icons-react";
import useFetchApi, { useApiCall } from "@utils/api";
import { ExternalLinkIcon } from "lucide-react";
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";
import PageContainer from "@/layouts/PageContainer";
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");
const initialDNSGroups = useGroupIdsToGroups(
settings?.disabled_management_groups,
);
return (
<PageContainer>
<div className={"p-default py-6"}>
@@ -54,11 +64,17 @@ export default function NameServerSettings() {
</InlineLink>
in our documentation.
</Paragraph>
<RestrictedAccess page={"DNS Settings"}>
{!isLoading && (
<SettingDisabledManagementGroups
initial={settings?.disabled_management_groups}
/>
<RestrictedAccess page={"DNS Settings"} hasAccess={permission.dns.read}>
{!isLoading && initialDNSGroups !== undefined ? (
<SettingDisabledManagementGroups initialGroups={initialDNSGroups} />
) : (
<div>
<Skeleton
width={"100%"}
className={"mt-8 max-w-xl"}
height={240}
/>
</div>
)}
</RestrictedAccess>
</div>
@@ -67,16 +83,17 @@ export default function NameServerSettings() {
}
const SettingDisabledManagementGroups = ({
initial,
initialGroups,
}: {
initial: string[] | undefined;
initialGroups: Group[];
}) => {
const settingRequest = useApiCall<NameserverSettings>("/dns/settings");
const { mutate } = useSWRConfig();
const { permission } = usePermissions();
const [selectedGroups, setSelectedGroups, { save: saveGroups }] =
useGroupHelper({
initial: initial || [],
initial: initialGroups,
});
const { hasChanges, updateRef: updateChangesRef } = useHasChanges([
@@ -108,8 +125,10 @@ const SettingDisabledManagementGroups = ({
Peers in these groups will require manual domain name resolution
</HelpText>
<PeerGroupSelector
dataCy={"dns-groups-selector"}
onChange={setSelectedGroups}
values={selectedGroups}
disabled={!permission.dns.update}
/>
</div>
<div
@@ -121,7 +140,8 @@ const SettingDisabledManagementGroups = ({
variant={"primary"}
size={"sm"}
onClick={saveSettings}
disabled={!hasChanges}
disabled={!hasChanges || !permission.dns.update}
data-cy={"save-changes"}
>
Save Changes
</Button>

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

@@ -0,0 +1,8 @@
import { globalMetaTitle } from "@utils/meta";
import type { Metadata } from "next";
import BlankLayout from "@/layouts/BlankLayout";
export const metadata: Metadata = {
title: `Network - Networks - ${globalMetaTitle}`,
};
export default BlankLayout;

View File

@@ -0,0 +1,232 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import Card from "@components/Card";
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import Separator from "@components/Separator";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import useRedirect from "@hooks/useRedirect";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import {
ArrowUpRightIcon,
HelpCircle,
PencilLineIcon,
ServerIcon,
ShieldCheckIcon,
ShieldXIcon,
} from "lucide-react";
import { useSearchParams } from "next/navigation";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Network } from "@/interfaces/Network";
import PageContainer from "@/layouts/PageContainer";
import { NetworkInformationSquare } from "@/modules/networks/misc/NetworkInformationSquare";
import NetworkModal from "@/modules/networks/NetworkModal";
import { NetworkProvider } from "@/modules/networks/NetworkProvider";
import { ResourcesSection } from "@/modules/networks/resources/ResourcesSection";
import { NetworkRoutingPeersSection } from "@/modules/networks/routing-peers/NetworkRoutingPeersSection";
export default function NetworkDetailPage() {
const queryParameter = useSearchParams();
const networkId = queryParameter.get("id");
const { data: network, isLoading } = useFetchApi<Network>(
`/networks/${networkId}`,
true,
);
useRedirect("/networks", false, !networkId);
return network && !isLoading ? (
<NetworkOverview network={network} />
) : (
<FullScreenLoading />
);
}
function NetworkOverview({ network }: Readonly<{ network: Network }>) {
const { permission } = usePermissions();
const [networkModal, setNetworkModal] = useState(false);
const { mutate } = useSWRConfig();
const isActive = !!(
network?.routing_peers_count && network.routing_peers_count > 0
);
return (
<PageContainer>
<NetworkProvider network={network}>
<div className={"p-default py-6 mb-4"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/networks"}
label={"Networks"}
disabled={!permission.networks.read}
icon={<NetworkRoutesIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/network"}
label={network.name}
active={true}
/>
</Breadcrumbs>
<div className={"flex justify-between max-w-6xl"}>
<div
className={cn(
"flex items-center",
!network.description && "gap-2",
)}
>
<NetworkInformationSquare
name={network.name}
active={isActive}
size={"lg"}
description={network.description}
/>
{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}
onUpdated={() => {
mutate(`/networks/${network.id}`);
}}
network={network}
/>
</div>
</div>
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
<NetworkInformationCard network={network} />
</div>
</div>
<Separator />
<ResourcesSection network={network} />
<div className={"h-3"} />
<Separator />
<NetworkRoutingPeersSection network={network} />
</NetworkProvider>
</PageContainer>
);
}
function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
const isHighlyAvailable = !!(
network?.routing_peers_count && network?.routing_peers_count >= 2
);
const disabledText = useMemo(
() => (
<>
High availability is currently{" "}
<span className={"text-yellow-400 font-medium"}>inactive</span> for this
network.
</>
),
[],
);
const enabledText = useMemo(
() => (
<>
High availability is{" "}
<span className={"text-green-500 font-medium"}>active</span> for this
network.
</>
),
[],
);
const policyCount = network.policies?.length ?? 0;
return (
<Card>
<Card.List>
<Card.ListItem
tooltip={false}
label={
<>
<ServerIcon size={16} />
High Availability
</>
}
value={
<FullTooltip
interactive={false}
content={
<div className={"max-w-xs text-xs"}>
{isHighlyAvailable ? enabledText : disabledText}
{isHighlyAvailable ? (
<div className={"inline-flex mt-2"}>
You can add more routing peers to increase the
availability of this network.
</div>
) : (
<div className={"inline-flex mt-2"}>
Go ahead and add more routing peers or groups with routing
peers to enable high availability for this network.
</div>
)}
</div>
}
>
<div
className={cn(
"flex gap-2.5 items-center text-nb-gray-300 text-sm cursor-help",
)}
>
<span
className={cn(
"h-2 w-2 rounded-full",
!isHighlyAvailable ? "bg-yellow-400" : "bg-green-500",
)}
></span>
{isHighlyAvailable ? "Active" : "Inactive"}
<HelpCircle size={12} />
</div>
</FullTooltip>
}
/>
<Card.ListItem
tooltip={false}
label={
policyCount > 0 ? (
<>
<ShieldCheckIcon size={16} className={"text-green-500"} />
{policyCount}{" "}
{policyCount === 1 ? "Active Policy" : "Active Policies"}
</>
) : (
<>
<ShieldXIcon size={16} className={"text-red-500"} />
No Active Policies
</>
)
}
value={
policyCount > 0 ? (
<InlineLink href={"/access-control"}>
Go to Policies
<ArrowUpRightIcon size={14} />
</InlineLink>
) : null
}
/>
</Card.List>
</Card>
);
}

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: `Networks - ${globalMetaTitle}`,
};
export default BlankLayout;

View File

@@ -0,0 +1,63 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import useFetchApi from "@utils/api";
import { ExternalLinkIcon } from "lucide-react";
import React, { 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>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/networks"}
label={"Networks"}
icon={<NetworkRoutesIcon size={13} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Networks</h1>
<Paragraph>
Networks allow you to access internal resources in LANs and VPCs
without installing NetBird on every machine.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink
href={"https://docs.netbird.io/how-to/networks"}
target={"_blank"}
>
Networks
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
<RestrictedAccess hasAccess={permission.networks.read}>
<Suspense fallback={<SkeletonTable />}>
<NetworksTable
data={networks}
isLoading={isLoading}
headingTarget={portalTarget}
/>
</Suspense>
</RestrictedAccess>
</PageContainer>
);
}

View File

@@ -19,17 +19,19 @@ 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 { getOperatingSystem } from "@hooks/useOperatingSystem";
import useRedirect from "@hooks/useRedirect";
import { IconCloudLock, IconInfoCircle } from "@tabler/icons-react";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import dayjs from "dayjs";
import { isEmpty, trim } from "lodash";
import {
Barcode,
Cpu,
FlagIcon,
Globe,
@@ -40,6 +42,7 @@ import {
NetworkIcon,
PencilIcon,
TerminalSquare,
TimerResetIcon,
} from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { toASCII } from "punycode";
@@ -52,26 +55,56 @@ 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 { OperatingSystem } from "@/interfaces/OperatingSystem";
import type { Peer } from "@/interfaces/Peer";
import PageContainer from "@/layouts/PageContainer";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection";
import { PeerExpirationToggle } from "@/modules/peer/PeerExpirationToggle";
import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection";
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 ?? "";
let ssh = peer?.ssh_enabled ? "1" : "0";
let expiration = peer?.login_expiration_enabled ? "1" : "0";
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 />
<PeerOverview key={peerKey} />
</PeerProvider>
) : (
<FullScreenLoading />
@@ -79,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();
@@ -88,20 +144,15 @@ function PeerOverview() {
const [loginExpiration, setLoginExpiration] = useState(
peer.login_expiration_enabled,
);
const [inactivityExpiration, setInactivityExpiration] = useState(
peer.inactivity_expiration_enabled,
);
const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] =
useGroupHelper({
initial: peerGroups,
peer,
});
/**
* Check the operating system of the peer, if it is linux, then show the routes table, otherwise hide it.
*/
const isLinux = useMemo(() => {
const operatingSystem = getOperatingSystem(peer.os);
return operatingSystem == OperatingSystem.LINUX;
}, [peer.os]);
/**
* Detect if there are changes in the peer information, if there are changes, then enable the save button.
*/
@@ -110,248 +161,259 @@ function PeerOverview() {
ssh,
selectedGroups,
loginExpiration,
inactivityExpiration,
]);
const updatePeer = async () => {
const updateRequest = update(name, ssh, loginExpiration);
let batchCall: Promise<any>[] = [];
const groupCalls = getAllGroupCalls();
const batchCall = groupCalls
? [...groupCalls, updateRequest]
: [updateRequest];
if (permission.peers.update) {
const updateRequest = update({
name,
ssh,
loginExpiration,
inactivityExpiration,
});
batchCall = groupCalls ? [...groupCalls, updateRequest] : [updateRequest];
} else {
batchCall = [...groupCalls];
}
notify({
title: name,
description: "Peer was successfully saved",
promise: Promise.all(batchCall).then(() => {
mutate("/peers/" + peer.id);
mutate("/groups");
updateHasChangedRef([name, ssh, selectedGroups, loginExpiration]);
updateHasChangedRef([
name,
ssh,
selectedGroups,
loginExpiration,
inactivityExpiration,
]);
}),
loadingMessage: "Saving the peer...",
});
};
const { isUser } = 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"}>
<FullTooltip
content={
<div
className={
"flex gap-2 items-center !text-nb-gray-300 text-xs"
}
>
{!peer.user_id ? (
<>
<>
<IconInfoCircle size={14} />
<span>
Login expiration is disabled for all peers added
with an setup-key.
</span>
</>
</>
) : (
<>
<LockIcon size={14} />
<span>
{`You don't have the required permissions to update this
setting.`}
</span>
</>
)}
</div>
}
className={"w-full block"}
disabled={!!peer.user_id && !isUser}
>
<FancyToggleSwitch
disabled={!peer.user_id || isUser}
value={loginExpiration}
onChange={setLoginExpiration}
label={
<>
<IconCloudLock size={16} />
Login Expiration
</>
}
helpText={
"Enable to require SSO login peers to re-authenticate when their login expires."
}
/>
</FullTooltip>
<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>
<div>
<Label>Assigned Groups</Label>
<HelpText>
Use groups to control what this peer can access.
</HelpText>
<FullTooltip
content={
{permission.peers.update && (
<Modal
open={showEditNameModal}
onOpenChange={setShowEditNameModal}
>
<ModalTrigger>
<div
className={
"flex gap-2 items-center !text-nb-gray-300 text-xs"
"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"
}
>
<LockIcon size={14} />
<span>
{`You don't have the required permissions to update this
setting.`}
</span>
<PencilIcon size={16} />
</div>
}
interactive={false}
className={"w-full block"}
disabled={!isUser}
>
<PeerGroupSelector
disabled={isUser}
onChange={setSelectedGroups}
values={selectedGroups}
hideAllGroup={true}
</ModalTrigger>
<EditNameModal
onSuccess={(newName) => {
setName(newName);
setShowEditNameModal(false);
}}
peer={peer}
initialName={name}
key={showEditNameModal ? 1 : 0}
/>
</FullTooltip>
</div>
</div>
</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 || !permission.peers.read || !permission.groups.update
}
>
Save Changes
</Button>
</div>
</div>
{isLinux && !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>
</>
);
}
};
function PeerInformationCard({ peer }: { peer: Peer }) {
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();
const countryText = useMemo(() => {
@@ -359,7 +421,7 @@ function PeerInformationCard({ peer }: { peer: Peer }) {
}, [getRegionByPeer, peer]);
return (
<Card>
<Card className={"w-full xl:w-1/2"}>
<Card.List>
<Card.ListItem
copy
@@ -387,14 +449,20 @@ function PeerInformationCard({ peer }: { peer: Peer }) {
<Card.ListItem
copy
copyText={"Domain name"}
copyText={"DNS label"}
label={
<>
<Globe size={16} />
Domain Name
</>
}
className={
peer?.extra_dns_labels && peer.extra_dns_labels.length > 0
? "items-start"
: ""
}
value={peer.dns_label}
extraText={peer?.extra_dns_labels}
/>
<Card.ListItem
@@ -446,6 +514,19 @@ function PeerInformationCard({ peer }: { peer: Peer }) {
}
value={peer.os}
/>
{peer.serial_number && peer.serial_number !== "" && (
<Card.ListItem
label={
<>
<Barcode size={16} />
Serial Number
</>
}
value={peer.serial_number}
/>
)}
<Card.ListItem
label={
<>
@@ -473,15 +554,17 @@ function PeerInformationCard({ peer }: { 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>
);
@@ -492,7 +575,8 @@ interface ModalProps {
peer: Peer;
initialName: string;
}
function EditNameModal({ onSuccess, peer, initialName }: ModalProps) {
function EditNameModal({ onSuccess, peer, initialName }: Readonly<ModalProps>) {
const [name, setName] = useState(initialName);
const isDisabled = useMemo(() => {

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,23 +6,33 @@ import {
AlertOctagonIcon,
FolderGit2Icon,
LockIcon,
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 DangerZoneTab from "@/modules/settings/DangerZoneTab";
import GroupsTab from "@/modules/settings/GroupsTab";
import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab";
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(() => {
@@ -35,28 +45,38 @@ 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="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>
</>
)}
<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 && <DangerZoneTab account={account} />}
</div>
</RestrictedAccess>
@@ -64,3 +84,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,13 +21,16 @@ 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";
import { Role, User } from "@/interfaces/User";
import PageContainer from "@/layouts/PageContainer";
import AccessTokensTable from "@/modules/access-tokens/AccessTokensTable";
import CreateAccessTokenModal from "@/modules/access-tokens/CreateAccessTokenModal";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { useGroupIdsToGroups } from "@/modules/groups/useGroupIdsToGroups";
import UserBlockCell from "@/modules/users/table-cells/UserBlockCell";
import UserStatusCell from "@/modules/users/table-cells/UserStatusCell";
import { UserRoleSelector } from "@/modules/users/UserRoleSelector";
@@ -34,10 +38,12 @@ 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}`,
);
const { isOwnerOrAdmin } = useLoggedInUser();
const user = useMemo(() => {
return users?.find((u) => u.id === userId);
@@ -45,25 +51,40 @@ export default function UserPage() {
useRedirect("/team/users", false, !userId);
return !isLoading && user ? (
<UserOverview user={user} />
) : (
<FullScreenLoading />
);
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={[]} />;
}
if (isOwnerOrAdmin && user && !isLoading && userGroups) {
return <UserOverview user={user} initialGroups={userGroups} />;
}
return <FullScreenLoading />;
}
type Props = {
user: User;
initialGroups: Group[];
};
function UserOverview({ user }: Props) {
function UserOverview({ user, initialGroups }: Readonly<Props>) {
const router = useRouter();
const userRequest = useApiCall<User>("/users");
const { mutate } = useSWRConfig();
const { loggedInUser, isOwnerOrAdmin, isUser } = useLoggedInUser();
const isLoggedInUser = loggedInUser ? loggedInUser?.id === user.id : false;
const { permission } = usePermissions();
const initialGroups = user.auto_groups;
const [selectedGroups, setSelectedGroups, { save: saveGroups }] =
useGroupHelper({
initial: initialGroups,
@@ -107,7 +128,7 @@ function UserOverview({ user }: Props) {
<Breadcrumbs.Item
href={"/team"}
label={"Team"}
disabled={isUser}
disabled={!permission.users.read}
icon={<TeamIcon size={13} />}
/>
@@ -121,7 +142,7 @@ function UserOverview({ user }: Props) {
<Breadcrumbs.Item
href={"/team/users"}
label={"Users"}
disabled={isUser}
disabled={!permission.users.read}
icon={<User2 size={16} />}
/>
)}
@@ -178,8 +199,9 @@ function UserOverview({ user }: Props) {
<Button
variant={"primary"}
className={"w-full"}
disabled={!hasChanges}
disabled={!hasChanges || !permission.users.update}
onClick={save}
data-cy={"save-changes"}
>
Save Changes
</Button>
@@ -190,7 +212,7 @@ function UserOverview({ user }: Props) {
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
<UserInformationCard user={user} />
<div className={"flex flex-col gap-8 w-1/2 "}>
{!user.is_service_user && (
{!user.is_service_user && isOwnerOrAdmin && (
<div>
<Label>Auto-assigned groups</Label>
<HelpText>
@@ -201,6 +223,7 @@ function UserOverview({ user }: Props) {
onChange={setSelectedGroups}
values={selectedGroups}
hideAllGroup={true}
dataCy={"user-group-selector"}
/>
</div>
)}
@@ -217,11 +240,7 @@ function UserOverview({ user }: Props) {
onChange={setRole}
hideOwner={user.is_service_user}
currentUser={user}
disabled={
isLoggedInUser ||
!isOwnerOrAdmin ||
user.role === Role.Owner
}
disabled={isLoggedInUser || !permission.users.update}
/>
</div>
</div>
@@ -229,7 +248,7 @@ function UserOverview({ user }: 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"}>
@@ -244,7 +263,11 @@ function UserOverview({ user }: Props) {
<div className={"inline-flex gap-4 justify-end"}>
<div>
<CreateAccessTokenModal user={user}>
<Button variant={"primary"}>
<Button
variant={"primary"}
data-cy={"access-token-open-modal"}
disabled={!permission.pats.create}
>
<IconCirclePlus size={16} />
Create Access Token
</Button>
@@ -261,7 +284,7 @@ function UserOverview({ user }: 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"),
@@ -293,6 +316,7 @@ function UserInformationCard({ user }: { user: User }) {
)}
<Card.ListItem
tooltip={false}
label={
<>
<GalleryHorizontalEnd size={16} />
@@ -306,6 +330,7 @@ function UserInformationCard({ user }: { user: User }) {
<>
{!user.is_current && user.role != Role.Owner && (
<Card.ListItem
tooltip={false}
label={
<>
<Ban size={16} />

View File

@@ -10,12 +10,16 @@ import useFetchApi from "@utils/api";
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";
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",
);
@@ -56,11 +60,11 @@ 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}
isLoading={isLoading}
isLoading={isLoading || isGroupsLoading}
headingTarget={portalTarget}
/>
</Suspense>

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

@@ -12,7 +12,7 @@ export default function CircleIcon({
size = 11,
inactiveDot = "gray",
className,
}: Props) {
}: Readonly<Props>) {
return (
<span
style={{ width: size + "px", height: size + "px" }}

View File

@@ -0,0 +1,39 @@
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
export default function EntraIcon(props: Readonly<IconProps>) {
return (
<svg
width="231"
height="231"
viewBox="0 0 231 231"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...iconProperties(props)}
>
<path
d="M48.7923 180.077C53.7717 183.183 62.0492 186.635 70.8015 186.635C78.771 186.635 86.1758 184.325 92.3102 180.385C92.3102 180.385 92.323 180.385 92.3358 180.373L115.5 165.896V218.167C111.83 218.167 108.134 217.166 104.925 215.164L48.7923 180.077Z"
fill="#225086"
/>
<path
d="M100.78 19.3398L4.53017 127.91C-2.90033 136.303 -0.962501 148.982 8.67533 155.001C8.67533 155.001 44.3007 177.267 48.7923 180.077C53.7717 183.183 62.0492 186.635 70.8015 186.635C78.771 186.635 86.1758 184.325 92.3102 180.385C92.3102 180.385 92.323 180.385 92.3358 180.373L115.5 165.896L59.4953 130.887L115.513 67.6958V12.8333C110.072 12.8333 104.63 15.0022 100.78 19.3398Z"
fill="#66DDFF"
/>
<path
d="M59.4953 130.887L60.1627 131.298L115.5 165.896H115.513V67.7087L115.5 67.6958L59.4953 130.887Z"
fill="#CBF8FF"
/>
<path
d="M222.325 155.001C231.963 148.982 233.9 136.303 226.47 127.91L163.317 56.672C158.222 54.2978 152.511 52.9375 146.467 52.9375C134.596 52.9375 123.983 58.058 116.925 66.1045L115.526 67.683L171.53 130.874L115.513 165.884V218.154C119.196 218.154 122.866 217.153 126.075 215.151L222.325 154.988V155.001Z"
fill="#074793"
/>
<path
d="M115.513 12.8333V67.6958L116.912 66.1173C123.97 58.0708 134.583 52.9503 146.454 52.9503C152.511 52.9503 158.209 54.3235 163.304 56.6848L130.207 19.3527C126.37 15.015 120.929 12.8462 115.5 12.8462L115.513 12.8333Z"
fill="#0294E4"
/>
<path
d="M171.518 130.887L115.513 67.7087V165.884L171.518 130.887Z"
fill="#96BCC2"
/>
</svg>
);
}

View File

@@ -0,0 +1,31 @@
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
export default function GoogleIcon(props: Readonly<IconProps>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
{...iconProperties(props)}
>
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
<path d="M1 1h22v22H1z" fill="none" />
</svg>
);
}

View File

@@ -0,0 +1,36 @@
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
export default function JWTIcon(props: Readonly<IconProps>) {
return (
<svg
height="2500"
viewBox=".4 .3 99.7 100"
width="2500"
xmlns="http://www.w3.org/2000/svg"
{...iconProperties(props)}
>
<g fill="none">
<path
d="m57.8 27.2-.1-26.9h-15l.1 26.9 7.5 10.3zm-15 46.1v27h15v-27l-7.5-10.3z"
fill="#fff"
/>
<path
d="m57.8 73.3 15.8 21.8 12.1-8.8-15.8-21.8-12.1-3.9zm-15-46.1-15.9-21.8-12.1 8.8 15.8 21.8 12.2 3.9z"
fill="#00f2e6"
/>
<path
d="m30.6 36-25.6-8.3-4.6 14.2 25.6 8.4 12.1-4zm31.8 18.2 7.5 10.3 25.6 8.3 4.6-14.2-25.6-8.3z"
fill="#00b9f1"
/>
<path
d="m74.5 50.3 25.6-8.4-4.6-14.2-25.6 8.3-7.5 10.3zm-48.5 0-25.6 8.3 4.6 14.2 25.6-8.3 7.5-10.3z"
fill="#d63aff"
/>
<path
d="m30.6 64.5-15.8 21.8 12.1 8.8 15.9-21.8v-12.7zm39.3-28.5 15.8-21.8-12.1-8.8-15.8 21.8v12.7z"
fill="#fb015b"
/>
</g>
</svg>
);
}

File diff suppressed because one or more lines are too long

View File

@@ -15,7 +15,9 @@ export const OIDCError = () => {
const params = useSearchParams();
const errorParam = params.get("error");
const accessDenied = errorParam === "access_denied";
const invalidRequest = errorParam === "invalid_request";
const [title, setTitle] = useState(params.get("error_description"));
const errorDescription = params.get("error_description");
const { logout, login } = useOidc();
useEffect(() => {
@@ -72,9 +74,14 @@ export const OIDCError = () => {
</>
) : (
<>
<Paragraph className={"text-center mt-2"}>
<Paragraph className={"text-center mt-2 block"}>
There was an error logging you in. <br />
Error: {oidcUserLoadingState}
Error:{" "}
<span className={"inline capitalize"}>
{invalidRequest && errorDescription
? errorDescription
: oidcUserLoadingState}
</span>
</Paragraph>
<Button
variant={"primary"}

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

@@ -10,6 +10,7 @@ export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
ButtonVariants {
disabled?: boolean;
stopPropagation?: boolean;
}
export const buttonVariants = cva(
@@ -35,11 +36,21 @@ export const buttonVariants = cva(
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 ",
"dark:bg-nb-gray-900/30 dark:text-gray-400 dark:border-gray-700/40 dark:hover:text-white dark:hover:bg-zinc-800/50",
],
secondaryLighter: [
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 ",
"dark:bg-nb-gray-900/70 dark:text-gray-400 dark:border-gray-700/70 dark:hover:text-white dark:hover:bg-nb-gray-800/60",
],
input: [
"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 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 ",
@@ -63,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
@@ -98,6 +109,7 @@ const Button = forwardRef(
rounded = true,
border = 1,
size = "md",
stopPropagation = true,
...props
}: ButtonProps,
ref: React.ForwardedRef<HTMLButtonElement>,
@@ -117,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,28 @@
import { cn } from "@utils/helpers";
import { InfoIcon } from "lucide-react";
import * as React from "react";
type Props = {
icon?: React.ReactNode;
children?: React.ReactNode;
className?: string;
};
export const Callout = ({
children,
icon = <InfoIcon size={14} className={"shrink-0 relative top-[1px]"} />,
className,
}: Props) => {
return (
<div
className={cn(
"px-4 py-3 rounded-md border text-sm font-normal flex gap-3",
"bg-nb-gray-900/60 border-nb-gray-800/80 text-nb-gray-300",
className,
)}
>
{icon}
<div>{children}</div>
</div>
);
};

View File

@@ -7,6 +7,7 @@ import React from "react";
interface Props extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
function Card({ children, className, ...props }: Props) {
return (
<div
@@ -32,6 +33,7 @@ type CardListItemProps = {
copy?: boolean;
copyText?: string;
tooltip?: boolean;
extraText?: string[];
};
function CardListItem({
@@ -41,9 +43,8 @@ function CardListItem({
copy = false,
copyText,
tooltip = true,
extraText = [],
}: CardListItemProps) {
const [, copyToClipBoard] = useCopyToClipboard(value as string);
return (
<li
className={cn(
@@ -52,29 +53,68 @@ function CardListItem({
)}
>
<div className={"flex gap-2.5 items-center text-sm"}>{label}</div>
<div
className={cn(
"text-right text-nb-gray-400 text-sm flex items-center gap-2",
copy && "cursor-pointer hover:text-nb-gray-300 transition-all",
)}
onClick={() =>
copy &&
copyToClipBoard(
`${copyText ? copyText : label} has been copied to clipboard.`,
)
}
>
{tooltip ? (
<TextWithTooltip text={value as string} maxChars={40} />
) : (
value
)}
{copy && <Copy size={13} className={"shrink-0"} />}
<div className={"flex flex-col gap-2"}>
<CardTextItem
label={label}
value={value}
copy={copy}
copyText={copyText}
tooltip={tooltip}
/>
{extraText?.map((extraLabel, index) => (
<CardTextItem
key={index}
label={label}
value={extraLabel}
copy={copy}
copyText={copyText}
tooltip={tooltip}
/>
))}
</div>
</li>
);
}
type CardTextItemProps = {
label: React.ReactNode;
value: React.ReactNode;
copy?: boolean;
copyText?: string;
tooltip?: boolean;
};
const CardTextItem = ({
label,
value,
copy = false,
copyText,
tooltip = true,
}: CardTextItemProps) => {
const [, copyToClipBoard] = useCopyToClipboard(value as string);
return (
<div
className={cn(
"text-right text-nb-gray-400 text-sm flex items-center gap-2",
copy && "cursor-pointer hover:text-nb-gray-300 transition-all",
)}
onClick={() =>
copy &&
copyToClipBoard(
`${copyText ? copyText : label} has been copied to clipboard.`,
)
}
>
{tooltip ? (
<TextWithTooltip text={value as string} maxChars={40} />
) : (
value
)}
{copy && <Copy size={13} className={"shrink-0"} />}
</div>
);
};
Card.List = CardList;
Card.ListItem = CardListItem;

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

@@ -6,9 +6,18 @@ import useCopyToClipboard from "@/hooks/useCopyToClipboard";
type Props = {
children: React.ReactNode;
message?: string;
iconAlignment?: "left" | "right";
className?: string;
alwaysShowIcon?: boolean;
};
export default function CopyToClipboardText({ children, message }: Props) {
export default function CopyToClipboardText({
children,
message,
iconAlignment = "right",
className,
alwaysShowIcon = false,
}: Props) {
const [wrapper, copyToClipboard, copied] = useCopyToClipboard();
return (
@@ -16,6 +25,7 @@ export default function CopyToClipboardText({ children, message }: Props) {
className={cn(
"flex gap-2 items-center group cursor-pointer transition-all hover:underline underline-offset-4 decoration-dashed decoration-nb-gray-600",
!copied && "hover:opacity-90",
className,
)}
onClick={(e) => {
e.stopPropagation();
@@ -28,17 +38,21 @@ export default function CopyToClipboardText({ children, message }: Props) {
{copied ? (
<CheckIcon
className={
"text-nb-gray-100 opacity-0 group-hover:opacity-100 shrink-0"
}
size={12}
className={cn(
"text-nb-gray-100 group-hover:opacity-100 shrink-0",
iconAlignment === "left" ? "order-first" : "order-last",
!alwaysShowIcon && "opacity-0",
)}
size={11}
/>
) : (
<CopyIcon
className={
"text-nb-gray-100 opacity-0 group-hover:opacity-100 shrink-0"
}
size={12}
className={cn(
"text-nb-gray-100 group-hover:opacity-100 shrink-0",
iconAlignment === "left" ? "order-first" : "order-last",
!alwaysShowIcon && "opacity-0",
)}
size={11}
/>
)}
</div>

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

@@ -1,11 +1,15 @@
import { cn } from "@utils/helpers";
import * as React from "react";
type Props = {
children: React.ReactNode;
className?: string;
};
export const DropdownInfoText = ({ children }: Props) => {
export const DropdownInfoText = ({ children, className }: Props) => {
return (
<div className={"text-center pt-2 mb-6 text-nb-gray-400"}>{children}</div>
<div className={cn("text-center pt-2 mb-6 text-nb-gray-400", className)}>
{children}
</div>
);
};

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

@@ -2,16 +2,51 @@ import HelpText from "@components/HelpText";
import { Label } from "@components/Label";
import { ToggleSwitch } from "@components/ToggleSwitch";
import { cn } from "@utils/helpers";
import { cva, VariantProps } from "class-variance-authority";
import React from "react";
type Props = {
export const fancyToggleSwitchVariants = cva([], {
variants: {
variant: {
default: ["px-6 py-4 border rounded-md"],
blank: null,
},
state: {
true: null,
false: null,
},
},
compoundVariants: [
{
variant: "default",
state: true,
className: ["border-nb-gray-800 bg-nb-gray-900/70"],
},
{
variant: "default",
state: false,
className: [
"border-nb-gray-910 bg-nb-gray-900/30 hover:bg-nb-gray-900/40",
],
},
],
});
export type FancyToggleSwitchVariants = VariantProps<
typeof fancyToggleSwitchVariants
>;
interface Props extends FancyToggleSwitchVariants {
value: boolean;
onChange: (value: boolean) => void;
helpText?: React.ReactNode;
label?: React.ReactNode;
children?: React.ReactNode;
disabled?: boolean;
};
dataCy?: string;
className?: string;
}
export default function FancyToggleSwitch({
value,
onChange,
@@ -19,28 +54,49 @@ export default function FancyToggleSwitch({
label,
children,
disabled = false,
}: Props) {
dataCy,
className,
variant = "default",
}: Readonly<Props>) {
const handleToggle = () => {
if (disabled) return;
onChange(!value);
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (disabled) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleToggle();
}
};
return (
<div
onClick={() => {
if (disabled) return;
onChange(!value);
}}
onClick={handleToggle}
onKeyDown={handleKeyDown}
tabIndex={-1}
role={"switch"}
aria-checked={value}
className={cn(
"px-5 py-3.5 border rounded-md cursor-pointer transition-all duration-300 relative z-[1]",
value
? "border-nb-gray-800 bg-nb-gray-900/70"
: "border-nb-gray-800 bg-nb-gray-900/30 hover:bg-nb-gray-900/40",
"cursor-pointer transition-all duration-300 relative z-[1]",
"inline-block text-left w-full",
disabled && "opacity-50 pointer-events-none",
fancyToggleSwitchVariants({ variant, state: value }),
className,
)}
>
<div className={"flex justify-between gap-10 "}>
<div className={"flex justify-between gap-10"}>
<div className={"max-w-sm"}>
<Label>{label}</Label>
<HelpText margin={false}>{helpText}</HelpText>
</div>
<div className={"mt-2"}>
<ToggleSwitch checked={value} onCheckedChange={onChange} />
<div className={"mt-2 pr-1"}>
<ToggleSwitch
checked={value}
onCheckedChange={onChange}
dataCy={dataCy}
/>
</div>
</div>
<div>{children && value ? children : null}</div>

View File

@@ -22,6 +22,8 @@ type Props = {
keepOpen?: boolean;
customOpen?: boolean;
customOnOpenChange?: React.Dispatch<React.SetStateAction<boolean>>;
delayDuration?: number;
skipDelayDuration?: number;
} & TooltipProps;
export default function FullTooltip({
children,
@@ -37,6 +39,8 @@ export default function FullTooltip({
keepOpen = false,
customOpen,
customOnOpenChange,
delayDuration = 1,
skipDelayDuration = 300,
}: Props) {
const [open, setOpen] = useState(!!keepOpen);
@@ -46,9 +50,13 @@ export default function FullTooltip({
};
return !disabled ? (
<TooltipProvider disableHoverableContent={!interactive}>
<TooltipProvider
disableHoverableContent={!interactive}
delayDuration={delayDuration}
skipDelayDuration={skipDelayDuration}
>
<Tooltip
delayDuration={1}
delayDuration={delayDuration}
open={customOpen || open}
onOpenChange={customOnOpenChange || handleOpen}
>

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

@@ -1,12 +1,15 @@
import FullTooltip from "@components/FullTooltip";
import Paragraph from "@components/Paragraph";
import { cn } from "@utils/helpers";
import { cva } from "class-variance-authority";
import { cva, VariantProps } from "class-variance-authority";
import { AlertCircle } from "lucide-react";
import * as React from "react";
type InputVariants = VariantProps<typeof inputVariants>;
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
extends React.InputHTMLAttributes<HTMLInputElement>,
InputVariants {
customPrefix?: React.ReactNode;
customSuffix?: React.ReactNode;
maxWidthClass?: string;
@@ -14,6 +17,7 @@ export interface InputProps
error?: string;
errorTooltip?: boolean;
errorTooltipPosition?: "top" | "top-right";
prefixClassName?: string;
}
const inputVariants = cva("", {
@@ -23,6 +27,10 @@ const inputVariants = cva("", {
"dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-200 dark:border-nb-gray-700",
"ring-offset-neutral-200/20 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20 focus-visible:ring-neutral-300/10",
],
darker: [
"dark:bg-nb-gray-920 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-300 dark:border-nb-gray-800",
"ring-offset-neutral-200/20 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20 focus-visible:ring-neutral-300/10",
],
error: [
"dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-200 dark:border-red-500 text-red-500",
"ring-offset-red-500/10 dark:ring-offset-red-500/10 dark:focus-visible:ring-red-500/10 focus-visible:ring-red-500/10",
@@ -51,6 +59,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
error,
errorTooltip = false,
errorTooltipPosition = "top",
variant = "default",
prefixClassName,
...props
},
ref,
@@ -66,7 +76,8 @@ 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,
)}
>
{customPrefix}
@@ -76,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}
@@ -87,8 +98,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
ref={ref}
{...props}
className={cn(
inputVariants({ variant: error ? "error" : "default" }),
"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 ",
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-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

@@ -1,29 +1,48 @@
import Badge from "@components/Badge";
import { Checkbox } from "@components/Checkbox";
import { CommandItem } from "@components/Command";
import { DropdownInfoText } from "@components/DropdownInfoText";
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
import { Radio, RadioItem } from "@components/Radio";
import { ScrollArea } from "@components/ScrollArea";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import { AccessControlGroupCount } from "@components/ui/AccessControlGroupCount";
import GroupBadge from "@components/ui/GroupBadge";
import GroupBadgeWithEditPeers from "@components/ui/GroupBadgeWithEditPeers";
import ResourceBadge from "@components/ui/ResourceBadge";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList";
import { useSearch } from "@hooks/useSearch";
import useSortedDropdownOptions from "@hooks/useSortedDropdownOptions";
import { IconArrowBack } from "@tabler/icons-react";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import { Command, CommandGroup, CommandInput, CommandList } from "cmdk";
import { sortBy, trim, unionBy } from "lodash";
import {
ChevronsUpDown,
FolderGit2,
GlobeIcon,
Layers3,
Layers3Icon,
MonitorSmartphoneIcon,
NetworkIcon,
SearchIcon,
WorkflowIcon,
} from "lucide-react";
import * as React from "react";
import { useEffect, useMemo, useState } from "react";
import { Fragment, useEffect, useMemo, useState } from "react";
import Skeleton from "react-loading-skeleton";
import { useGroups } from "@/contexts/GroupsProvider";
import { useElementSize } from "@/hooks/useElementSize";
import type { Group, GroupPeer } from "@/interfaces/Group";
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[];
@@ -39,6 +58,15 @@ interface MultiSelectProps {
showRoutes?: boolean;
disabledGroups?: Group[];
dataCy?: string;
showResourceCounter?: boolean;
showResources?: boolean;
resource?: PolicyRuleResource;
onResourceChange?: (resource?: PolicyRuleResource) => void;
placeholder?: string;
customTrigger?: React.ReactNode;
align?: "start" | "end";
side?: "top" | "bottom";
users?: User[];
}
export function PeerGroupSelector({
onChange,
@@ -54,12 +82,26 @@ export function PeerGroupSelector({
showRoutes = false,
disabledGroups,
dataCy = "group-selector-dropdown",
showResourceCounter = true,
showResources = false,
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",
);
// Update dropdown options when groups change
useEffect(() => {
@@ -91,23 +133,40 @@ export function PeerGroupSelector({
// Add group to the groupOptions if it does not exist
const selectGroup = (name: string) => {
onResourceChange?.(undefined);
const group = groups?.find((group) => group.name == name);
const option = dropdownOptions.find((option) => option.name == name);
const groupPeers: GroupPeer[] | undefined =
(group?.peers as GroupPeer[]) || [];
const groupResources: GroupResource[] | undefined =
(group?.resources as GroupResource[]) || [];
if (peer) groupPeers?.push({ id: peer?.id as string, name: peer?.name });
if (!group && !option) {
addDropdownOptions([{ name: name, peers: groupPeers }]);
addDropdownOptions([
{ name: name, peers: groupPeers, resources: groupResources },
]);
}
if (max == 1 && values.length == 1) {
onChange([{ name: name, id: group?.id, peers: groupPeers }]);
onChange([
{
name: name,
id: group?.id,
peers: groupPeers,
resources: groupResources,
},
]);
} else {
onChange((previous) => [
...previous,
{ name: name, id: group?.id, peers: groupPeers },
{
name: name,
id: group?.id,
peers: groupPeers,
resources: groupResources,
},
]);
}
@@ -142,6 +201,8 @@ export function PeerGroupSelector({
const [slice, setSlice] = useState(10);
const [tab, setTab] = useState("groups");
useEffect(() => {
if (open) {
setTimeout(() => {
@@ -164,98 +225,143 @@ export function PeerGroupSelector({
open,
);
// Reset the search input when switching tabs
useEffect(() => {
setSearch("");
setTimeout(() => {
searchRef.current?.focus();
}, 0);
}, [tab]);
const searchPlaceholder =
tab === "groups"
? 'Search groups or add new group by pressing "Enter"...'
: "Search resource...";
const selectResource = (resource?: NetworkResource) => {
onResourceChange?.(
resource
? ({
id: resource?.id,
type: resource?.type,
} as PolicyRuleResource)
: undefined,
);
onChange([]);
};
return (
<Popover
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
if (!isOpen && search.length > 0) {
setTimeout(() => {
setSearch("");
}, 100);
}, 200);
}
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 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"
}
>
{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 && (
<span className={"pl-1"}>Add or select group(s)...</span>
{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",
)}
</div>
disabled={disabled}
data-cy={dataCy}
ref={inputRef}
>
<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>
);
})}
<div className={"pl-2"}>
<ChevronsUpDown
size={18}
className={"shrink-0 group-hover:text-nb-gray-300 transition-all"}
/>
</div>
</button>
{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>
)}
</PopoverTrigger>
<PopoverContent
className="w-full p-0 shadow-sm shadow-nb-gray-950"
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
@@ -281,9 +387,7 @@ export function PeerGroupSelector({
ref={searchRef}
value={search}
onValueChange={setSearch}
placeholder={
'Search groups or add new group by pressing "Enter"...'
}
placeholder={searchPlaceholder}
/>
<div
className={
@@ -309,98 +413,326 @@ export function PeerGroupSelector({
</div>
</div>
<CommandGroup>
<ScrollArea
className={"max-h-[195px] flex flex-col gap-1 pl-2 py-2 pr-3"}
>
{searchedGroupNotFound && (
<CommandItem
key={search}
onSelect={() => {
toggleGroupByName(search);
searchRef.current?.focus();
}}
value={search}
onClick={(e) => e.preventDefault()}
<Tabs defaultValue={"groups"} value={tab} onValueChange={setTab}>
{showResources && <TabTriggers searchRef={searchRef} />}
<TabsContent value={"groups"} className={"p-0 my-0"}>
<CommandGroup>
<ScrollArea
className={cn(
"max-h-[195px] flex flex-col gap-1 pl-2 py-2 pr-3",
sortedDropdownOptions.length == 0 && !search && "py-0",
)}
>
<Badge variant={"gray-ghost"}>
{folderIcon}
{search}
</Badge>
<div className={"text-neutral-500 dark:text-nb-gray-300"}>
Add this group by pressing{" "}
<span className={"font-bold text-netbird"}>
{"'Enter'"}
</span>
</div>
</CommandItem>
)}
{sortedDropdownOptions.slice(0, slice).map((option) => {
const isSelected =
values.find((group) => group.name == option.name) !=
undefined;
const peerCount =
option.peers?.length ?? option?.peers_count ?? 0;
const isDisabled = disabledGroups
? disabledGroups?.findIndex((g) => g.id === option.id) !==
-1
: false;
return (
<FullTooltip
content={
<div className={"text-xs max-w-xs"}>
This group is already part of the routing peer and can
not be used for the access control groups.
</div>
}
disabled={!isDisabled}
className={"w-full block"}
key={option.name}
>
{searchedGroupNotFound && (
<CommandItem
key={option.name}
value={option.name + option.id}
disabled={isDisabled}
key={search}
onSelect={() => {
if (peer != undefined && option.name == "All") return; // Prevent removing the "All" group
if (isDisabled) return;
toggleGroupByName(option.name);
toggleGroupByName(search);
searchRef.current?.focus();
}}
className={cn(isDisabled && "opacity-40")}
value={search}
onClick={(e) => e.preventDefault()}
>
<div className={"flex items-center gap-2"}>
<GroupBadge group={option} showNewBadge={true} />
</div>
<div className={"flex items-center gap-5"}>
{option?.id && showRoutes && (
<AccessControlGroupCount group_id={option.id} />
)}
<div
className={
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
}
>
{peerIcon}
{peerCount} Peer(s)
<Checkbox checked={isSelected} />
</div>
<Badge variant={"gray-ghost"}>
{folderIcon}
{search}
</Badge>
<div
className={"text-neutral-500 dark:text-nb-gray-300"}
>
Add this group by pressing{" "}
<span className={"font-bold text-netbird"}>
{"'Enter'"}
</span>
</div>
</CommandItem>
</FullTooltip>
);
})}
</ScrollArea>
</CommandGroup>
)}
{sortedDropdownOptions.slice(0, slice).map((option) => {
const isSelected =
values.find((group) => group.name == option.name) !=
undefined;
const peerCount =
option.peers?.length ?? option?.peers_count ?? 0;
const isDisabled = disabledGroups
? disabledGroups?.findIndex(
(g) => g.id === option.id,
) !== -1
: false;
if (hideAllGroup && option?.name === "All") return;
return (
<FullTooltip
content={
<div className={"text-xs max-w-xs"}>
This group is already part of the routing peer and
can not be used for the access control groups.
</div>
}
disabled={!isDisabled}
className={"w-full block"}
key={option.name}
>
<CommandItem
key={option.name}
value={option.name + option.id}
disabled={isDisabled}
onSelect={() => {
if (peer != undefined && option.name == "All")
return; // Prevent removing the "All" group
if (isDisabled) return;
toggleGroupByName(option.name);
searchRef.current?.focus();
}}
className={cn(isDisabled && "opacity-40")}
onClick={(e) => e.preventDefault()}
>
<div className={"flex items-center gap-2"}>
<GroupBadge group={option} showNewBadge={true} />
</div>
<div className={"flex items-center gap-5"}>
{option?.id && showRoutes && (
<AccessControlGroupCount group_id={option.id} />
)}
{showResourceCounter && (
<ResourcesCounter group={option} />
)}
<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>
</CommandItem>
</FullTooltip>
);
})}
</ScrollArea>
</CommandGroup>
</TabsContent>
{showResources && (
<TabsContent value={"resources"} className={"p-0 my-0"}>
<ResourcesList
search={search}
resources={resources}
isLoading={isLoading}
value={resource}
onChange={selectResource}
/>
</TabsContent>
)}
</Tabs>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
const TabTriggers = ({
searchRef,
}: {
searchRef: React.MutableRefObject<HTMLInputElement | null>;
}) => {
return (
<TabsList justify={"start"} className={"px-3"}>
<TabsTrigger
value={"groups"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<FolderGit2
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Groups
</TabsTrigger>
<TabsTrigger
value={"resources"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<Layers3Icon
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Resource
</TabsTrigger>
</TabsList>
);
};
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
className={
"text-nb-gray-300 font-medium flex items-center gap-2 transition-all"
}
>
<Layers3 size={14} className={"shrink-0"} />
{group.resources_count} Resource(s)
</div>
) : null;
};
const resourcesSearchPredicate = (item: NetworkResource, query: string) => {
const lowerCaseQuery = query.toLowerCase();
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
return item.address.toLowerCase().includes(lowerCaseQuery);
};
const ResourcesList = ({
search,
resources,
isLoading,
value,
onChange,
}: {
search: string;
resources?: NetworkResource[];
isLoading: boolean;
value?: PolicyRuleResource;
onChange: (resource: NetworkResource) => void;
}) => {
const [filteredItems, _, setSearch] = useSearch(
resources || [],
resourcesSearchPredicate,
{ filter: true, debounce: 150 },
);
useEffect(() => {
setSearch(search);
}, [search, setSearch]);
if (isLoading) {
return (
<div className={"max-h-[195px] flex flex-col gap-1 py-2 px-2"}>
<Skeleton height={42} className={"rounded-md"} />
<Skeleton height={42} className={"rounded-md"} />
<Skeleton height={42} className={"rounded-md"} />
<Skeleton height={42} className={"rounded-md"} />
</div>
);
}
if (search != "" && filteredItems.length == 0) {
return (
<DropdownInfoText className={"mt-5 max-w-sm mx-auto"}>
There are no resources matching your search. Please try a different
search term.
</DropdownInfoText>
);
}
if (search == "" && filteredItems.length == 0) {
return (
<DropdownInfoText className={"mt-5 max-w-sm mx-auto"}>
There are no resources available yet. <br />
Go to <InlineLink href={"/networks"}>Networks</InlineLink> to add some
resources.
</DropdownInfoText>
);
}
return (
<Radio defaultValue={value?.id} name={"resource"} value={value?.id}>
<VirtualScrollAreaList
items={filteredItems}
onSelect={onChange}
itemClassName={"dark:aria-selected:bg-nb-gray-800/20"}
renderItem={(res) => {
return (
<Fragment key={res.id}>
<div className={"flex items-center gap-2"}>
<Badge
useHover={true}
data-cy={"group-badge"}
variant={"gray-ghost"}
className={cn("transition-all group whitespace-nowrap")}
onClick={(e) => {
e.preventDefault();
}}
>
{res.type === "host" && (
<WorkflowIcon size={12} className={"shrink-0"} />
)}
{res.type === "domain" && (
<GlobeIcon size={12} className={"shrink-0"} />
)}
{res.type === "subnet" && (
<NetworkIcon size={12} className={"shrink-0"} />
)}
<TextWithTooltip text={res?.name || ""} maxChars={20} />
</Badge>
</div>
<div className={"flex items-center gap-5"}>
<div
className={
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
}
>
{res.address}
<RadioItem value={res.id} />
</div>
</div>
</Fragment>
);
}}
/>
</Radio>
);
};

View File

@@ -1,31 +1,26 @@
import { DropdownInfoText } from "@components/DropdownInfoText";
import { DropdownInput } from "@components/DropdownInput";
import FullTooltip from "@components/FullTooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { useSearch } from "@hooks/useSearch";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import { isRoutingPeerSupported } from "@utils/version";
import { sortBy, unionBy } from "lodash";
import { ChevronsUpDown, MapPin } from "lucide-react";
import { ArrowUpCircleIcon, ChevronsUpDown, MapPin } from "lucide-react";
import * as React from "react";
import { memo, useEffect, useState } from "react";
import { FcLinux } from "react-icons/fc";
import { useElementSize } from "@/hooks/useElementSize";
import { getOperatingSystem } from "@/hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { Peer } from "@/interfaces/Peer";
import { OSLogo } from "@/modules/peers/PeerOSCell";
const MapPinIcon = memo(() => <MapPin size={12} />);
MapPinIcon.displayName = "MapPinIcon";
const LinuxIcon = memo(() => (
<span className={"grayscale brightness-[100%] contrast-[40%]"}>
<FcLinux className={"text-white text-lg min-w-[20px] brightness-150"} />
</span>
));
LinuxIcon.displayName = "LinuxIcon";
interface MultiSelectProps {
value?: Peer;
onChange: React.Dispatch<React.SetStateAction<Peer | undefined>>;
@@ -63,11 +58,6 @@ export function PeerSelector({
// Sort
let options = sortBy([...peers], "name") as Peer[];
// Filter out peers that are not linux
options = options.filter((peer) => {
return getOperatingSystem(peer.os) === OperatingSystem.LINUX;
});
// Filter out excluded peers
if (excludedPeers) {
options = options.filter((peer) => {
@@ -128,8 +118,7 @@ export function PeerSelector({
}
>
<div className={"flex items-center gap-2.5 text-sm"}>
<LinuxIcon />
<TextWithTooltip text={value.name} maxChars={20} />
<TextWithTooltip text={value.name} maxChars={22} />
</div>
<div
@@ -151,7 +140,7 @@ export function PeerSelector({
</PopoverTrigger>
<PopoverContent
hideWhenDetached={false}
className="w-full p-0 shadow-sm shadow-nb-gray-950"
className="w-full p-0 shadow-sm shadow-nb-gray-950"
style={{
width: width,
}}
@@ -166,15 +155,15 @@ export function PeerSelector({
placeholder={"Search for peers by name or ip..."}
/>
{unfilteredItems.length == 0 && (
<DropdownInfoText>
{
"Seems like you don't have any linux peers to assign as a routing peer."
}
</DropdownInfoText>
{unfilteredItems.length == 0 && !search && (
<div className={"max-w-xs mx-auto"}>
<DropdownInfoText>
{"No peers available to select."}
</DropdownInfoText>
</div>
)}
{filteredItems.length == 0 && (
{filteredItems.length == 0 && search != "" && (
<DropdownInfoText>
There are no peers matching your search.
</DropdownInfoText>
@@ -183,10 +172,35 @@ export function PeerSelector({
{filteredItems.length > 0 && (
<VirtualScrollAreaList
items={filteredItems}
onSelect={togglePeer}
onSelect={(item) => {
const isSupported = isRoutingPeerSupported(
item.version,
item.os,
);
if (!isSupported) return;
togglePeer(item);
}}
renderItem={(option) => {
const os = getOperatingSystem(option.os);
const isSupported = isRoutingPeerSupported(
option.version,
option.os,
);
return (
<>
<FullTooltip
disabled={isSupported}
interactive={false}
delayDuration={200}
skipDelayDuration={350}
className={"w-full flex items-center justify-between"}
content={
<div className={"max-w-[240px] text-xs"}>
Please update NetBird to at least{" "}
<span className={"text-netbird"}>v0.36.6</span> or later
to use this peer as a routing peer.
</div>
}
>
<div
className={cn(
"flex items-center gap-2.5 text-sm",
@@ -195,8 +209,35 @@ export function PeerSelector({
: "text-nb-gray-300",
)}
>
<LinuxIcon />
<TextWithTooltip text={option.name} maxChars={20} />
<div
className={cn(
"flex items-center justify-center grayscale brightness-[100%] contrast-[40%]",
"w-4 h-4 shrink-0",
os === OperatingSystem.WINDOWS && "p-[2.5px]",
os === OperatingSystem.APPLE && "p-[2.7px]",
os === OperatingSystem.FREEBSD && "p-[1.5px]",
!isSupported && "opacity-50",
)}
>
<OSLogo os={option.os} />
</div>
<div className={cn(!isSupported && "opacity-50")}>
<TextWithTooltip
text={option.name}
maxChars={22}
hideTooltip={!isSupported}
/>
</div>
{!isSupported && (
<div className={"relative"}>
<span className="animate-ping absolute left-0 inline-flex h-[14px] w-[14px] rounded-full bg-netbird opacity-20"></span>
<ArrowUpCircleIcon
size={14}
className={"text-netbird"}
/>
</div>
)}
</div>
<div
@@ -205,12 +246,13 @@ export function PeerSelector({
value && value.id == option.id
? "text-white"
: "text-nb-gray-300",
!isSupported && "opacity-50",
)}
>
<MapPinIcon />
{option.ip}
</div>
</>
</FullTooltip>
);
}}
/>

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

71
src/components/Radio.tsx Normal file
View File

@@ -0,0 +1,71 @@
import * as RadioPrimitive from "@radix-ui/react-radio-group";
import { cn } from "@utils/helpers";
import { cva, VariantProps } from "class-variance-authority";
import * as React from "react";
import { forwardRef } from "react";
type RadioVariants = VariantProps<typeof variants>;
const variants = cva([], {
variants: {
variant: {
default: [
"dark:data-[state=unchecked]:bg-nb-gray-950 dark:border-nb-gray-900 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300",
"dark:data-[state=checked]:bg-netbird",
],
},
},
});
const Radio = forwardRef<
React.ElementRef<typeof RadioPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioPrimitive.Root> & RadioVariants
>(
(
{ className, children, variant = "default", defaultValue, ...props },
ref,
) => (
<RadioPrimitive.Root
ref={ref}
defaultValue={defaultValue}
name={props.name}
{...props}
>
{children}
</RadioPrimitive.Root>
),
);
Radio.displayName = RadioPrimitive.Root.displayName;
type Props = {
value: string;
className?: string;
} & RadioVariants;
const RadioItem = ({ value, className, variant = "default" }: Props) => {
return (
<RadioPrimitive.Item
value={value}
className={cn(
variants({ variant }),
"border-neutral-900",
"peer h-5 w-5 shrink-0 rounded-full border",
"ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50 relative",
className,
)}
>
<RadioPrimitive.Indicator asChild={true}>
<div
className={cn(
"h-2 w-2 bg-netbird absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex items-center justify-center rounded-full",
"data-[state=checked]:bg-white data-[state=checked]:text-neutral-50 ",
)}
></div>
</RadioPrimitive.Indicator>
</RadioPrimitive.Item>
);
};
RadioItem.displayName = RadioPrimitive.Item.displayName;
export { Radio, RadioItem };

View File

@@ -1,5 +1,3 @@
"use client";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@utils/helpers";
import * as React from "react";
@@ -15,46 +13,31 @@ const ScrollArea = React.forwardRef<
>(({ className, children, withoutViewport = false, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn(
"relative will-change-scroll webkit-scroll",
className,
"overflow-hidden",
)}
className={cn("relative overflow-hidden", className)}
{...props}
>
{withoutViewport ? (
children
) : (
<ScrollAreaViewport disableOverflowY={false}>
{children}
</ScrollAreaViewport>
<ScrollAreaViewport>{children}</ScrollAreaViewport>
)}
<ScrollBar />
<ScrollBar orientation="horizontal" />
<ScrollBar orientation="vertical" />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
type AdditionalScrollAreaViewportProps = {
disableOverflowY?: boolean;
};
const ScrollAreaViewport = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Viewport> &
AdditionalScrollAreaViewportProps
>(({ disableOverflowY = true, ...props }, ref) => {
return (
<ScrollAreaPrimitive.Viewport
ref={ref}
className="h-full w-full rounded-[inherit] will-change-scroll webkit-scroll"
{...props}
style={
disableOverflowY ? { overflowY: undefined, ...props.style } : undefined
}
/>
);
});
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<ScrollAreaPrimitive.Viewport
ref={ref}
className={cn("h-full w-full rounded-[inherit]", className)}
{...props}
/>
));
ScrollAreaViewport.displayName = ScrollAreaPrimitive.Viewport.displayName;
const ScrollBar = React.forwardRef<
@@ -63,14 +46,11 @@ const ScrollBar = React.forwardRef<
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
style={{ boxSizing: "unset", overflow: undefined }}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 border-t border-t-transparent p-[1px]",
"flex select-none touch-none transition-colors",
orientation === "vertical" && "h-full w-2.5 p-[1px]",
orientation === "horizontal" && "w-full h-2.5 p-[1px] bottom-0",
className,
)}
{...props}
@@ -79,6 +59,7 @@ const ScrollBar = React.forwardRef<
className={cn(
"relative rounded-full bg-neutral-200 dark:bg-nb-gray-800",
orientation === "vertical" && "flex-1",
orientation === "horizontal" && "h-full",
)}
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>

View File

@@ -1,7 +1,3 @@
export default function Separator() {
return (
<span
className={"h-[1px] w-full dark:bg-nb-gray-900 bg-nb-gray-100 block"}
></span>
);
return <span className={"h-[1px] w-full bg-zinc-700/40 block"}></span>;
}

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,38 +61,75 @@ 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-[.95rem] w-full ",
"font-normal ",
"rounded-lg text-[.87rem] w-full relative font-normal",
className,
isChild ? "pl-7 pr-2 py-2 mt-1 mb-0.5" : "py-2 px-3",
isChild
? "pl-7 pr-2 py-[.45rem] mt-1 mb-0.5"
: "py-[.45rem] px-3",
isActive
? "text-gray-900 bg-gray-200 dark:text-white dark:bg-nb-gray-900"
: "text-gray-600 hover:bg-gray-200 dark:text-nb-gray-400 dark:hover:bg-nb-gray-900/50",
)}
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

@@ -4,9 +4,18 @@ import React from "react";
type Props = {
children: React.ReactNode;
className?: string;
horizontal?: boolean;
};
export default function Steps({ children, className }: Props) {
return <div className={cn("pt-4", className)}>{children}</div>;
export default function Steps({
children,
className,
horizontal = false,
}: Readonly<Props>) {
return (
<div className={cn("pt-4", horizontal && "flex", className)}>
{children}
</div>
);
}
type StepProps = {
@@ -14,21 +23,32 @@ type StepProps = {
step: number;
line?: boolean;
center?: boolean;
horizontal?: boolean;
};
const Step = ({ children, step, line = true, center = false }: StepProps) => {
const Step = ({
children,
step,
line = true,
center = false,
horizontal,
}: StepProps) => {
return (
<div
className={cn(
"flex gap-4 items-start min-w-full justify-start relative pb-6 -mx-1.5 group px-[2px]",
"flex gap-4 items-start justify-start relative pb-6 -mx-1.5 group px-[2px]",
center && "items-center",
horizontal ? "flex-col items-center" : "min-w-full",
)}
>
{line && (
<span
className={
"h-full w-[2px] bg-nb-gray-100 dark:bg-nb-gray-800 absolute left-0 ml-[18px] z-0 transition-all"
}
className={cn(
"bg-nb-gray-100 dark:bg-nb-gray-800 z-0 transition-all",
horizontal
? "w-full h-[2px] absolute mt-[16px] transform translate-x-1/2"
: "h-full w-[2px] absolute left-0 ml-[18px]",
)}
></span>
)}

View File

@@ -39,7 +39,7 @@ const Tabs = React.forwardRef<
Tabs.displayName = TabsPrimitive.Root.displayName;
type TabListProps = {
justify?: "start" | "end" | "center";
justify?: "start" | "end" | "center" | "between";
};
const TabsList = React.forwardRef<
@@ -54,6 +54,7 @@ const TabsList = React.forwardRef<
justify == "center" && "justify-center justify-items-end",
justify == "start" && "justify-start",
justify == "end" && "justify-end",
justify == "between" && "justify-between",
)}
{...props}
>
@@ -63,7 +64,9 @@ const TabsList = React.forwardRef<
}
/>
<ScrollArea>
<div className={"relative z-[1] flex flex-nowrap"}>{props.children}</div>
<div className={"relative z-[1] flex flex-nowrap w-full "}>
{props.children}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</TabsPrimitive.List>

View File

@@ -36,29 +36,36 @@ const switchVariants = cva("", {
const ToggleSwitch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> & SwitchVariants
>(({ className, size = "default", variant = "default", ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:ring-neutral-300 dark:focus-visible:ring-offset-neutral-950 ",
className,
switchVariants({ size, variant }),
)}
{...props}
onClick={(e) => {
e.stopPropagation();
props.onClick?.(e);
}}
ref={ref}
>
<SwitchPrimitives.Thumb
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> &
SwitchVariants & { dataCy?: string }
>(
(
{ className, size = "default", variant = "default", dataCy, ...props },
ref,
) => (
<SwitchPrimitives.Root
className={cn(
switchVariants({ "thumb-size": size }),
"pointer-events-none block rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=unchecked]:translate-x-0 dark:bg-white",
"peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:ring-neutral-300 dark:focus-visible:ring-offset-neutral-950 ",
className,
switchVariants({ size, variant }),
)}
/>
</SwitchPrimitives.Root>
));
{...props}
data-cy={dataCy}
onClick={(e) => {
e.stopPropagation();
props.onClick?.(e);
}}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
switchVariants({ "thumb-size": size }),
"pointer-events-none block rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=unchecked]:translate-x-0 dark:bg-white",
)}
/>
</SwitchPrimitives.Root>
),
);
ToggleSwitch.displayName = SwitchPrimitives.Root.displayName;
export { ToggleSwitch };

View File

@@ -10,6 +10,9 @@ const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
export const tooltipClasses =
"z-[9999] overflow-hidden rounded-md border border-neutral-200 bg-white text-sm text-neutral-950 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-nb-gray-930 dark:bg-nb-gray-940 dark:text-neutral-50";
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
@@ -19,10 +22,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
asChild={true}
sideOffset={sideOffset}
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",
className,
)}
className={cn(tooltipClasses, className)}
{...props}
>
<div>{props.children}</div>

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

@@ -11,17 +11,36 @@ type Props = {
onChange: (value: string) => void;
children: React.ReactNode;
};
const TabSwitchContext = React.createContext<{
switchTab: (value: string) => void;
}>({
switchTab: () => {},
});
export const useTabSwitchContext = () => {
return React.useContext(TabSwitchContext);
};
function VerticalTabs({ value, onChange, children }: Props) {
return (
<TabContext.Provider value={value || ""}>
<Tabs.Root
orientation={"vertical"}
className={"block lg:flex bg-nb-gray"}
value={value}
onValueChange={(value) => onChange(value)}
<TabSwitchContext.Provider
value={{
switchTab: (value: string) => {
onChange(value);
},
}}
>
{children}
</Tabs.Root>
<Tabs.Root
orientation={"vertical"}
className={"block lg:flex bg-nb-gray"}
value={value}
onValueChange={(value) => onChange(value)}
>
{children}
</Tabs.Root>
</TabSwitchContext.Provider>
</TabContext.Provider>
);
}
@@ -32,7 +51,7 @@ function List({ children }: { children: React.ReactNode }) {
<Tabs.List
className={cn(
"px-4 py-4 whitespace-nowrap overflow-y-hidden shrink-0 no-scrollbar",
"lg:h-full items-start bg-nb-gray border-b border-nb-gray-930",
"lg:h-full items-start bg-nb-gray border-b-0 border-nb-gray-930",
"flex lg:flex-col lg:gap-1",
)}
style={{

View File

@@ -10,14 +10,26 @@ 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,
}: Props<T>) {
renderBeforeItem,
itemClassName,
itemWrapperClassName,
scrollAreaClassName,
maxHeight,
estimatedItemHeight = 36,
}: Readonly<Props<T>>) {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const [selected, setSelected] = useState(0);
@@ -65,30 +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 as T)}
ariaSelected={selected === index}
>
{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,
}}
@@ -103,14 +132,30 @@ type ItemWrapperProps = {
onMouseEnter?: () => void;
onClick?: () => void;
ariaSelected?: boolean;
className?: string;
itemClassName?: string;
isLast?: boolean;
};
export const VirtualScrollListItemWrapper = memo(
({ id, children, onClick, onMouseEnter, ariaSelected }: ItemWrapperProps) => {
({
id,
children,
onClick,
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}
>
@@ -118,6 +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",
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

@@ -11,6 +11,7 @@ interface Props extends IconVariant {
margin?: string;
truncate?: boolean;
children?: React.ReactNode;
center?: boolean;
}
export default function ModalHeader({
icon,
@@ -21,13 +22,21 @@ export default function ModalHeader({
margin = "mt-0",
truncate = false,
children,
center,
}: Props) {
return (
<div className={cn(className, "min-w-0")}>
<div className={"flex items-start gap-5 pr-10 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={"min-w-0"}>
<h2 className={"text-lg my-0 leading-[1.5]"}>{title}</h2>
<div className={cn("min-w-0", center && "text-center")}>
<h2
className={cn(
"text-lg my-0 leading-[1.5]",
center && "text-center",
)}
>
{title}
</h2>
{children ? (
<>{children}</>
) : (

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

@@ -101,11 +101,11 @@ const TableRow = React.forwardRef<
<tr
ref={ref}
className={cn(
" transition-colors data-[state=selected]:bg-neutral-100 dark:data-[state=selected]:bg-nb-gray-930/70",
" transition-colors group/table-row data-[state=selected]:bg-neutral-100 dark:data-[state=selected]:bg-nb-gray-930/70",
"dark:data-[state=selected]:border-nb-gray-900",
minimal
? "dark:hover:bg-nb-gray-900/10"
: "border-b dark:border-zinc-700/40 dark:hover:bg-nb-gray-900/20 hover:bg-neutral-100/50",
: "border-b dark:border-zinc-700/40 dark:hover:bg-nb-gray-940 hover:bg-neutral-100/50",
className,
)}
{...props}

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,37 +3,57 @@ import Button from "@components/Button";
import { Modal, ModalTrigger } from "@components/modal/Modal";
import useFetchApi from "@utils/api";
import { PlusCircle } from "lucide-react";
import { memo, useState } from "react";
import React, { memo, useState } from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { Peer } from "@/interfaces/Peer";
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
function AddPeerButton() {
const { permission } = usePermissions();
const { data: peers } = useFetchApi<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"}
disabled={!permission.peers.create}
>
<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

@@ -4,7 +4,7 @@ export const GradientFadedBackground = () => {
return (
<div
className={
"h-full w-full absolute left-0 top-0 rounded-md overflow-hidden z-0"
"h-full w-full absolute left-0 top-0 rounded-md overflow-hidden z-0 pointer-events-none"
}
>
<div

View File

@@ -1,7 +1,9 @@
import Badge from "@components/Badge";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
import { SmallBadge } from "@components/ui/SmallBadge";
import TruncatedText from "@components/ui/TruncatedText";
import { cn } from "@utils/helpers";
import { FolderGit2, XIcon } from "lucide-react";
import { XIcon } from "lucide-react";
import * as React from "react";
import { Group } from "@/interfaces/Group";
@@ -12,7 +14,11 @@ type Props = {
children?: React.ReactNode;
className?: string;
showNewBadge?: boolean;
maxChars?: number;
maxWidth?: string;
hideTooltip?: boolean;
};
export default function GroupBadge({
onClick,
group,
@@ -20,13 +26,17 @@ export default function GroupBadge({
children,
className,
showNewBadge = false,
}: Props) {
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"}
className={cn("transition-all group whitespace-nowrap", className)}
onClick={(e) => {
@@ -34,20 +44,15 @@ export default function GroupBadge({
onClick?.(e);
}}
>
<FolderGit2 size={12} className={"shrink-0"} />
<TextWithTooltip text={group?.name || ""} maxChars={20} />
<GroupBadgeIcon id={group?.id} issued={group?.issued} />
<TruncatedText
text={group?.name || ""}
maxChars={maxChars}
maxWidth={maxWidth}
hideTooltip={hideTooltip}
/>
{children}
{isNew && showNewBadge && (
<span
className={
"text-[7px] relative top-[.25px] leading-[0] bg-green-900 border border-green-500/20 py-1.5 px-1 rounded-[3px] text-green-400"
}
>
NEW
</span>
)}
{isNew && showNewBadge && <SmallBadge />}
{showX && (
<XIcon
size={12}

View File

@@ -0,0 +1,32 @@
import { FolderGit2 } from "lucide-react";
import * as React from "react";
import EntraIcon from "@/assets/icons/EntraIcon";
import GoogleIcon from "@/assets/icons/GoogleIcon";
import JWTIcon from "@/assets/icons/JWTIcon";
import OktaIcon from "@/assets/icons/OktaIcon";
import { useGroups } from "@/contexts/GroupsProvider";
import { GroupIssued } from "@/interfaces/Group";
import { useGroupIdentification } from "@/modules/groups/useGroupIdentification";
export const GroupBadgeIcon = ({
id,
issued,
}: {
id?: string;
issued?: GroupIssued;
}) => {
const { groups } = useGroups();
const group = groups?.find((g) => g.id === id);
const { isAzureGroup, isGoogleGroup, isOktaGroup, isJWTGroup } =
useGroupIdentification({ id, issued: issued ?? group?.issued });
if (isGoogleGroup)
return <GoogleIcon size={11} className={"shrink-0 mr-0.5"} />;
if (isAzureGroup)
return <EntraIcon size={13} className={"shrink-0 mr-0.5"} />;
if (isOktaGroup) return <OktaIcon size={11} className={"shrink-0 mr-0.5"} />;
if (isJWTGroup) return <JWTIcon size={12} className={"shrink-0"} />;
return <FolderGit2 size={12} className={"shrink-0"} />;
};

View File

@@ -13,6 +13,7 @@ type Props = {
onRemove: () => void;
onError?: (error: boolean) => void;
error?: string;
disabled?: boolean;
};
enum ActionType {
ADD = "ADD",
@@ -38,6 +39,7 @@ export default function InputDomain({
onChange,
onRemove,
onError,
disabled,
}: Readonly<Props>) {
const [name, setName] = useState(value?.name || "");
@@ -74,6 +76,7 @@ export default function InputDomain({
value={name}
error={domainError}
onChange={handleNameChange}
disabled={disabled}
/>
</div>
@@ -81,6 +84,7 @@ export default function InputDomain({
className={"h-[42px]"}
variant={"default-outline"}
onClick={onRemove}
disabled={disabled}
>
<MinusCircleIcon size={15} />
</Button>

View File

@@ -9,8 +9,8 @@ export default function LoginExpiredBadge({ loginExpired }: Props) {
return loginExpired ? (
<Tooltip delayDuration={1}>
<TooltipTrigger>
<Badge variant={"red"} className={"px-3"}>
<AlertTriangle size={13} className={"mr-1"} />
<Badge variant={"red"} className={"px-2"}>
<AlertTriangle size={12} />
Login required
</Badge>
</TooltipTrigger>

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,23 +19,35 @@ 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"}>
<div
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 && (
<Badge
@@ -48,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,16 @@
import * as React from "react";
type Props = {
text?: string;
};
export const NewBadge = ({ text = "NEW" }: Props) => {
return (
<span
className={
"text-[7px] relative top-[.25px] leading-[0] bg-green-900 border border-green-500/20 py-1.5 px-1 rounded-[3px] text-green-400"
}
>
{text}
</span>
);
};

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

@@ -0,0 +1,58 @@
import Badge from "@components/Badge";
import TruncatedText from "@components/ui/TruncatedText";
import { cn } from "@utils/helpers";
import { GlobeIcon, NetworkIcon, WorkflowIcon, XIcon } from "lucide-react";
import * as React from "react";
import { NetworkResource } from "@/interfaces/Network";
type Props = {
resource?: NetworkResource;
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
showX?: boolean;
children?: React.ReactNode;
className?: string;
};
export default function ResourceBadge({
onClick,
resource,
showX = false,
children,
className,
}: Readonly<Props>) {
if (!resource) return;
return (
<Badge
key={resource.id || resource?.name}
useHover={true}
data-cy={"resource-badge"}
variant={"gray-ghost"}
className={cn("transition-all group whitespace-nowrap", className)}
onClick={(e) => {
e.preventDefault();
onClick?.(e);
}}
>
{resource.type === "host" && (
<WorkflowIcon size={12} className={"shrink-0"} />
)}
{resource.type === "domain" && (
<GlobeIcon size={12} className={"shrink-0"} />
)}
{resource.type === "subnet" && (
<NetworkIcon size={12} className={"shrink-0"} />
)}
<TruncatedText text={resource?.name || ""} maxChars={20} />
{children}
{showX && (
<XIcon
size={12}
className={
"cursor-pointer group-hover:text-nb-gray-100 transition-all shrink-0"
}
/>
)}
</Badge>
);
}

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

@@ -0,0 +1,43 @@
import { cn } from "@utils/helpers";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
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",
},
},
});
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) => {
return (
<span
className={cn(
smallBadgeVariants({ variant }),
"text-[7px] relative -top-[.25px] leading-[0] py-[0.39rem] px-1 rounded-[3px]",
className,
)}
>
{children}
<span className={cn("relative top-[0.4px]", textClassName)}>{text}</span>
</span>
);
};

View File

@@ -25,6 +25,8 @@ export default function TextWithTooltip({
disabled={charCount <= maxChars || hideTooltip}
interactive={false}
className={"truncate w-full min-w-0"}
skipDelayDuration={350}
delayDuration={200}
content={
<div className={"max-w-xs break-all whitespace-normal text-xs"}>
{text}
@@ -32,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

@@ -0,0 +1,92 @@
import * as HoverCard from "@radix-ui/react-hover-card";
import { cn } from "@utils/helpers";
import React, { useMemo, useState } from "react";
type Props = {
text?: string;
className?: string;
maxChars?: number;
maxWidth?: string; // Optional CSS width value
hideTooltip?: boolean;
};
export default function TruncatedText({
text,
className,
maxChars = 40,
maxWidth,
hideTooltip = false,
}: 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]);
// 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]);
// 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={containerStyle}>
<div ref={contentRef} className={cn(className, "truncate")}>
{text}
</div>
</div>
);
}
return (
<HoverCard.Root
openDelay={650}
closeDelay={100}
open={open}
onOpenChange={setOpen}
>
<HoverCard.Trigger asChild={true}>
<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>
<HoverCard.Content
onMouseLeave={() => setOpen(false)}
onMouseEnter={() => setOpen(false)}
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",
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">
{text}
</div>
</div>
</HoverCard.Content>
</HoverCard.Portal>
</HoverCard.Root>
);
}

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,27 +16,20 @@ 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", () => logout(), []);
const [dropdownOpen, setDropdownOpen] = useState(false);
const { permission } = useLoggedInUser();
return (
<DropdownMenu
modal={false}
@@ -45,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">
@@ -69,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
@@ -96,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

@@ -24,8 +24,9 @@ type DialogOptions = {
description?: string | React.ReactNode;
confirmText?: string;
cancelText?: string;
type?: "default" | "warning" | "danger";
type?: "default" | "warning" | "danger" | "center";
children?: React.ReactNode;
maxWidthClass?: string;
};
export default function DialogProvider({ children }: Props) {
@@ -51,6 +52,7 @@ export default function DialogProvider({ children }: Props) {
default: "",
warning: <AlertCircle size={18} />,
danger: <AlertTriangle size={18} />,
center: "",
};
return (
@@ -61,8 +63,12 @@ export default function DialogProvider({ children }: Props) {
onOpenChange={(open) => fn.current && fn.current(open)}
>
{dialogOptions && (
<ModalContent maxWidthClass={"max-w-lg"} showClose={false}>
<ModalContent
maxWidthClass={dialogOptions.maxWidthClass || "max-w-[400px]"}
showClose={false}
>
<ModalHeader
center={dialogOptions.type == "center"}
title={dialogOptions.title || "Confirmation"}
margin={"mt-1"}
description={

View File

@@ -1,9 +1,8 @@
import useFetchApi, { useApiCall } from "@utils/api";
import { merge, sortBy, unionBy } from "lodash";
import { usePathname } from "next/navigation";
import React, { useEffect, useState } from "react";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { Group } from "@/interfaces/Group";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group, GroupResource } from "@/interfaces/Group";
import { Peer } from "@/interfaces/Peer";
type Props = {
@@ -25,18 +24,29 @@ const GroupContext = React.createContext(
);
export default function GroupsProvider({ children }: Props) {
const path = usePathname();
const { permission } = useLoggedInUser();
const { isRestricted } = usePermissions();
return path === "/peers" && permission.dashboard_view == "blocked" ? (
return isRestricted ? (
<>{children}</>
) : (
<GroupsProviderContent>{children}</GroupsProviderContent>
);
}
export function GroupsProviderContent({ children }: Props) {
const { data: groups, mutate, isLoading } = useFetchApi<Group[]>("/groups");
type ProviderContentProps = {
children: React.ReactNode;
};
export function GroupsProviderContent({
children,
}: Readonly<ProviderContentProps>) {
const { permission } = usePermissions();
const {
data: groups,
mutate,
isLoading,
} = useFetchApi<Group[]>("/groups", false, true, permission.groups.read);
const groupRequest = useApiCall<Group>("/groups", true);
const [dropdownOptions, setDropdownOptions] = useState<Group[]>([]);
@@ -92,6 +102,13 @@ export function GroupsProviderContent({ children }: Props) {
return peer.id;
}) 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;
}) as string[];
if (group.name === "All") return Promise.resolve(group);
const groupID =
@@ -102,6 +119,7 @@ export function GroupsProviderContent({ children }: Props) {
{
name: group.name,
peers: peers,
resources: resources,
},
`/${group.id}`,
);
@@ -109,6 +127,7 @@ export function GroupsProviderContent({ children }: Props) {
return groupRequest.post({
name: group.name,
peers: peers,
resources: resources,
});
}
};

View File

@@ -20,12 +20,13 @@ const PeerContext = React.createContext(
peer: Peer;
user?: User;
peerGroups: Group[];
update: (
name: string,
ssh: boolean,
loginExpiration: boolean,
approval_required?: boolean,
) => Promise<Peer>;
update: (props: {
name?: string;
ssh?: boolean;
loginExpiration?: boolean;
inactivityExpiration?: boolean;
approval_required?: boolean;
}) => Promise<Peer>;
openSSHDialog: () => Promise<boolean>;
deletePeer: () => void;
isLoading: boolean;
@@ -61,23 +62,30 @@ export default function PeerProvider({ children, peer }: Props) {
}
};
const update = async (
name: string,
ssh: boolean,
loginExpiration: boolean,
approval_required?: boolean,
) => {
const update = async (props: {
name?: string;
ssh?: boolean;
loginExpiration?: boolean;
inactivityExpiration?: boolean;
approval_required?: boolean;
}) => {
return peerRequest.put(
{
peerId: peer?.id,
name: name != undefined ? name : peer.name,
ssh_enabled: ssh != undefined ? ssh : peer.ssh_enabled,
name: props.name != undefined ? props.name : peer.name,
ssh_enabled: props.ssh != undefined ? props.ssh : peer.ssh_enabled,
login_expiration_enabled:
loginExpiration != undefined
? loginExpiration
props.loginExpiration != undefined
? props.loginExpiration
: peer.login_expiration_enabled,
inactivity_expiration_enabled:
props?.inactivityExpiration == undefined
? undefined
: props.inactivityExpiration,
approval_required:
approval_required == undefined ? undefined : approval_required,
props?.approval_required == undefined
? undefined
: props.approval_required,
},
`/${peer.id}`,
);

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

@@ -20,6 +20,7 @@ const RoutesContext = React.createContext(
toUpdate: Partial<Route>,
onSuccess?: (route: Route) => void,
message?: string,
options?: { remove_access_control_groups?: boolean },
) => void;
},
);
@@ -33,6 +34,7 @@ export default function RoutesProvider({ children }: Readonly<Props>) {
toUpdate: Partial<Route>,
onSuccess?: (route: Route) => void,
message?: string,
options?: { remove_access_control_groups?: boolean },
) => {
const hasDomains = route.domains ? route.domains.length > 0 : false;
@@ -54,10 +56,11 @@ export default function RoutesProvider({ children }: Readonly<Props>) {
metric: toUpdate.metric ?? route.metric ?? 9999,
masquerade: toUpdate.masquerade ?? route.masquerade ?? true,
groups: toUpdate.groups ?? route.groups ?? [],
access_control_groups:
toUpdate.access_control_groups ??
route.access_control_groups ??
undefined,
access_control_groups: options?.remove_access_control_groups
? undefined
: toUpdate.access_control_groups ??
route.access_control_groups ??
undefined,
},
`/${route.id}`,
)

View File

@@ -1,8 +1,13 @@
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 { User } from "@/interfaces/User";
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 isOwner = loggedInUser ? loggedInUser?.role === "owner" : false;
const isAdmin = loggedInUser ? loggedInUser?.role === "admin" : false;
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

@@ -0,0 +1,33 @@
import { TimeRange, useTimeFormatter } from "@hooks/useTimeFormatter";
import { useState } from "react";
type Props = {
enabled: boolean;
expirationInSeconds: number;
timeRange?: TimeRange;
};
export const useExpirationState = ({
enabled,
expirationInSeconds,
timeRange = ["hours", "days"],
}: Props) => {
const [isEnabled, setIsEnabled] = useState(enabled);
const [expiresInSeconds] = useState(expirationInSeconds || 86400);
const { value: seconds, time: unit } = useTimeFormatter(
expiresInSeconds,
timeRange,
);
const [expiresIn, setExpiresIn] = useState(seconds);
const [expireInterval, setExpireInterval] = useState<string>(unit);
return [
isEnabled,
setIsEnabled,
expiresIn,
setExpiresIn,
expireInterval,
setExpireInterval,
] as const;
};

View File

@@ -25,6 +25,7 @@ export function useSetupKeyPlaceholders() {
expires_in: 0,
usage_limit: null,
ephemeral: randomBoolean(),
allow_extra_dns_labels: randomBoolean(),
} as SetupKey);
}

View File

@@ -0,0 +1,63 @@
import { useMemo } from "react";
export type TimeUnit = "seconds" | "minutes" | "hours" | "days";
export type TimeRange = TimeUnit[];
const TIME_CONVERSIONS: Record<string, number> = {
seconds: 1,
minutes: 60,
hours: 3600,
days: 86400,
};
interface FormattedTime {
value: string;
time: TimeUnit | string;
}
export const isValidTimeUnit = (unit: string): unit is TimeUnit => {
return unit in TIME_CONVERSIONS;
};
export const convertToSeconds = (
value: string,
unit: TimeUnit | string,
): number => {
if (!isValidTimeUnit(unit)) {
console.warn(`Invalid time unit: ${unit}`);
}
return Math.round(parseFloat(value) * TIME_CONVERSIONS[unit]);
};
export const useTimeFormatter = (
seconds: number,
range: TimeRange,
): FormattedTime => {
return useMemo(() => {
const smallerUnit = range[0];
const largestUnit = range[range.length - 1];
const largestIndex = range.indexOf(largestUnit);
if (TIME_CONVERSIONS[smallerUnit] >= TIME_CONVERSIONS[largestUnit]) {
console.warn("First unit must be smaller than second unit");
}
if (seconds === TIME_CONVERSIONS.days && largestUnit === "days") {
return { value: "24", time: "hours" };
}
// Convert seconds to all units in range
const converted = range.map((unit) => {
const value = seconds / TIME_CONVERSIONS[unit];
return {
value: Number.isInteger(value) ? value.toString() : value.toFixed(2),
time: unit,
};
});
const { value, time } =
converted.reverse().find(({ value }) => parseFloat(value) >= 1) ||
converted[largestIndex];
return { value, time };
}, [seconds, range]);
};

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