Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2046fee21 | ||
|
|
8e2cbe1d2a | ||
|
|
8a08583225 | ||
|
|
1defac4e34 | ||
|
|
fa68f98cd0 | ||
|
|
3f6e4c4e4f | ||
|
|
0e2661caea | ||
|
|
d7c5f7e183 | ||
|
|
ebbe865ce0 | ||
|
|
6c0ab88488 | ||
|
|
a50576d851 | ||
|
|
676250266c | ||
|
|
042c65a652 | ||
|
|
96f2d39e54 | ||
|
|
61e11d3740 | ||
|
|
c8e3b50f1b | ||
|
|
25be69e7bb | ||
|
|
43e5d5cf53 | ||
|
|
18819d6fdf | ||
|
|
158804c1ac | ||
|
|
14d2d68819 | ||
|
|
40902b3629 | ||
|
|
fa9bcea4ab | ||
|
|
3ba7acdecf | ||
|
|
c7775ade8c |
64
CONTRIBUTOR_LICENSE_AGREEMENT.md
Normal file
64
CONTRIBUTOR_LICENSE_AGREEMENT.md
Normal 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 Contributor’s 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 Contributor‘s Contribution(s) alone or by combination of Contributor’s Contribution(s) with the Work to which such Contribution(s) was Submitted.
|
||||
|
||||
3.3 NetBird hereby accepts such licenses.
|
||||
|
||||
## 4 Contributor’s Representations
|
||||
4.1 Contributor represents that Contributor is legally entitled to grant the above license. If Contributor’s employer has IP Rights to Contributor’s 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 Contributor’s 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.
|
||||
|
||||
@@ -12,5 +12,6 @@
|
||||
"tokenSource": "$NETBIRD_TOKEN_SOURCE",
|
||||
"dragQueryParams": "$NETBIRD_DRAG_QUERY_PARAMS",
|
||||
"hotjarTrackID": "$NETBIRD_HOTJAR_TRACK_ID",
|
||||
"googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID"
|
||||
"googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID",
|
||||
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID"
|
||||
}
|
||||
@@ -58,13 +58,14 @@ export NETBIRD_MGMT_API_ENDPOINT=$(echo $NETBIRD_MGMT_API_ENDPOINT | sed -E 's/(
|
||||
export NETBIRD_MGMT_GRPC_API_ENDPOINT=${NETBIRD_MGMT_GRPC_API_ENDPOINT}
|
||||
export NETBIRD_HOTJAR_TRACK_ID=${NETBIRD_HOTJAR_TRACK_ID}
|
||||
export NETBIRD_GOOGLE_ANALYTICS_ID=${NETBIRD_GOOGLE_ANALYTICS_ID}
|
||||
export NETBIRD_GOOGLE_TAG_MANAGER_ID=${NETBIRD_GOOGLE_TAG_MANAGER_ID}
|
||||
export NETBIRD_TOKEN_SOURCE=${NETBIRD_TOKEN_SOURCE:-accessToken}
|
||||
export NETBIRD_DRAG_QUERY_PARAMS=${NETBIRD_DRAG_QUERY_PARAMS:-false}
|
||||
|
||||
echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}"
|
||||
|
||||
# replace ENVs in the config
|
||||
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS"
|
||||
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS"
|
||||
|
||||
OIDC_TRUSTED_DOMAINS="/usr/share/nginx/html/OidcTrustedDomains.js"
|
||||
envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS"
|
||||
|
||||
2025
package-lock.json
generated
2025
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -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.481.0",
|
||||
"next": "^14.2.28",
|
||||
"next-themes": "^0.2.1",
|
||||
"punycode": "^2.3.1",
|
||||
"react": "^18",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^8.9.1",
|
||||
"react-dom": "^18",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-ga4": "^2.1.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-hotjar": "^6.2.0",
|
||||
@@ -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"
|
||||
|
||||
10
src/app/(dashboard)/(deprecated)/activity/page.tsx
Normal file
10
src/app/(dashboard)/(deprecated)/activity/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { useRedirect } from "@hooks/useRedirect";
|
||||
import React from "react";
|
||||
|
||||
export default function Redirect() {
|
||||
useRedirect("/events/audit");
|
||||
return <FullScreenLoading height={"auto"} />;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
import GroupsProvider from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import PoliciesProvider from "@/contexts/PoliciesProvider";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
@@ -19,6 +20,8 @@ const AccessControlTable = lazy(
|
||||
() => import("@/modules/access-control/table/AccessControlTable"),
|
||||
);
|
||||
export default function AccessControlPage() {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { data: policies, isLoading } = useFetchApi<Policy[]>("/policies");
|
||||
|
||||
const { ref: headingRef, portalTarget } =
|
||||
@@ -53,7 +56,10 @@ export default function AccessControlPage() {
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<RestrictedAccess page={"Access Control"}>
|
||||
<RestrictedAccess
|
||||
page={"Access Control"}
|
||||
hasAccess={permission.policies.read}
|
||||
>
|
||||
<PoliciesProvider>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<AccessControlTable
|
||||
|
||||
@@ -10,6 +10,7 @@ import useFetchApi from "@utils/api";
|
||||
import { ExternalLinkIcon, ServerIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import DNSIcon from "@/assets/icons/DNSIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { NameserverGroup } from "@/interfaces/Nameserver";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
@@ -18,6 +19,8 @@ const NameserverGroupTable = lazy(
|
||||
);
|
||||
|
||||
export default function NameServers() {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { data: nameserverGroups, isLoading } =
|
||||
useFetchApi<NameserverGroup[]>("/dns/nameservers");
|
||||
|
||||
@@ -57,7 +60,10 @@ export default function NameServers() {
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<RestrictedAccess page={"Nameservers"}>
|
||||
<RestrictedAccess
|
||||
page={"Nameservers"}
|
||||
hasAccess={permission.nameservers.read}
|
||||
>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<NameserverGroupTable
|
||||
nameserverGroups={nameserverGroups}
|
||||
|
||||
@@ -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>
|
||||
|
||||
8
src/app/(dashboard)/events/audit/layout.tsx
Normal file
8
src/app/(dashboard)/events/audit/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Audit Events - Activity - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
@@ -6,15 +6,19 @@ import Paragraph from "@components/Paragraph";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { ExternalLinkIcon, LogsIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import ActivityIcon from "@/assets/icons/ActivityIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { ActivityEvent } from "@/interfaces/ActivityEvent";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import ActivityTable from "@/modules/activity/ActivityTable";
|
||||
|
||||
export default function Activity() {
|
||||
const { data: events, isLoading } = useFetchApi<ActivityEvent[]>("/events");
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { data: events, isLoading } =
|
||||
useFetchApi<ActivityEvent[]>("/events/audit");
|
||||
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
@@ -24,30 +28,31 @@ export default function Activity() {
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/activity"}
|
||||
label={"Activity"}
|
||||
disabled={true}
|
||||
icon={<ActivityIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/events/audit"}
|
||||
label={"Audit Events"}
|
||||
icon={<LogsIcon size={18} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Activity Events</h1>
|
||||
<Paragraph>
|
||||
Here you can see all the account and network activity events.
|
||||
</Paragraph>
|
||||
<h1 ref={headingRef}>Audit Events</h1>
|
||||
<Paragraph>Here you can see all the audit activity events.</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about{" "}
|
||||
<InlineLink
|
||||
href={
|
||||
"https://docs.netbird.io/how-to/monitor-system-and-network-activity"
|
||||
}
|
||||
href={"https://docs.netbird.io/how-to/audit-events-logging"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Activity Events
|
||||
Audit Events
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess page={"Activity"}>
|
||||
<RestrictedAccess page={"Activity"} hasAccess={permission.events.read}>
|
||||
<ActivityTable
|
||||
events={events}
|
||||
isLoading={isLoading}
|
||||
@@ -11,6 +11,7 @@ import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import PeersProvider from "@/contexts/PeersProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import RoutesProvider from "@/contexts/RoutesProvider";
|
||||
import { Route } from "@/interfaces/Route";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
@@ -21,6 +22,7 @@ const NetworkRoutesTable = lazy(
|
||||
);
|
||||
|
||||
export default function NetworkRoutes() {
|
||||
const { permission } = usePermissions();
|
||||
const { data: routes, isLoading } = useFetchApi<Route[]>("/routes");
|
||||
const groupedRoutes = useGroupedRoutes({ routes });
|
||||
|
||||
@@ -59,7 +61,7 @@ export default function NetworkRoutes() {
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<RestrictedAccess>
|
||||
<RestrictedAccess hasAccess={permission.routes.read}>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<NetworkRoutesTable
|
||||
isLoading={isLoading}
|
||||
|
||||
8
src/app/(dashboard)/network/layout.tsx
Normal file
8
src/app/(dashboard)/network/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Network - Networks - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
232
src/app/(dashboard)/network/page.tsx
Normal file
232
src/app/(dashboard)/network/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
src/app/(dashboard)/networks/layout.tsx
Normal file
8
src/app/(dashboard)/networks/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Networks - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
63
src/app/(dashboard)/networks/page.tsx
Normal file
63
src/app/(dashboard)/networks/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,270 +144,276 @@ 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.
|
||||
*/
|
||||
const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([
|
||||
name,
|
||||
ssh,
|
||||
selectedGroups,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
]);
|
||||
|
||||
const updatePeer = async () => {
|
||||
const updateRequest = update(name, ssh, loginExpiration);
|
||||
const updatePeer = async (newName?: string) => {
|
||||
let batchCall: Promise<any>[] = [];
|
||||
const groupCalls = getAllGroupCalls();
|
||||
const batchCall = groupCalls
|
||||
? [...groupCalls, updateRequest]
|
||||
: [updateRequest];
|
||||
|
||||
if (permission.peers.update) {
|
||||
const updateRequest = update({
|
||||
name: newName ?? name,
|
||||
ssh,
|
||||
loginExpiration,
|
||||
inactivityExpiration,
|
||||
});
|
||||
batchCall = groupCalls ? [...groupCalls, updateRequest] : [updateRequest];
|
||||
} else {
|
||||
batchCall = [...groupCalls];
|
||||
}
|
||||
|
||||
notify({
|
||||
title: name,
|
||||
description: "Peer was successfully saved",
|
||||
promise: Promise.all(batchCall).then(() => {
|
||||
mutate("/peers/" + peer.id);
|
||||
mutate("/groups");
|
||||
updateHasChangedRef([name, ssh, selectedGroups, loginExpiration]);
|
||||
updateHasChangedRef([
|
||||
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) => {
|
||||
updatePeer(newName).then(() => {
|
||||
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(() => {
|
||||
|
||||
@@ -9,18 +9,19 @@ import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import PeersProvider, { usePeers } from "@/contexts/PeersProvider";
|
||||
import { useLoggedInUser, useUsers } from "@/contexts/UsersProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useUsers } from "@/contexts/UsersProvider";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
const PeersTable = lazy(() => import("@/modules/peers/PeersTable"));
|
||||
|
||||
export default function Peers() {
|
||||
const { permission } = useLoggedInUser();
|
||||
const { isRestricted } = usePermissions();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
{permission.dashboard_view === "blocked" ? (
|
||||
{isRestricted ? (
|
||||
<PeersBlockedView />
|
||||
) : (
|
||||
<PeersProvider>
|
||||
@@ -104,7 +105,7 @@ function PeersBlockedView() {
|
||||
<div className={"px-3 pt-1 pb-8 max-w-3xl w-full"}>
|
||||
<div
|
||||
className={
|
||||
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40 stepper-bg-variant"
|
||||
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40"
|
||||
}
|
||||
>
|
||||
<SetupModalContent header={false} footer={false} />
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ExternalLinkIcon, ShieldCheck } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
import GroupsProvider from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import PoliciesProvider from "@/contexts/PoliciesProvider";
|
||||
import { PostureCheck } from "@/interfaces/PostureCheck";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
@@ -19,6 +20,7 @@ const PostureCheckTable = lazy(
|
||||
() => import("@/modules/posture-checks/table/PostureCheckTable"),
|
||||
);
|
||||
export default function PostureChecksPage() {
|
||||
const { permission } = usePermissions();
|
||||
const { data: postureChecks, isLoading } =
|
||||
useFetchApi<PostureCheck[]>("/posture-checks");
|
||||
|
||||
@@ -59,7 +61,10 @@ export default function PostureChecksPage() {
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<RestrictedAccess page={"Posture Checks"}>
|
||||
<RestrictedAccess
|
||||
page={"Posture Checks"}
|
||||
hasAccess={permission.policies.read}
|
||||
>
|
||||
<PoliciesProvider>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<PostureCheckTable
|
||||
|
||||
@@ -6,23 +6,35 @@ import {
|
||||
AlertOctagonIcon,
|
||||
FolderGit2Icon,
|
||||
LockIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
NetworkIcon,
|
||||
ShieldIcon,
|
||||
} from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { useAccount } from "@/modules/account/useAccount";
|
||||
import AuthenticationTab from "@/modules/settings/AuthenticationTab";
|
||||
import ClientSettingsTab from "@/modules/settings/ClientSettingsTab";
|
||||
import DangerZoneTab from "@/modules/settings/DangerZoneTab";
|
||||
import GroupsTab from "@/modules/settings/GroupsTab";
|
||||
import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab";
|
||||
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 +47,43 @@ export default function NetBirdSettings() {
|
||||
<PageContainer>
|
||||
<VerticalTabs value={tab} onChange={setTab}>
|
||||
<VerticalTabs.List>
|
||||
<VerticalTabs.Trigger value="authentication">
|
||||
<ShieldIcon size={14} />
|
||||
Authentication
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value="groups">
|
||||
<FolderGit2Icon size={14} />
|
||||
Groups
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value="permissions">
|
||||
<LockIcon size={14} />
|
||||
Permissions
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value="danger-zone" disabled={!isOwner}>
|
||||
<AlertOctagonIcon size={14} />
|
||||
Danger zone
|
||||
</VerticalTabs.Trigger>
|
||||
{permission.settings.read && (
|
||||
<>
|
||||
<VerticalTabs.Trigger value="authentication">
|
||||
<ShieldIcon size={14} />
|
||||
Authentication
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value="groups">
|
||||
<FolderGit2Icon size={14} />
|
||||
Groups
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value="permissions">
|
||||
<LockIcon size={14} />
|
||||
Permissions
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value="networks">
|
||||
<NetworkIcon size={14} />
|
||||
Networks
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value="clients">
|
||||
<MonitorSmartphoneIcon size={14} />
|
||||
Clients
|
||||
</VerticalTabs.Trigger>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DangerZoneTabTrigger />
|
||||
</VerticalTabs.List>
|
||||
<RestrictedAccess page={"Settings"}>
|
||||
<RestrictedAccess
|
||||
page={"Settings"}
|
||||
hasAccess={permission.settings.read}
|
||||
>
|
||||
<div className={"border-l border-nb-gray-930 w-full"}>
|
||||
{account && <AuthenticationTab account={account} />}
|
||||
{account && <PermissionsTab account={account} />}
|
||||
{account && <GroupsTab account={account} />}
|
||||
{account && <NetworkSettingsTab account={account} />}
|
||||
{account && <ClientSettingsTab account={account} />}
|
||||
{account && <DangerZoneTab account={account} />}
|
||||
</div>
|
||||
</RestrictedAccess>
|
||||
@@ -64,3 +91,16 @@ export default function NetBirdSettings() {
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const DangerZoneTabTrigger = () => {
|
||||
const { isOwner } = useLoggedInUser();
|
||||
|
||||
return (
|
||||
isOwner && (
|
||||
<VerticalTabs.Trigger value="danger-zone" disabled={!isOwner}>
|
||||
<AlertOctagonIcon size={14} />
|
||||
Danger zone
|
||||
</VerticalTabs.Trigger>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense, useMemo } from "react";
|
||||
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { SetupKey } from "@/interfaces/SetupKey";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
@@ -21,6 +22,7 @@ const SetupKeysTable = lazy(
|
||||
|
||||
export default function SetupKeys() {
|
||||
const { data: setupKeys, isLoading } = useFetchApi<SetupKey[]>("/setup-keys");
|
||||
const { permission } = usePermissions();
|
||||
const { groups } = useGroups();
|
||||
|
||||
const setupKeysWithGroups = useMemo(() => {
|
||||
@@ -71,7 +73,10 @@ export default function SetupKeys() {
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess page={"Setup Keys"}>
|
||||
<RestrictedAccess
|
||||
page={"Setup Keys"}
|
||||
hasAccess={permission.setup_keys.read}
|
||||
>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<SetupKeysTable
|
||||
headingTarget={portalTarget}
|
||||
|
||||
@@ -11,6 +11,7 @@ import useFetchApi from "@utils/api";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import TeamIcon from "@/assets/icons/TeamIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { User } from "@/interfaces/User";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
@@ -19,6 +20,7 @@ const ServiceUsersTable = lazy(
|
||||
);
|
||||
|
||||
export default function ServiceUsers() {
|
||||
const { permission } = usePermissions();
|
||||
const { data: users, isLoading } = useFetchApi<User[]>(
|
||||
"/users?service_user=true",
|
||||
);
|
||||
@@ -59,7 +61,10 @@ export default function ServiceUsers() {
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess page={"Service Users"}>
|
||||
<RestrictedAccess
|
||||
page={"Service Users"}
|
||||
hasAccess={permission.users.read}
|
||||
>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<ServiceUsersTable
|
||||
users={users}
|
||||
|
||||
@@ -10,6 +10,7 @@ import Paragraph from "@components/Paragraph";
|
||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import Separator from "@components/Separator";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import useRedirect from "@hooks/useRedirect";
|
||||
import { IconCirclePlus, IconSettings2 } from "@tabler/icons-react";
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
@@ -20,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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -67,10 +67,93 @@ p {
|
||||
}
|
||||
|
||||
.stepper-bg-variant .step-circle {
|
||||
@apply !border-[#1d2024];
|
||||
@apply !border-nb-gray-940;
|
||||
}
|
||||
|
||||
.webkit-scroll{
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-webkit-transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Timescape Root element
|
||||
*/
|
||||
.timescape {
|
||||
@apply flex items-center gap-[1px] rounded-md py-2 px-3 select-none w-fit cursor-text bg-nb-gray-900;
|
||||
}
|
||||
|
||||
/**
|
||||
* Date and time input elements
|
||||
*/
|
||||
.timescape input {
|
||||
@apply cursor-text px-0.5 py-1 bg-transparent h-fit border-0 outline-0 select-none box-content caret-transparent text-nb-gray-200 text-sm placeholder-nb-gray-300;
|
||||
font-variant-numeric: tabular-nums;
|
||||
/* For the calculation of the input width these are important */
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.timescape input:focus {
|
||||
@apply bg-nb-gray-700 text-white rounded py-1 px-0.5 border-0 outline-0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Separator elements
|
||||
*/
|
||||
.timescape .separator {
|
||||
@apply text-gray-400 m-0 text-[80%] -top-[1px] relative;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fade in animation
|
||||
*/
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.4s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
|
||||
.animate-slow-ping {
|
||||
animation: ping 1.6s cubic-bezier(0, 0, 0.2, 1) infinite
|
||||
}
|
||||
|
||||
@keyframes ping {
|
||||
75%, 100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slow-pulse {
|
||||
animation: pulse 2.5s cubic-bezier(0.4, 0, 0.6, 1) infinite
|
||||
}
|
||||
|
||||
|
||||
@keyframes pulse {
|
||||
60% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bg-scroll {
|
||||
0% {
|
||||
background-position: 0% 100%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-bg-scroll {
|
||||
animation: bg-scroll 4s linear infinite;
|
||||
}
|
||||
.animate-bg-scroll-faster {
|
||||
animation: bg-scroll 1.8s linear infinite;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ export default function Home() {
|
||||
}
|
||||
|
||||
const Redirect = ({ url, queryParams }: Props) => {
|
||||
useRedirect(url == "/" ? "/peers" : url + (queryParams && `?${queryParams}`));
|
||||
const params = queryParams && `?${queryParams}`;
|
||||
useRedirect(url == "/" ? `/peers${params}` : `${url}${params}`);
|
||||
return <FullScreenLoading />;
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function CircleIcon({
|
||||
size = 11,
|
||||
inactiveDot = "gray",
|
||||
className,
|
||||
}: Props) {
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<span
|
||||
style={{ width: size + "px", height: size + "px" }}
|
||||
|
||||
39
src/assets/icons/EntraIcon.tsx
Normal file
39
src/assets/icons/EntraIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/assets/icons/GoogleIcon.tsx
Normal file
31
src/assets/icons/GoogleIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/assets/icons/JWTIcon.tsx
Normal file
36
src/assets/icons/JWTIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/assets/icons/OktaIcon.tsx
Normal file
18
src/assets/icons/OktaIcon.tsx
Normal file
File diff suppressed because one or more lines are too long
12
src/assets/nameservers/dns0-zero.svg
Normal file
12
src/assets/nameservers/dns0-zero.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg width="573" height="148" viewBox="0 0 573 148" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4_4)">
|
||||
<path d="M0.739014 125.602V33.09H28.239C34.491 33.09 39.919 34.274 44.524 36.638C49.128 39.004 52.698 42.341 55.233 46.65C57.767 50.958 59.034 56.071 59.034 61.984V96.58C59.034 102.41 57.767 107.5 55.233 111.85C52.698 116.203 49.128 119.581 44.523 121.99C39.918 124.397 34.491 125.601 28.239 125.601L0.739014 125.602ZM16.58 111.028H28.24C32.801 111.028 36.433 109.741 39.138 107.163C41.841 104.587 43.193 101.06 43.193 96.581V61.984C43.193 57.592 41.841 54.107 39.138 51.529C36.433 48.952 32.801 47.663 28.239 47.663H16.58V111.028ZM76.396 125.602V33.09H95.786L121.512 107.86C121.216 104.673 120.942 101.483 120.688 98.292C120.386 94.5366 120.154 90.776 119.991 87.012C119.821 83.169 119.738 79.812 119.738 76.938V33.09H134.185V125.602H114.795L89.323 50.832C89.491 53.283 89.703 56.239 89.956 59.702C90.2137 63.2478 90.4251 66.7967 90.59 70.348C90.758 73.982 90.844 77.318 90.844 80.36V125.602H76.396ZM181.582 126.869C175.245 126.869 169.752 125.812 165.107 123.701C160.46 121.591 156.889 118.569 154.398 114.64C151.905 110.711 150.616 106.086 150.533 100.763H166.374C166.374 104.565 167.747 107.543 170.494 109.698C173.238 111.852 176.976 112.929 181.709 112.929C186.271 112.929 189.839 111.874 192.417 109.761C194.993 107.65 196.282 104.735 196.282 101.017C196.282 97.892 195.374 95.167 193.558 92.842C191.74 90.52 189.142 88.936 185.764 88.09L175.119 85.175C167.852 83.318 162.256 79.979 158.327 75.164C154.398 70.348 152.434 64.518 152.434 57.675C152.434 52.438 153.616 47.875 155.982 43.988C158.347 40.103 161.705 37.103 166.058 34.99C170.408 32.88 175.54 31.822 181.455 31.822C190.409 31.822 197.506 34.125 202.745 38.729C207.983 43.335 210.645 49.523 210.73 57.295H194.888C194.888 53.663 193.704 50.812 191.34 48.741C188.974 46.671 185.637 45.636 181.328 45.636C177.188 45.636 173.978 46.608 171.697 48.551C169.416 50.495 168.275 53.24 168.275 56.788C168.275 60 169.141 62.724 170.873 64.962C172.603 67.202 175.119 68.786 178.413 69.714L189.439 72.756C196.789 74.616 202.407 77.932 206.294 82.704C210.179 87.478 212.124 93.371 212.124 100.383C212.124 105.623 210.856 110.248 208.322 114.26C205.787 118.273 202.239 121.378 197.677 123.574C193.114 125.77 187.748 126.869 181.582 126.869ZM257.366 126.869C251.366 126.869 246.17 125.729 241.778 123.448C237.384 121.167 233.985 117.957 231.577 113.816C229.169 109.678 227.965 104.818 227.965 99.242V59.45C227.965 53.874 229.169 49.017 231.577 44.876C233.984 40.738 237.384 37.526 241.778 35.245C246.17 32.964 251.366 31.823 257.366 31.823C263.449 31.823 268.665 32.963 273.017 35.245C277.367 37.526 280.747 40.738 283.156 44.876C285.563 49.016 286.767 53.874 286.767 59.45V99.243C286.767 104.819 285.563 109.679 283.156 113.817C280.748 117.957 277.346 121.167 272.954 123.449C268.56 125.729 263.364 126.869 257.366 126.869ZM257.366 113.183C261.758 113.183 265.265 111.915 267.885 109.381C270.502 106.846 271.813 103.468 271.813 99.242V59.45C271.813 55.227 270.503 51.846 267.885 49.312C265.265 46.777 261.758 45.51 257.366 45.51C252.972 45.51 249.466 46.777 246.848 49.312C244.228 51.846 242.918 55.227 242.918 59.45V99.243C242.918 103.469 244.228 106.847 246.848 109.382C249.465 111.916 252.972 113.183 257.366 113.183ZM257.366 87.583C254.915 87.583 252.909 86.781 251.346 85.175C249.782 83.571 249.002 81.5 249.002 78.965C249.002 76.431 249.762 74.403 251.283 72.883C252.803 71.362 254.831 70.601 257.366 70.601C259.901 70.601 261.928 71.361 263.449 72.883C264.969 74.403 265.73 76.431 265.73 78.966C265.73 81.5 264.97 83.571 263.45 85.176C261.928 86.781 259.9 87.583 257.366 87.583Z" fill="black"/>
|
||||
<path d="M332.69 126.999C329.057 126.999 326.164 125.941 324.01 123.831C321.855 121.72 320.778 118.847 320.778 115.213C320.778 111.581 321.855 108.686 324.01 106.532C326.164 104.378 329.057 103.3 332.69 103.3C336.322 103.3 339.217 104.378 341.372 106.532C343.526 108.686 344.603 111.582 344.603 115.213C344.603 118.847 343.526 121.72 341.372 123.831C339.217 125.941 336.322 126.999 332.691 126.999H332.69ZM381.862 125.732V33.219H437.369V47.032H397.449V71.112H432.934V84.798H397.45V111.918H437.37V125.732H381.862ZM484.766 126.999C475.725 126.999 468.65 124.528 463.539 119.585C458.426 114.643 455.872 107.906 455.872 99.372V33.219H471.84V99.245C471.84 103.639 472.937 107.061 475.135 109.51C477.331 111.962 480.54 113.185 484.766 113.185C488.905 113.185 492.095 111.962 494.334 109.51C496.572 107.06 497.693 103.639 497.693 99.245V33.219H513.66V99.372C513.66 107.906 511.126 114.642 506.057 119.585C500.987 124.528 493.891 126.999 484.767 126.999H484.766Z" fill="#686868"/>
|
||||
<path d="M549 0H573V148H549V0Z" fill="#FFCC03"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4_4">
|
||||
<rect width="573" height="148" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
12
src/assets/nameservers/dns0.svg
Normal file
12
src/assets/nameservers/dns0.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg width="573" height="148" viewBox="0 0 573 148" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4_4)">
|
||||
<path d="M0.739014 125.602V33.09H28.239C34.491 33.09 39.919 34.274 44.524 36.638C49.128 39.004 52.698 42.341 55.233 46.65C57.767 50.958 59.034 56.071 59.034 61.984V96.58C59.034 102.41 57.767 107.5 55.233 111.85C52.698 116.203 49.128 119.581 44.523 121.99C39.918 124.397 34.491 125.601 28.239 125.601L0.739014 125.602ZM16.58 111.028H28.24C32.801 111.028 36.433 109.741 39.138 107.163C41.841 104.587 43.193 101.06 43.193 96.581V61.984C43.193 57.592 41.841 54.107 39.138 51.529C36.433 48.952 32.801 47.663 28.239 47.663H16.58V111.028ZM76.396 125.602V33.09H95.786L121.512 107.86C121.216 104.673 120.942 101.483 120.688 98.292C120.386 94.5366 120.154 90.776 119.991 87.012C119.821 83.169 119.738 79.812 119.738 76.938V33.09H134.185V125.602H114.795L89.323 50.832C89.491 53.283 89.703 56.239 89.956 59.702C90.2137 63.2478 90.4251 66.7967 90.59 70.348C90.758 73.982 90.844 77.318 90.844 80.36V125.602H76.396ZM181.582 126.869C175.245 126.869 169.752 125.812 165.107 123.701C160.46 121.591 156.889 118.569 154.398 114.64C151.905 110.711 150.616 106.086 150.533 100.763H166.374C166.374 104.565 167.747 107.543 170.494 109.698C173.238 111.852 176.976 112.929 181.709 112.929C186.271 112.929 189.839 111.874 192.417 109.761C194.993 107.65 196.282 104.735 196.282 101.017C196.282 97.892 195.374 95.167 193.558 92.842C191.74 90.52 189.142 88.936 185.764 88.09L175.119 85.175C167.852 83.318 162.256 79.979 158.327 75.164C154.398 70.348 152.434 64.518 152.434 57.675C152.434 52.438 153.616 47.875 155.982 43.988C158.347 40.103 161.705 37.103 166.058 34.99C170.408 32.88 175.54 31.822 181.455 31.822C190.409 31.822 197.506 34.125 202.745 38.729C207.983 43.335 210.645 49.523 210.73 57.295H194.888C194.888 53.663 193.704 50.812 191.34 48.741C188.974 46.671 185.637 45.636 181.328 45.636C177.188 45.636 173.978 46.608 171.697 48.551C169.416 50.495 168.275 53.24 168.275 56.788C168.275 60 169.141 62.724 170.873 64.962C172.603 67.202 175.119 68.786 178.413 69.714L189.439 72.756C196.789 74.616 202.407 77.932 206.294 82.704C210.179 87.478 212.124 93.371 212.124 100.383C212.124 105.623 210.856 110.248 208.322 114.26C205.787 118.273 202.239 121.378 197.677 123.574C193.114 125.77 187.748 126.869 181.582 126.869ZM257.366 126.869C251.366 126.869 246.17 125.729 241.778 123.448C237.384 121.167 233.985 117.957 231.577 113.816C229.169 109.678 227.965 104.818 227.965 99.242V59.45C227.965 53.874 229.169 49.017 231.577 44.876C233.984 40.738 237.384 37.526 241.778 35.245C246.17 32.964 251.366 31.823 257.366 31.823C263.449 31.823 268.665 32.963 273.017 35.245C277.367 37.526 280.747 40.738 283.156 44.876C285.563 49.016 286.767 53.874 286.767 59.45V99.243C286.767 104.819 285.563 109.679 283.156 113.817C280.748 117.957 277.346 121.167 272.954 123.449C268.56 125.729 263.364 126.869 257.366 126.869ZM257.366 113.183C261.758 113.183 265.265 111.915 267.885 109.381C270.502 106.846 271.813 103.468 271.813 99.242V59.45C271.813 55.227 270.503 51.846 267.885 49.312C265.265 46.777 261.758 45.51 257.366 45.51C252.972 45.51 249.466 46.777 246.848 49.312C244.228 51.846 242.918 55.227 242.918 59.45V99.243C242.918 103.469 244.228 106.847 246.848 109.382C249.465 111.916 252.972 113.183 257.366 113.183ZM257.366 87.583C254.915 87.583 252.909 86.781 251.346 85.175C249.782 83.571 249.002 81.5 249.002 78.965C249.002 76.431 249.762 74.403 251.283 72.883C252.803 71.362 254.831 70.601 257.366 70.601C259.901 70.601 261.928 71.361 263.449 72.883C264.969 74.403 265.73 76.431 265.73 78.966C265.73 81.5 264.97 83.571 263.45 85.176C261.928 86.781 259.9 87.583 257.366 87.583Z" fill="black"/>
|
||||
<path d="M332.69 126.999C329.057 126.999 326.164 125.941 324.01 123.831C321.855 121.72 320.778 118.847 320.778 115.213C320.778 111.581 321.855 108.686 324.01 106.532C326.164 104.378 329.057 103.3 332.69 103.3C336.322 103.3 339.217 104.378 341.372 106.532C343.526 108.686 344.603 111.582 344.603 115.213C344.603 118.847 343.526 121.72 341.372 123.831C339.217 125.941 336.322 126.999 332.691 126.999H332.69ZM381.862 125.732V33.219H437.369V47.032H397.449V71.112H432.934V84.798H397.45V111.918H437.37V125.732H381.862ZM484.766 126.999C475.725 126.999 468.65 124.528 463.539 119.585C458.426 114.643 455.872 107.906 455.872 99.372V33.219H471.84V99.245C471.84 103.639 472.937 107.061 475.135 109.51C477.331 111.962 480.54 113.185 484.766 113.185C488.905 113.185 492.095 111.962 494.334 109.51C496.572 107.06 497.693 103.639 497.693 99.245V33.219H513.66V99.372C513.66 107.906 511.126 114.642 506.057 119.585C500.987 124.528 493.891 126.999 484.767 126.999H484.766Z" fill="#359CEF"/>
|
||||
<path d="M549 0H573V148H549V0Z" fill="#FFCC03"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4_4">
|
||||
<rect width="573" height="148" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -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"}
|
||||
|
||||
@@ -48,14 +48,24 @@ export default function OIDCProvider({ children }: Props) {
|
||||
const [, setQueryParams] = useLocalStorage("netbird-query-params", params);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
params?.includes("tab") ||
|
||||
params?.includes("search") ||
|
||||
params?.includes("id")
|
||||
) {
|
||||
setQueryParams(params);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const validParams = [
|
||||
"tab",
|
||||
"search",
|
||||
"id",
|
||||
"invite",
|
||||
"utm_source",
|
||||
"utm_medium",
|
||||
"utm_content",
|
||||
"utm_campaign",
|
||||
"hs_id",
|
||||
];
|
||||
|
||||
try {
|
||||
const urlParams = new URLSearchParams(params);
|
||||
if (validParams.some((param) => urlParams.has(param))) {
|
||||
setQueryParams(params);
|
||||
}
|
||||
} catch (e) {}
|
||||
}, []);
|
||||
|
||||
const withCustomHistory = () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ interface Props extends React.HTMLAttributes<HTMLDivElement>, BadgeVariants {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
useHover?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const variants = cva("", {
|
||||
@@ -53,14 +54,16 @@ export default function Badge({
|
||||
className,
|
||||
variant = "blue",
|
||||
useHover = false,
|
||||
disabled = false,
|
||||
...props
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 cursor-inherit whitespace-nowrap rounded-md text-[12px] py-1.5 px-3 font-normal flex gap-1.5 items-center justify-center transition-all",
|
||||
className,
|
||||
variants({ variant, hover: useHover ? variant : "none" }),
|
||||
disabled && "cursor-not-allowed opacity-50 select-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -4,12 +4,13 @@ import { cva, VariantProps } from "class-variance-authority";
|
||||
import classNames from "classnames";
|
||||
import React, { forwardRef } from "react";
|
||||
|
||||
type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>;
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
ButtonVariants {
|
||||
disabled?: boolean;
|
||||
stopPropagation?: boolean;
|
||||
}
|
||||
|
||||
export const buttonVariants = cva(
|
||||
@@ -27,7 +28,7 @@ export const buttonVariants = cva(
|
||||
"dark:focus:ring-zinc-800/50 dark:bg-nb-gray dark:text-gray-400 dark:border-gray-700/30 dark:hover:text-white dark:hover:bg-zinc-800/50",
|
||||
],
|
||||
primary: [
|
||||
"dark:focus:ring-netbird-600/50 dark:ring-offset-neutral-950/50 enabled:dark:bg-netbird disabled:dark:bg-nb-gray-920 dark:text-gray-100 enabled:dark:hover:text-white enabled:dark:hover:bg-netbird-500/80",
|
||||
"dark:focus:ring-netbird-600/50 dark:ring-offset-neutral-950/50 enabled:dark:bg-netbird disabled:dark:bg-nb-gray-910 dark:text-gray-100 enabled:dark:hover:text-white enabled:dark:hover:bg-netbird-500/80",
|
||||
"enabled:bg-netbird enabled:text-white enabled:focus:ring-netbird-400/50 enabled:hover:bg-netbird-500",
|
||||
],
|
||||
secondary: [
|
||||
@@ -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-900 dark:hover:bg-nb-gray-900/50",
|
||||
],
|
||||
dotted: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900 border-dashed",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 ",
|
||||
@@ -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);
|
||||
}}
|
||||
>
|
||||
|
||||
39
src/components/Callout.tsx
Normal file
39
src/components/Callout.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { cn } from "@utils/helpers";
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
type CalloutVariants = VariantProps<typeof calloutVariants>;
|
||||
|
||||
type Props = {
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & CalloutVariants;
|
||||
|
||||
export const calloutVariants = cva(
|
||||
["px-4 py-3.5 rounded-md border text-sm font-normal flex gap-3 font-light"],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-nb-gray-900/60 border-nb-gray-800/80 text-nb-gray-300",
|
||||
warning: "bg-netbird-500/10 border-netbird-400/20 text-netbird-150",
|
||||
info: "bg-sky-400/10 border-sky-400/20 text-sky-100",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const Callout = ({
|
||||
children,
|
||||
icon = <InfoIcon size={14} className={"shrink-0 relative top-[2px]"} />,
|
||||
className,
|
||||
variant = "default",
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className={cn(calloutVariants({ variant }), className)}>
|
||||
{icon}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import Button from "@components/Button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
|
||||
import { AbsoluteDateTimeInput } from "@components/ui/AbsoluteDateTimeInput";
|
||||
import { Calendar } from "@components/ui/Calendar";
|
||||
import { cn } from "@utils/helpers";
|
||||
import dayjs from "dayjs";
|
||||
import { debounce } from "lodash";
|
||||
import { Calendar as CalendarIcon } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { DateRange } from "react-day-picker";
|
||||
@@ -28,6 +30,14 @@ const defaultRanges = {
|
||||
from: dayjs().subtract(14, "day").startOf("day").toDate(),
|
||||
to: dayjs().endOf("day").toDate(),
|
||||
},
|
||||
last2Days: {
|
||||
from: dayjs().subtract(2, "day").startOf("day").toDate(),
|
||||
to: dayjs().endOf("day").toDate(),
|
||||
},
|
||||
last7Days: {
|
||||
from: dayjs().subtract(7, "day").startOf("day").toDate(),
|
||||
to: dayjs().endOf("day").toDate(),
|
||||
},
|
||||
lastMonth: {
|
||||
from: dayjs().subtract(1, "month").startOf("day").toDate(),
|
||||
to: dayjs().endOf("day").toDate(),
|
||||
@@ -47,12 +57,18 @@ const isEqualDateRange = (a: DateRange | undefined, b: DateRange) => {
|
||||
return aFromDay === bFromDay && aToDay === bToDay;
|
||||
};
|
||||
|
||||
export function DatePickerWithRange({ className, value, onChange }: Props) {
|
||||
export function DatePickerWithRange({
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
}: Readonly<Props>) {
|
||||
const isActive = useMemo(() => {
|
||||
return {
|
||||
today: isEqualDateRange(value, defaultRanges.today),
|
||||
yesterday: isEqualDateRange(value, defaultRanges.yesterday),
|
||||
last14Days: isEqualDateRange(value, defaultRanges.last14Days),
|
||||
last2Days: isEqualDateRange(value, defaultRanges.last2Days),
|
||||
last7Days: isEqualDateRange(value, defaultRanges.last7Days),
|
||||
lastMonth: isEqualDateRange(value, defaultRanges.lastMonth),
|
||||
allTime: isEqualDateRange(value, defaultRanges.allTime),
|
||||
};
|
||||
@@ -64,6 +80,8 @@ export function DatePickerWithRange({ className, value, onChange }: Props) {
|
||||
if (isActive.allTime) return "All Time";
|
||||
if (isActive.lastMonth) return "Last Month";
|
||||
if (isActive.last14Days) return "Last 14 Days";
|
||||
if (isActive.last2Days) return "Last 2 Days";
|
||||
if (isActive.last7Days) return "Last 7 Days";
|
||||
if (isActive.yesterday) return "Yesterday";
|
||||
if (isActive.today) return "Today";
|
||||
|
||||
@@ -76,10 +94,25 @@ export function DatePickerWithRange({ className, value, onChange }: Props) {
|
||||
const [calendarOpen, setCalendarOpen] = useState(false);
|
||||
|
||||
const updateRangeAndClose = (range: DateRange) => {
|
||||
setCalendarOpen(false);
|
||||
onChange?.(range);
|
||||
};
|
||||
|
||||
const debouncedOnChange = useMemo(() => {
|
||||
return onChange ? debounce(onChange, 500) : undefined;
|
||||
}, [onChange]);
|
||||
|
||||
const handleOnSelect = (range?: DateRange) => {
|
||||
let from = range?.from
|
||||
? dayjs(range.from).startOf("day").toDate()
|
||||
: undefined;
|
||||
let to = range?.to ? dayjs(range.to).endOf("day").toDate() : undefined;
|
||||
if (!from && !to) {
|
||||
onChange?.(undefined);
|
||||
return;
|
||||
}
|
||||
onChange?.({ from, to });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("grid gap-2", className)}>
|
||||
<Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
|
||||
@@ -93,10 +126,16 @@ export function DatePickerWithRange({ className, value, onChange }: Props) {
|
||||
{displayDateValue}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start" sideOffset={10}>
|
||||
<PopoverContent
|
||||
className="w-auto p-0"
|
||||
align="start"
|
||||
side={"right"}
|
||||
sideOffset={10}
|
||||
alignOffset={-100}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"px-4 py-3 flex flex-wrap gap-2 max-w-[280px] sm:max-w-none border-b border-nb-gray-800 items-center justify-between w-full"
|
||||
"px-3 py-2 flex flex-wrap gap-2 max-w-[280px] sm:max-w-none border-b border-nb-gray-800 items-center justify-between w-full"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
@@ -139,23 +178,10 @@ export function DatePickerWithRange({ className, value, onChange }: Props) {
|
||||
mode="range"
|
||||
defaultMonth={value?.from}
|
||||
selected={value}
|
||||
onSelect={(range) => {
|
||||
let from =
|
||||
range && range.from
|
||||
? dayjs(range.from).startOf("day").toDate()
|
||||
: undefined;
|
||||
let to =
|
||||
range && range.to
|
||||
? dayjs(range.to).endOf("day").toDate()
|
||||
: undefined;
|
||||
if (!from && !to) {
|
||||
onChange?.(undefined);
|
||||
return;
|
||||
}
|
||||
onChange?.({ from, to });
|
||||
}}
|
||||
onSelect={handleOnSelect}
|
||||
numberOfMonths={2}
|
||||
/>
|
||||
<AbsoluteDateTimeInput value={value} onChange={debouncedOnChange} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
@@ -168,7 +194,11 @@ type CalendarButtonProps = {
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
function CalendarButton({ label, onClick, active }: CalendarButtonProps) {
|
||||
function CalendarButton({
|
||||
label,
|
||||
onClick,
|
||||
active,
|
||||
}: Readonly<CalendarButtonProps>) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,10 +8,22 @@ type Props = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
};
|
||||
hideEnterIcon?: boolean;
|
||||
className?: string;
|
||||
} & React.InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
export const DropdownInput = forwardRef<HTMLInputElement, Props>(
|
||||
({ value, onChange, placeholder = "Search..." }, ref) => {
|
||||
(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Search...",
|
||||
className,
|
||||
hideEnterIcon = false,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<div className={"relative w-full"}>
|
||||
<input
|
||||
@@ -21,25 +33,31 @@ export const DropdownInput = forwardRef<HTMLInputElement, Props>(
|
||||
"border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center",
|
||||
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
|
||||
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10",
|
||||
className,
|
||||
)}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
{...props}
|
||||
/>
|
||||
<div className={"absolute left-0 top-0 h-full flex items-center pl-4"}>
|
||||
<div className={"flex items-center"}>
|
||||
<SearchIcon size={14} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={"absolute right-0 top-0 h-full flex items-center pr-4"}>
|
||||
{!hideEnterIcon && (
|
||||
<div
|
||||
className={
|
||||
"flex items-center bg-nb-gray-800 py-1 px-1.5 rounded-[4px] border border-nb-gray-500"
|
||||
}
|
||||
className={"absolute right-0 top-0 h-full flex items-center pr-4"}
|
||||
>
|
||||
<IconArrowBack size={10} />
|
||||
<div
|
||||
className={
|
||||
"flex items-center bg-nb-gray-800 py-1 px-1.5 rounded-[4px] border border-nb-gray-500"
|
||||
}
|
||||
>
|
||||
<IconArrowBack size={10} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -22,13 +22,16 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "danger";
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
>(({ className, inset, children, variant = "default", ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-gray-100 data-[state=open]:bg-gray-100 dark:focus:bg-gray-800 dark:data-[state=open]:bg-gray-800",
|
||||
"relative flex select-none items-center rounded-md pr-2 pl-3 py-1.5 text-sm outline-none",
|
||||
"transition-colors focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer ",
|
||||
inset && "pl-8",
|
||||
menuItemVariants({ variant }),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -47,7 +50,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-200 bg-white p-1 text-gray-950 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-50",
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-200 bg-white p-1 text-gray-950 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-nb-gray-900 dark:bg-nb-gray-940 dark:text-gray-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -78,7 +81,7 @@ const menuItemVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"dark:focus:bg-nb-gray-900 dark:focus:text-gray-50 dark:text-gray-400",
|
||||
"dark:focus:bg-nb-gray-900 dark:focus:text-gray-50 dark:text-gray-400 dark:data-[state=open]:bg-nb-gray-900 dark:data-[state=open]:text-gray-50",
|
||||
danger:
|
||||
"dark:focus:bg-red-900/20 dark:focus:text-red-500 dark:text-red-500",
|
||||
},
|
||||
@@ -181,7 +184,7 @@ const DropdownMenuSeparator = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-gray-100 dark:bg-gray-800", className)}
|
||||
className={cn("-mx-1 my-1 h-px bg-gray-100 dark:bg-nb-gray-910", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
TooltipVariants,
|
||||
} from "@components/Tooltip";
|
||||
import { TooltipProps } from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@utils/helpers";
|
||||
@@ -22,7 +23,11 @@ type Props = {
|
||||
keepOpen?: boolean;
|
||||
customOpen?: boolean;
|
||||
customOnOpenChange?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
} & TooltipProps;
|
||||
delayDuration?: number;
|
||||
skipDelayDuration?: number;
|
||||
} & TooltipProps &
|
||||
TooltipVariants;
|
||||
|
||||
export default function FullTooltip({
|
||||
children,
|
||||
content,
|
||||
@@ -37,6 +42,9 @@ export default function FullTooltip({
|
||||
keepOpen = false,
|
||||
customOpen,
|
||||
customOnOpenChange,
|
||||
delayDuration = 1,
|
||||
skipDelayDuration = 300,
|
||||
variant = "default",
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(!!keepOpen);
|
||||
|
||||
@@ -46,9 +54,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}
|
||||
>
|
||||
@@ -58,7 +70,7 @@ export default function FullTooltip({
|
||||
<div
|
||||
className={cn(
|
||||
isAction ? "cursor-pointer" : "cursor-default",
|
||||
"inline-flex items-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md",
|
||||
"inline-flex items-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -74,6 +86,7 @@ export default function FullTooltip({
|
||||
alignOffset={20}
|
||||
forceMount={true}
|
||||
className={contentClassName}
|
||||
variant={variant}
|
||||
align={align}
|
||||
side={side}
|
||||
>
|
||||
|
||||
@@ -11,26 +11,45 @@ interface Props extends LinkProps, InlineLinkProps {
|
||||
target?: "_blank" | "_self" | "_parent" | "_top";
|
||||
}
|
||||
|
||||
const linkVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "text-netbird hover:underline font-normal",
|
||||
faded: "text-nb-gray-400 hover:text-nb-gray-300 hover:underline",
|
||||
interface InlineButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
InlineLinkProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
target?: "_blank" | "_self" | "_parent" | "_top";
|
||||
}
|
||||
|
||||
export const linkVariants = cva(
|
||||
"underline-offset-4 items-center transition-all duration-200 inline-flex texts-inherit gap-1",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "text-netbird hover:underline font-normal",
|
||||
faded: "text-nb-gray-400 hover:text-nb-gray-300 hover:underline",
|
||||
white: "text-nb-gray-100 hover:text-white hover:underline",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
export default function InlineLink({ variant = "default", ...props }: Props) {
|
||||
return (
|
||||
<Link
|
||||
{...props}
|
||||
className={cn(
|
||||
"underline-offset-4 texts-inherit gap-1 items-center transition-all duration-200 inline-flex",
|
||||
props.className,
|
||||
linkVariants({ variant }),
|
||||
)}
|
||||
>
|
||||
<Link {...props} className={cn(props.className, linkVariants({ variant }))}>
|
||||
{props.children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function InlineButtonLink({
|
||||
variant = "default",
|
||||
...props
|
||||
}: InlineButtonProps) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={cn(props.className, linkVariants({ variant }))}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -12,6 +12,7 @@ const variants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: ["bg-nb-gray-800 border-nb-gray-700 text-nb-gray-300 "],
|
||||
darker: ["bg-nb-gray-930 border-nb-gray-900 text-nb-gray-250 "],
|
||||
netbird: ["bg-netbird-100 text-netbird border-netbird "],
|
||||
},
|
||||
size: {
|
||||
@@ -30,7 +31,7 @@ export default function Kbd({
|
||||
size = "default",
|
||||
disabled = false,
|
||||
className,
|
||||
}: Props) {
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
42
src/components/NetBirdLogo.tsx
Normal file
42
src/components/NetBirdLogo.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { cn } from "@utils/helpers";
|
||||
import Image from "next/image";
|
||||
import * as React from "react";
|
||||
import NetBirdLogoMark from "@/assets/netbird.svg";
|
||||
import NetBirdLogoFull from "@/assets/netbird-full.svg";
|
||||
|
||||
type Props = {
|
||||
size?: "default" | "large";
|
||||
mobile?: boolean;
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
default: {
|
||||
desktop: 22,
|
||||
mobile: 30,
|
||||
},
|
||||
large: {
|
||||
desktop: 24,
|
||||
mobile: 40,
|
||||
},
|
||||
};
|
||||
|
||||
export const NetBirdLogo = ({ size = "default", mobile = true }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<Image
|
||||
src={NetBirdLogoFull}
|
||||
height={sizes[size].desktop}
|
||||
alt={"NetBird Logo"}
|
||||
className={cn(mobile && "hidden md:block")}
|
||||
/>
|
||||
{mobile && (
|
||||
<Image
|
||||
src={NetBirdLogoMark}
|
||||
width={sizes[size].mobile}
|
||||
alt={"NetBird Logo"}
|
||||
className={cn(mobile && "md:hidden ml-4")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -12,12 +12,15 @@ export interface NotifyProps<T> {
|
||||
title: string;
|
||||
description: string;
|
||||
promise?: Promise<T | ErrorResponse>;
|
||||
loadingTitle?: string;
|
||||
loadingMessage?: string;
|
||||
duration?: number;
|
||||
icon?: React.ReactNode;
|
||||
backgroundColor?: string;
|
||||
preventSuccessToast?: boolean;
|
||||
errorMessages?: ErrorResponse[];
|
||||
}
|
||||
|
||||
interface NotificationProps<T> extends NotifyProps<T> {
|
||||
t: Toast;
|
||||
}
|
||||
@@ -28,9 +31,11 @@ export default function Notification<T>({
|
||||
backgroundColor,
|
||||
t,
|
||||
promise,
|
||||
loadingTitle,
|
||||
loadingMessage,
|
||||
duration = 3500,
|
||||
preventSuccessToast = false,
|
||||
errorMessages,
|
||||
}: NotificationProps<T>) {
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(!!promise);
|
||||
@@ -51,15 +56,27 @@ export default function Notification<T>({
|
||||
if (promise) {
|
||||
promise
|
||||
.then(() => {
|
||||
if (preventSuccessToast) setPreventSuccess(true);
|
||||
setLoading(false);
|
||||
closeToast();
|
||||
if (preventSuccessToast) setPreventSuccess(true);
|
||||
})
|
||||
.catch((e) => {
|
||||
const err = e as ErrorResponse;
|
||||
const message = err.message || "Something went wrong...";
|
||||
let message = err.message || "Something went wrong...";
|
||||
message = message.charAt(0).toUpperCase() + message.slice(1);
|
||||
const code: number = err.code || 418;
|
||||
setError(`Code ${code}: ${message}`);
|
||||
|
||||
if (errorMessages) {
|
||||
const errorMessage = errorMessages.find(
|
||||
(error) => error.code === code,
|
||||
);
|
||||
if (errorMessage) {
|
||||
setError(errorMessage.message);
|
||||
}
|
||||
} else {
|
||||
setError(`Code ${code}: ${message}`);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
closeToast();
|
||||
});
|
||||
@@ -101,7 +118,9 @@ export default function Notification<T>({
|
||||
</div>
|
||||
<div className={"flex flex-col text-sm"}>
|
||||
<p>
|
||||
<span className={"font-semibold"}>{title}</span>
|
||||
<span className={"font-semibold"}>
|
||||
{loading ? loadingTitle || title : title}
|
||||
</span>
|
||||
</p>
|
||||
<p
|
||||
className={"text-xs dark:text-nb-gray-300 text-gray-600 mt-0.5"}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,36 @@ export function PeerSelector({
|
||||
{filteredItems.length > 0 && (
|
||||
<VirtualScrollAreaList
|
||||
items={filteredItems}
|
||||
onSelect={togglePeer}
|
||||
estimatedItemHeight={37}
|
||||
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 +210,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 +247,13 @@ export function PeerSelector({
|
||||
value && value.id == option.id
|
||||
? "text-white"
|
||||
: "text-nb-gray-300",
|
||||
!isSupported && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<MapPinIcon />
|
||||
{option.ip}
|
||||
</div>
|
||||
</>
|
||||
</FullTooltip>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -2,33 +2,62 @@
|
||||
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
type PopoverVariants = VariantProps<typeof popoverVariants>;
|
||||
|
||||
export const popoverVariants = cva([], {
|
||||
variants: {
|
||||
variant: {
|
||||
lighter: [
|
||||
"rounded-md border border-neutral-200 bg-white px-5 py-3 text-sm text-neutral-950 shadow-md",
|
||||
"dark:border-nb-gray-800 dark:bg-nb-gray-920 dark:text-neutral-50",
|
||||
],
|
||||
dark: [
|
||||
"rounded-md border border-neutral-200 bg-white px-5 py-3 text-sm text-neutral-950 shadow-md",
|
||||
"dark:border-nb-gray-900 dark:bg-nb-gray-940 dark:text-gray-50",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden",
|
||||
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"rounded-md border border-neutral-200 bg-white px-5 py-3 text-sm text-neutral-950 shadow-md",
|
||||
"dark:border-nb-gray-800 dark:bg-nb-gray-920 dark:text-neutral-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> &
|
||||
PopoverVariants
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
variant = "lighter",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden",
|
||||
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
popoverVariants({ variant }),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
),
|
||||
);
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverContent, PopoverTrigger };
|
||||
|
||||
@@ -1,261 +1,328 @@
|
||||
import Badge from "@components/Badge";
|
||||
import { Callout } from "@components/Callout";
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import { CommandItem } from "@components/Command";
|
||||
import { DropdownInfoText } from "@components/DropdownInfoText";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
|
||||
import { IconArrowBack } from "@tabler/icons-react";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Command, CommandGroup, CommandInput, CommandList } from "cmdk";
|
||||
import { trim } from "lodash";
|
||||
import { orderBy, trim } from "lodash";
|
||||
import { ChevronsUpDown, SearchIcon, XIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useElementSize } from "@/hooks/useElementSize";
|
||||
import { PortRange } from "@/interfaces/Policy";
|
||||
|
||||
interface MultiSelectProps {
|
||||
values: number[];
|
||||
onChange: React.Dispatch<React.SetStateAction<number[]>>;
|
||||
ports: number[];
|
||||
onPortsChange: React.Dispatch<React.SetStateAction<number[]>>;
|
||||
portRanges?: PortRange[];
|
||||
onPortRangesChange?: React.Dispatch<React.SetStateAction<PortRange[]>>;
|
||||
max?: number;
|
||||
disabled?: boolean;
|
||||
popoverWidth?: "auto" | number;
|
||||
showAll?: boolean;
|
||||
}
|
||||
|
||||
const isValidPort = (p: number) => p >= 1 && p <= 65535;
|
||||
|
||||
const parseRange = (value: string): PortRange | undefined => {
|
||||
const parts = value.split("-").map((x) => Number(trim(x)));
|
||||
if (parts.length !== 2) return undefined;
|
||||
const [start, end] = parts;
|
||||
if (!isValidPort(start) || !isValidPort(end) || start >= end)
|
||||
return undefined;
|
||||
return { start, end };
|
||||
};
|
||||
|
||||
const parsePortInput = (value: string): number | PortRange | undefined => {
|
||||
const trimmed = trim(value);
|
||||
if (/^\d{1,5}-\d{1,5}$/.test(trimmed)) return parseRange(trimmed);
|
||||
const port = Number(trimmed);
|
||||
return isValidPort(port) ? port : undefined;
|
||||
};
|
||||
|
||||
export function PortSelector({
|
||||
onChange,
|
||||
values,
|
||||
max,
|
||||
onPortsChange,
|
||||
ports,
|
||||
portRanges = [],
|
||||
onPortRangesChange,
|
||||
disabled = false,
|
||||
popoverWidth = "auto",
|
||||
showAll = false,
|
||||
}: MultiSelectProps) {
|
||||
}: Readonly<MultiSelectProps>) {
|
||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const toggle = (x: number) => {
|
||||
if (isNaN(Number(x))) return;
|
||||
const port = Number(x);
|
||||
if (port < 1 || port > 65535) return;
|
||||
const [portsInput, setPortsInput] = useState<string[]>(() => {
|
||||
const p = ports.map(String);
|
||||
const pr = portRanges.map((r) => {
|
||||
if (r.start === r.end) return String(r.start);
|
||||
return `${r.start}-${r.end}`;
|
||||
});
|
||||
return orderBy([...p, ...pr], [(x) => Number(x.split("-")[0])], ["asc"]);
|
||||
});
|
||||
|
||||
const isSelected = values.includes(port);
|
||||
if (isSelected) {
|
||||
onChange((previous) => previous.filter((y) => y !== port));
|
||||
} else {
|
||||
onChange((previous) => [...previous, port]);
|
||||
setSearch("");
|
||||
}
|
||||
useEffect(() => {
|
||||
const parsed = portsInput.map(parsePortInput).filter(Boolean);
|
||||
const newPorts: number[] = [];
|
||||
const newRanges: PortRange[] = [];
|
||||
parsed.forEach((entry) => {
|
||||
if (typeof entry === "number") newPorts.push(entry);
|
||||
else if (entry !== undefined) newRanges.push(entry);
|
||||
});
|
||||
onPortsChange(newPorts);
|
||||
onPortRangesChange?.(newRanges);
|
||||
}, [portsInput]);
|
||||
|
||||
const toggle = (value: string) => {
|
||||
if (disabled) return;
|
||||
setPortsInput((prev) =>
|
||||
prev.includes(value) ? prev.filter((e) => e !== value) : [...prev, value],
|
||||
);
|
||||
setSearch("");
|
||||
};
|
||||
|
||||
const notFound = useMemo(() => {
|
||||
const isSearching = search.length > 0;
|
||||
const found =
|
||||
values.filter((item) => item == Number(trim(search))).length == 0;
|
||||
return isSearching && found;
|
||||
}, [search, values]);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const trimmed = trim(search);
|
||||
return (
|
||||
trimmed &&
|
||||
!portsInput.includes(trimmed) &&
|
||||
parsePortInput(trimmed) &&
|
||||
isSearching
|
||||
);
|
||||
}, [search, portsInput]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setTimeout(() => {
|
||||
setSearch("");
|
||||
}, 100);
|
||||
}
|
||||
setOpen(isOpen);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"min-h-[48px] w-full relative items-center",
|
||||
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3",
|
||||
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer hover:dark:bg-nb-gray-900/50",
|
||||
)}
|
||||
data-cy={"port-selector"}
|
||||
disabled={disabled}
|
||||
ref={inputRef}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-2 border-nb-gray-700 flex-wrap h-full"
|
||||
}
|
||||
>
|
||||
{values.length === 0 && showAll && (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
className={"uppercase tracking-wider font-medium py-1"}
|
||||
>
|
||||
All
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{values.map((x) => (
|
||||
<Badge
|
||||
key={x}
|
||||
variant={"gray"}
|
||||
onClick={() => toggle(x)}
|
||||
className={"uppercase tracking-wider font-medium py-1"}
|
||||
>
|
||||
{x}
|
||||
<XIcon
|
||||
size={12}
|
||||
className={"cursor-pointer group-hover:text-black"}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
{values.length == 0 && <span>Select ports...</span>}
|
||||
</div>
|
||||
|
||||
<ChevronsUpDown size={18} className={"shrink-0"} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950"
|
||||
style={{
|
||||
width: popoverWidth === "auto" ? width : popoverWidth,
|
||||
<>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setTimeout(() => {
|
||||
setSearch("");
|
||||
}, 100);
|
||||
}
|
||||
setOpen(isOpen);
|
||||
}}
|
||||
align="start"
|
||||
side={"top"}
|
||||
sideOffset={10}
|
||||
>
|
||||
<Command
|
||||
className={"w-full flex"}
|
||||
loop
|
||||
filter={(value, search) => {
|
||||
const formatValue = trim(value.toLowerCase());
|
||||
const formatSearch = trim(search.toLowerCase());
|
||||
if (formatValue.includes(formatSearch)) return 1;
|
||||
return 0;
|
||||
}}
|
||||
>
|
||||
<CommandList className={"w-full"}>
|
||||
<div className={"relative"}>
|
||||
<CommandInput
|
||||
className={cn(
|
||||
"min-h-[42px] w-full relative",
|
||||
"border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center",
|
||||
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
|
||||
"dark:placeholder:text-neutral-500 font-light placeholder:text-neutral-500 pl-10",
|
||||
)}
|
||||
data-cy={"port-input"}
|
||||
typeof={"number"}
|
||||
ref={searchRef}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder={'Add new ports by pressing "Enter"...'}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
"absolute left-0 top-0 h-full flex items-center pl-4"
|
||||
}
|
||||
>
|
||||
<div className={"flex items-center"}>
|
||||
<SearchIcon size={14} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"absolute right-0 top-0 h-full flex items-center pr-4"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex items-center bg-nb-gray-800 py-1 px-1.5 rounded-[4px] border border-nb-gray-500"
|
||||
}
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"min-h-[48px] w-full relative items-center",
|
||||
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3",
|
||||
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer hover:dark:bg-nb-gray-900/50",
|
||||
)}
|
||||
data-cy={"port-selector"}
|
||||
disabled={disabled}
|
||||
ref={inputRef}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-2 border-nb-gray-700 flex-wrap h-full"
|
||||
}
|
||||
>
|
||||
{portsInput.length === 0 && showAll && (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
className={"uppercase tracking-wider font-medium py-1"}
|
||||
>
|
||||
<IconArrowBack size={10} />
|
||||
</div>
|
||||
</div>
|
||||
All
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{portsInput.map((x) => (
|
||||
<Badge
|
||||
key={x}
|
||||
variant={"gray"}
|
||||
onClick={() => toggle(x)}
|
||||
className={"uppercase tracking-wider font-medium py-1"}
|
||||
>
|
||||
{x}
|
||||
<XIcon
|
||||
size={12}
|
||||
className={"cursor-pointer group-hover:text-black"}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
{ports.length == 0 && <span>Select ports...</span>}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2",
|
||||
values.length != 0 && "p-2",
|
||||
values.length != 0 && search && "p-2",
|
||||
values.length == 0 && search && "p-2",
|
||||
)}
|
||||
>
|
||||
{notFound && (
|
||||
<ChevronsUpDown size={18} className={"shrink-0"} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950"
|
||||
style={{
|
||||
width: popoverWidth === "auto" ? width : popoverWidth,
|
||||
}}
|
||||
align="start"
|
||||
side={"top"}
|
||||
sideOffset={10}
|
||||
>
|
||||
<Command
|
||||
className={"w-full flex"}
|
||||
loop
|
||||
filter={(value, search) => {
|
||||
const formatValue = trim(value.toLowerCase());
|
||||
const formatSearch = trim(search.toLowerCase());
|
||||
if (formatValue.includes(formatSearch)) return 1;
|
||||
return 0;
|
||||
}}
|
||||
>
|
||||
<CommandList className={"w-full"}>
|
||||
<div className={"relative"}>
|
||||
<CommandInput
|
||||
className={cn(
|
||||
"min-h-[42px] w-full relative",
|
||||
"border-b-0 border-t-0 border-r-0 border-l-0 border-neutral-200 dark:border-nb-gray-700 items-center",
|
||||
"bg-transparent text-sm outline-none focus-visible:outline-none ring-0 focus-visible:ring-0",
|
||||
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10",
|
||||
)}
|
||||
data-cy={"port-input"}
|
||||
typeof={"number"}
|
||||
ref={searchRef}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder={
|
||||
'Add a port or a range e.g. 80 or 1-1023 and press "Enter" to add...'
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
"absolute left-0 top-0 h-full flex items-center pl-4"
|
||||
}
|
||||
>
|
||||
<div className={"flex items-center"}>
|
||||
<SearchIcon size={14} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"absolute right-0 top-0 h-full flex items-center pr-4"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex items-center bg-nb-gray-800 py-1 px-1.5 rounded-[4px] border border-nb-gray-500"
|
||||
}
|
||||
>
|
||||
<IconArrowBack size={10} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2",
|
||||
portsInput.length != 0 && "p-2",
|
||||
portsInput.length != 0 && search && "p-2",
|
||||
notFound && "p-2",
|
||||
)}
|
||||
>
|
||||
{!notFound && search && !portsInput.includes(search) && (
|
||||
<div className={"text-sm"}>
|
||||
<DropdownInfoText className={"mb-[18px] pt-[4px]"}>
|
||||
{
|
||||
"Please add a valid port or port range (e.g. 80, 443, 1-1023)"
|
||||
}
|
||||
</DropdownInfoText>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notFound && (
|
||||
<CommandGroup>
|
||||
<div
|
||||
className={cn(
|
||||
"max-h-[180px] overflow-y-auto flex flex-col gap-1",
|
||||
)}
|
||||
>
|
||||
<CommandItem
|
||||
key={search}
|
||||
onSelect={() => {
|
||||
toggle(search);
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
value={search}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
className={
|
||||
"uppercase tracking-wider font-medium py-1"
|
||||
}
|
||||
>
|
||||
{search}
|
||||
</Badge>
|
||||
<div
|
||||
className={"text-neutral-500 dark:text-nb-gray-300"}
|
||||
>
|
||||
Add this port or range by pressing{" "}
|
||||
<span className={"font-bold text-netbird"}>
|
||||
{"'Enter'"}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</div>
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
<CommandGroup>
|
||||
<div
|
||||
className={cn(
|
||||
"max-h-[180px] overflow-y-auto flex flex-col gap-1",
|
||||
)}
|
||||
>
|
||||
<CommandItem
|
||||
key={search}
|
||||
onSelect={() => {
|
||||
toggle(Number(search));
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
value={search}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
className={"uppercase tracking-wider font-medium py-1"}
|
||||
>
|
||||
{search}
|
||||
</Badge>
|
||||
<div className={"text-neutral-500 dark:text-nb-gray-300"}>
|
||||
Add this port by pressing{" "}
|
||||
<span className={"font-bold text-netbird"}>
|
||||
{"'Enter'"}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</div>
|
||||
</CommandGroup>
|
||||
)}
|
||||
{portsInput.map((option) => {
|
||||
const isSelected = portsInput.includes(option);
|
||||
return (
|
||||
<CommandItem
|
||||
key={option}
|
||||
value={option.toString()}
|
||||
onSelect={() => {
|
||||
toggle(option);
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
className={
|
||||
"uppercase tracking-wider font-medium py-1"
|
||||
}
|
||||
>
|
||||
{option}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<CommandGroup>
|
||||
<div
|
||||
className={cn(
|
||||
"max-h-[180px] overflow-y-auto flex flex-col gap-1",
|
||||
)}
|
||||
>
|
||||
{values.map((option) => {
|
||||
const isSelected = values.includes(option);
|
||||
return (
|
||||
<CommandItem
|
||||
key={option}
|
||||
value={option.toString()}
|
||||
onSelect={() => {
|
||||
toggle(option);
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
<div
|
||||
className={
|
||||
"uppercase tracking-wider font-medium py-1"
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
{option}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
<Checkbox checked={isSelected} />
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CommandGroup>
|
||||
</div>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Checkbox checked={isSelected} />
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CommandGroup>
|
||||
</div>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{portRanges?.length > 0 && (
|
||||
<Callout variant={"info"} className={"mt-4"}>
|
||||
Port ranges requires NetBird client{" "}
|
||||
<span className={"text-white font-normal"}>v0.48</span> or higher.
|
||||
</Callout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
71
src/components/Radio.tsx
Normal file
71
src/components/Radio.tsx
Normal 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 };
|
||||
67
src/components/RadioCard.tsx
Normal file
67
src/components/RadioCard.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { ReactNode } from "react"; // or replace with clsx or similar
|
||||
import { cn } from "@/utils/helpers";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
icon?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const RadioCard = ({
|
||||
value,
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
icon,
|
||||
}: Props) => {
|
||||
return (
|
||||
<RadioGroup.Item
|
||||
value={value}
|
||||
className={cn(
|
||||
"peer relative block cursor-pointer rounded-lg border border-nb-gray-900 bg-nb-gray-930/60 px-5 py-3 transition-all focus:outline-none",
|
||||
"data-[state=checked]:border-nb-gray-400 data-[state=checked]:bg-nb-gray-920",
|
||||
"outline-none focus:ring-0 focus:bg-nb-gray-930 focus:border-nb-gray-920",
|
||||
"hover:bg-nb-gray-930",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="text-nb-gray-100 font-normal text-sm text-left gap-2 flex items-center">
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
<div className="text-nb-gray-300 text-[0.8rem] text-left">
|
||||
{description}
|
||||
</div>
|
||||
</RadioGroup.Item>
|
||||
);
|
||||
};
|
||||
|
||||
type RadioCardGroupProps = {
|
||||
value: string;
|
||||
onValueChange: (val: string) => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
"aria-label"?: string;
|
||||
};
|
||||
|
||||
export const RadioCardGroup = ({
|
||||
value,
|
||||
onValueChange,
|
||||
children,
|
||||
className,
|
||||
"aria-label": ariaLabel = "Options",
|
||||
}: RadioCardGroupProps) => {
|
||||
return (
|
||||
<RadioGroup.Root
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</RadioGroup.Root>
|
||||
);
|
||||
};
|
||||
@@ -1,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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,38 @@ type StepProps = {
|
||||
step: number;
|
||||
line?: boolean;
|
||||
center?: boolean;
|
||||
horizontal?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Step = ({ children, step, line = true, center = false }: StepProps) => {
|
||||
const Step = ({
|
||||
children,
|
||||
step,
|
||||
line = true,
|
||||
center = false,
|
||||
horizontal,
|
||||
disabled = false,
|
||||
className,
|
||||
}: 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",
|
||||
disabled && "opacity-40 pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -37,6 +63,7 @@ const Step = ({ children, step, line = true, center = false }: StepProps) => {
|
||||
"h-[34px] w-[34px] shrink-0 rounded-full flex items-center justify-center font-medium text-xs relative z-0 border-4 transition-all",
|
||||
"dark:bg-nb-gray-900 dark:text-nb-gray-400 dark:border-nb-gray dark:group-hover:bg-nb-gray-800",
|
||||
"bg-nb-gray-100 text-nb-gray-400 border-white group-hover:bg-nb-gray-200 step-circle",
|
||||
"[.stepper-bg-variant]:border-nb-gray-940",
|
||||
)}
|
||||
>
|
||||
{step}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
@@ -10,25 +11,58 @@ const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
export type TooltipVariants = VariantProps<typeof tooltipVariants>;
|
||||
|
||||
export const tooltipVariants = cva(
|
||||
[
|
||||
"z-[9999] overflow-hidden rounded-md border text-sm shadow-md",
|
||||
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
"bg-white dark:bg-nb-gray-940",
|
||||
"text-neutral-950 dark:text-neutral-50",
|
||||
"border-neutral-200 dark:border-nb-gray-930",
|
||||
],
|
||||
lighter: [
|
||||
"bg-white dark:bg-nb-gray-920",
|
||||
"text-neutral-950 dark:text-neutral-50",
|
||||
"border-neutral-200 dark:border-nb-gray-900",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className = "px-4 py-2.5", sideOffset = 7, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
asChild={true}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"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,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div>{props.children}</div>
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> &
|
||||
TooltipVariants
|
||||
>(
|
||||
(
|
||||
{
|
||||
className = "px-4 py-2.5",
|
||||
sideOffset = 7,
|
||||
variant = "default",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
asChild={true}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(tooltipVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
<div>{props.children}</div>
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
),
|
||||
);
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
|
||||
|
||||
219
src/components/UserSelector.tsx
Normal file
219
src/components/UserSelector.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { DropdownInfoText } from "@components/DropdownInfoText";
|
||||
import { DropdownInput } from "@components/DropdownInput";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList";
|
||||
import { useSearch } from "@hooks/useSearch";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ChevronsUpDown, MapPin } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { memo, useState } from "react";
|
||||
import { useElementSize } from "@/hooks/useElementSize";
|
||||
import { User } from "@/interfaces/User";
|
||||
import { SmallUserAvatar } from "@/modules/users/SmallUserAvatar";
|
||||
|
||||
const MapPinIcon = memo(() => <MapPin size={12} />);
|
||||
MapPinIcon.displayName = "MapPinIcon";
|
||||
|
||||
interface MultiSelectProps {
|
||||
value?: User;
|
||||
onChange: React.Dispatch<React.SetStateAction<User | undefined>>;
|
||||
excludedPeers?: string[];
|
||||
disabled?: boolean;
|
||||
options?: User[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const searchPredicate = (u: User, query: string) => {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
try {
|
||||
if (u.name.toLowerCase().includes(lowerCaseQuery)) return true;
|
||||
return !!u?.email?.toLowerCase().includes(lowerCaseQuery);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export function UserSelector({
|
||||
onChange,
|
||||
value,
|
||||
disabled = false,
|
||||
options = [],
|
||||
placeholder = "Select a user...",
|
||||
}: MultiSelectProps) {
|
||||
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
|
||||
|
||||
const [filteredItems, search, setSearch] = useSearch(
|
||||
options,
|
||||
searchPredicate,
|
||||
{ filter: true, debounce: 150 },
|
||||
);
|
||||
|
||||
const toggleUser = (user: User) => {
|
||||
const isSelected = value && value.id == user.id;
|
||||
if (isSelected) {
|
||||
onChange(undefined);
|
||||
} else {
|
||||
onChange(user);
|
||||
setSearch("");
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setTimeout(() => {
|
||||
setSearch("");
|
||||
}, 100);
|
||||
}
|
||||
setOpen(isOpen);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"min-h-[46px] w-full relative items-center group",
|
||||
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3",
|
||||
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer enabled:hover:dark:bg-nb-gray-900/50",
|
||||
"disabled:opacity-40 disabled:cursor-default",
|
||||
)}
|
||||
disabled={disabled}
|
||||
ref={inputRef}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex items-center w-full gap-2 border-nb-gray-700 flex-wrap h-full"
|
||||
}
|
||||
>
|
||||
{value ? (
|
||||
<UserListItem
|
||||
user={value}
|
||||
className={"bg-nb-gray-800"}
|
||||
variant={"selected"}
|
||||
/>
|
||||
) : (
|
||||
<span>{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChevronsUpDown size={18} className={"shrink-0"} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
hideWhenDetached={false}
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950"
|
||||
style={{
|
||||
width: width,
|
||||
}}
|
||||
align="start"
|
||||
side={"top"}
|
||||
sideOffset={10}
|
||||
>
|
||||
<div className={"w-full"}>
|
||||
<DropdownInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
hideEnterIcon={true}
|
||||
placeholder={"Search for users by name or email..."}
|
||||
/>
|
||||
|
||||
{options.length == 0 && !search && (
|
||||
<div className={"max-w-xs mx-auto"}>
|
||||
<DropdownInfoText>
|
||||
{
|
||||
"There are no users to select. Invite some users for this tenant before unlinking."
|
||||
}
|
||||
</DropdownInfoText>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredItems.length == 0 && search != "" && (
|
||||
<DropdownInfoText>
|
||||
There are no users matching your search.
|
||||
</DropdownInfoText>
|
||||
)}
|
||||
|
||||
{filteredItems.length > 0 && (
|
||||
<VirtualScrollAreaList
|
||||
items={filteredItems}
|
||||
onSelect={toggleUser}
|
||||
estimatedItemHeight={52}
|
||||
scrollAreaClassName={"pt-0"}
|
||||
renderItem={(option) => {
|
||||
return (
|
||||
<div>
|
||||
<UserListItem user={option} className={"bg-nb-gray-800"} />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
type UserListItemProps = {
|
||||
user: User;
|
||||
className?: string;
|
||||
variant?: "default" | "selected";
|
||||
};
|
||||
|
||||
export const UserListItem = ({
|
||||
user,
|
||||
className,
|
||||
variant,
|
||||
}: UserListItemProps) => {
|
||||
const isSystemUser = user?.email === "NetBird" || user?.email === "";
|
||||
const maxChars = variant === "selected" ? 30 : 20;
|
||||
|
||||
return (
|
||||
<div className={"flex items-center gap-2 w-full text-left"}>
|
||||
<SmallUserAvatar
|
||||
name={user?.name}
|
||||
email={user?.email}
|
||||
id={user?.id}
|
||||
className={cn(
|
||||
variant === "selected" && "w-5 h-5 text-[9px]",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col w-full",
|
||||
variant === "selected" && "flex-row",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-nb-gray-200 flex items-center relative gap-1.5 w-full text-xs",
|
||||
variant === "selected" && "text-[0.85rem]",
|
||||
)}
|
||||
>
|
||||
<TextWithTooltip
|
||||
text={isSystemUser ? "System" : user?.name || user?.id}
|
||||
maxChars={maxChars}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"text-nb-gray-350 font-light flex items-center gap-1 text-xs",
|
||||
variant === "selected" && "text-xs pr-3 font-normal",
|
||||
)}
|
||||
>
|
||||
<TextWithTooltip
|
||||
text={user?.email || "NetBird"}
|
||||
maxChars={maxChars}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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={{
|
||||
|
||||
@@ -10,15 +10,38 @@ import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
|
||||
type Props<T extends { id?: string }> = {
|
||||
items: T[];
|
||||
onSelect: (item: T) => void;
|
||||
renderItem?: (item: T) => React.ReactNode;
|
||||
renderItem?: (item: T, selected?: boolean) => React.ReactNode;
|
||||
renderHeading?: (item: T) => React.ReactNode;
|
||||
renderBeforeItem?: (item: T) => React.ReactNode;
|
||||
itemClassName?: string;
|
||||
itemWrapperClassName?: string;
|
||||
scrollAreaClassName?: string;
|
||||
maxHeight?: number;
|
||||
estimatedItemHeight?: number;
|
||||
estimatedHeadingHeight?: number;
|
||||
heightAdjustment?: number;
|
||||
groupKey?: (item: T) => string | undefined;
|
||||
};
|
||||
|
||||
export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
items,
|
||||
onSelect,
|
||||
renderItem,
|
||||
}: Props<T>) {
|
||||
renderBeforeItem,
|
||||
renderHeading,
|
||||
itemClassName,
|
||||
itemWrapperClassName,
|
||||
scrollAreaClassName,
|
||||
maxHeight,
|
||||
estimatedItemHeight = 36,
|
||||
estimatedHeadingHeight = 16,
|
||||
heightAdjustment = 8,
|
||||
groupKey,
|
||||
}: Readonly<Props<T>>) {
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const [lastInputMethod, setLastInputMethod] = useState<"mouse" | "keyboard">(
|
||||
"mouse",
|
||||
);
|
||||
const [selected, setSelected] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -35,6 +58,7 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
|
||||
const navigation = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
setLastInputMethod("keyboard");
|
||||
if (items.length === 0) return;
|
||||
const length = items.length - 1;
|
||||
if (e.code === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) {
|
||||
@@ -57,38 +81,99 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouse = () => setLastInputMethod("mouse");
|
||||
|
||||
window.addEventListener("keydown", navigation);
|
||||
window.addEventListener("mousemove", handleMouse);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", navigation);
|
||||
window.removeEventListener("mousemove", handleMouse);
|
||||
};
|
||||
}, [navigation]);
|
||||
|
||||
const headingCount = useMemo(() => {
|
||||
if (!groupKey) return 0;
|
||||
|
||||
let count = 0;
|
||||
let prev: string | undefined;
|
||||
|
||||
for (const item of items) {
|
||||
const key = groupKey(item);
|
||||
if (key !== prev) {
|
||||
count++;
|
||||
prev = key;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}, [items, groupKey]);
|
||||
|
||||
const renderMemoizedItem = useMemo(() => renderItem, [renderItem]);
|
||||
|
||||
const scrollAreaHeight = { maxHeight: maxHeight ?? 195 };
|
||||
|
||||
const virtuosoHeight = {
|
||||
height: Math.min(
|
||||
items.length * estimatedItemHeight +
|
||||
headingCount * estimatedHeadingHeight +
|
||||
+(8 + heightAdjustment),
|
||||
maxHeight ?? 195,
|
||||
),
|
||||
};
|
||||
|
||||
const fixedItemHeight = useMemo(() => {
|
||||
if (!groupKey) return estimatedItemHeight;
|
||||
if (items.length === 0) return 0;
|
||||
const h = virtuosoHeight.height / items.length;
|
||||
if (isNaN(h)) return estimatedItemHeight;
|
||||
return h;
|
||||
}, [estimatedItemHeight, groupKey, items.length, virtuosoHeight.height]);
|
||||
|
||||
return (
|
||||
<MemoizedScrollArea
|
||||
withoutViewport={true}
|
||||
className={"max-h-[195px] flex flex-col gap-1 py-2"}
|
||||
className={cn("flex flex-col gap-1 pt-2", scrollAreaClassName)}
|
||||
style={scrollAreaHeight}
|
||||
>
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
overscan={50}
|
||||
data={items}
|
||||
defaultItemHeight={fixedItemHeight}
|
||||
totalCount={items.length}
|
||||
computeItemKey={(index) => items[index].id as string}
|
||||
context={{ selected, setSelected, onClick: onSelect }}
|
||||
itemContent={(index, option, { selected, setSelected, onClick }) => {
|
||||
const group = groupKey?.(option);
|
||||
const prevGroup =
|
||||
index > 0 ? groupKey?.(items[index - 1]) : undefined;
|
||||
const showHeading = group && group !== prevGroup;
|
||||
|
||||
return (
|
||||
<VirtualScrollListItemWrapper
|
||||
onMouseEnter={() => setSelected(index)}
|
||||
id={option.id}
|
||||
onClick={() => onClick(option as T)}
|
||||
ariaSelected={selected === index}
|
||||
>
|
||||
{renderMemoizedItem ? renderMemoizedItem(option) : option.id}
|
||||
</VirtualScrollListItemWrapper>
|
||||
<div>
|
||||
{showHeading && renderHeading?.(option)}
|
||||
{renderBeforeItem?.(option)}
|
||||
<VirtualScrollListItemWrapper
|
||||
onMouseEnter={() => {
|
||||
if (lastInputMethod === "mouse") {
|
||||
setSelected(index);
|
||||
}
|
||||
}}
|
||||
id={option.id}
|
||||
onClick={() => onClick(option)}
|
||||
ariaSelected={selected === index}
|
||||
itemClassName={itemClassName}
|
||||
className={itemWrapperClassName}
|
||||
isLast={index === items.length - 1}
|
||||
>
|
||||
{renderMemoizedItem
|
||||
? renderMemoizedItem(option, selected === index)
|
||||
: option.id}
|
||||
</VirtualScrollListItemWrapper>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
style={{ height: 195 }}
|
||||
style={virtuosoHeight}
|
||||
components={{
|
||||
Scroller: MemoizedScrollAreaViewport,
|
||||
}}
|
||||
@@ -103,21 +188,34 @@ 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"}
|
||||
onMouseEnter={onMouseEnter}
|
||||
className={cn("pr-3 pl-2 webkit-scroll", isLast && "pb-2", className)}
|
||||
onMouseOver={onMouseEnter}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs flex justify-between py-2 px-3 cursor-pointer items-center rounded-md",
|
||||
"text-xs flex justify-between py-2 px-3 cursor-pointer items-center rounded-md group/list-item",
|
||||
"bg-transparent dark:aria-selected:bg-nb-gray-800/50",
|
||||
itemClassName,
|
||||
)}
|
||||
aria-selected={ariaSelected}
|
||||
role={"listitem"}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DialogTriggerProps } from "@radix-ui/react-dialog";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { headerHeight } from "@/layouts/Header";
|
||||
|
||||
const Modal = DialogPrimitive.Root;
|
||||
|
||||
@@ -33,7 +34,7 @@ const ModalOverlay = React.forwardRef<
|
||||
className={cn(
|
||||
"fixed top-0 left-0 bottom-0 right-0 grid z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 ",
|
||||
"mx-auto place-items-start overflow-y-auto md:py-16",
|
||||
"bg-black/30 dark:bg-black/50 backdrop-blur-sm",
|
||||
"bg-black/30 dark:bg-black/40 backdrop-blur-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -66,7 +67,7 @@ const ModalContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mx-auto relative top-0 z-[52] grid w-full border border-neutral-200 bg-white py-6 dark:shadow-lg shadow-sm duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1 data-[state=open]:slide-in-from-left-1 sm:rounded-lg md:w-full dark:border-nb-gray-900 dark:bg-nb-gray",
|
||||
"mx-auto relative top-0 z-[52] grid w-full focus:outline-0 border border-neutral-200 bg-white py-6 dark:shadow-lg shadow-sm duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1 data-[state=open]:slide-in-from-left-1 sm:rounded-lg md:w-full dark:border-nb-gray-900 dark:bg-nb-gray",
|
||||
className,
|
||||
maxWidthClass,
|
||||
)}
|
||||
@@ -92,6 +93,62 @@ const ModalContent = React.forwardRef<
|
||||
);
|
||||
ModalContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const SidebarModalContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> &
|
||||
ModalContentProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
showClose = true,
|
||||
maxWidthClass = "max-w-3xl",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<ModalPortal>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed top-0 left-0 bottom-0 right-0 grid z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
)}
|
||||
>
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ml-auto mt-auto relative bottom-0 z-[52] grid w-full border border-zinc-700/40 bg-white py-6 dark:shadow-lg shadow-sm duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-out-to-left-1 data-[state=open]:slide-in-from-left-1 md:w-full dark:border-nb-gray-900 dark:bg-nb-gray",
|
||||
"border-t-0 border-r-0 border-b-0 shadow-2xl",
|
||||
className,
|
||||
maxWidthClass,
|
||||
)}
|
||||
{...props}
|
||||
style={{
|
||||
height: `calc(100vh - ${headerHeight + 100 - 2}px)`,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<>
|
||||
{children}
|
||||
{showClose && (
|
||||
<DialogPrimitive.Close
|
||||
data-cy={"modal-close"}
|
||||
className="absolute right-4 z-10 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</>
|
||||
</DialogPrimitive.Content>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
);
|
||||
},
|
||||
);
|
||||
SidebarModalContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
type ModalFooterProps = {
|
||||
variant?: "setup" | "default";
|
||||
separator?: boolean;
|
||||
@@ -158,4 +215,5 @@ export {
|
||||
ModalPortal,
|
||||
ModalTitle,
|
||||
ModalTrigger,
|
||||
SidebarModalContent,
|
||||
};
|
||||
|
||||
@@ -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}</>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Button from "@components/Button";
|
||||
import Button, { ButtonVariants } from "@components/Button";
|
||||
import { CommandItem } from "@components/Command";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
|
||||
@@ -36,6 +36,7 @@ interface SelectDropdownProps {
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
isLoading?: boolean;
|
||||
variant?: ButtonVariants["variant"];
|
||||
}
|
||||
|
||||
export function SelectDropdown({
|
||||
@@ -49,15 +50,13 @@ export function SelectDropdown({
|
||||
placeholder = "Select...",
|
||||
searchPlaceholder = "Search...",
|
||||
isLoading = false,
|
||||
}: SelectDropdownProps) {
|
||||
variant = "input",
|
||||
}: Readonly<SelectDropdownProps>) {
|
||||
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
|
||||
|
||||
const toggle = (selectedValue: string) => {
|
||||
const isSelected = value == selectedValue;
|
||||
if (isSelected) {
|
||||
} else {
|
||||
onChange && onChange(selectedValue);
|
||||
}
|
||||
if (!isSelected) onChange?.(selectedValue);
|
||||
setTimeout(() => {
|
||||
setSearch("");
|
||||
}, 100);
|
||||
@@ -66,18 +65,6 @@ export function SelectDropdown({
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const [slice, setSlice] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
setSlice(options.length);
|
||||
}, 100);
|
||||
} else {
|
||||
setSlice(10);
|
||||
}
|
||||
}, [open, options]);
|
||||
|
||||
const selected = options.find((o) => o.value === value);
|
||||
|
||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||
@@ -96,7 +83,6 @@ export function SelectDropdown({
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
setSlice(10);
|
||||
if (!isOpen) {
|
||||
setTimeout(() => {
|
||||
setSearch("");
|
||||
@@ -107,7 +93,7 @@ export function SelectDropdown({
|
||||
>
|
||||
<PopoverTrigger asChild={true} disabled={disabled || isLoading}>
|
||||
<Button
|
||||
variant={"input"}
|
||||
variant={variant}
|
||||
disabled={disabled || isLoading}
|
||||
ref={inputRef}
|
||||
className={"w-full"}
|
||||
@@ -146,7 +132,7 @@ export function SelectDropdown({
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950"
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950 focus:outline-none"
|
||||
style={{
|
||||
width: popoverWidth === "auto" ? width : popoverWidth,
|
||||
}}
|
||||
|
||||
@@ -6,16 +6,26 @@ type Props = {
|
||||
withHeader?: boolean;
|
||||
};
|
||||
|
||||
export default function SkeletonTable({ withHeader = true }: Props) {
|
||||
export default function SkeletonTable({ withHeader = true }: Readonly<Props>) {
|
||||
return (
|
||||
<div className={"w-full"}>
|
||||
{withHeader && <SkeletonTableHeader />}
|
||||
<Skeleton
|
||||
height={48}
|
||||
containerClassName={"flex"}
|
||||
className={cn(withHeader && "mt-8")}
|
||||
/>
|
||||
<div>
|
||||
<div className={"mt-6"}>
|
||||
<TableSkeletonRow />
|
||||
<TableSkeletonRow odd />
|
||||
<TableSkeletonRow />
|
||||
<TableSkeletonRow odd />
|
||||
<TableSkeletonRow />
|
||||
<TableSkeletonRow odd />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableContentSkeleton() {
|
||||
return (
|
||||
<div className={"w-full"}>
|
||||
<div className={"mt-6"}>
|
||||
<TableSkeletonRow />
|
||||
<TableSkeletonRow odd />
|
||||
<TableSkeletonRow />
|
||||
@@ -31,7 +41,7 @@ type RowProps = {
|
||||
odd?: boolean;
|
||||
};
|
||||
|
||||
export function TableSkeletonRow({ odd = false }: RowProps) {
|
||||
export function TableSkeletonRow({ odd = false }: Readonly<RowProps>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { TableContentSkeleton } from "@components/skeletons/SkeletonTable";
|
||||
import DataTableGlobalSearch from "@components/table/DataTableGlobalSearch";
|
||||
import { DataTableHeadingPortal } from "@components/table/DataTableHeadingPortal";
|
||||
import { DataTablePagination } from "@components/table/DataTablePagination";
|
||||
@@ -40,10 +40,10 @@ import {
|
||||
import { FilterFn } from "@tanstack/table-core";
|
||||
import { cn, removeAllSpaces } from "@utils/helpers";
|
||||
import dayjs from "dayjs";
|
||||
import { trim } from "lodash";
|
||||
import { isEqual, trim } from "lodash";
|
||||
import { usePathname } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
|
||||
declare module "@tanstack/table-core" {
|
||||
@@ -84,7 +84,6 @@ const isWithinRange: FilterFn<any> = (
|
||||
) => {
|
||||
const date = dayjs(row.getValue(columnId));
|
||||
const [start, end] = value;
|
||||
//If one filter defined and date is null filter it
|
||||
if ((start || end) && !date) return false;
|
||||
if (start && !end) {
|
||||
return date.isAfter(dayjs(start));
|
||||
@@ -101,7 +100,7 @@ const arrIncludesSomeExact: FilterFn<any> = (
|
||||
value: string[],
|
||||
) => {
|
||||
const rowValue = row.getValue(columnId);
|
||||
if (!rowValue) return false;
|
||||
if (!rowValue && rowValue !== 0) return false;
|
||||
return value.some((val) => val === rowValue);
|
||||
};
|
||||
|
||||
@@ -147,6 +146,8 @@ interface DataTableProps<TData, TValue> {
|
||||
showSearchAndFilters?: boolean;
|
||||
rightSide?: (table: TanStackTable<TData>) => React.ReactNode;
|
||||
manualPagination?: boolean;
|
||||
manualFiltering?: boolean;
|
||||
manualColumnFiltering?: boolean;
|
||||
showHeader?: boolean;
|
||||
rowSelection?: RowSelectionState;
|
||||
setRowSelection?: React.Dispatch<React.SetStateAction<RowSelectionState>>;
|
||||
@@ -163,14 +164,23 @@ interface DataTableProps<TData, TValue> {
|
||||
initialPageSize?: number;
|
||||
uniqueKey?: string;
|
||||
resetRowSelectionOnSearch?: boolean;
|
||||
pageCount?: number;
|
||||
pagination?: { pageIndex: number; pageSize: number };
|
||||
onPaginationChange?: (pagination: {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
}) => void;
|
||||
totalRecords?: number;
|
||||
globalFilter?: string;
|
||||
onGlobalFilterChange?: (value: string) => void;
|
||||
columnFilters?: ColumnFiltersState;
|
||||
onColumnFiltersChange?: (filters: ColumnFiltersState) => void;
|
||||
initialFilters?: ColumnFiltersState;
|
||||
initialSearch?: string;
|
||||
onSearchClick?: () => void;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>(props: DataTableProps<TData, TValue>) {
|
||||
if (props.isLoading) return <SkeletonTable withHeader={!props.minimal} />;
|
||||
return <DataTableContent {...props} />;
|
||||
}
|
||||
|
||||
export function DataTableContent<TData, TValue>({
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
children,
|
||||
@@ -196,6 +206,8 @@ export function DataTableContent<TData, TValue>({
|
||||
searchClassName,
|
||||
rightSide,
|
||||
manualPagination = false,
|
||||
manualFiltering = false,
|
||||
manualColumnFiltering = false,
|
||||
showHeader = true,
|
||||
rowSelection,
|
||||
setRowSelection,
|
||||
@@ -212,18 +224,33 @@ export function DataTableContent<TData, TValue>({
|
||||
initialPageSize = 10,
|
||||
uniqueKey,
|
||||
resetRowSelectionOnSearch = true,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
pageCount,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
totalRecords,
|
||||
globalFilter,
|
||||
onGlobalFilterChange,
|
||||
columnFilters: externalColumnFilters,
|
||||
onColumnFiltersChange: externalOnColumnFiltersChange,
|
||||
initialFilters,
|
||||
initialSearch,
|
||||
onSearchClick,
|
||||
}: Readonly<DataTableProps<TData, TValue>>) {
|
||||
const path = usePathname();
|
||||
const isInitialRender = useRef(true);
|
||||
|
||||
const [columnFilters, setColumnFilters] = useLocalStorage<ColumnFiltersState>(
|
||||
`netbird-table-columns${uniqueKey ? "/" + (uniqueKey as string) : path}`,
|
||||
[],
|
||||
keepStateInLocalStorage,
|
||||
);
|
||||
const [globalSearch, setGlobalSearch] = useLocalStorage(
|
||||
const [localColumnFilters, setLocalColumnFilters] =
|
||||
useLocalStorage<ColumnFiltersState>(
|
||||
`netbird-table-columns${uniqueKey ? "/" + (uniqueKey as string) : path}`,
|
||||
[],
|
||||
keepStateInLocalStorage && !manualColumnFiltering,
|
||||
initialFilters,
|
||||
);
|
||||
const [localGlobalSearch, setLocalGlobalSearch] = useLocalStorage(
|
||||
`netbird-table-search${uniqueKey ? "/" + (uniqueKey as string) : path}`,
|
||||
"",
|
||||
keepStateInLocalStorage,
|
||||
globalFilter || "",
|
||||
keepStateInLocalStorage && !manualFiltering,
|
||||
initialSearch,
|
||||
);
|
||||
|
||||
const [paginationState, setPaginationState] =
|
||||
@@ -232,10 +259,10 @@ export function DataTableContent<TData, TValue>({
|
||||
uniqueKey ? "/" + (uniqueKey as string) : path
|
||||
}`,
|
||||
{
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
pageIndex: pagination?.pageIndex ?? 0,
|
||||
pageSize: pagination?.pageSize ?? initialPageSize,
|
||||
},
|
||||
keepStateInLocalStorage,
|
||||
keepStateInLocalStorage && !manualPagination,
|
||||
);
|
||||
|
||||
const hasInitialData = !!(data && data.length > 0);
|
||||
@@ -253,19 +280,30 @@ export function DataTableContent<TData, TValue>({
|
||||
autoResetAll: false,
|
||||
autoResetExpanded: false,
|
||||
manualPagination: manualPagination,
|
||||
manualFiltering: manualFiltering || manualColumnFiltering,
|
||||
pageCount: pageCount,
|
||||
state: {
|
||||
sorting,
|
||||
rowSelection: rowSelection ?? {},
|
||||
columnFilters,
|
||||
columnFilters: manualColumnFiltering
|
||||
? externalColumnFilters || []
|
||||
: localColumnFilters,
|
||||
columnVisibility: columnVisibility,
|
||||
globalFilter: globalSearch,
|
||||
pagination: paginationState,
|
||||
globalFilter: manualFiltering ? globalFilter : localGlobalSearch,
|
||||
pagination: manualPagination
|
||||
? {
|
||||
pageIndex: pagination?.pageIndex ?? 0,
|
||||
pageSize: pagination?.pageSize ?? initialPageSize,
|
||||
}
|
||||
: paginationState,
|
||||
},
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageIndex: 0,
|
||||
pageSize: initialPageSize || 10,
|
||||
pageIndex: pagination?.pageIndex ?? 0,
|
||||
pageSize: pagination?.pageSize ?? initialPageSize,
|
||||
},
|
||||
columnFilters: initialFilters,
|
||||
globalFilter: initialSearch,
|
||||
},
|
||||
sortingFns: {
|
||||
checkbox: checkboxSort,
|
||||
@@ -273,8 +311,36 @@ export function DataTableContent<TData, TValue>({
|
||||
getRowId: useRowId ? (row) => row.id : undefined,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onPaginationChange: setPaginationState,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onPaginationChange: (updater) => {
|
||||
if (manualPagination) {
|
||||
if (isInitialRender.current) {
|
||||
isInitialRender.current = false;
|
||||
return;
|
||||
}
|
||||
if (typeof updater === "function") {
|
||||
const newState = updater(pagination!);
|
||||
onPaginationChange?.(newState);
|
||||
} else {
|
||||
onPaginationChange?.(updater);
|
||||
}
|
||||
} else {
|
||||
setPaginationState(updater);
|
||||
}
|
||||
},
|
||||
onColumnFiltersChange: (filters) => {
|
||||
if (manualColumnFiltering) {
|
||||
externalOnColumnFiltersChange?.(filters as ColumnFiltersState);
|
||||
} else {
|
||||
setLocalColumnFilters(filters as ColumnFiltersState);
|
||||
}
|
||||
},
|
||||
onGlobalFilterChange: (value) => {
|
||||
if (manualFiltering) {
|
||||
onGlobalFilterChange?.(value);
|
||||
} else {
|
||||
setLocalGlobalSearch(value);
|
||||
}
|
||||
},
|
||||
globalFilterFn: fuzzyFilter,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
@@ -298,12 +364,53 @@ export function DataTableContent<TData, TValue>({
|
||||
*/
|
||||
const resetFilters = () => {
|
||||
table.setPageIndex(0);
|
||||
setColumnFilters([]);
|
||||
setGlobalSearch("");
|
||||
if (manualColumnFiltering) {
|
||||
externalOnColumnFiltersChange?.([]);
|
||||
} else {
|
||||
setLocalColumnFilters([]);
|
||||
}
|
||||
if (manualFiltering) {
|
||||
onGlobalFilterChange?.("");
|
||||
} else {
|
||||
setLocalGlobalSearch("");
|
||||
}
|
||||
setRowSelection?.({});
|
||||
onFilterReset?.();
|
||||
setSearchKey((prev) => (prev === 0 ? 1 : 0));
|
||||
};
|
||||
|
||||
const [searchKey, setSearchKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (manualPagination && pagination) {
|
||||
const currentPagination = table.getState().pagination;
|
||||
if (isEqual(currentPagination, pagination)) return;
|
||||
|
||||
table.setPagination({
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
}
|
||||
}, [manualPagination, pagination, table]);
|
||||
|
||||
useEffect(() => {
|
||||
if (manualFiltering && globalFilter !== undefined) {
|
||||
const currentGlobalFilter = table.getState().globalFilter;
|
||||
if (currentGlobalFilter !== globalFilter) {
|
||||
table.setGlobalFilter(globalFilter);
|
||||
}
|
||||
}
|
||||
}, [manualFiltering, globalFilter, table]);
|
||||
|
||||
useEffect(() => {
|
||||
if (manualColumnFiltering && externalColumnFilters) {
|
||||
const currentFilters = table.getState().columnFilters;
|
||||
if (!isEqual(currentFilters, externalColumnFilters)) {
|
||||
table.setColumnFilters(externalColumnFilters);
|
||||
}
|
||||
}
|
||||
}, [manualColumnFiltering, externalColumnFilters, table]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative table-fixed-scroll", className)}>
|
||||
{showSearchAndFilters && (
|
||||
@@ -315,44 +422,48 @@ export function DataTableContent<TData, TValue>({
|
||||
>
|
||||
<DataTableGlobalSearch
|
||||
className={searchClassName}
|
||||
disabled={!hasInitialData}
|
||||
globalSearch={globalSearch}
|
||||
disabled={false} // Never disable the search input
|
||||
key={searchKey}
|
||||
onClick={onSearchClick}
|
||||
isLoading={isLoading}
|
||||
globalSearch={
|
||||
manualFiltering ? globalFilter || "" : localGlobalSearch
|
||||
}
|
||||
setGlobalSearch={(val) => {
|
||||
table.setPageIndex(0);
|
||||
setGlobalSearch(val);
|
||||
if (manualFiltering) {
|
||||
onGlobalFilterChange?.(val);
|
||||
} else {
|
||||
setLocalGlobalSearch(val);
|
||||
}
|
||||
resetRowSelectionOnSearch && setRowSelection?.({});
|
||||
}}
|
||||
placeholder={searchPlaceholder}
|
||||
/>
|
||||
{children && children(table)}
|
||||
{children?.(table)}
|
||||
{showResetFilterButton && (
|
||||
<DataTableResetFilterButton onClick={resetFilters} table={table} />
|
||||
)}
|
||||
<div className={"flex gap-4 flex-wrap grow"}>
|
||||
<div className={"flex gap-4 flex-wrap"}></div>
|
||||
{rightSide && rightSide(table)}
|
||||
{rightSide?.(table)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{aboveTable && aboveTable(table)}
|
||||
{aboveTable?.(table)}
|
||||
|
||||
{!hasInitialData && !isLoading && (
|
||||
<TableWrapper
|
||||
wrapperComponent={wrapperComponent}
|
||||
wrapperProps={wrapperProps}
|
||||
>
|
||||
{getStartedCard}
|
||||
</TableWrapper>
|
||||
)}
|
||||
|
||||
{hasInitialData && !isLoading && (
|
||||
<TableWrapper
|
||||
wrapperComponent={wrapperComponent}
|
||||
wrapperProps={wrapperProps}
|
||||
>
|
||||
<TableWrapper
|
||||
wrapperComponent={wrapperComponent}
|
||||
wrapperProps={wrapperProps}
|
||||
>
|
||||
{isLoading ? (
|
||||
<TableContentSkeleton />
|
||||
) : !hasInitialData ? (
|
||||
getStartedCard
|
||||
) : (
|
||||
<TableComponent
|
||||
className={cn("relative mt-8", tableClassName)}
|
||||
className={cn("relative mt-6", tableClassName)}
|
||||
minimal={minimal}
|
||||
>
|
||||
{showHeader && as == "table" && (
|
||||
@@ -394,94 +505,99 @@ export function DataTableContent<TData, TValue>({
|
||||
)}
|
||||
>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<AccordionItem
|
||||
value={row.original.id}
|
||||
asChild={true}
|
||||
key={row.original.id}
|
||||
>
|
||||
<>
|
||||
<TableRowComponent
|
||||
minimal={minimal}
|
||||
data-row-id={row.original.id}
|
||||
className={cn(
|
||||
(onRowClick || renderExpandedRow) &&
|
||||
"cursor-pointer relative group/accordion",
|
||||
rowClassName,
|
||||
)}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
data-accordion={
|
||||
accordion?.includes(row.original.id)
|
||||
? "opened"
|
||||
: "closed"
|
||||
}
|
||||
onClick={(e) => {
|
||||
if (renderExpandedRow) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setAccordion((prev) => {
|
||||
if (prev?.includes(row.original.id)) {
|
||||
return prev.filter(
|
||||
(item) => item !== row.original.id,
|
||||
);
|
||||
} else {
|
||||
return [...(prev ?? []), row.original.id];
|
||||
}
|
||||
});
|
||||
table.getRowModel().rows.map((row) => {
|
||||
const expandedRow = renderExpandedRow?.(row.original);
|
||||
return (
|
||||
<AccordionItem
|
||||
value={row.original.id}
|
||||
asChild={true}
|
||||
key={row.id}
|
||||
>
|
||||
<>
|
||||
<TableRowComponent
|
||||
minimal={minimal}
|
||||
data-row-id={row.original.id}
|
||||
className={cn(
|
||||
(onRowClick || renderExpandedRow) &&
|
||||
"relative group/accordion",
|
||||
(onRowClick || expandedRow) && "cursor-pointer",
|
||||
rowClassName,
|
||||
)}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
data-accordion={
|
||||
accordion?.includes(row.original.id)
|
||||
? "opened"
|
||||
: "closed"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCellComponent
|
||||
key={cell.id}
|
||||
className={cn("relative", tableCellClassName)}
|
||||
minimal={minimal}
|
||||
inset={inset}
|
||||
onClick={() => {
|
||||
onRowClick && onRowClick(row, cell.column.id);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"absolute left-0 top-0 w-full h-full z-0"
|
||||
onClick={(e) => {
|
||||
if (expandedRow) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setAccordion((prev) => {
|
||||
if (prev?.includes(row.original.id)) {
|
||||
return prev.filter(
|
||||
(item) => item !== row.original.id,
|
||||
);
|
||||
} else {
|
||||
return [...(prev ?? []), row.original.id];
|
||||
}
|
||||
></div>
|
||||
<div className={"relative z-[1]"}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</div>
|
||||
</TableCellComponent>
|
||||
))}
|
||||
</>
|
||||
</TableRowComponent>
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCellComponent
|
||||
key={cell.id}
|
||||
className={cn("relative", tableCellClassName)}
|
||||
minimal={minimal}
|
||||
inset={inset}
|
||||
onClick={() => {
|
||||
onRowClick &&
|
||||
onRowClick(row, cell.column.id);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"absolute left-0 top-0 w-full h-full z-0"
|
||||
}
|
||||
></div>
|
||||
<div className={"relative z-[1]"}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</div>
|
||||
</TableCellComponent>
|
||||
))}
|
||||
</>
|
||||
</TableRowComponent>
|
||||
|
||||
{renderExpandedRow && (
|
||||
<AccordionContent asChild={true}>
|
||||
<TableRowComponent
|
||||
data-row-id={row.id + "-expanded-row"}
|
||||
key={row.id + "-expanded-row"}
|
||||
minimal={minimal}
|
||||
className={cn(
|
||||
onRowClick && "cursor-pointer relative",
|
||||
rowClassName,
|
||||
)}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
<TableDataUnstyledComponent
|
||||
className={"w-full"}
|
||||
colSpan={row.getVisibleCells().length}
|
||||
{expandedRow && (
|
||||
<AccordionContent asChild={true}>
|
||||
<TableRowComponent
|
||||
data-row-id={row.id + "-expanded-row"}
|
||||
key={row.id + "-expanded-row"}
|
||||
minimal={minimal}
|
||||
className={cn(
|
||||
onRowClick && "cursor-pointer relative",
|
||||
rowClassName,
|
||||
)}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{renderExpandedRow(row.original)}
|
||||
</TableDataUnstyledComponent>
|
||||
</TableRowComponent>
|
||||
</AccordionContent>
|
||||
)}
|
||||
</>
|
||||
</AccordionItem>
|
||||
))
|
||||
<TableDataUnstyledComponent
|
||||
className={"w-full"}
|
||||
colSpan={row.getVisibleCells().length}
|
||||
>
|
||||
{expandedRow}
|
||||
</TableDataUnstyledComponent>
|
||||
</TableRowComponent>
|
||||
</AccordionContent>
|
||||
)}
|
||||
</>
|
||||
</AccordionItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRowUnstyledComponent>
|
||||
<TableCellComponent
|
||||
@@ -495,14 +611,15 @@ export function DataTableContent<TData, TValue>({
|
||||
</TableBodyComponent>
|
||||
</Accordion>
|
||||
</TableComponent>
|
||||
</TableWrapper>
|
||||
)}
|
||||
)}
|
||||
</TableWrapper>
|
||||
|
||||
<div className={paginationClassName}>
|
||||
<DataTablePagination
|
||||
table={table}
|
||||
text={text}
|
||||
paginationPadding={paginationPaddingClassName}
|
||||
totalRecords={totalRecords}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
276
src/components/table/DataTableFilter.tsx
Normal file
276
src/components/table/DataTableFilter.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import Button from "@components/Button";
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import { DropdownInfoText } from "@components/DropdownInfoText";
|
||||
import { DropdownInput } from "@components/DropdownInput";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
|
||||
import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList";
|
||||
import { useSearch } from "@hooks/useSearch";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import { concat, sortBy, uniqBy } from "lodash";
|
||||
import { FilterIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
interface Props<TData> {
|
||||
table: Table<TData>;
|
||||
filters: Filter<TData>[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter
|
||||
* @param columnId - Column ID to filter
|
||||
* @param group - Group name for the filter
|
||||
* @param item - Function to render the filter item
|
||||
*/
|
||||
interface Filter<TData> {
|
||||
columnId: keyof TData | string;
|
||||
group?: string;
|
||||
item: (item: TData, value: string) => string | React.ReactNode;
|
||||
exclude?: string[];
|
||||
}
|
||||
|
||||
interface FilterItem<TData> {
|
||||
id: string;
|
||||
value: string;
|
||||
showGroupHeading: boolean;
|
||||
columnId: keyof TData | string;
|
||||
group?: string;
|
||||
original: TData;
|
||||
renderItem: () => React.ReactNode;
|
||||
}
|
||||
|
||||
type SearchPredicate<TData> = (
|
||||
item: FilterItem<TData>,
|
||||
query: string,
|
||||
) => boolean;
|
||||
|
||||
const searchPredicate: SearchPredicate<any> = (item, query) => {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
let itemValue = String(item?.value || "").toLowerCase();
|
||||
return itemValue.includes(lowerCaseQuery);
|
||||
};
|
||||
|
||||
/**
|
||||
* @desc Generic filter button. Filters are based on the table data and are displayed in a popover with search functionality.
|
||||
* @param table - Table instance from tanstack/react-table
|
||||
* @param filters - Array of filters to display
|
||||
* @param filters.columnId Id of the column to filter. This column must have a filterFn: "arrIncludesSomeExact" in the column definition of the table.
|
||||
* @param filters.group - Group name for the filter
|
||||
* @param filters.item - Function to render the filter item
|
||||
* @param disabled - Disable the filter button
|
||||
* @returns React.ReactNode
|
||||
* @example
|
||||
* <DataTableFilter table={table} disabled={false}
|
||||
* filters={[{
|
||||
* columnId: "name",
|
||||
* group: "Users",
|
||||
* item: (item) => item.name,
|
||||
* }]}
|
||||
* />
|
||||
*/
|
||||
export function DataTableFilter<TData>({
|
||||
table,
|
||||
filters,
|
||||
disabled = false,
|
||||
}: Readonly<Props<TData>>) {
|
||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
filters.flatMap((filter) => {
|
||||
const getTableColumnValues = (columnId: string) => {
|
||||
return [
|
||||
...new Set(
|
||||
table
|
||||
.getPreFilteredRowModel()
|
||||
.rows.map((row) => {
|
||||
return {
|
||||
value: row?.getValue(columnId),
|
||||
original: row.original,
|
||||
};
|
||||
})
|
||||
.filter((value) => value !== undefined),
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
// Get unique values from table rows
|
||||
let tableRows = uniqBy(
|
||||
getTableColumnValues(filter.columnId as string),
|
||||
"value",
|
||||
);
|
||||
|
||||
// Filter out excluded values
|
||||
if (filter.exclude) {
|
||||
tableRows = tableRows.filter(
|
||||
(row) => !filter.exclude?.includes(row.value as string),
|
||||
);
|
||||
}
|
||||
|
||||
// Sort values
|
||||
tableRows = sortBy(tableRows, (row) => {
|
||||
return isNaN(Number(row?.value)) ? row?.value : Number(row?.value);
|
||||
});
|
||||
|
||||
const groupCounts: Record<string, number> = {};
|
||||
return tableRows.map((row) => {
|
||||
const groupKey = filter?.group ?? "Ungrouped";
|
||||
groupCounts[groupKey] = (groupCounts[groupKey] || 0) + 1;
|
||||
|
||||
return {
|
||||
id: `${String(filter.columnId)}-${row.value}`,
|
||||
value: row.value,
|
||||
showGroupHeading: groupCounts[groupKey] === 1,
|
||||
columnId: filter.columnId,
|
||||
group: filter?.group,
|
||||
original: row.original,
|
||||
renderItem: () => filter?.item(row.original, String(row.value)),
|
||||
} as FilterItem<TData>;
|
||||
});
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const [filteredItems, search, setSearch] = useSearch<FilterItem<TData>>(
|
||||
options,
|
||||
searchPredicate,
|
||||
{
|
||||
filter: true,
|
||||
debounce: 150,
|
||||
},
|
||||
);
|
||||
|
||||
const onOpenChange = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
setTimeout(() => {
|
||||
setSearch("");
|
||||
}, 100);
|
||||
}
|
||||
setOpen(isOpen);
|
||||
};
|
||||
|
||||
const getCurrentTableFilters = useCallback((columnId: string) => {
|
||||
return table.getColumn(columnId)?.getFilterValue() as string[] | undefined;
|
||||
}, []);
|
||||
|
||||
const onSelect = (item: FilterItem<TData>) => {
|
||||
table.setPageIndex(0);
|
||||
|
||||
const currentFilters = getCurrentTableFilters(item.columnId as string);
|
||||
const column = table.getColumn(item.columnId as string);
|
||||
|
||||
const newFilters = currentFilters?.includes(item.value)
|
||||
? currentFilters.filter((f) => f !== item.value)
|
||||
: concat(currentFilters ?? [], item.value);
|
||||
|
||||
if (newFilters.length == 0) {
|
||||
column?.setFilterValue(undefined);
|
||||
} else {
|
||||
column?.setFilterValue(newFilters);
|
||||
}
|
||||
|
||||
searchRef.current?.focus();
|
||||
};
|
||||
|
||||
const activeFiltersCount = useMemo(() => {
|
||||
let columnIds = filters.map((filter) => filter.columnId as string);
|
||||
let activeFilters = columnIds.map((columnId) => {
|
||||
return getCurrentTableFilters(columnId);
|
||||
});
|
||||
return activeFilters.flat().filter((filter) => filter !== undefined).length;
|
||||
}, [filters, getCurrentTableFilters]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild={true}>
|
||||
<Button variant={"secondary"} disabled={disabled}>
|
||||
<FilterIcon size={15} className={"shrink-0"} />
|
||||
<span>
|
||||
<span className={"text-white"}>
|
||||
{activeFiltersCount > 0 && activeFiltersCount}
|
||||
</span>
|
||||
{activeFiltersCount > 0 ? ` Filter(s)` : "Filter"}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
hideWhenDetached={false}
|
||||
className="w-full p-0 shadow-sm shadow-nb-gray-950"
|
||||
style={{
|
||||
width: "400px",
|
||||
}}
|
||||
align="start"
|
||||
side={"bottom"}
|
||||
sideOffset={10}
|
||||
>
|
||||
<div className={"w-full"}>
|
||||
<DropdownInput
|
||||
ref={searchRef}
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={"Search filters..."}
|
||||
hideEnterIcon={true}
|
||||
/>
|
||||
|
||||
{filteredItems.length == 0 && search != "" && (
|
||||
<DropdownInfoText className={"mb-4"}>
|
||||
There are no filters matching your search.
|
||||
</DropdownInfoText>
|
||||
)}
|
||||
|
||||
<VirtualScrollAreaList
|
||||
items={filteredItems}
|
||||
maxHeight={270}
|
||||
scrollAreaClassName={"pt-0"}
|
||||
renderItem={(option) => {
|
||||
const currentTableFilters = getCurrentTableFilters(
|
||||
option.columnId as string,
|
||||
);
|
||||
const isActive = currentTableFilters?.includes(option.value);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2 justify-between w-full"
|
||||
}
|
||||
key={option.id}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-2 whitespace-nowrap text-xs font-normal tracking-wide"
|
||||
}
|
||||
>
|
||||
<div>{option?.renderItem()}</div>
|
||||
</div>
|
||||
<Checkbox checked={isActive} />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const ListItemHeading = ({
|
||||
children,
|
||||
show = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
show: boolean;
|
||||
}) => {
|
||||
if (!show) return null;
|
||||
return (
|
||||
<p
|
||||
className={
|
||||
"!text-nb-gray-400 text-xs uppercase font-medium tracking-wider pb-1 pl-5 mb-.5 mt-4"
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
@@ -1,38 +1,74 @@
|
||||
import { Input } from "@components/Input";
|
||||
import Kbd from "@components/Kbd";
|
||||
import { useDebounce } from "@hooks/useDebounce";
|
||||
import { Search } from "lucide-react";
|
||||
import React from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
|
||||
interface Props extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
setGlobalSearch: (value: string) => void;
|
||||
globalSearch?: string;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function DataTableGlobalSearch({
|
||||
setGlobalSearch,
|
||||
globalSearch,
|
||||
className = "min-w-[300px] max-w-[400px] grow",
|
||||
isLoading,
|
||||
onClick,
|
||||
...props
|
||||
}: Props) {
|
||||
}: Readonly<Props>) {
|
||||
const ref = React.useRef<HTMLInputElement>(null);
|
||||
const [inputValue, setInputValue] = useState(globalSearch || "");
|
||||
const debouncedValue = useDebounce(inputValue, 800);
|
||||
|
||||
// Call setGlobalSearch when debounced value changes
|
||||
useEffect(() => {
|
||||
setGlobalSearch(debouncedValue);
|
||||
}, [debouncedValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (globalSearch !== undefined && globalSearch !== inputValue) {
|
||||
setInputValue(globalSearch);
|
||||
}
|
||||
}, [globalSearch]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setGlobalSearch(e.target.value);
|
||||
setInputValue(e.target.value);
|
||||
};
|
||||
|
||||
useHotkeys("mod+k", () => ref.current?.focus(), []);
|
||||
useHotkeys(
|
||||
"mod+k",
|
||||
() => {
|
||||
if (onClick) {
|
||||
onClick?.();
|
||||
} else {
|
||||
ref.current?.focus();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
ref={ref}
|
||||
onFocus={(e) => {
|
||||
if (onClick) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
}
|
||||
}}
|
||||
icon={<Search size={15} />}
|
||||
value={globalSearch}
|
||||
value={inputValue} // Shows immediate updates
|
||||
onChange={handleChange}
|
||||
maxWidthClass={className}
|
||||
customSuffix={<Kbd>⌘ K</Kbd>}
|
||||
disabled={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,65 +12,78 @@ interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>;
|
||||
text?: string;
|
||||
paginationPadding?: string;
|
||||
totalRecords?: number;
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
text = "rows",
|
||||
paginationPadding = "px-8 py-8",
|
||||
totalRecords,
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const allRows = table.getFilteredRowModel().rows.length;
|
||||
const rowsPerPage = table.getState().pagination.pageSize;
|
||||
const currentPage = table.getState().pagination.pageIndex + 1;
|
||||
const isLastPage = currentPage === table.getPageCount();
|
||||
const showingFrom = (currentPage - 1) * rowsPerPage + 1;
|
||||
const showingTo = isLastPage ? allRows : showingFrom + rowsPerPage - 1;
|
||||
const pageCount = table.getPageCount();
|
||||
|
||||
return pageCount > 1 ? (
|
||||
<div className={cn("flex items-center justify-between", paginationPadding)}>
|
||||
<div className="text-nb-gray-400">
|
||||
Showing{" "}
|
||||
<span className={"font-medium text-white"}>
|
||||
{showingFrom} to {showingTo}
|
||||
</span>{" "}
|
||||
of <span className={"font-medium text-white"}>{allRows}</span> {text}
|
||||
</div>
|
||||
<div className={"flex items-center gap-3"}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<ButtonGroup>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronsLeft size={16} />
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button>
|
||||
<div>
|
||||
{table.getState().pagination.pageIndex + 1} of {pageCount}
|
||||
</div>
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronsRight size={18} />
|
||||
</ButtonGroup.Button>
|
||||
</ButtonGroup>
|
||||
const totalRows =
|
||||
totalRecords !== undefined
|
||||
? totalRecords
|
||||
: table.getFilteredRowModel().rows.length;
|
||||
|
||||
const showingFrom = totalRows === 0 ? 0 : (currentPage - 1) * rowsPerPage + 1;
|
||||
const showingTo = Math.min(currentPage * rowsPerPage, totalRows);
|
||||
|
||||
return (
|
||||
pageCount > 1 && (
|
||||
<div
|
||||
className={cn("flex items-center justify-between", paginationPadding)}
|
||||
>
|
||||
<div className="text-nb-gray-400">
|
||||
Showing{" "}
|
||||
<span className={"font-medium text-white"}>
|
||||
{showingFrom} to {showingTo}
|
||||
</span>{" "}
|
||||
of <span className={"font-medium text-white"}>{totalRows}</span>{" "}
|
||||
{text}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<div className={"flex items-center gap-3"}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<ButtonGroup>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronsLeft size={16} />
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button>
|
||||
<div>
|
||||
{currentPage} of {pageCount}
|
||||
</div>
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronsRight size={18} />
|
||||
</ButtonGroup.Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,48 +8,54 @@ import { useState } from "react";
|
||||
interface Props<TData> {
|
||||
table: Table<TData>;
|
||||
onClick: () => void;
|
||||
hasServerSideFilters?: boolean;
|
||||
}
|
||||
|
||||
export default function DataTableResetFilterButton<TData>({
|
||||
table,
|
||||
onClick,
|
||||
hasServerSideFilters = undefined,
|
||||
}: Props<TData>) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const isDisabled =
|
||||
table.getState().columnFilters.length <= 0 &&
|
||||
table.getState().globalFilter === "";
|
||||
|
||||
return !isDisabled ? (
|
||||
<Tooltip delayDuration={1}>
|
||||
<TooltipTrigger
|
||||
asChild={true}
|
||||
onMouseOver={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
className={"h-[42px]"}
|
||||
variant={"secondary"}
|
||||
disabled={isDisabled}
|
||||
onClick={onClick}
|
||||
const hasClientSideFilters =
|
||||
table.getState().globalFilter !== "" ||
|
||||
table.getState().columnFilters.length > 0;
|
||||
|
||||
const showButton = hasServerSideFilters ?? hasClientSideFilters;
|
||||
|
||||
return (
|
||||
showButton && (
|
||||
<Tooltip delayDuration={1}>
|
||||
<TooltipTrigger
|
||||
asChild={true}
|
||||
onMouseOver={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<FilterX size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<Button
|
||||
className={"h-[42px]"}
|
||||
variant={"secondary"}
|
||||
onClick={onClick}
|
||||
>
|
||||
<FilterX size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent
|
||||
sideOffset={10}
|
||||
className={"px-3 py-2"}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (hovered) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<span className={"text-xs text-neutral-300"}>
|
||||
Reset Filters & Search
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null;
|
||||
<TooltipContent
|
||||
sideOffset={10}
|
||||
className={"px-3 py-2"}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (hovered) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<span className={"text-xs text-neutral-300"}>
|
||||
Reset Filters & Search
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
87
src/components/ui/AbsoluteDateTimeInput.tsx
Normal file
87
src/components/ui/AbsoluteDateTimeInput.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useEffect } from "react";
|
||||
import { DateRange } from "react-day-picker";
|
||||
import { useTimescape } from "timescape/react";
|
||||
|
||||
type Props = {
|
||||
value?: DateRange;
|
||||
onChange?: (range: DateRange | undefined) => void;
|
||||
};
|
||||
export const AbsoluteDateTimeInput = ({ value, onChange }: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"px-4 py-4 flex flex-wrap gap-2 sm:max-w-none border-t border-nb-gray-800"
|
||||
}
|
||||
>
|
||||
<div className={"flex items-center gap-2 w-full justify-between"}>
|
||||
<div className={"text-sm flex flex-col gap-1 text-nb-gray-300"}>
|
||||
<Time
|
||||
value={value?.from}
|
||||
onChange={(e) => {
|
||||
if (e?.getTime() === value?.from?.getTime()) return;
|
||||
onChange?.({ from: e, to: value?.to });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className={"text-nb-gray-300"}>-</span>
|
||||
<div className={"text-sm flex flex-col gap-1 text-nb-gray-300"}>
|
||||
<Time
|
||||
value={value?.to}
|
||||
onChange={(e) => {
|
||||
if (e?.getTime() === value?.to?.getTime()) return;
|
||||
onChange?.({ from: value?.from, to: e });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Time = ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value?: Date;
|
||||
onChange?: (date?: Date) => void;
|
||||
}) => {
|
||||
const { getRootProps, getInputProps, options, update } = useTimescape({
|
||||
date: value,
|
||||
minDate: undefined,
|
||||
maxDate: undefined,
|
||||
hour12: true,
|
||||
digits: "2-digit",
|
||||
wrapAround: false,
|
||||
snapToStep: false,
|
||||
wheelControl: true,
|
||||
disallowPartial: false,
|
||||
onChangeDate: onChange,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (options.date?.getTime() !== value?.getTime()) {
|
||||
update({ ...options, date: value });
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className={"timescape w-full"} {...getRootProps()}>
|
||||
<div>
|
||||
<input {...getInputProps("years")} />
|
||||
<span className={"separator"}>/</span>
|
||||
<input {...getInputProps("months")} />
|
||||
<span className={"separator"}>/</span>
|
||||
<input {...getInputProps("days")} />
|
||||
</div>
|
||||
<span className={"separator px-1"}>⋆</span>
|
||||
<div>
|
||||
<input {...getInputProps("hours")} />
|
||||
<span className={"separator"}>:</span>
|
||||
<input {...getInputProps("minutes")} />
|
||||
<span className={"separator"}>:</span>
|
||||
<input {...getInputProps("seconds")} />
|
||||
<input {...getInputProps("am/pm")} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import Button from "@components/Button";
|
||||
import { Modal, ModalTrigger } from "@components/modal/Modal";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { PlusCircle } from "lucide-react";
|
||||
import { memo, useState } from "react";
|
||||
import React, { memo, useState } from "react";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
|
||||
@@ -12,28 +12,41 @@ function AddPeerButton() {
|
||||
const { data: peers } = useFetchApi<Peer[]>("/peers");
|
||||
const { oidcUser: user } = useOidcUser();
|
||||
|
||||
const [hasOnboardingFormCompleted] = useLocalStorage(
|
||||
"netbird-onboarding-modal",
|
||||
false,
|
||||
);
|
||||
|
||||
const [isFirstRun, setIsFirstRun] = useLocalStorage<boolean>(
|
||||
"netbird-first-run",
|
||||
!(peers && peers.length > 0),
|
||||
);
|
||||
|
||||
const [setupModal, setSetupModal] = useState(isFirstRun);
|
||||
const [installModal, setInstallModal] = useState(
|
||||
!hasOnboardingFormCompleted
|
||||
? process.env.APP_ENV !== "test"
|
||||
? false
|
||||
: isFirstRun
|
||||
: isFirstRun,
|
||||
);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setSetupModal(open);
|
||||
setInstallModal(open);
|
||||
setIsFirstRun(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={setupModal} onOpenChange={handleOpenChange}>
|
||||
<ModalTrigger asChild>
|
||||
<Button variant={"primary"} size={"sm"} className={"ml-auto"}>
|
||||
<PlusCircle size={16} />
|
||||
Add Peer
|
||||
</Button>
|
||||
</ModalTrigger>
|
||||
<SetupModal user={user} />
|
||||
</Modal>
|
||||
<>
|
||||
<Modal open={installModal} onOpenChange={handleOpenChange}>
|
||||
<ModalTrigger asChild>
|
||||
<Button variant={"primary"} size={"sm"} className={"ml-auto"}>
|
||||
<PlusCircle size={16} />
|
||||
Add Peer
|
||||
</Button>
|
||||
</ModalTrigger>
|
||||
<SetupModal user={user} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Card from "@components/Card";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { cn } from "@utils/helpers";
|
||||
import React from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
|
||||
@@ -51,7 +52,9 @@ export default function GetStartedTest({
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
<Paragraph className={"justify-center my-3"}>
|
||||
<Paragraph
|
||||
className={cn("justify-center mt-3", button && "mb-3")}
|
||||
>
|
||||
{description}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
|
||||
export const GradientFadedBackground = () => {
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const GradientFadedBackground = ({ className }: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"h-full w-full absolute left-0 top-0 rounded-md overflow-hidden z-0"
|
||||
}
|
||||
className={cn(
|
||||
"h-full w-full absolute left-0 top-0 rounded-md overflow-hidden z-0 pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
|
||||
@@ -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}
|
||||
|
||||
32
src/components/ui/GroupBadgeIcon.tsx
Normal file
32
src/components/ui/GroupBadgeIcon.tsx
Normal 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"} />;
|
||||
};
|
||||
@@ -13,6 +13,10 @@ type Props = {
|
||||
onRemove: () => void;
|
||||
onError?: (error: boolean) => void;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
showRemoveButton?: boolean;
|
||||
preventLeadingAndTrailingDots?: boolean;
|
||||
allowWildcard?: boolean;
|
||||
};
|
||||
enum ActionType {
|
||||
ADD = "ADD",
|
||||
@@ -38,6 +42,10 @@ export default function InputDomain({
|
||||
onChange,
|
||||
onRemove,
|
||||
onError,
|
||||
disabled,
|
||||
preventLeadingAndTrailingDots,
|
||||
allowWildcard = true,
|
||||
showRemoveButton = true,
|
||||
}: Readonly<Props>) {
|
||||
const [name, setName] = useState(value?.name || "");
|
||||
|
||||
@@ -50,7 +58,11 @@ export default function InputDomain({
|
||||
if (name == "") {
|
||||
return "";
|
||||
}
|
||||
const valid = validator.isValidDomain(name);
|
||||
const valid = validator.isValidDomain(name, {
|
||||
allowOnlyTld: true,
|
||||
allowWildcard,
|
||||
preventLeadingAndTrailingDots,
|
||||
});
|
||||
if (!valid) {
|
||||
return "Please enter a valid domain, e.g. example.com or intra.example.com";
|
||||
}
|
||||
@@ -74,16 +86,20 @@ export default function InputDomain({
|
||||
value={name}
|
||||
error={domainError}
|
||||
onChange={handleNameChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className={"h-[42px]"}
|
||||
variant={"default-outline"}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<MinusCircleIcon size={15} />
|
||||
</Button>
|
||||
{showRemoveButton && (
|
||||
<Button
|
||||
className={"h-[42px]"}
|
||||
variant={"default-outline"}
|
||||
onClick={onRemove}
|
||||
disabled={disabled}
|
||||
>
|
||||
<MinusCircleIcon size={15} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,9 +8,10 @@ import {
|
||||
} from "@components/Tooltip";
|
||||
import GroupBadge from "@components/ui/GroupBadge";
|
||||
import PeerBadge from "@components/ui/PeerBadge";
|
||||
import { orderBy } from "lodash";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ArrowRightIcon, PencilLineIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
|
||||
@@ -18,29 +19,60 @@ type Props = {
|
||||
groups: Group[];
|
||||
label?: string;
|
||||
description?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function MultipleGroups({
|
||||
groups,
|
||||
label = "Assigned Groups",
|
||||
description = "Use groups to control what this peer can access",
|
||||
}: Props) {
|
||||
if (!groups) return <EmptyRow />;
|
||||
const orderedGroups = orderBy(groups, ["peers_count", "name"], ["desc"]);
|
||||
onClick,
|
||||
className,
|
||||
}: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
if (!groups || groups?.length === 0) return <EmptyRow />;
|
||||
const orderedGroups = groups.sort((a, b) => {
|
||||
if (a.name === "All") return 1;
|
||||
if (b.name === "All") return -1;
|
||||
const aPeerCount = a.peers_count ?? 0;
|
||||
const bPeerCount = b.peers_count ?? 0;
|
||||
if (aPeerCount !== bPeerCount) return bPeerCount - aPeerCount;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
const firstGroup = orderedGroups.length > 0 ? orderedGroups[0] : undefined;
|
||||
const otherGroups = orderedGroups.length > 0 ? orderedGroups.slice(1) : [];
|
||||
|
||||
return (
|
||||
<TooltipProvider disableHoverableContent={false}>
|
||||
<Tooltip delayDuration={1}>
|
||||
<TooltipProvider
|
||||
disableHoverableContent={false}
|
||||
delayDuration={200}
|
||||
skipDelayDuration={200}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<div className={"inline-flex items-center gap-2 z-0"}>
|
||||
{firstGroup && <GroupBadge group={firstGroup} />}
|
||||
<div
|
||||
className={cn("inline-flex items-center gap-2 z-0", className)}
|
||||
data-cy={"multiple-groups"}
|
||||
onClick={onClick}
|
||||
>
|
||||
{firstGroup && (
|
||||
<GroupBadge
|
||||
group={firstGroup}
|
||||
className={
|
||||
permission.groups.update ? "group-hover:bg-nb-gray-800" : ""
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{otherGroups && otherGroups.length > 0 && (
|
||||
<Badge
|
||||
variant={"gray-ghost"}
|
||||
useHover={true}
|
||||
className={"px-3 gap-2 whitespace-nowrap"}
|
||||
className={cn(
|
||||
"px-3 gap-2 whitespace-nowrap",
|
||||
permission.groups.update ? "group-hover:bg-nb-gray-800" : "",
|
||||
)}
|
||||
>
|
||||
+ {otherGroups.length}
|
||||
</Badge>
|
||||
@@ -48,7 +80,10 @@ export default function MultipleGroups({
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{orderedGroups && orderedGroups.length > 0 && (
|
||||
<TooltipContent className={"p-0"}>
|
||||
<TooltipContent
|
||||
className={"p-0"}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={"text-sm font-medium text-left px-5 pt-3"}>
|
||||
{label}
|
||||
</div>
|
||||
@@ -82,3 +117,15 @@ export default function MultipleGroups({
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const TransparentEditIconButton = () => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"h-[34px] w-[34px] !p-0 opacity-0 group-hover:opacity-100 flex items-center justify-center text-nb-gray-400 hover:text-nb-gray-100"
|
||||
}
|
||||
>
|
||||
<PencilLineIcon size={16} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
16
src/components/ui/NewBadge.tsx
Normal file
16
src/components/ui/NewBadge.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,9 @@
|
||||
import Button from "@components/Button";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { FilterX } from "lucide-react";
|
||||
import React from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useCallback } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
|
||||
type Props = {
|
||||
@@ -10,6 +12,8 @@ type Props = {
|
||||
description?: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
hasFiltersApplied?: boolean;
|
||||
onResetFilters?: () => void;
|
||||
};
|
||||
export default function NoResults({
|
||||
icon,
|
||||
@@ -17,7 +21,32 @@ export default function NoResults({
|
||||
description = "We couldn't find any results. Please try a different search term or change your filters.",
|
||||
children,
|
||||
className,
|
||||
hasFiltersApplied = false,
|
||||
onResetFilters,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const handleResetClick = useCallback(() => {
|
||||
if (onResetFilters) {
|
||||
onResetFilters();
|
||||
|
||||
const params = new URLSearchParams();
|
||||
|
||||
const page_size = searchParams.get("page_size");
|
||||
|
||||
params.set("page", "1");
|
||||
|
||||
if (page_size) {
|
||||
params.set("page_size", page_size);
|
||||
}
|
||||
|
||||
const newUrl = `${pathname}?${params.toString()}`;
|
||||
router.push(newUrl);
|
||||
}
|
||||
}, [onResetFilters, router, pathname, searchParams]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative overflow-hidden", className)}>
|
||||
<div
|
||||
@@ -49,6 +78,16 @@ export default function NoResults({
|
||||
<Paragraph className={"justify-center my-2 !text-nb-gray-400"}>
|
||||
{description}
|
||||
</Paragraph>
|
||||
{hasFiltersApplied && onResetFilters && (
|
||||
<Button
|
||||
onClick={handleResetClick}
|
||||
variant="secondary"
|
||||
className="mt-4"
|
||||
>
|
||||
<FilterX size={16} />
|
||||
Reset Filters & Search
|
||||
</Button>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
93
src/components/ui/PageNotFound.tsx
Normal file
93
src/components/ui/PageNotFound.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import Button from "@components/Button";
|
||||
import Card from "@components/Card";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SquareIcon from "@components/SquareIcon";
|
||||
import { CircleAlertIcon, Undo2Icon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
type Props = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
export const PageNotFound = ({
|
||||
title = "The requested page was not found",
|
||||
description = "The page you are attempting to access cannot be found. Please verify the URL or return to the dashboard to continue browsing.",
|
||||
}: Props) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"px-8"}>
|
||||
<div
|
||||
className={
|
||||
"absolute left-0 top-0 h-full w-full p-10 flex items-center justify-center mx-auto backdrop-blur-sm"
|
||||
}
|
||||
>
|
||||
<Card className={"relative overflow-hidden max-w-4xl"}>
|
||||
<div
|
||||
className={
|
||||
"absolute z-20 bg-gradient-to-b dark:to-nb-gray-950 dark:from-nb-gray-950/40 w-full h-full"
|
||||
}
|
||||
></div>
|
||||
<div
|
||||
className={
|
||||
"absolute w-full h-full left-0 top-0 z-10 px-5 py-3 overflow-hidden"
|
||||
}
|
||||
>
|
||||
<div className={"flex flex-col gap-2"}>
|
||||
<Skeleton className={"w-full"} height={70} duration={4} />
|
||||
<Skeleton className={"w-full"} height={70} duration={4} />
|
||||
<Skeleton className={"w-full"} height={70} duration={4} />
|
||||
<Skeleton className={"w-full"} height={70} duration={4} />
|
||||
<Skeleton className={"w-full"} height={70} duration={4} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={"w-full h-full z-20 relative left-0 top-0 flex py-8"}
|
||||
>
|
||||
<div className={"inline-flex justify-center w-full"}>
|
||||
<div>
|
||||
<div className={"max-w-2xl relative z-50"}>
|
||||
<div className={"text-center flex flex-col gap-2 p-8"}>
|
||||
<div className={"mx-auto"}>
|
||||
{" "}
|
||||
<SquareIcon
|
||||
icon={<CircleAlertIcon size={20} />}
|
||||
color={"netbird"}
|
||||
size={"large"}
|
||||
/>
|
||||
</div>
|
||||
<div className={"text-center"}>
|
||||
<h1
|
||||
className={
|
||||
"text-3xl font-medium mx-auto mt-3 capitalize"
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
<Paragraph className={"justify-center my-3 max-w-xl"}>
|
||||
{description}
|
||||
</Paragraph>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
className={"mt-3"}
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<Undo2Icon size={15} className={"shrink-0"} />
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,15 @@
|
||||
import Badge from "@components/Badge";
|
||||
import { cn } from "@utils/helpers";
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import LongArrowLeftIcon from "@/assets/icons/LongArrowLeftIcon";
|
||||
import { PolicyRuleResource } from "@/interfaces/Policy";
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean;
|
||||
value: Direction;
|
||||
onChange: (value: Direction) => void;
|
||||
className?: string;
|
||||
destinationResource?: PolicyRuleResource;
|
||||
};
|
||||
|
||||
export type Direction = "bi" | "in" | "out";
|
||||
@@ -17,31 +19,8 @@ export default function PolicyDirection({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: Props) {
|
||||
const toggleIn = () => {
|
||||
if (value == "in") {
|
||||
onChange("out");
|
||||
return;
|
||||
}
|
||||
if (value == "bi") {
|
||||
onChange("out");
|
||||
} else {
|
||||
onChange("bi");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleOut = () => {
|
||||
if (value == "out") {
|
||||
onChange("in");
|
||||
return;
|
||||
}
|
||||
if (value == "bi") {
|
||||
onChange("in");
|
||||
} else {
|
||||
onChange("bi");
|
||||
}
|
||||
};
|
||||
|
||||
destinationResource,
|
||||
}: Readonly<Props>) {
|
||||
const toggleDirection = () => {
|
||||
if (value == "bi") {
|
||||
onChange("in");
|
||||
@@ -55,8 +34,34 @@ export default function PolicyDirection({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [disabled]);
|
||||
|
||||
const topBadgeClass = useMemo(() => {
|
||||
if (destinationResource) return "blueDark";
|
||||
if (value === "bi") return "green";
|
||||
if (value === "in") return "blueDark";
|
||||
return "gray";
|
||||
}, [value, destinationResource]);
|
||||
|
||||
const topArrowClass = useMemo(() => {
|
||||
if (destinationResource) return "fill-sky-500";
|
||||
if (value === "bi") return "fill-green-500";
|
||||
if (value === "in") return "fill-sky-500";
|
||||
return "fill-gray-500";
|
||||
}, [value, destinationResource]);
|
||||
|
||||
const bottomBadgeClass = useMemo(() => {
|
||||
if (destinationResource) return "gray";
|
||||
if (value === "bi") return "green";
|
||||
return "gray";
|
||||
}, [value, destinationResource]);
|
||||
|
||||
const bottomArrowClass = useMemo(() => {
|
||||
if (destinationResource) return "fill-gray-500";
|
||||
if (value === "bi") return "fill-green-500";
|
||||
return "fill-gray-500";
|
||||
}, [value, destinationResource]);
|
||||
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
className={cn(
|
||||
"flex flex-col gap-2 mt-[23px] cursor-pointer select-none",
|
||||
disabled && "opacity-50 pointer-events-none",
|
||||
@@ -66,39 +71,20 @@ export default function PolicyDirection({
|
||||
onClick={toggleDirection}
|
||||
data-cy={"policy-direction"}
|
||||
>
|
||||
<Badge
|
||||
variant={value == "bi" ? "green" : value == "in" ? "blueDark" : "gray"}
|
||||
className={"px-4 py-1"}
|
||||
>
|
||||
<Badge variant={topBadgeClass} className={"px-4 py-1"}>
|
||||
<LongArrowLeftIcon
|
||||
size={40}
|
||||
autoHeight={true}
|
||||
className={cn(
|
||||
value == "bi"
|
||||
? "fill-green-500"
|
||||
: value == "in"
|
||||
? "fill-sky-500"
|
||||
: "fill-gray-500",
|
||||
"rotate-180",
|
||||
)}
|
||||
className={cn(topArrowClass, "rotate-180")}
|
||||
/>
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={value == "bi" ? "green" : value == "out" ? "blueDark" : "gray"}
|
||||
className={"px-4 py-1"}
|
||||
>
|
||||
<Badge variant={bottomBadgeClass} className={"px-4 py-1"}>
|
||||
<LongArrowLeftIcon
|
||||
size={40}
|
||||
autoHeight={true}
|
||||
className={cn(
|
||||
value == "bi"
|
||||
? "fill-green-500"
|
||||
: value == "out"
|
||||
? "fill-sky-500"
|
||||
: "fill-gray-500",
|
||||
)}
|
||||
className={cn(bottomArrowClass)}
|
||||
/>
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
58
src/components/ui/ResourceBadge.tsx
Normal file
58
src/components/ui/ResourceBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
43
src/components/ui/SmallBadge.tsx
Normal file
43
src/components/ui/SmallBadge.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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`,
|
||||
}}
|
||||
|
||||
92
src/components/ui/TruncatedText.tsx
Normal file
92
src/components/ui/TruncatedText.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,30 @@
|
||||
import { cn, generateColorFromString } from "@utils/helpers";
|
||||
import { cn, generateColorFromUser } from "@utils/helpers";
|
||||
import { Avatar } from "flowbite-react";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { useApplicationContext } from "@/contexts/ApplicationProvider";
|
||||
|
||||
type Props = {
|
||||
size?: "default" | "small" | "large";
|
||||
size?: "default" | "small" | "large" | "medium";
|
||||
};
|
||||
export const UserAvatar = ({ size = "default" }: Props) => {
|
||||
const { user } = useApplicationContext();
|
||||
|
||||
const [pictureLoaded, setPictureLoaded] = useState(true);
|
||||
|
||||
const getAvatarSize = () => {
|
||||
if (size === "small") return "sm";
|
||||
if (size === "large") return "lg";
|
||||
return "md";
|
||||
};
|
||||
|
||||
return pictureLoaded ? (
|
||||
<Avatar
|
||||
alt=""
|
||||
img={user?.picture}
|
||||
rounded
|
||||
onError={() => setPictureLoaded(false)}
|
||||
size={size == "small" ? "sm" : size == "large" ? "lg" : "md"}
|
||||
size={getAvatarSize()}
|
||||
className={"shrink-0"}
|
||||
/>
|
||||
) : (
|
||||
@@ -26,13 +32,12 @@ export const UserAvatar = ({ size = "default" }: Props) => {
|
||||
className={cn(
|
||||
"rounded-full flex items-center justify-center bg-nb-gray-900 text-netbird uppercase",
|
||||
size == "small" && "w-8 h-8",
|
||||
size == "medium" && "w-[2.3rem] h-[2.3rem]",
|
||||
size == "default" && "w-10 h-10",
|
||||
size == "large" && "w-12 h-12",
|
||||
)}
|
||||
style={{
|
||||
color: user?.name
|
||||
? generateColorFromString(user?.name || user?.id || "System User")
|
||||
: "#808080",
|
||||
color: generateColorFromUser(user),
|
||||
}}
|
||||
>
|
||||
{user?.name?.charAt(0) || user?.id?.charAt(0)}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useOidc } from "@axa-fr/react-oidc";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -17,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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user