Compare commits

..

20 Commits

Author SHA1 Message Date
Eduard Gert
6d4716cdad Remove integrations from public repo and sync changes (#369)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Change icon size

* Remove integrations

* Add no cache header

* Add analytics event tracking

* Add small announcement improvements

* Remove peer approval setting

* Do not load countries when user has no permission

* Add tab query params to settings

* Decrease navigation font size

* Change order of providers

* Increase padding for modals

* Show page only when user is fully loaded and found

* Remove unused state

* Remove integrations page
2024-04-02 14:06:38 +02:00
amplitudes
859916b1df fix: user deletion notification (#367) 2024-04-02 12:26:45 +02:00
Eduard Gert
80ce7d21b0 Fix issue where the first users cache is not populated (#366) 2024-03-28 11:27:00 +01:00
Eduard Gert
06fdbd8ec4 Hide profile settings and announcements for blocked dashboard view (#365) 2024-03-28 10:25:21 +01:00
Eduard Gert
973cceff79 Add setting to change dashboard view for regular users (#362) 2024-03-27 16:09:58 +01:00
Eduard Gert
f4a2d6fae8 Add Okta SCIM integration (#361)
* Add Okta integration (wip)

* Update okta setup dialog

* Add okta integration images

* Add error handling for 500 status codes

* Add okta integration

* Fix lint warnings

* Update azures last sync time

* Remove 'on' from step, disable copy for HTTP Header

* Update text for custom IDP
2024-03-27 15:55:56 +01:00
Eduard Gert
cb922b46b7 Add 'Offline' filter to peers table (#364) 2024-03-26 20:03:24 +01:00
Eduard Gert
4c56ae704c Show peers for regular users but hide / disable actions (delete, enable ssh etc.) (#360)
* Show peers for regular users but hide / disable actions (delete, enable ssh etc.)

* Do not load countries for regular users
2024-03-21 14:21:26 +01:00
Eduard Gert
fe6d8c9bd5 Add support for decimal expiration time and switch to days if interval exceeds 48h (#357)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Add helper function to check for integer

* Add support for decimal expiration time and switch to days if interval exceeds 48h
2024-03-15 15:54:06 +01:00
Eduard Gert
121976d101 Add option to copy peer details (ip, public ip, hostname, domain name) in detailed peer view (#356) 2024-03-15 13:46:27 +01:00
Eduard Gert
f7071e00b6 Add reset filter button (#355) 2024-03-15 13:43:00 +01:00
Eduard Gert
6b73ccf102 Fix search resetting when selecting a group (#354) 2024-03-15 13:35:25 +01:00
Eduard Gert
87dcd00264 Fix peer groups occasionally not refreshing (#351)
* Trigger groups refresh when visiting peers page

* Disable exhaustive-deps linter

---------

Co-authored-by: Maycon Santos <mlsmaycon@gmail.com>
2024-03-15 13:34:47 +01:00
Eduard Gert
99f1bcc375 Reduce information visible to regular users (non-adminstrators) (#353)
reducing visibility to display only add peer information
2024-03-15 13:25:40 +01:00
Eduard Gert
bf34c55110 Fix JWT group sync checkbox using wrong variable (#352) 2024-03-12 17:23:42 +01:00
Eduard Gert
1dfc6e2d75 Add announcement banner to show updates or important information (#350)
* Add contrast color

* Add crypto-js for md5 hash

* Add announcement banner
2024-03-11 15:31:52 +01:00
Eduard Gert
b7860a8786 Filter peers by id instead of name in peer dropdown selector (#347) 2024-03-09 18:07:45 +01:00
Eduard Gert
c9172e3a5f Show full netbird logo on desktop and netbird logomark on mobile (#348) 2024-03-09 18:07:26 +01:00
Eduard Gert
78d75134f9 Add better description for posture check activity events (#349) 2024-03-09 17:14:41 +01:00
Eduard Gert
071feb02f9 Fix SSO expiration dropdown to reflect the actual "Hours" or "Days" (#345) 2024-03-01 17:01:26 +01:00
82 changed files with 1356 additions and 3618 deletions

View File

@@ -1,4 +1,3 @@
# simple server configuration to replace nginx's default
server {
listen 80 default_server;
listen [::]:80 default_server;
@@ -7,10 +6,14 @@ server {
location / {
try_files $uri $uri.html $uri/ =404;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
expires off;
}
error_page 404 /404.html;
location = /404.html {
internal;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
expires off;
}
}

12
package-lock.json generated
View File

@@ -27,6 +27,7 @@
"@tabler/icons-react": "^2.39.0",
"@tanstack/match-sorter-utils": "^8.8.4",
"@tanstack/react-table": "^8.10.7",
"@types/crypto-js": "^4.2.2",
"@types/lodash": "^4.14.200",
"@types/node": "20.10.6",
"@types/react": "^18",
@@ -35,6 +36,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"crypto-js": "^4.2.0",
"date-fns": "^2.30.0",
"dayjs": "^1.11.10",
"eslint": "^8",
@@ -1661,6 +1663,11 @@
"resolved": "https://registry.npmjs.org/@types/base64-js/-/base64-js-1.3.2.tgz",
"integrity": "sha512-Q2Xn2/vQHRGLRXhQ5+BSLwhHkR3JVflxVKywH0Q6fVoAiUE8fFYL2pE5/l2ZiOiBDfA8qUqRnSxln4G/NFz1Sg=="
},
"node_modules/@types/crypto-js": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz",
"integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ=="
},
"node_modules/@types/jquery": {
"version": "3.5.29",
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.29.tgz",
@@ -2974,6 +2981,11 @@
"node": ">= 8"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
},
"node_modules/css-mediaquery": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz",

View File

@@ -32,6 +32,7 @@
"@tabler/icons-react": "^2.39.0",
"@tanstack/match-sorter-utils": "^8.8.4",
"@tanstack/react-table": "^8.10.7",
"@types/crypto-js": "^4.2.2",
"@types/lodash": "^4.14.200",
"@types/node": "20.10.6",
"@types/react": "^18",
@@ -40,6 +41,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"crypto-js": "^4.2.0",
"date-fns": "^2.30.0",
"dayjs": "^1.11.10",
"eslint": "^8",

View File

@@ -5,14 +5,12 @@ import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import useFetchApi from "@utils/api";
import { isLocalDev, isNetBirdHosted } from "@utils/netbird";
import { ExternalLinkIcon } from "lucide-react";
import React from "react";
import ActivityIcon from "@/assets/icons/ActivityIcon";
import { ActivityEvent } from "@/interfaces/ActivityEvent";
import PageContainer from "@/layouts/PageContainer";
import ActivityTable from "@/modules/activity/ActivityTable";
import { EventStreamingCard } from "@/modules/integrations/event-streaming/EventStreamingCard";
export default function Activity() {
const { data: events, isLoading } = useFetchApi<ActivityEvent[]>("/events");
@@ -50,7 +48,6 @@ export default function Activity() {
</Paragraph>
</div>
<RestrictedAccess page={"Activity"}>
{(isLocalDev() || isNetBirdHosted()) && <EventStreamingCard />}
<ActivityTable events={events} isLoading={isLoading} />
</RestrictedAccess>
</PageContainer>

View File

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

View File

@@ -1,39 +0,0 @@
"use client";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { VerticalTabs } from "@components/VerticalTabs";
import { FileText, FingerprintIcon } from "lucide-react";
import { useSearchParams } from "next/navigation";
import React, { useState } from "react";
import PageContainer from "@/layouts/PageContainer";
import EventStreamingTab from "@/modules/integrations/event-streaming/EventStreamingTab";
import IdentityProviderTab from "@/modules/integrations/idp-sync/IdentityProviderTab";
export default function Integrations() {
const searchParams = useSearchParams();
const currentTab = searchParams.get("tab");
const [tab, setTab] = useState(currentTab || "event-streaming");
return (
<PageContainer>
<VerticalTabs value={tab} onChange={setTab}>
<VerticalTabs.List>
<VerticalTabs.Trigger value="event-streaming">
<FileText size={14} />
Event Streaming
</VerticalTabs.Trigger>
<VerticalTabs.Trigger value="identity-provider">
<FingerprintIcon size={14} />
Identity Provider
</VerticalTabs.Trigger>
</VerticalTabs.List>
<RestrictedAccess page={"Integrations"}>
<div className={"border-l border-nb-gray-930 w-full"}>
<EventStreamingTab />
<IdentityProviderTab />
</div>
</RestrictedAccess>
</VerticalTabs>
</PageContainer>
);
}

View File

@@ -32,6 +32,7 @@ import {
FlagIcon,
Globe,
History,
LockIcon,
MapPin,
MonitorSmartphoneIcon,
NetworkIcon,
@@ -50,6 +51,7 @@ import PeerIcon from "@/assets/icons/PeerIcon";
import { useCountries } from "@/contexts/CountryProvider";
import PeerProvider, { usePeer } from "@/contexts/PeerProvider";
import RoutesProvider from "@/contexts/RoutesProvider";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { useHasChanges } from "@/hooks/useHasChanges";
import { getOperatingSystem } from "@/hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
@@ -124,6 +126,8 @@ function PeerOverview() {
});
};
const { isUser } = useLoggedInUser();
return (
<PageContainer>
<RoutesProvider>
@@ -148,29 +152,31 @@ function PeerOverview() {
/>
<TextWithTooltip text={name} maxChars={30} />
<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>
{!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>
@@ -192,7 +198,7 @@ function PeerOverview() {
variant={"primary"}
className={"w-full"}
onClick={() => updatePeer()}
disabled={!hasChanges}
disabled={!hasChanges || isUser}
>
Save Changes
</Button>
@@ -210,18 +216,32 @@ function PeerOverview() {
"flex gap-2 items-center !text-nb-gray-300 text-xs"
}
>
<IconInfoCircle size={14} />
<span>
Login expiration is disabled for all peers added with an
setup-key.
</span>
{!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}
disabled={!!peer.user_id && !isUser}
>
<FancyToggleSwitch
disabled={!peer.user_id}
disabled={!peer.user_id || isUser}
value={loginExpiration}
onChange={setLoginExpiration}
label={
@@ -235,33 +255,74 @@ function PeerOverview() {
}
/>
</FullTooltip>
<FancyToggleSwitch
value={ssh}
onChange={(set) =>
!set
? setSsh(false)
: openSSHDialog().then((confirm) => setSsh(confirm))
<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>
}
label={
<>
<TerminalSquare size={16} />
SSH Access
</>
}
helpText={
"Enable the SSH server on this peer to access the machine via an secure shell."
}
/>
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>
<PeerGroupSelector
onChange={setSelectedGroups}
values={selectedGroups}
peer={peer}
/>
<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}
>
<PeerGroupSelector
disabled={isUser}
onChange={setSelectedGroups}
values={selectedGroups}
peer={peer}
/>
</FullTooltip>
</div>
</div>
</div>
@@ -269,7 +330,7 @@ function PeerOverview() {
<Separator />
{isLinux ? (
{isLinux && !isUser ? (
<div className={"px-8 py-6"}>
<div className={"max-w-6xl"}>
<div className={"flex justify-between items-center"}>
@@ -306,6 +367,8 @@ function PeerInformationCard({ peer }: { peer: Peer }) {
<Card>
<Card.List>
<Card.ListItem
copy
copyText={"NetBird IP-Address"}
label={
<>
<MapPin size={16} />
@@ -316,6 +379,8 @@ function PeerInformationCard({ peer }: { peer: Peer }) {
/>
<Card.ListItem
copy
copyText={"Public IP-Address"}
label={
<>
<NetworkIcon size={16} />
@@ -325,6 +390,30 @@ function PeerInformationCard({ peer }: { peer: Peer }) {
value={peer.connection_ip}
/>
<Card.ListItem
copy
copyText={"Domain name"}
label={
<>
<Globe size={16} />
Domain Name
</>
}
value={peer.dns_label}
/>
<Card.ListItem
copy
copyText={"Hostname"}
label={
<>
<MonitorSmartphoneIcon size={16} />
Hostname
</>
}
value={peer.hostname}
/>
<Card.ListItem
label={
<>
@@ -353,24 +442,6 @@ function PeerInformationCard({ peer }: { peer: Peer }) {
}
/>
<Card.ListItem
label={
<>
<Globe size={16} />
Domain Name
</>
}
value={peer.dns_label}
/>
<Card.ListItem
label={
<>
<MonitorSmartphoneIcon size={16} />
Hostname
</>
}
value={peer.hostname}
/>
<Card.ListItem
label={
<>

View File

@@ -6,17 +6,39 @@ import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import useFetchApi from "@utils/api";
import { ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense } from "react";
import React, { lazy, Suspense, useEffect } from "react";
import PeerIcon from "@/assets/icons/PeerIcon";
import { useUsers } from "@/contexts/UsersProvider";
import { useGroups } from "@/contexts/GroupsProvider";
import { useLoggedInUser, useUsers } from "@/contexts/UsersProvider";
import { Peer } from "@/interfaces/Peer";
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();
return (
<PageContainer>
{permission?.dashboard_view === "blocked" ? (
<PeersBlockedView />
) : (
<PeersView />
)}
</PageContainer>
);
}
function PeersView() {
const { data: peers, isLoading } = useFetchApi<Peer[]>("/peers");
const { users } = useUsers();
const { refresh } = useGroups();
useEffect(() => {
refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const peersWithUser = peers?.map((peer) => {
if (!users) return peer;
@@ -27,7 +49,7 @@ export default function Peers() {
});
return (
<PageContainer>
<>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
@@ -56,6 +78,37 @@ export default function Peers() {
<Suspense fallback={<SkeletonTable />}>
<PeersTable isLoading={isLoading} peers={peersWithUser} />
</Suspense>
</PageContainer>
</>
);
}
function PeersBlockedView() {
return (
<div className={"flex items-center justify-center flex-col"}>
<div className={"p-default py-6 max-w-3xl text-center"}>
<h1>Add new device to your network</h1>
<Paragraph className={"inline"}>
To get started, install NetBird and log in using your email account.
After that you should be connected. If you have further questions
check out our{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/getting-started#installation"}
target={"_blank"}
>
Installation Guide
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<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"
}
>
<SetupModalContent header={false} footer={false} />
</div>
</div>
</div>
);
}

View File

@@ -2,20 +2,35 @@
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { VerticalTabs } from "@components/VerticalTabs";
import { AlertOctagonIcon, FolderGit2Icon, ShieldIcon } from "lucide-react";
import React, { useState } from "react";
import {
AlertOctagonIcon,
FolderGit2Icon,
LockIcon,
ShieldIcon,
} from "lucide-react";
import { useSearchParams } from "next/navigation";
import React, { useEffect, useState } from "react";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import PageContainer from "@/layouts/PageContainer";
import { useAccount } from "@/modules/account/useAccount";
import AuthenticationTab from "@/modules/settings/AuthenticationTab";
import DangerZoneTab from "@/modules/settings/DangerZoneTab";
import GroupsTab from "@/modules/settings/GroupsTab";
import PermissionsTab from "@/modules/settings/PermissionsTab";
export default function NetBirdSettings() {
const [tab, setTab] = useState("authentication");
const queryParams = useSearchParams();
const queryTab = queryParams.get("tab");
const [tab, setTab] = useState(queryTab || "authentication");
const { isOwner } = useLoggedInUser();
const account = useAccount();
useEffect(() => {
if (queryTab) {
setTab(queryTab);
}
}, [queryTab]);
return (
<PageContainer>
<VerticalTabs value={tab} onChange={setTab}>
@@ -28,6 +43,10 @@ export default function NetBirdSettings() {
<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
@@ -36,6 +55,7 @@ export default function NetBirdSettings() {
<RestrictedAccess page={"Settings"}>
<div className={"border-l border-nb-gray-930 w-full"}>
{account && <AuthenticationTab account={account} />}
{account && <PermissionsTab account={account} />}
{account && <GroupsTab account={account} />}
{account && <DangerZoneTab account={account} />}
</div>

View File

@@ -57,7 +57,7 @@ function UserOverview({ user }: Props) {
const router = useRouter();
const userRequest = useApiCall<User>("/users");
const { mutate } = useSWRConfig();
const { loggedInUser, isOwnerOrAdmin } = useLoggedInUser();
const { loggedInUser, isOwnerOrAdmin, isUser } = useLoggedInUser();
const isLoggedInUser = loggedInUser ? loggedInUser?.id === user.id : false;
const initialGroups = user.auto_groups;
@@ -104,6 +104,7 @@ function UserOverview({ user }: Props) {
<Breadcrumbs.Item
href={"/team"}
label={"Team"}
disabled={isUser}
icon={<TeamIcon size={13} />}
/>
@@ -117,6 +118,7 @@ function UserOverview({ user }: Props) {
<Breadcrumbs.Item
href={"/team/users"}
label={"Users"}
disabled={isUser}
icon={<User2 size={16} />}
/>
)}
@@ -156,28 +158,30 @@ function UserOverview({ user }: Props) {
</h1>
</div>
</div>
<div className={"flex gap-4"}>
<Button
variant={"default"}
className={"w-full"}
onClick={() => {
user.is_service_user
? router.push("/team/service-users")
: router.push("/team/users");
}}
>
Cancel
</Button>
{!isUser && (
<div className={"flex gap-4"}>
<Button
variant={"default"}
className={"w-full"}
onClick={() => {
user.is_service_user
? router.push("/team/service-users")
: router.push("/team/users");
}}
>
Cancel
</Button>
<Button
variant={"primary"}
className={"w-full"}
disabled={!hasChanges}
onClick={save}
>
Save Changes
</Button>
</div>
<Button
variant={"primary"}
className={"w-full"}
disabled={!hasChanges}
onClick={save}
>
Save Changes
</Button>
</div>
)}
</div>
<div className={"flex gap-10 w-full mt-8 max-w-6xl"}>
@@ -190,6 +194,7 @@ function UserOverview({ user }: Props) {
Groups will be assigned to peers added by this user.
</HelpText>
<PeerGroupSelector
disabled={isUser}
onChange={setSelectedGroups}
values={selectedGroups}
/>

View File

@@ -64,4 +64,8 @@ p {
display: table;
position: relative;
width: 100%;
}
.stepper-bg-variant .step-circle {
@apply !border-[#1d2024];
}

View File

@@ -5,7 +5,7 @@ export type IconProps = {
};
export const defaultIconProps: IconProps = {
size: 16,
size: 15,
className:
"dark:fill-nb-gray-400 fill-gray-500 peer-data-[active=true]/icon:dark:fill-white peer-data-[active=true]/icon:fill-gray-900 shrink-0",
autoHeight: false,

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1,19 @@
<svg width="133" height="23" viewBox="0 0 133 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_0_3)">
<path d="M46.9438 7.5013C48.1229 8.64688 48.7082 10.3025 48.7082 12.4683V21.6663H46.1411V12.8362C46.1411 11.2809 45.7481 10.0851 44.9704 9.26566C44.1928 8.43783 43.1308 8.0281 41.7846 8.0281C40.4383 8.0281 39.3345 8.45455 38.5234 9.30747C37.7123 10.1604 37.3109 11.4063 37.3109 13.0369V21.6663H34.7188V6.06305H37.3109V8.28732C37.821 7.49294 38.5234 6.87416 39.4014 6.43934C40.2878 6.00452 41.2578 5.78711 42.3197 5.78711C44.2179 5.78711 45.7565 6.36408 46.9355 7.50966L46.9438 7.5013Z" fill="#F2F2F2"/>
<path d="M67.1048 14.8344H54.6288C54.7208 16.373 55.2476 17.5771 56.2092 18.4384C57.1708 19.2997 58.3331 19.7345 59.6961 19.7345C60.8166 19.7345 61.7531 19.4753 62.4973 18.9485C63.2499 18.4301 63.7767 17.7277 64.0777 16.858H66.8706C66.4525 18.3548 65.6163 19.5756 64.3621 20.5205C63.1078 21.4571 61.5525 21.9337 59.6878 21.9337C58.2077 21.9337 56.8865 21.5992 55.7159 20.9386C54.5452 20.278 53.6337 19.3331 52.9648 18.1039C52.2958 16.8831 51.9697 15.4616 51.9697 13.8477C51.9697 12.2339 52.2958 10.8207 52.9397 9.60825C53.5836 8.39578 54.495 7.45924 55.6573 6.80702C56.828 6.15479 58.1659 5.82031 59.6878 5.82031C61.2096 5.82031 62.4806 6.14643 63.6178 6.79029C64.7551 7.43416 65.6331 8.32052 66.2518 9.44938C66.8706 10.5782 67.18 11.8576 67.18 13.2791C67.18 13.7725 67.1549 14.2909 67.0964 14.8428L67.1048 14.8344ZM63.8603 10.1769C63.4255 9.4661 62.8318 8.92258 62.0793 8.55465C61.3267 8.18673 60.4989 8.00277 59.5874 8.00277C58.2746 8.00277 57.1625 8.42086 56.2427 9.25705C55.3228 10.0932 54.796 11.2472 54.6623 12.7356H64.5126C64.5126 11.7489 64.2952 10.896 63.8603 10.1852V10.1769Z" fill="#F2F2F2"/>
<path d="M73.7695 8.20355V17.4016C73.7695 18.1626 73.9284 18.6977 74.2545 19.0071C74.5806 19.3165 75.1409 19.4754 75.9352 19.4754H77.8418V21.6662H75.5088C74.0622 21.6662 72.9835 21.3317 72.2644 20.6711C71.5452 20.0105 71.1857 18.9151 71.1857 17.3933V8.19519H69.1621V6.0629H71.1857V2.13281H73.7779V6.0629H77.8501V8.19519H73.7779L73.7695 8.20355Z" fill="#F2F2F2"/>
<path d="M85.9022 6.68902C86.9307 6.10369 88.093 5.80266 89.4058 5.80266C90.8106 5.80266 92.0732 6.13714 93.1937 6.79773C94.3142 7.46668 95.2006 8.39485 95.8444 9.59896C96.4883 10.8031 96.8144 12.2079 96.8144 13.7966C96.8144 15.3854 96.4883 16.7818 95.8444 18.011C95.2006 19.2486 94.3142 20.2018 93.1854 20.8875C92.0565 21.5732 90.7939 21.916 89.4141 21.916C88.0344 21.916 86.8805 21.6234 85.8687 21.0297C84.8569 20.4443 84.0876 19.6918 83.5775 18.7803V21.6568H80.9854V0.601562H83.5775V8.97182C84.1127 8.04365 84.8904 7.28272 85.9105 6.69738L85.9022 6.68902ZM93.4529 10.7362C92.9763 9.86654 92.3408 9.19759 91.5297 8.74605C90.7186 8.29451 89.8322 8.06037 88.8706 8.06037C87.909 8.06037 87.0394 8.29451 86.2366 8.75441C85.4255 9.22268 84.7817 9.89163 84.2967 10.778C83.8117 11.6643 83.5692 12.6845 83.5692 13.8384C83.5692 14.9924 83.8117 16.046 84.2967 16.9323C84.7817 17.8187 85.4255 18.4877 86.2366 18.9559C87.0394 19.4242 87.9174 19.65 88.8706 19.65C89.8239 19.65 90.727 19.4158 91.5297 18.9559C92.3324 18.4877 92.9763 17.8187 93.4529 16.9323C93.9296 16.046 94.1637 15.0091 94.1637 13.8134C94.1637 12.6176 93.9296 11.6142 93.4529 10.7362Z" fill="#F2F2F2"/>
<path d="M100.318 3.01864C99.9749 2.67581 99.8076 2.25771 99.8076 1.76436C99.8076 1.27101 99.9749 0.852913 100.318 0.510076C100.661 0.167238 101.079 0 101.572 0C102.065 0 102.45 0.167238 102.784 0.510076C103.119 0.852913 103.286 1.27101 103.286 1.76436C103.286 2.25771 103.119 2.67581 102.784 3.01864C102.45 3.36148 102.049 3.52872 101.572 3.52872C101.095 3.52872 100.661 3.36148 100.318 3.01864ZM102.826 6.06237V21.6657H100.234V6.06237H102.826Z" fill="#F2F2F2"/>
<path d="M111.773 6.52155C112.617 6.0282 113.646 5.77734 114.867 5.77734V8.45315H114.181C111.28 8.45315 109.825 10.0252 109.825 13.1776V21.6649H107.232V6.06165H109.825V8.5953C110.276 7.70058 110.928 7.00654 111.773 6.51319V6.52155Z" fill="#F2F2F2"/>
<path d="M117.861 9.60732C118.505 8.40321 119.391 7.46668 120.52 6.80609C121.649 6.1455 122.92 5.81102 124.325 5.81102C125.537 5.81102 126.666 6.09533 127.711 6.64721C128.757 7.20746 129.551 7.94331 130.103 8.85475V0.601562H132.72V21.6735H130.103V18.7385C129.593 19.6667 128.832 20.436 127.828 21.0297C126.825 21.6317 125.646 21.9244 124.3 21.9244C122.953 21.9244 121.657 21.5816 120.528 20.8959C119.4 20.2102 118.513 19.257 117.869 18.0194C117.226 16.7818 116.899 15.377 116.899 13.805C116.899 12.233 117.226 10.8114 117.869 9.60732H117.861ZM129.392 10.7613C128.915 9.89163 128.28 9.22268 127.469 8.75441C126.658 8.28614 125.771 8.06037 124.81 8.06037C123.848 8.06037 122.962 8.28614 122.159 8.74605C121.356 9.20595 120.729 9.86654 120.253 10.7362C119.776 11.6058 119.542 12.6343 119.542 13.8134C119.542 14.9924 119.776 16.046 120.253 16.9323C120.729 17.8187 121.365 18.4877 122.159 18.9559C122.953 19.4242 123.84 19.65 124.81 19.65C125.78 19.65 126.666 19.4158 127.469 18.9559C128.272 18.4877 128.915 17.8187 129.392 16.9323C129.869 16.046 130.103 15.0175 130.103 13.8384C130.103 12.6594 129.869 11.6393 129.392 10.7613Z" fill="#F2F2F2"/>
<path d="M21.4651 0.568359C17.8193 0.902835 16.0047 3.00167 15.3191 4.06363L4.66602 22.5183H17.5182L30.1949 0.568359H21.4651Z" fill="#F68330"/>
<path d="M17.5265 22.5187L0 3.9302C0 3.9302 19.8177 -1.39633 21.7493 15.2188L17.5265 22.5187Z" fill="#F68330"/>
<path d="M14.9255 4.75055L9.54883 14.0657L17.5177 22.5196L21.7405 15.2029C21.0715 9.49174 18.287 6.37276 14.9255 4.74219" fill="#F35E32"/>
</g>
<defs>
<clipPath id="clip0_0_3">
<rect width="132.72" height="22.5186" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -15,13 +15,25 @@ type ItemProps = {
label: string;
icon?: React.ReactNode;
active?: boolean;
disabled?: boolean;
};
export const Item = ({ href, label, icon, active }: ItemProps) => {
export const Item = ({
href,
label,
icon,
active,
disabled = false,
}: ItemProps) => {
const router = useRouter();
return (
<div className={"flex items-center gap-2 group"}>
<div
className={cn(
"flex items-center gap-2 group",
disabled && "pointer-events-none",
)}
>
<ChevronRightIcon
size={16}
className={"text-nb-gray-400 group-first:hidden"}

View File

@@ -30,6 +30,7 @@ type CardListItemProps = {
value: React.ReactNode;
className?: string;
copy?: boolean;
copyText?: string;
tooltip?: boolean;
};
@@ -38,6 +39,7 @@ function CardListItem({
value,
className,
copy = false,
copyText,
tooltip = true,
}: CardListItemProps) {
const [, copyToClipBoard] = useCopyToClipboard(value as string);
@@ -56,7 +58,10 @@ function CardListItem({
copy && "cursor-pointer hover:text-nb-gray-300 transition-all",
)}
onClick={() =>
copy && copyToClipBoard(`${label} has been copied to clipboard.`)
copy &&
copyToClipBoard(
`${copyText ? copyText : label} has been copied to clipboard.`,
)
}
>
{tooltip ? (
@@ -64,7 +69,7 @@ function CardListItem({
) : (
value
)}
{copy && <Copy size={13} />}
{copy && <Copy size={13} className={"shrink-0"} />}
</div>
</li>
);

View File

@@ -86,8 +86,6 @@ export function PeerGroupSelector({
}
if (max == 1) setOpen(false);
setSearch("");
};
// Remove group from the groupOptions if it does not have an id
@@ -145,6 +143,7 @@ export function PeerGroupSelector({
"min-h-[46px] 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",
"disabled:pointer-events-none disabled:opacity-30",
)}
disabled={disabled}
ref={inputRef}

View File

@@ -1,7 +1,6 @@
import { CommandItem } from "@components/Command";
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
import { ScrollArea } from "@components/ScrollArea";
import SmallOSIcon from "@components/ui/SmallOSIcon";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import { IconArrowBack } from "@tabler/icons-react";
import useFetchApi from "@utils/api";
@@ -11,6 +10,7 @@ import { sortBy, trim, unionBy } from "lodash";
import { ChevronsUpDown, MapPin, SearchIcon } from "lucide-react";
import * as React from "react";
import { useEffect, useMemo, useState } from "react";
import { FcLinux } from "react-icons/fc";
import { useElementSize } from "@/hooks/useElementSize";
import { getOperatingSystem } from "@/hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
@@ -45,7 +45,7 @@ export function PeerSelector({
// Filter out peers that are not linux
options = options.filter((peer) => {
return getOperatingSystem(peer.os) == OperatingSystem.LINUX;
return getOperatingSystem(peer.os) === OperatingSystem.LINUX;
});
// Filter out excluded peers
@@ -56,7 +56,7 @@ export function PeerSelector({
});
}
setDropdownOptions(unionBy(options, dropdownOptions, "name"));
setDropdownOptions(unionBy(options, dropdownOptions, "id"));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [peers]);
@@ -100,6 +100,12 @@ export function PeerSelector({
}
}, [open, dropdownOptions]);
const LinuxIcon = (
<span className={"grayscale brightness-[100%] contrast-[40%]"}>
<FcLinux className={"text-white text-lg min-w-[20px] brightness-150"} />
</span>
);
return (
<Popover
open={open}
@@ -135,7 +141,7 @@ export function PeerSelector({
}
>
<div className={"flex items-center gap-2.5 text-sm"}>
<SmallOSIcon os={value.os} />
{LinuxIcon}
<TextWithTooltip text={value.name} maxChars={20} />
</div>
@@ -249,7 +255,7 @@ export function PeerSelector({
}}
>
<div className={"flex items-center gap-2.5 text-sm"}>
<SmallOSIcon os={option.os} />
{LinuxIcon}
<TextWithTooltip text={option.name} maxChars={20} />
</div>

View File

@@ -60,7 +60,7 @@ export default function SidebarItem({
<li className={"px-4 cursor-pointer"}>
<button
className={classNames(
"rounded-lg text-base w-full ",
"rounded-lg text-[.95rem] w-full ",
"font-normal ",
className,
isChild ? "pl-7 pr-2 py-2 mt-1 mb-0.5" : "py-2 px-3",

View File

@@ -36,7 +36,7 @@ const Step = ({ children, step, line = true, center = false }: StepProps) => {
className={cn(
"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",
"bg-nb-gray-100 text-nb-gray-400 border-white group-hover:bg-nb-gray-200 step-circle",
)}
>
{step}

View File

@@ -2,6 +2,7 @@
import SkeletonTable from "@components/skeletons/SkeletonTable";
import DataTableGlobalSearch from "@components/table/DataTableGlobalSearch";
import { DataTablePagination } from "@components/table/DataTablePagination";
import DataTableResetFilterButton from "@components/table/DataTableResetFilterButton";
import {
Table,
TableBody,
@@ -223,6 +224,15 @@ export function DataTableContent<TData, TValue>({
const TableDataUnstyledComponent = as === "table" ? "td" : "div";
const TableRowUnstyledComponent = as === "table" ? "tr" : "div";
/**
* Reset all filters, search & set pagination to first page
*/
const resetFilters = () => {
table.setPageIndex(0);
setColumnFilters([]);
setGlobalSearch("");
};
return (
<div className={cn("relative table-fixed-scroll", className)}>
{!minimal && (
@@ -238,6 +248,7 @@ export function DataTableContent<TData, TValue>({
placeholder={searchPlaceholder}
/>
{children && children(table)}
<DataTableResetFilterButton onClick={resetFilters} table={table} />
<div className={"flex gap-4 flex-wrap grow"}>
<div className={"flex gap-4 flex-wrap"}></div>
{rightSide && rightSide(table)}

View File

@@ -0,0 +1,55 @@
import Button from "@components/Button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@components/Tooltip";
import { Table } from "@tanstack/react-table";
import { FilterX } from "lucide-react";
import * as React from "react";
import { useState } from "react";
interface Props<TData> {
table: Table<TData>;
onClick: () => void;
}
export default function DataTableResetFilterButton<TData>({
table,
onClick,
}: 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}
>
<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;
}

View File

@@ -0,0 +1,92 @@
import InlineLink from "@components/InlineLink";
import { cn } from "@utils/helpers";
import { cva, VariantProps } from "class-variance-authority";
import { ArrowRightIcon, XIcon } from "lucide-react";
import * as React from "react";
import { useAnnouncement } from "@/contexts/AnnouncementProvider";
const variants = cva(
{},
{
variants: {
variant: {
default:
"bg-nb-gray-900/50 border-nb-gray-800/30 border-b text-nb-gray-200",
important: "from-netbird to-netbird-400 bg-gradient-to-b text-white",
},
tagBadge: {
default: "bg-nb-gray-200/10 text-nb-gray-100 font-medium",
important: "bg-white text-netbird font-medium",
},
closeButton: {
default:
"bg-nb-gray-900 rounded-md p-1 text-nb-gray-300 hover:bg-nb-gray-800",
important:
"bg-netbird-100 rounded-md p-1 text-netbird-600 hover:bg-white",
},
inlineLink: {
default: "text-nb-blue-400 hover:underline",
important: "!text-white underline hover:opacity-80",
},
},
},
);
export type AnnouncementVariant = VariantProps<typeof variants>;
export const AnnouncementBanner = () => {
const { bannerHeight, closeAnnouncement, announcements } = useAnnouncement();
const announcement = announcements?.find((a) => a.isOpen);
return announcement ? (
<div
className={cn(
"flex items-center justify-center text-sm px-8 font-light",
variants({ variant: announcement.variant }),
)}
style={{ height: bannerHeight }}
>
<div className={"flex items-center gap-2"}>
{announcement.tag && (
<div
className={cn(
"bg-nb-gray-200/10 backdrop-blur text-nb-gray-100 font-medium tracking-wide uppercase text-[10px] py-2.5 px-2 rounded-md leading-[0]",
variants({ tagBadge: announcement.variant }),
)}
>
{announcement.tag}
</div>
)}
<div>
{announcement.text}
{announcement.link && (
<InlineLink
href={announcement.link || "#"}
target={announcement.isExternal ? "_blank" : undefined}
className={cn(
"ml-2 !text-sm",
variants({ inlineLink: announcement.variant }),
)}
>
{announcement.linkText || "Learn more"}
<ArrowRightIcon size={14} />
</InlineLink>
)}
</div>
</div>
{announcement.closeable && (
<div className={"absolute right-0 px-4"}>
<div
className={cn(
"rounded-md p-1 text-nb-gray-300 transition-all cursor-pointer",
variants({ closeButton: announcement.variant }),
)}
onClick={() => closeAnnouncement(announcement.hash)}
>
<XIcon size={14} />
</div>
</div>
)}
</div>
) : null;
};

View File

@@ -5,7 +5,9 @@ import * as React from "react";
type Props = {
data: {
label: string;
value: string;
value: string | React.ReactNode;
noCopy?: boolean;
tooltip?: boolean;
}[];
className?: string;
};
@@ -16,10 +18,11 @@ export const MinimalList = ({ data, className }: Props) => {
{data.map((item, index) => {
return (
<Card.ListItem
copy
copy={!item.noCopy}
label={item.label}
value={item.value}
key={index}
tooltip={item.tooltip !== false}
/>
);
})}

View File

@@ -1,26 +0,0 @@
import Image from "next/image";
import { useMemo } from "react";
import { FaWindows } from "react-icons/fa6";
import { FcAndroidOs, FcLinux } from "react-icons/fc";
import AppleLogo from "@/assets/os-icons/apple.svg";
import { getOperatingSystem } from "@/hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
export default function SmallOSIcon({ os }: { os: string }) {
const icon = useMemo(() => {
return getOperatingSystem(os.toLowerCase());
}, [os]);
if (icon === OperatingSystem.WINDOWS)
return <FaWindows className={"text-white text-md min-w-[20px]"} />;
if (icon === OperatingSystem.APPLE)
return (
<div className={"min-w-[20px] flex items-center justify-center"}>
<Image src={AppleLogo} alt={""} width={12} />
</div>
);
if (icon === OperatingSystem.ANDROID)
return <FcAndroidOs className={"text-white text-xl min-w-[20px]"} />;
return <FcLinux className={"text-white text-lg min-w-[20px]"} />;
}

View File

@@ -36,6 +36,7 @@ export default function UserDropdown() {
useHotkeys("shift+mod+l", () => logout(), []);
const [dropdownOpen, setDropdownOpen] = useState(false);
const { permission } = useLoggedInUser();
return (
<DropdownMenu
@@ -67,19 +68,23 @@ export default function UserDropdown() {
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
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>
{permission.dashboard_view !== "blocked" && (
<DropdownMenuItem
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}>
<div className={"flex gap-3 items-center"}>
<LogOutIcon size={14} />

View File

@@ -19,6 +19,7 @@ const AnalyticsContext = React.createContext(
{} as {
initialized: boolean;
trackPageView: () => void;
trackEvent: (category: string, action: string, label: string) => void;
},
);
const config = loadConfig();
@@ -51,8 +52,20 @@ export default function AnalyticsProvider({ children }: Props) {
ReactGA.send({ hitType: "pageview", page: path, title: document.title });
};
const trackEvent = (category: string, action: string, label: string) => {
if (isProduction() && ReactGA.isInitialized) {
ReactGA.event({
category: category,
action: action,
label: label,
});
}
};
return (
<AnalyticsContext.Provider value={{ initialized, trackPageView }}>
<AnalyticsContext.Provider
value={{ initialized, trackPageView, trackEvent }}
>
{children}
</AnalyticsContext.Provider>
);

View File

@@ -0,0 +1,103 @@
import { AnnouncementVariant } from "@components/ui/AnnouncementBanner";
import { useLocalStorage } from "@hooks/useLocalStorage";
import md5 from "crypto-js/md5";
import React, { useEffect, useState } from "react";
import { useLoggedInUser } from "@/contexts/UsersProvider";
const initialAnnouncements: Announcement[] = [];
export interface Announcement extends AnnouncementVariant {
tag: string;
text: string;
link?: string;
linkText?: string;
isExternal?: boolean;
closeable: boolean;
isCloudOnly: boolean;
}
interface AnnouncementInfo extends Announcement {
isOpen: boolean;
hash: string;
}
type Props = {
children: React.ReactNode;
};
const AnnouncementContext = React.createContext(
{} as {
bannerHeight: number;
announcements?: AnnouncementInfo[];
closeAnnouncement: (hash: string) => void;
setAnnouncements: React.Dispatch<
React.SetStateAction<AnnouncementInfo[] | undefined>
>;
},
);
const bannerHeight = 40;
export default function AnnouncementProvider({ children }: Props) {
const [height, setHeight] = useState(0);
const [closedAnnouncements, setClosedAnnouncements] = useLocalStorage<
string[]
>("netbird-closed-announcements", []);
const [announcements, setAnnouncements] = useState<AnnouncementInfo[]>();
const { permission } = useLoggedInUser();
useEffect(() => {
if (announcements && announcements.length > 0) return;
if (permission?.dashboard_view === "blocked") return;
const initial = initialAnnouncements.map((announcement) => {
const hash = md5(announcement.text).toString();
const isOpen = !closedAnnouncements.some((h) => h === hash);
return {
...announcement,
hash,
isOpen,
} as AnnouncementInfo;
});
if (initial.length > 0) {
setAnnouncements(initial);
}
}, [closedAnnouncements, announcements]);
const closeAnnouncement = (hash: string) => {
setClosedAnnouncements([...closedAnnouncements, hash]);
setAnnouncements(() => {
return announcements?.map((a) => {
if (a.hash === hash) {
return { ...a, isOpen: false };
}
return a;
});
});
};
useEffect(() => {
const isAnnouncementOpen = announcements?.some((a) => a.isOpen);
if (isAnnouncementOpen) {
setHeight(bannerHeight);
} else {
setHeight(0);
}
}, [announcements]);
return (
<AnnouncementContext.Provider
value={{
bannerHeight: height,
announcements,
closeAnnouncement,
setAnnouncements,
}}
>
{children}
</AnnouncementContext.Provider>
);
}
export const useAnnouncement = () => {
return React.useContext(AnnouncementContext);
};

View File

@@ -3,7 +3,14 @@ import FullScreenLoading from "@components/ui/FullScreenLoading";
import { useApiCall } from "@utils/api";
import { useIsMd } from "@utils/responsive";
import { getLatestNetbirdRelease } from "@utils/version";
import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
import React, {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { User } from "@/interfaces/User";
import type { NetbirdRelease } from "@/interfaces/Version";
@@ -32,13 +39,27 @@ export default function ApplicationProvider({ children }: Props) {
const userRequest = useApiCall<User[]>("/users", true);
const [show, setShow] = useState(false);
const requestCalled = useRef(false);
const maxTries = 3;
const populateCache = useCallback(
async (tries = 0) => {
if (tries >= maxTries) {
setShow(true);
return Promise.reject();
}
try {
await userRequest.get().then(() => setShow(true));
return Promise.resolve();
} catch (e) {
setTimeout(() => populateCache(tries + 1), 500);
}
},
[userRequest, setShow],
);
useEffect(() => {
if (!requestCalled.current) {
userRequest
.get()
.then(() => setShow(true))
.catch(() => setShow(true));
populateCache().then();
requestCalled.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@@ -1,5 +1,6 @@
import useFetchApi from "@utils/api";
import React, { useCallback } from "react";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { Country } from "@/interfaces/Country";
import { Peer } from "@/interfaces/Peer";
@@ -16,9 +17,25 @@ const CountryContext = React.createContext(
);
export default function CountryProvider({ children }: Props) {
const { permission } = useLoggedInUser();
const getRegionByPeer = (peer: Peer) => "Unknown";
return permission?.dashboard_view != "full" ? (
<CountryContext.Provider
value={{ countries: [], isLoading: false, getRegionByPeer }}
>
{children}
</CountryContext.Provider>
) : (
<CountryProviderContent>{children}</CountryProviderContent>
);
}
function CountryProviderContent({ children }: Props) {
const { data: countries, isLoading } = useFetchApi<Country[]>(
"/locations/countries",
false,
true,
false,
);

View File

@@ -81,10 +81,13 @@ export default function DialogProvider({ children }: Props) {
/>
{dialogOptions.children && (
<div className={"px-8 pt-4"}>{dialogOptions.children}</div>
<div className={"px-8 pt-0"}>{dialogOptions.children}</div>
)}
<ModalFooter className={"items-center gap-2"} separator={false}>
<ModalFooter
className={"items-center gap-2 pt-5"}
separator={false}
>
<ModalClose asChild={true}>
<Button
variant={"secondary"}

View File

@@ -1,5 +1,7 @@
import useFetchApi from "@utils/api";
import { usePathname } from "next/navigation";
import React, { useState } from "react";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { Group } from "@/interfaces/Group";
type Props = {
@@ -17,11 +19,22 @@ const GroupContext = React.createContext(
);
export default function GroupsProvider({ children }: Props) {
const path = usePathname();
const { permission } = useLoggedInUser();
return path === "/peers" && permission.dashboard_view == "blocked" ? (
<>{children}</>
) : (
<GroupsProviderContent>{children}</GroupsProviderContent>
);
}
export function GroupsProviderContent({ children }: Props) {
const { data: groups, mutate, isLoading } = useFetchApi<Group[]>("/groups");
const [dropdownOptions, setDropdownOptions] = useState<Group[]>([]);
const refresh = () => {
mutate().then();
if (groups && !isLoading) mutate().then();
};
return (

View File

@@ -1,6 +1,7 @@
import FullScreenLoading from "@components/ui/FullScreenLoading";
import useFetchApi from "@utils/api";
import React, { useMemo } from "react";
import { Permission } from "@/interfaces/Permission";
import { User } from "@/interfaces/User";
type Props = {
@@ -26,7 +27,7 @@ export default function UsersProvider({ children }: Props) {
return users?.find((user) => user.is_current);
}, [users]);
return !isLoading ? (
return !isLoading && loggedInUser ? (
<UsersContext.Provider value={{ users, refresh, loggedInUser }}>
{children}
</UsersContext.Provider>
@@ -43,5 +44,19 @@ export const useLoggedInUser = () => {
const isAdmin = loggedInUser ? loggedInUser?.role === "admin" : false;
const isUser = !isOwner && !isAdmin;
const isOwnerOrAdmin = isOwner || isAdmin;
return { loggedInUser, isOwner, isAdmin, isUser, isOwnerOrAdmin } as const;
const permission = useMemo(() => {
return {
dashboard_view: loggedInUser?.permissions.dashboard_view || "blocked",
} as Permission;
}, [loggedInUser]);
return {
loggedInUser,
isOwner,
isAdmin,
isUser,
isOwnerOrAdmin,
permission,
} as const;
};

View File

@@ -13,10 +13,13 @@ export default function useOperatingSystem() {
}
export const getOperatingSystem = (os: string) => {
if (os.includes("darwin")) return OperatingSystem.APPLE as const;
if (os.includes("mac")) return OperatingSystem.APPLE as const;
if (os.includes("android")) return OperatingSystem.ANDROID as const;
if (os.includes("ios")) return OperatingSystem.IOS as const;
if (os.includes("win")) return OperatingSystem.WINDOWS as const;
if (os.toLowerCase().includes("darwin"))
return OperatingSystem.APPLE as const;
if (os.toLowerCase().includes("mac")) return OperatingSystem.APPLE as const;
if (os.toLowerCase().includes("android"))
return OperatingSystem.ANDROID as const;
if (os.toLowerCase().includes("ios")) return OperatingSystem.IOS as const;
if (os.toLowerCase().includes("windows"))
return OperatingSystem.WINDOWS as const;
return OperatingSystem.LINUX as const;
};

View File

@@ -10,5 +10,6 @@ export interface Account {
jwt_groups_enabled: boolean;
jwt_groups_claim_name: string;
jwt_allow_groups: string[];
regular_users_view_blocked: boolean;
};
}

View File

@@ -17,6 +17,14 @@ export interface AzureADIntegration {
user_group_prefixes: string[];
}
export interface OktaIntegration {
id: string;
enabled: boolean;
group_prefixes: string[];
user_group_prefixes: string[];
auth_token: string;
}
export interface IdentityProviderLog {
id: number;
level: string;

View File

@@ -0,0 +1,3 @@
export interface Permission {
dashboard_view: "limited" | "full" | "blocked";
}

View File

@@ -1,3 +1,5 @@
import { Permission } from "@/interfaces/Permission";
export interface User {
id: string;
email?: string;
@@ -9,6 +11,7 @@ export interface User {
is_service_user?: boolean;
is_blocked?: boolean;
last_login?: Date;
permissions: Permission;
}
export enum Role {

View File

@@ -9,14 +9,17 @@ import { useIsSm, useIsXs } from "@utils/responsive";
import { AnimatePresence, motion } from "framer-motion";
import { XIcon } from "lucide-react";
import React from "react";
import AnnouncementProvider, {
useAnnouncement,
} from "@/contexts/AnnouncementProvider";
import ApplicationProvider, {
useApplicationContext,
} from "@/contexts/ApplicationProvider";
import CountryProvider from "@/contexts/CountryProvider";
import GroupsProvider from "@/contexts/GroupsProvider";
import UsersProvider from "@/contexts/UsersProvider";
import UsersProvider, { useLoggedInUser } from "@/contexts/UsersProvider";
import Navigation from "@/layouts/Navigation";
import Navbar from "./Header";
import Navbar, { headerHeight } from "./Header";
export default function DashboardLayout({
children,
@@ -26,11 +29,13 @@ export default function DashboardLayout({
return (
<ApplicationProvider>
<UsersProvider>
<GroupsProvider>
<CountryProvider>
<DashboardPageContent>{children}</DashboardPageContent>
</CountryProvider>
</GroupsProvider>
<AnnouncementProvider>
<GroupsProvider>
<CountryProvider>
<DashboardPageContent>{children}</DashboardPageContent>
</CountryProvider>
</GroupsProvider>
</AnnouncementProvider>
</UsersProvider>
</ApplicationProvider>
);
@@ -41,9 +46,10 @@ function DashboardPageContent({ children }: { children: React.ReactNode }) {
const { mobileNavOpen, toggleMobileNav } = useApplicationContext();
const isSm = useIsSm();
const isXs = useIsXs();
const { permission } = useLoggedInUser();
const navOpenPageWidth = isSm ? "50%" : isXs ? "65%" : "80%";
const { bannerHeight } = useAnnouncement();
return (
<div className={cn("flex flex-col h-screen", mobileNavOpen && "flex")}>
{mobileNavOpen && (
@@ -145,13 +151,16 @@ function DashboardPageContent({ children }: { children: React.ReactNode }) {
}}
>
<Navbar />
<div
className={"flex flex-row flex-grow"}
style={{
height: "calc(100vh - 75px)",
height: `calc(100vh - ${headerHeight + bannerHeight}px)`,
}}
>
<Navigation hideOnMobile />
{permission.dashboard_view !== "blocked" && (
<Navigation hideOnMobile />
)}
{children}
</div>
</motion.div>

View File

@@ -1,59 +1,99 @@
"use client";
import Button from "@components/Button";
import { AnnouncementBanner } from "@components/ui/AnnouncementBanner";
import DarkModeToggle from "@components/ui/DarkModeToggle";
import UserDropdown from "@components/ui/UserDropdown";
import { Navbar } from "flowbite-react";
import { cn } from "@utils/helpers";
import { MenuIcon } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import React, { useMemo } from "react";
import NetBirdLogo from "@/assets/netbird.svg";
import NetBirdLogoFull from "@/assets/netbird-full.svg";
import { useAnnouncement } from "@/contexts/AnnouncementProvider";
import { useApplicationContext } from "@/contexts/ApplicationProvider";
import { useLoggedInUser } from "@/contexts/UsersProvider";
export const headerHeight = 75;
export default function NavbarWithDropdown() {
const router = useRouter();
const Logo = useMemo(() => {
return <Image src={NetBirdLogo} width={30} alt={"NetBird Logo"} />;
return (
<>
<Image
src={NetBirdLogoFull}
height={22}
alt={"NetBird Logo"}
className={"hidden md:block"}
/>
<Image
src={NetBirdLogo}
width={30}
alt={"NetBird Logo"}
className={"md:hidden"}
/>
</>
);
}, []);
const { toggleMobileNav } = useApplicationContext();
const { bannerHeight } = useAnnouncement();
const { permission } = useLoggedInUser();
return (
<>
<Navbar
fluid
className={
"border-b dark:border-zinc-700/40 fixed z-50 h-[75px] px-3 md:px-4 w-full"
}
<div
className={"fixed z-50 w-full"}
style={{
height: headerHeight + bannerHeight,
}}
>
<div className={"flex items-center gap-4 md:hidden"}>
<Button
className={"!px-3 md:hidden"}
variant={"default-outline"}
onClick={toggleMobileNav}
>
<div>
<MenuIcon size={20} className={"relative"} />
</div>
</Button>
</div>
<Navbar.Brand
onClick={() => router.push("/peers")}
className={"cursor-pointer hover:opacity-70 transition-all"}
<AnnouncementBanner />
<div
className={cn(
"bg-white px-2 py-4 dark:border-gray-700 dark:bg-nb-gray/50 backdrop-blur-lg sm:px-6",
"border-b dark:border-zinc-700/40 px-3 md:px-4 w-full",
"flex justify-between items-center transition-all",
)}
>
{Logo}
</Navbar.Brand>
<div className="flex md:order-2 gap-4">
<div className={"hidden md:block"}>
<DarkModeToggle />
<div className={"flex items-center gap-4 md:hidden"}>
<Button
className={cn(
"!px-3 md:hidden",
permission.dashboard_view == "blocked" &&
"opacity-0 pointer-events-none",
)}
variant={"default-outline"}
onClick={toggleMobileNav}
>
<div>
<MenuIcon size={20} className={"relative"} />
</div>
</Button>
</div>
<div
onClick={() => router.push("/peers")}
className={"cursor-pointer hover:opacity-70 transition-all"}
>
{Logo}
</div>
<UserDropdown />
<div className="flex md:order-2 gap-4">
<div className={"hidden md:block"}>
<DarkModeToggle />
</div>
<UserDropdown />
</div>
</div>
</Navbar>
<div className={"h-[75px]"}></div>
</div>
<div
style={{
height: headerHeight + bannerHeight,
}}
></div>
</>
);
}

View File

@@ -2,21 +2,21 @@
import { ScrollArea } from "@components/ScrollArea";
import { cn } from "@utils/helpers";
import { isLocalDev, isNetBirdHosted } from "@utils/netbird";
import { CustomFlowbiteTheme, Sidebar } from "flowbite-react";
import { SidebarItemGroupProps } from "flowbite-react/lib/esm/components/Sidebar/SidebarItemGroup";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import ActivityIcon from "@/assets/icons/ActivityIcon";
import DNSIcon from "@/assets/icons/DNSIcon";
import DocsIcon from "@/assets/icons/DocsIcon";
import IntegrationIcon from "@/assets/icons/IntegrationIcon";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import PeerIcon from "@/assets/icons/PeerIcon";
import SettingsIcon from "@/assets/icons/SettingsIcon";
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
import TeamIcon from "@/assets/icons/TeamIcon";
import SidebarItem from "@/components/SidebarItem";
import { useAnnouncement } from "@/contexts/AnnouncementProvider";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { headerHeight } from "@/layouts/Header";
const customTheme: CustomFlowbiteTheme["sidebar"] = {
root: {
@@ -34,6 +34,7 @@ export default function Navigation({
hideOnMobile = false,
}: Props) {
const { isUser } = useLoggedInUser();
const { bannerHeight } = useAnnouncement();
return (
<Sidebar
@@ -42,123 +43,133 @@ export default function Navigation({
hideOnMobile ? "hidden md:block" : "",
fullWidth
? "w-auto max-w-[22rem]"
: "w-[15rem] min-w-[15rem] overflow-y-auto",
: "w-[15rem] max-w-[15rem] min-w-[15rem] overflow-y-auto",
)}
theme={customTheme}
style={{
height: fullWidth ? "calc(100vh - 75px)" : "100%",
height: fullWidth
? `calc(100vh - ${headerHeight + bannerHeight}px)`
: "100%",
}}
>
<Sidebar.Items className={cn(fullWidth ? "w-10/12" : "fixed")}>
<Sidebar.Items className={cn(fullWidth ? "w-10/12" : "fixed h-full")}>
<ScrollArea
style={{
height: !fullWidth ? "calc(100vh - 75px)" : "100%",
height: !fullWidth
? `calc(100vh - ${headerHeight + bannerHeight}px)`
: "100%",
}}
className={"pt-4"}
>
<SidebarItemGroup>
<SidebarItem icon={<PeerIcon />} label="Peers" href={"/peers"} />
{!isUser && (
<>
<div
className={
"flex flex-col justify-between pt-4 w-[15rem] max-w-[15rem] min-w-[15rem]"
}
style={{
height: !fullWidth
? `calc(100vh - ${headerHeight + bannerHeight}px)`
: "100%",
}}
>
<div>
<SidebarItemGroup>
<SidebarItem
icon={<SetupKeysIcon />}
label="Setup Keys"
href={"/setup-keys"}
icon={<PeerIcon />}
label="Peers"
href={"/peers"}
/>
<SidebarItem
icon={<AccessControlIcon />}
label="Access Control"
collapsible
>
{!isUser && (
<>
<SidebarItem
icon={<SetupKeysIcon />}
label="Setup Keys"
href={"/setup-keys"}
/>
<SidebarItem
icon={<AccessControlIcon />}
label="Access Control"
collapsible
>
<SidebarItem
label="Policies"
href={"/access-control"}
isChild
exactPathMatch={true}
/>
<SidebarItem
label="Posture Checks"
isChild
href={"/posture-checks"}
exactPathMatch={true}
/>
</SidebarItem>
<SidebarItem
icon={<NetworkRoutesIcon />}
label="Network Routes"
href={"/network-routes"}
/>
<SidebarItem
icon={<DNSIcon />}
label="DNS"
collapsible
exactPathMatch={true}
>
<SidebarItem
label="Nameservers"
isChild
href={"/dns/nameservers"}
/>
<SidebarItem
label="DNS Settings"
isChild
href={"/dns/settings"}
/>
</SidebarItem>
<SidebarItem icon={<TeamIcon />} label="Team" collapsible>
<SidebarItem label="Users" isChild href={"/team/users"} />
<SidebarItem
label="Service Users"
isChild
href={"/team/service-users"}
/>
</SidebarItem>
<SidebarItem
icon={<ActivityIcon />}
label="Activity"
href={"/activity"}
/>
</>
)}
{isUser && (
<SidebarItem
label="Policies"
href={"/access-control"}
isChild
icon={<DocsIcon />}
href={"https://docs.netbird.io/"}
target={"_blank"}
label="Documentation"
/>
)}
</SidebarItemGroup>
{!isUser && (
<SidebarItemGroup>
<SidebarItem
icon={<SettingsIcon />}
label="Settings"
href={"/settings"}
exactPathMatch={true}
/>
<SidebarItem
label="Posture Checks"
isChild
href={"/posture-checks"}
exactPathMatch={true}
/>
</SidebarItem>
<SidebarItem
icon={<NetworkRoutesIcon />}
label="Network Routes"
href={"/network-routes"}
/>
<SidebarItem
icon={<DNSIcon />}
label="DNS"
collapsible
exactPathMatch={true}
>
<SidebarItem
label="Nameservers"
isChild
href={"/dns/nameservers"}
icon={<DocsIcon />}
href={"https://docs.netbird.io/"}
target={"_blank"}
label="Documentation"
/>
<SidebarItem
label="DNS Settings"
isChild
href={"/dns/settings"}
/>
</SidebarItem>
<SidebarItem icon={<TeamIcon />} label="Team" collapsible>
<SidebarItem label="Users" isChild href={"/team/users"} />
<SidebarItem
label="Service Users"
isChild
href={"/team/service-users"}
/>
</SidebarItem>
<SidebarItem
icon={<ActivityIcon />}
label="Activity"
href={"/activity"}
/>
</>
)}
{isUser && (
<SidebarItem
icon={<DocsIcon />}
href={"https://docs.netbird.io/"}
target={"_blank"}
label="Documentation"
/>
)}
</SidebarItemGroup>
{!isUser && (
<SidebarItemGroup>
<SidebarItem
icon={<SettingsIcon />}
label="Settings"
href={"/settings"}
exactPathMatch={true}
/>
{(isLocalDev() || isNetBirdHosted()) && (
<SidebarItem
icon={<IntegrationIcon />}
label="Integrations"
href={"/integrations"}
exactPathMatch={true}
/>
</SidebarItemGroup>
)}
<SidebarItem
icon={<DocsIcon />}
href={"https://docs.netbird.io/"}
target={"_blank"}
label="Documentation"
/>
</SidebarItemGroup>
)}
</div>
</div>
</ScrollArea>
</Sidebar.Items>
</Sidebar>
@@ -167,7 +178,10 @@ export default function Navigation({
export function SidebarItemGroup(props: SidebarItemGroupProps) {
return (
<Sidebar.ItemGroup className={"dark:border-zinc-700/40"} {...props}>
<Sidebar.ItemGroup
className={"dark:border-zinc-700/40 space-y-1.5"}
{...props}
>
{props.children}
</Sidebar.ItemGroup>
);

View File

@@ -31,6 +31,10 @@ export default function ActivityDescription({ event }: Props) {
if (!m) return null;
/**
* Setup Key
*/
if (event.activity_code == "setupkey.revoke")
return (
<div className={"inline"}>
@@ -54,13 +58,6 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
if (event.activity_code == "dashboard.login")
return (
<div className={"inline"}>
<Value>{m.username}</Value> logged in to the dashboard
</div>
);
if (event.activity_code == "setupkey.group.delete")
return (
<div className={"inline"}>
@@ -77,6 +74,20 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
/**
* Dashboard
*/
if (event.activity_code == "dashboard.login")
return (
<div className={"inline"}>
<Value>{m.username}</Value> logged in to the dashboard
</div>
);
/**
* Policy
*/
if (event.activity_code == "policy.update")
return (
<div className={"inline"}>
@@ -98,6 +109,10 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
/**
* Route
*/
if (event.activity_code == "route.delete")
return (
<div className={"inline"}>
@@ -122,6 +137,10 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
/**
* User
*/
if (event.activity_code == "user.peer.delete")
return (
<div className={"inline"}>
@@ -150,6 +169,86 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
if (event.activity_code == "user.invite")
return (
<div className={"inline"}>
<Value>{event.meta.username}</Value> <Value>{event.meta.email}</Value>{" "}
was invited.
</div>
);
if (event.activity_code == "user.group.add")
return (
<div className={"inline"}>
Group <Value>{event.meta.group}</Value> was added to user{" "}
<Value>{event.meta.username}</Value>
</div>
);
if (event.activity_code == "user.block")
return (
<div className={"inline"}>
User <Value>{event.meta.username}</Value>{" "}
<Value>{event.meta.email}</Value>
was blocked
</div>
);
if (event.activity_code == "user.unblock")
return (
<div className={"inline"}>
User <Value>{event.meta.username}</Value>{" "}
<Value>{event.meta.email}</Value>
was unblocked
</div>
);
if (event.activity_code == "user.delete")
return (
<div className={"inline"}>
User <Value>{event.meta.username}</Value>{" "}
<Value>{event.meta.email}</Value> was deleted
</div>
);
if (event.activity_code == "user.group.delete")
return (
<div className={"inline"}>
Group <Value>{event.meta.group}</Value> was removed from user{" "}
<Value>{event.meta.username}</Value> <Value>{event.meta.email}</Value>
</div>
);
if (event.activity_code == "user.role.update")
return (
<div className={"inline"}>
Role <Value>{event.meta.role}</Value> was updated of user{" "}
<Value>{event.meta.username}</Value> <Value>{event.meta.email}</Value>
</div>
);
/**
* Service User
*/
if (event.activity_code == "service.user.create")
return (
<div className={"inline"}>
Service user <Value>{event.meta.name}</Value> was created
</div>
);
if (event.activity_code == "service.user.delete")
return (
<div className={"inline"}>
Service user <Value>{event.meta.name}</Value> was deleted
</div>
);
/**
* Peer
*/
if (event.activity_code == "peer.group.delete")
return (
<div className={"inline"}>
@@ -216,6 +315,10 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
/**
* Group
*/
if (event.activity_code == "group.add")
return (
<div className={"inline"}>
@@ -223,6 +326,17 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
if (event.activity_code == "group.delete")
return (
<div className={"inline"}>
Group <Value>{event.meta.name}</Value> was deleted
</div>
);
/**
* Account
*/
if (event.activity_code == "account.create")
return (
<div className={"inline"}>
@@ -230,21 +344,18 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
if (event.activity_code == "user.invite")
return (
<div className={"inline"}>
<Value>{event.meta.username}</Value> <Value>{event.meta.email}</Value>{" "}
was invited.
</div>
);
if (event.activity_code == "account.setting.peer.login.expiration.update")
return <div className={"inline"}>Global login expiration was updated</div>;
if (event.activity_code == "user.group.add")
return (
<div className={"inline"}>
Group <Value>{event.meta.group}</Value> was added to user{" "}
<Value>{event.meta.username}</Value>
</div>
);
if (event.activity_code == "account.setting.peer.login.expiration.enable")
return <div className={"inline"}>Global login expiration was enabled</div>;
if (event.activity_code == "account.setting.peer.login.expiration.disable")
return <div className={"inline"}>Global login expiration was disabled</div>;
/**
* Nameserver
*/
if (event.activity_code == "nameserver.group.add")
return (
@@ -267,14 +378,9 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
if (event.activity_code == "account.setting.peer.login.expiration.update")
return <div className={"inline"}>Global login expiration was updated</div>;
if (event.activity_code == "account.setting.peer.login.expiration.enable")
return <div className={"inline"}>Global login expiration was enabled</div>;
if (event.activity_code == "account.setting.peer.login.expiration.disable")
return <div className={"inline"}>Global login expiration was disabled</div>;
/**
* Personal Access Token
*/
if (event.activity_code == "personal.access.token.create")
return (
@@ -292,68 +398,9 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
if (event.activity_code == "user.block")
return (
<div className={"inline"}>
User <Value>{event.meta.username}</Value>{" "}
<Value>{event.meta.email}</Value>
was blocked
</div>
);
if (event.activity_code == "user.unblock")
return (
<div className={"inline"}>
User <Value>{event.meta.username}</Value>{" "}
<Value>{event.meta.email}</Value>
was unblocked
</div>
);
if (event.activity_code == "user.delete")
return (
<div className={"inline"}>
User <Value>{event.meta.username}</Value>{" "}
<Value>{event.meta.email}</Value> was deleted
</div>
);
if (event.activity_code == "user.group.delete")
return (
<div className={"inline"}>
Group <Value>{event.meta.group}</Value> was removed from user{" "}
<Value>{event.meta.username}</Value> <Value>{event.meta.email}</Value>
</div>
);
if (event.activity_code == "user.role.update")
return (
<div className={"inline"}>
Role <Value>{event.meta.role}</Value> was updated of user{" "}
<Value>{event.meta.username}</Value> <Value>{event.meta.email}</Value>
</div>
);
if (event.activity_code == "service.user.create")
return (
<div className={"inline"}>
Service user <Value>{event.meta.name}</Value> was created
</div>
);
if (event.activity_code == "service.user.delete")
return (
<div className={"inline"}>
Service user <Value>{event.meta.name}</Value> was deleted
</div>
);
if (event.activity_code == "group.delete")
return (
<div className={"inline"}>
Group <Value>{event.meta.name}</Value> was deleted
</div>
);
/**
* Integration
*/
if (event.activity_code == "integration.create") {
if (!event.meta.platform) return "Integration created";
@@ -385,7 +432,10 @@ export default function ActivityDescription({ event }: Props) {
);
}
// Group was added to DNS Management Setting that disables DNS for the group
/**
* DNS
*/
if (event.activity_code == "dns.setting.disabled.management.group.add")
return (
<div className={"inline"}>
@@ -402,6 +452,31 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
/**
* Posture Checks
*/
if (event.activity_code == "posture.check.updated")
return (
<div className={"inline"}>
Posture check <Value> {m.name}</Value> was updated
</div>
);
if (event.activity_code == "posture.check.created")
return (
<div className={"inline"}>
Posture check <Value> {m.name}</Value> was created
</div>
);
if (event.activity_code == "posture.check.deleted")
return (
<div className={"inline"}>
Posture check <Value> {m.name}</Value> was deleted
</div>
);
// TODO add activity texts
// rule.add
// rule.update

View File

@@ -12,6 +12,7 @@ import {
NetworkIcon,
Server,
Shield,
ShieldCheck,
User,
} from "lucide-react";
import React from "react";
@@ -70,6 +71,10 @@ export default function ActivityTypeIcon({
return <User size={size} className={cn(DEFAULT_CLASSES, className)} />;
} else if (code.startsWith("service")) {
return <Cog size={size} className={cn(DEFAULT_CLASSES, className)} />;
} else if (code.startsWith("posture")) {
return (
<ShieldCheck size={size} className={cn(DEFAULT_CLASSES, className)} />
);
} else {
return (
<HelpCircleIcon size={size} className={cn(DEFAULT_CLASSES, className)} />

View File

@@ -14,6 +14,7 @@ import { FolderGit2 } from "lucide-react";
import * as React from "react";
import { useMemo } from "react";
import { useGroups } from "@/contexts/GroupsProvider";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { Group } from "@/interfaces/Group";
import { Peer } from "@/interfaces/Peer";
import useGroupHelper from "@/modules/groups/useGroupHelper";
@@ -38,6 +39,7 @@ export default function GroupsRow({
peer,
}: Props) {
const { groups: allGroups } = useGroups();
const { isUser } = useLoggedInUser();
// Get the group by the id
const foundGroups = useMemo(() => {
@@ -54,7 +56,7 @@ export default function GroupsRow({
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setModal && setModal(true);
setModal && !isUser && setModal(true);
}}
>
<MultipleGroups groups={foundGroups} label={label} />

View File

@@ -1,104 +0,0 @@
import Button from "@components/Button";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import { ToggleSwitch } from "@components/ToggleSwitch";
import { cn } from "@utils/helpers";
import { ExternalLinkIcon, Repeat } from "lucide-react";
import { StaticImport } from "next/dist/shared/lib/get-img-props";
import Image from "next/image";
import * as React from "react";
type Props<T> = {
image: StaticImport | string;
name: string;
description: string;
url: {
title: string;
href: string;
};
data?: T;
switchState: boolean;
onEnabledChange: (enabled: boolean) => void;
children?: React.ReactNode;
onSetup?: () => void;
disabled?: boolean;
hideSwitch?: boolean;
};
export function IntegrationCard<T>({
image,
name,
description,
url,
data,
switchState,
onEnabledChange,
children,
onSetup,
disabled,
hideSwitch = false,
}: Props<T>) {
return (
<div
className={cn(
" border border-nb-gray-900/50 p-5 rounded-lg transition-all max-w-[360px] flex flex-col justify-between gap-4",
switchState ? "bg-nb-gray-930/50" : "bg-nb-gray-930/30",
disabled && "opacity-60 pointer-events-none",
)}
>
<div className={"flex flex-col gap-4"}>
<div className={"flex justify-between"}>
<div className={"flex gap-4"}>
<div
className={
"h-12 w-12 flex items-center justify-center rounded-md bg-nb-gray-900/70 p-2 border border-nb-gray-900/70"
}
>
<Image src={image} alt={name} className={"rounded-[4px]"} />
</div>
<div>
<h3 className={""}>{name}</h3>
<InlineLink
href={url.href}
target={"_blank"}
className={"text-sm font-light"}
variant={"faded"}
>
{url.title}
<ExternalLinkIcon size={12} />
</InlineLink>
</div>
</div>
{!hideSwitch && (
<div className={"flex items-center"}>
<ToggleSwitch
checked={switchState}
onCheckedChange={onEnabledChange}
className={"grow"}
/>
</div>
)}
</div>
<div>
<Paragraph className={"text-sm font-light"}>{description}</Paragraph>
</div>
</div>
{data == undefined ? (
<div>
<Button
variant={"secondary"}
size={"xs"}
className={"w-full items-center"}
onClick={onSetup}
>
<Repeat size={13} />
Connect {name}
</Button>
</div>
) : (
children
)}
</div>
);
}

View File

@@ -1,56 +0,0 @@
import Paragraph from "@components/Paragraph";
import { cn } from "@utils/helpers";
import { ArrowRightLeft } from "lucide-react";
import { StaticImport } from "next/dist/shared/lib/get-img-props";
import Image from "next/image";
import * as React from "react";
import netBirdLogo from "@/assets/netbird.svg";
type Props = {
image: StaticImport | string;
title: string;
description: string;
};
export const IntegrationModalHeader = ({
image,
title,
description,
}: Props) => {
return (
<>
<div className={"flex justify-center items-center gap-4 mt-5"}>
<div
className={
"h-12 w-12 flex items-center justify-center rounded-md bg-nb-gray-900/70 p-2 border border-nb-gray-900/70"
}
>
<Image
src={netBirdLogo}
alt={"NetBird"}
className={"rounded-[4px]"}
/>
</div>
<div>
<ArrowRightLeft size={24} className={"text-netbird"} />
</div>
<div
className={
"h-12 w-12 flex items-center justify-center rounded-md bg-nb-gray-900/70 p-2 border border-nb-gray-900/70"
}
>
<Image src={image} alt={""} className={"rounded-[4px]"} />
</div>
</div>
<div
className={
"mx-auto text-center flex flex-col items-center justify-center mt-6 z-[1]"
}
>
<h2 className={"text-lg my-0 leading-[1.5 text-center]"}>{title}</h2>
<Paragraph className={cn("text-sm text-center max-w-[450px] px-4")}>
{description}
</Paragraph>
</div>
</>
);
};

View File

@@ -1,70 +0,0 @@
import { IconCircleFilled } from "@tabler/icons-react";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import { FileText } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import * as React from "react";
import datadogLogo from "@/assets/integrations/datadog.png";
import { EventStream } from "@/interfaces/EventStream";
export const EventStreamingCard = () => {
const { data: eventStreamIntegrations } = useFetchApi<EventStream[]>(
"/integrations/event-streaming",
);
const dataDogSettings = eventStreamIntegrations?.find(
(integration) => integration.platform === "datadog",
);
const enabled = dataDogSettings ? dataDogSettings.enabled : false;
const router = useRouter();
return (
<div className={"p-default pb-6"}>
<div
onClick={() => router.push("/integrations")}
className={cn(
"border cursor-pointer border-nb-gray-900/50 bg-nb-gray-900/30 hover:bg-nb-gray-900/50 py-3 pl-3 pr-5 rounded-lg transition-all min-w-[310px] max-w-[400px]",
)}
>
<div className={"inline-flex gap-4 w-full"}>
<div
className={
"h-10 w-10 shrink-0 flex items-center justify-center rounded-md bg-nb-gray-900/70 p-2 border border-nb-gray-900/70"
}
>
{dataDogSettings?.enabled && (
<Image
src={datadogLogo}
alt={"Datadog"}
className={"rounded-[4px]"}
/>
)}
{!dataDogSettings && <FileText size={16} />}
</div>
<div className={""}>
<div className={"flex items-center gap-3 justify-between"}>
<div className={"font-medium text-sm flex gap-2 items-center"}>
Event Streaming
</div>
<div
className={cn(
"text-xs flex gap-2 items-center mb-2 font-medium",
enabled ? "text-green-500" : "text-nb-gray-500",
)}
>
<IconCircleFilled size={8} />
{enabled ? "Enabled" : "Disabled"}
</div>
</div>
<p className={"text-xs font-light !text-nb-gray-300 "}>
Stream your activity events to third-party services.
</p>
</div>
</div>
</div>
</div>
);
};

View File

@@ -1,49 +0,0 @@
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import * as Tabs from "@radix-ui/react-tabs";
import { ExternalLinkIcon, FileText } from "lucide-react";
import React from "react";
import IntegrationIcon from "@/assets/icons/IntegrationIcon";
import Datadog from "@/modules/integrations/event-streaming/datadog/Datadog";
export default function EventStreamingTab() {
return (
<Tabs.Content value={"event-streaming"}>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/integrations"}
label={"Integrations"}
icon={<IntegrationIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/integrations"}
label={"Event Streaming"}
icon={<FileText size={14} />}
active
/>
</Breadcrumbs>
<h1>Event Streaming</h1>
<Paragraph>
Event Streaming allows you to stream NetBirds activity events to
different third-party services.
</Paragraph>
<Paragraph>
Learn more about{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/activity-event-streaming"}
target={"_blank"}
>
Event Streaming
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
<div className={"gap-6 mt-6 flex flex-wrap"}>
<Datadog />
</div>
</div>
</Tabs.Content>
);
}

View File

@@ -1,75 +0,0 @@
import { notify } from "@components/Notification";
import { SkeletonIntegration } from "@components/skeletons/SkeletonIntegration";
import useFetchApi, { useApiCall } from "@utils/api";
import * as React from "react";
import { useState } from "react";
import { useSWRConfig } from "swr";
import integrationImage from "@/assets/integrations/datadog.png";
import { useDialog } from "@/contexts/DialogProvider";
import { EventStream } from "@/interfaces/EventStream";
import DatadogSetup from "@/modules/integrations/event-streaming/datadog/DatadogSetup";
import { IntegrationCard } from "@/modules/integrations/IntegrationCard";
export default function Datadog() {
const { mutate } = useSWRConfig();
const { data: eventStreamIntegrations, isLoading } = useFetchApi<
EventStream[]
>("/integrations/event-streaming");
const dataDogSettings = eventStreamIntegrations?.find(
(integration) => integration.platform === "datadog",
);
const integrationRequest = useApiCall<EventStream>(
"/integrations/event-streaming",
);
const [setupModal, setSetupModal] = useState(false);
const { confirm } = useDialog();
const toggleSwitch = async () => {
if (!dataDogSettings) return setSetupModal(true);
const choice = await confirm({
title: `Disconnect Datadog?`,
description:
"Disconnecting deletes the current configuration. You will need to start the setup process again.",
confirmText: "Disconnect",
cancelText: "Cancel",
type: "warning",
});
if (!choice) return;
notify({
title: "Datadog Integration",
description: `Datadog was successfully disconnected`,
promise: integrationRequest.del({}, "/" + dataDogSettings.id).then(() => {
mutate("/integrations/event-streaming");
}),
loadingMessage: "Disconnecting integration...",
});
};
return isLoading ? (
<>
<SkeletonIntegration />
</>
) : (
<>
<IntegrationCard
name="Datadog"
description="Datadog is a monitoring service for cloud-scale applications."
url={{
title: "datadoghq.com",
href: "https://www.datadoghq.com/",
}}
image={integrationImage}
data={dataDogSettings}
switchState={!dataDogSettings ? false : dataDogSettings.enabled}
onEnabledChange={toggleSwitch}
onSetup={() => setSetupModal(true)}
></IntegrationCard>
<DatadogSetup open={setupModal} onOpenChange={setSetupModal} />
</>
);
}

View File

@@ -1,44 +0,0 @@
import { CountryEURounded } from "@/assets/countries/CountryEURounded";
import { CountryJPRounded } from "@/assets/countries/CountryJPRounded";
import { CountryUSRounded } from "@/assets/countries/CountryUSRounded";
export const DatadogRegions = [
{
name: "Europe (EU)",
site_url: "https://app.datadoghq.eu",
send_logs_url: "https://http-intake.logs.datadoghq.eu/api/v2/logs",
icon: CountryEURounded,
},
{
name: "United States (US1)",
site_url: "https://app.datadoghq.com",
send_logs_url: "https://http-intake.logs.datadoghq.com/api/v2/logs",
icon: CountryUSRounded,
},
{
name: "United States (US3)",
site_url: "https://us3.datadoghq.com",
send_logs_url: "https://http-intake.logs.us3.datadoghq.com/api/v2/logs",
icon: CountryUSRounded,
},
{
name: "United States (US5)",
site_url: "https://us5.datadoghq.com",
send_logs_url: "https://http-intake.logs.us5.datadoghq.com/api/v2/logs",
icon: CountryUSRounded,
},
{
name: "United States (US1-FED)",
site_url: "https://app.ddog-gov.com",
send_logs_url: "https://http-intake.logs.ddog-gov.com/api/v2/logs",
icon: CountryUSRounded,
},
{
name: "Japan (AP1)",
site_url: "https://ap1.datadoghq.com",
send_logs_url: "https://http-intake.logs.ap1.datadoghq.com/api/v2/logs",
icon: CountryJPRounded,
},
] as const;
export const DatadogApiKeysPage = "/organization-settings/api-keys";

View File

@@ -1,277 +0,0 @@
import Button from "@components/Button";
import InlineLink from "@components/InlineLink";
import { Input } from "@components/Input";
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
import { notify } from "@components/Notification";
import {
SelectDropdown,
SelectOption,
} from "@components/select/SelectDropdown";
import Steps from "@components/Steps";
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
import { IconArrowLeft, IconArrowRight } from "@tabler/icons-react";
import { useApiCall } from "@utils/api";
import { cn } from "@utils/helpers";
import {
ExternalLinkIcon,
Globe,
GlobeIcon,
KeyRound,
Repeat,
} from "lucide-react";
import Link from "next/link";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import datadogLogo from "@/assets/integrations/datadog.png";
import { EventStream } from "@/interfaces/EventStream";
import {
DatadogApiKeysPage,
DatadogRegions,
} from "@/modules/integrations/event-streaming/datadog/DatadogRegions";
import { IntegrationModalHeader } from "@/modules/integrations/IntegrationModalHeader";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
};
export default function DatadogSetup({ open, onOpenChange, onSuccess }: Props) {
return (
<>
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
<SetupContent
onSuccess={() => {
onOpenChange(false);
onSuccess && onSuccess();
}}
/>
</Modal>
</>
);
}
type ModalProps = {
onSuccess: () => void;
};
export function SetupContent({ onSuccess }: ModalProps) {
const { mutate } = useSWRConfig();
const integrationRequest = useApiCall<EventStream>(
"/integrations/event-streaming",
);
const datadogRegions = DatadogRegions.map((region) => {
return {
label: region.name,
value: region.send_logs_url,
icon: region.icon,
} as SelectOption;
});
const [selectedRegion, setSelectedRegion] = useState(datadogRegions[0].value);
const changeRegion = (region: string) => {
setSelectedRegion(region);
setApiUrl(region);
};
const [apiKey, setApiKey] = useState("");
const [apiUrl, setApiUrl] = useState(datadogRegions[0].value);
const [step, setStep] = useState(1);
const apiKeyEntered = apiKey.length > 0 && apiKey != "";
const apiUrlEntered = apiUrl.length > 0 && apiUrl != "";
const apiKeyAndUrlEntered = apiKeyEntered && apiUrlEntered;
const apiPageUrl =
DatadogRegions.find((region) => region.send_logs_url == apiUrl)?.site_url +
DatadogApiKeysPage;
const connect = async () => {
notify({
title: "Datadog Integration",
description: `Datadog was successfully connected to NetBird.`,
promise: integrationRequest
.post({
platform: "datadog",
config: {
api_key: apiKey,
api_url: apiUrl,
},
enabled: true,
})
.then(() => {
mutate("/integrations/event-streaming");
onSuccess();
}),
loadingMessage: "Setting up integration...",
});
};
return (
<ModalContent
maxWidthClass={cn("relative", step === 1 ? "max-w-md" : "max-w-lg")}
showClose={true}
>
<GradientFadedBackground />
<IntegrationModalHeader
image={datadogLogo}
title={"Connect NetBird with Datadog"}
description={
"Start streaming your NetBird activity events to Datadog. Follow the steps below to get started."
}
/>
{step == 1 && (
<div className={"px-8 py-3 flex flex-col mt-4 z-0"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<GlobeIcon size={16} />
Select your Datadog region
</p>
<p className={"mb-3 mt-2"}>
To identify which region you are on please check out the{" "}
<InlineLink
href={"https://docs.datadoghq.com/getting_started/site/"}
target={"_blank"}
variant={"default"}
className={"inline"}
>
Datadog Documentation.
</InlineLink>
</p>
<SelectDropdown
value={selectedRegion}
onChange={changeRegion}
options={datadogRegions}
/>
<div className={"mt-3 hidden"}>
<Input
type={"text"}
className={"w-full"}
customPrefix={
<div className={"flex items-center gap-2"}>
<Globe size={16} className={"text-nb-gray-300"} />
</div>
}
placeholder={"https://http-intake.logs.datadoghq.eu/api/v2/logs"}
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
/>
</div>
<div className={"mb-3"}></div>
</div>
)}
{step == 2 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4 z-0"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<KeyRound size={16} />
Get your Datadog API Key
</p>
<Steps>
<Steps.Step step={1}>
<p>Navigate to Datadogs API Keys page</p>
<div className={"flex gap-4"}>
<Link href={apiPageUrl} passHref target={"_blank"}>
<Button variant={"primary"} size={"xs"}>
<ExternalLinkIcon size={14} />
API Keys
</Button>
</Link>
</div>
</Steps.Step>
<Steps.Step step={2}>
<p className={"font-normal"}>
Click{" "}
<div
className={
"inline-flex bg-nb-gray-900 py-1.5 px-2.5 rounded-md text-xs items-center mx-0.5"
}
>
+ New Key
</div>{" "}
at the top
</p>
</Steps.Step>
<Steps.Step step={3}>
<p className={"font-normal"}>
Give it a descriptive name like{" "}
<div
className={
"inline-flex bg-nb-gray-900 py-1.5 px-2.5 rounded-md text-xs items-center mx-0.5"
}
>
NetBird Activity Events
</div>
and click{" "}
<div
className={
"inline-flex bg-nb-gray-900 py-1.5 px-2.5 rounded-md text-xs items-center mx-0.5"
}
>
Create Key
</div>
</p>
</Steps.Step>
<Steps.Step step={4} line={false}>
<p className={"font-normal"}>Enter your API-Key</p>
</Steps.Step>
</Steps>
<div className={"mb-4"}>
<Input
type={"text"}
className={"w-full"}
customPrefix={
<div className={"flex items-center gap-2"}>
<KeyRound size={16} className={"text-nb-gray-300"} />
</div>
}
placeholder={"1c17401cf170f7ac33dd9dcdf8040eb2"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
</div>
</div>
)}
<ModalFooter className={"items-center gap-4"}>
{step == 1 && (
<Button
variant={"primary"}
className={"w-full"}
disabled={!apiUrlEntered}
onClick={() => setStep(2)}
>
Continue
<IconArrowRight size={16} />
</Button>
)}
{step == 2 && (
<>
<Button
variant={"secondary"}
className={"w-full"}
onClick={() => setStep(1)}
>
<IconArrowLeft size={16} />
Back
</Button>
<Button
variant={"primary"}
className={"w-full"}
disabled={!apiKeyAndUrlEntered}
onClick={connect}
>
<Repeat size={16} />
Connect
</Button>
</>
)}
</ModalFooter>
</ModalContent>
);
}

View File

@@ -1,109 +0,0 @@
import Button from "@components/Button";
import { Input } from "@components/Input";
import { useDebounce } from "@hooks/useDebounce";
import { Folder, MinusCircleIcon, PlusIcon } from "lucide-react";
import * as React from "react";
import { useEffect, useState } from "react";
type GroupPrefixInputProps = {
value: string[];
onChange: (values: string[]) => void;
addText?: string;
icon?: React.ReactNode;
text?: string;
placeholder?: string;
};
export function GroupPrefixInput({
value,
onChange,
addText = "Add group filter",
icon = <Folder size={14} />,
text = "Group starts with...",
placeholder = "e.g., NetBird_",
}: GroupPrefixInputProps) {
const [groupPrefixes, setGroupPrefixes] = useState<string[]>(value);
const prefixes = useDebounce(groupPrefixes, 100);
useEffect(() => {
onChange(prefixes);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [prefixes]);
const onChangeHandler = (
e: React.ChangeEvent<HTMLInputElement>,
index: number,
) => {
const newPrefixes = [...groupPrefixes];
newPrefixes[index] = e.target.value;
setGroupPrefixes(newPrefixes);
};
const onRemoveGroupPrefix = (index: number) => {
setGroupPrefixes((p) => {
const newPrefixes = [...p];
newPrefixes.splice(index, 1);
return newPrefixes;
});
};
const onAddGroupPrefix = () => {
setGroupPrefixes((p) => {
const newPrefixes = [...p];
newPrefixes.push("");
return newPrefixes;
});
};
return (
<div className={"mt-4"}>
{groupPrefixes.length > 0 && (
<div className={"flex gap-3 w-full mb-3"}>
<div className={"flex flex-col gap-2 w-full"}>
{groupPrefixes.map((g, i) => {
return (
<div className={"flex gap-2 w-full"} key={i}>
<div className={"w-full"}>
<Input
customPrefix={
<div className={"flex gap-2 items-center"}>
{icon}
<span>{text}</span>
</div>
}
placeholder={placeholder}
maxWidthClass={"w-full"}
value={g}
className={" !text-[13px]"}
onKeyDown={(event) => {
if (event.code === "Space") event.preventDefault();
}}
onChange={(e) => onChangeHandler(e, i)}
/>
</div>
<Button
className={"h-[42px]"}
variant={"default-outline"}
onClick={() => onRemoveGroupPrefix(i)}
>
<MinusCircleIcon size={15} />
</Button>
</div>
);
})}
</div>
</div>
)}
<Button
variant={"dotted"}
className={"w-full"}
size={"sm"}
onClick={onAddGroupPrefix}
>
<PlusIcon size={14} />
{addText}
</Button>
</div>
);
}

View File

@@ -1,87 +0,0 @@
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import { Label } from "@components/Label";
import Paragraph from "@components/Paragraph";
import { SkeletonIntegration } from "@components/skeletons/SkeletonIntegration";
import * as Tabs from "@radix-ui/react-tabs";
import { ExternalLinkIcon, FingerprintIcon } from "lucide-react";
import React from "react";
import IntegrationIcon from "@/assets/icons/IntegrationIcon";
import { useAccount } from "@/modules/account/useAccount";
import { AzureAD } from "@/modules/integrations/idp-sync/azure-ad/AzureAD";
import { GoogleWorkspace } from "@/modules/integrations/idp-sync/google-workspace/GoogleWorkspace";
import { useIntegrations } from "@/modules/integrations/idp-sync/useIntegrations";
export default function IdentityProviderTab() {
const account = useAccount();
useIntegrations();
return (
<Tabs.Content value={"identity-provider"}>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/integrations"}
label={"Integrations"}
icon={<IntegrationIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/settings"}
label={"Identity Provider"}
icon={<FingerprintIcon size={14} />}
active
/>
</Breadcrumbs>
<h1>Identity Provider</h1>
<Paragraph>
Configure your preferred Identity Provider (IdP) to synchronize your
users and groups to NetBird.
</Paragraph>
<Paragraph>
Learn more about{" "}
<InlineLink href={"#"} target={"_blank"}>
Identity Provider
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
<div className={"gap-6 mt-6 flex flex-wrap"}>
{!account ? (
<>
<SkeletonIntegration loadingHeight={196} />
<SkeletonIntegration loadingHeight={196} />
</>
) : (
<>
<GoogleWorkspace />
<AzureAD />
</>
)}
</div>
<div className={"flex flex-col gap-6 max-w-md mt-10"}>
<div
className={
"bg-netbird-950 px-6 py-4 rounded-md border border-netbird-500 "
}
>
<Label className={"!text-netbird-100 text-md"}>
Looking to enable a custom Identity Provider like Okta or
Jumpcloud?
</Label>
<p className={"!text-netbird-200 mt-2"}>
Please contact us at{" "}
<InlineLink
href={"mailto:support@netbird.io"}
className={"inline !text-netbird-500 font-medium"}
>
{" "}
support@netbird.io
</InlineLink>{" "}
</p>
</div>
</div>
</div>
</Tabs.Content>
);
}

View File

@@ -1,158 +0,0 @@
import Button from "@components/Button";
import FullTooltip from "@components/FullTooltip";
import { notify } from "@components/Notification";
import { SkeletonIntegration } from "@components/skeletons/SkeletonIntegration";
import useFetchApi, { useApiCall } from "@utils/api";
import dayjs from "dayjs";
import { RefreshCw, Settings } from "lucide-react";
import * as React from "react";
import { useEffect, useState } from "react";
import { useSWRConfig } from "swr";
import integrationImage from "@/assets/integrations/entra-id.png";
import {
AzureADIntegration,
IdentityProviderLog,
} from "@/interfaces/IdentityProvider";
import AzureADConfiguration from "@/modules/integrations/idp-sync/azure-ad/AzureADConfiguration";
import AzureADSetup from "@/modules/integrations/idp-sync/azure-ad/AzureADSetup";
import { useIntegrations } from "@/modules/integrations/idp-sync/useIntegrations";
import { IntegrationCard } from "@/modules/integrations/IntegrationCard";
export const AzureAD = () => {
const { mutate } = useSWRConfig();
const [setupModal, setSetupModal] = useState(false);
const {
azure: integration,
isAnyIntegrationEnabled,
isAzureLoading,
} = useIntegrations();
const azureRequest = useApiCall<AzureADIntegration>(
"/integrations/azure-idp",
);
const [enabled, setEnabled] = useState(
integration ? integration.enabled : false,
);
useEffect(() => {
setEnabled(integration?.enabled ?? false);
}, [integration]);
const toggleSwitch = async (state: boolean) => {
if (!integration) return setSetupModal(true);
notify({
title: "Entra ID (Azure AD) Integration",
description: `Entra ID (Azure AD) was successfully ${
state ? "enabled" : "disabled"
}`,
promise: azureRequest
.put(
{
enabled: state,
},
"/" + integration.id,
)
.then(() => {
mutate("/integrations/azure-idp");
setEnabled(state);
}),
loadingMessage: "Updating integration...",
});
};
return isAzureLoading ? (
<SkeletonIntegration loadingHeight={197} />
) : (
<>
<IntegrationCard
name="Entra ID (Azure AD)"
description="Microsoft Entra ID is a cloud-based identity and access management solution."
url={{
title: "microsoft.com",
href: "https://www.microsoft.com/en-us/security/business/identity-access/microsoft-entra-id",
}}
image={integrationImage}
data={integration}
disabled={enabled ? false : isAnyIntegrationEnabled}
switchState={enabled}
onEnabledChange={toggleSwitch}
onSetup={() => setSetupModal(true)}
>
{integration && <ConfigurationButton config={integration} />}
</IntegrationCard>
<AzureADSetup
open={setupModal}
onOpenChange={setSetupModal}
onSuccess={() => setEnabled(true)}
/>
</>
);
};
type ConfigurationProps = {
config: AzureADIntegration;
};
const ConfigurationButton = ({ config }: ConfigurationProps) => {
const { data: logs } = useFetchApi<IdentityProviderLog[]>(
`/integrations/azure-idp/${config.id}/logs`,
);
const { mutate } = useSWRConfig();
const syncRequest = useApiCall<{ response: boolean }>(
`/integrations/azure-idp/${config.id}/sync`,
);
const [configModal, setConfigModal] = useState(false);
const forceSync = async () => {
notify({
title: "Entra ID (Azure AD) Integration",
description: `Entra ID (Azure AD) was successfully synced`,
loadingMessage: "Syncing integration...",
promise: syncRequest.post({}).then(() => {
mutate(`/integrations/azure-idp/${config.id}/logs`);
}),
});
};
return (
<>
<div className={"flex gap-2"}>
<FullTooltip
content={
<div className={"text-xs"}>
Force synchronization of users and groups
</div>
}
disabled={!config.enabled}
className={"w-full"}
interactive={false}
>
<Button
variant={"secondary"}
size={"xs"}
className={"w-full items-center"}
onClick={forceSync}
disabled={!config.enabled}
>
<RefreshCw size={14} />
Synced {dayjs().to(logs?.[0]?.timestamp)}
</Button>
</FullTooltip>
<Button
variant={"secondary"}
size={"xs"}
className={"items-center"}
onClick={() => {
setConfigModal(true);
}}
>
<Settings size={14} />
</Button>
</div>
<AzureADConfiguration open={configModal} onOpenChange={setConfigModal} />
</>
);
};

View File

@@ -1,372 +0,0 @@
import Button from "@components/Button";
import HelpText from "@components/HelpText";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import {
Modal,
ModalClose,
ModalContent,
ModalFooter,
} from "@components/modal/Modal";
import { notify } from "@components/Notification";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
import { useHasChanges } from "@hooks/useHasChanges";
import { useApiCall } from "@utils/api";
import { cn } from "@utils/helpers";
import {
AlertOctagon,
Box,
Cog,
Folder,
FolderGit2,
KeyRound,
RefreshCw,
UserCircle,
} from "lucide-react";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import integrationImage from "@/assets/integrations/entra-id.png";
import { useDialog } from "@/contexts/DialogProvider";
import { AzureADIntegration } from "@/interfaces/IdentityProvider";
import { GroupPrefixInput } from "@/modules/integrations/idp-sync/GroupPrefixInput";
import { useIntegrations } from "@/modules/integrations/idp-sync/useIntegrations";
import { IntegrationModalHeader } from "@/modules/integrations/IntegrationModalHeader";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
};
export default function AzureADConfiguration({
open,
onOpenChange,
onSuccess,
}: Props) {
const { azure } = useIntegrations();
return (
<>
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
{azure && (
<ConfigurationContent
onSuccess={() => {
onOpenChange(false);
onSuccess && onSuccess();
}}
config={azure}
/>
)}
</Modal>
</>
);
}
type ModalProps = {
onSuccess: () => void;
config: AzureADIntegration;
};
export function ConfigurationContent({ onSuccess, config }: ModalProps) {
const { mutate } = useSWRConfig();
const { confirm } = useDialog();
const [tab, setTab] = useState<string>("settings");
const azureRequest = useApiCall<AzureADIntegration>(
"/integrations/azure-idp",
);
const clientSecretPlaceholder = "******************************";
const [clientSecret, setClientSecret] = useState(clientSecretPlaceholder);
const [clientId, setClientId] = useState(config.clientId);
const [tenantId, setTenantId] = useState(config.tenantId);
const [interval, setInterval] = useState(config.syncInterval.toString());
const [groupPrefixes, setGroupPrefixes] = useState<string[]>(
config.group_prefixes || [],
);
const [userGroupPrefixes, setUserGroupPrefixes] = useState<string[]>(
config.user_group_prefixes || [],
);
const deleteIntegration = async () => {
const choice = await confirm({
title: `Delete integration?`,
description: "Are you sure you want to delete this integration?",
confirmText: "Delete",
cancelText: "Cancel",
type: "danger",
});
if (!choice) return;
notify({
title: "Entra ID (Azure AD) Integration",
description: `Entra ID (Azure AD) was successfully deleted`,
promise: azureRequest.del({}, `/${config.id}`).then(() => {
mutate("/integrations/azure-idp");
onSuccess();
}),
loadingMessage: "Deleting integration...",
});
};
const updateIntegration = async () => {
notify({
title: "Entra ID (Azure AD) Integration",
description: `Entra ID (Azure AD) was successfully updated`,
promise: azureRequest
.put(
{
client_id: clientId,
tenant_id: tenantId,
client_secret:
clientSecretPlaceholder == clientSecret
? undefined
: btoa(clientSecret),
sync_interval: interval ? parseInt(interval) : 300,
group_prefixes: groupPrefixes || [],
user_group_prefixes: userGroupPrefixes || [],
},
`/${config.id}`,
)
.then(() => {
mutate("/integrations/azure-idp");
onSuccess();
}),
loadingMessage: "Updating integration...",
});
};
const { hasChanges } = useHasChanges([
clientId,
tenantId,
clientSecret,
interval,
groupPrefixes,
userGroupPrefixes,
]);
return (
<ModalContent
maxWidthClass={cn("relative max-w-xl")}
showClose={true}
className={""}
autoFocus={false}
>
<GradientFadedBackground />
<IntegrationModalHeader
image={integrationImage}
title={"Entra ID (Azure AD) Configuration"}
description={"Sync your users and groups from Entra ID to NetBird."}
/>
<Tabs
defaultValue={tab}
onValueChange={(v) => setTab(v)}
className={"mt-6"}
>
<TabsList justify={"start"} className={"px-8"}>
<TabsTrigger value={"settings"}>
<Cog
size={16}
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
/>
Settings
</TabsTrigger>
<TabsTrigger value={"group-sync"}>
<FolderGit2
size={16}
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
/>
Group Sync
</TabsTrigger>
<TabsTrigger value={"user-sync"}>
<UserCircle
size={16}
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
/>
User Sync
</TabsTrigger>
<TabsTrigger value={"danger"}>
<AlertOctagon
size={16}
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
/>
Danger Zone
</TabsTrigger>
</TabsList>
<TabsContent value={"settings"} className={"px-8 text-sm"}>
<div className={"flex-col gap-3 flex"}>
<Input
type={"text"}
autoCorrect={"off"}
autoComplete={"off"}
className={"w-full"}
customPrefix={
<div className={"min-w-[165px] flex gap-2 items-center"}>
<Box size={16} />
Application (client) ID
</div>
}
placeholder={"62d3a656-c87d-4f30-a242-5b6347e29e9f"}
value={clientId}
onChange={(e) => setClientId(e.target.value)}
/>
<Input
autoCorrect={"off"}
autoComplete={"off"}
type={"text"}
className={"w-full"}
customPrefix={
<div className={"min-w-[165px] flex gap-2 items-center"}>
<Folder size={16} />
Directory (tenant) ID
</div>
}
placeholder={"5d60468a-65b7-45eb-a61a-53ecfbcd1ea3"}
value={tenantId}
onChange={(e) => setTenantId(e.target.value)}
/>
<Input
autoCorrect={"off"}
type={"text"}
className={"w-full"}
customPrefix={
<div className={"min-w-[165px] flex gap-2 items-center"}>
<KeyRound size={16} />
Client Secret
</div>
}
placeholder={"YdV7Q~JJ62Xl.LvYoBanxZR2sJA2va_3UbqvncY8"}
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
/>
<div className={"flex justify-between mt-4"}>
<div>
<Label>Sync Interval</Label>
<HelpText className={"max-w-[300px]"}>
The interval in seconds when the synchronization should
happen.
</HelpText>
</div>
<Input
maxWidthClass={"max-w-[400px]"}
placeholder={"300"}
min={1}
max={99999}
value={interval}
type={"number"}
onChange={(e) => setInterval(e.target.value)}
customPrefix={
<RefreshCw size={16} className={"text-nb-gray-300"} />
}
customSuffix={"Seconds"}
/>
</div>
</div>
</TabsContent>
<TabsContent value={"group-sync"} className={"px-8"}>
<div>
<Label>
<div className={"flex gap-2 items-center"}>
<FolderGit2 size={16} />
Synchronize Groups
</div>
</Label>
<HelpText className={"max-w-lg mt-2"}>
By default,{" "}
<span className={"text-netbird font-semibold"}>All Groups</span>{" "}
will be synchronized from your IdP to NetBird. <br />
If you want to synchronize only groups that start with a specific
prefix, you can add them below.
</HelpText>
</div>
<GroupPrefixInput value={groupPrefixes} onChange={setGroupPrefixes} />
</TabsContent>
<TabsContent value={"user-sync"} className={"px-8"}>
<div>
<Label>
<div className={"flex gap-2 items-center"}>
<UserCircle size={16} />
Synchronize Users
</div>
</Label>
<HelpText className={"max-w-lg mt-2"}>
By default,{" "}
<span className={"text-netbird font-semibold"}>All Users</span>{" "}
will be synchronized from your IdP to NetBird. <br />
If you want to synchronize only users that belong to a specific
group, you can add them below.
</HelpText>
</div>
<GroupPrefixInput
addText={"Add user group filter"}
text={"User group starts with..."}
value={userGroupPrefixes}
onChange={setUserGroupPrefixes}
/>
</TabsContent>
<TabsContent value={"danger"} className={"px-8"}>
<div>
<Label>
<div className={"flex gap-2 items-center"}>
<AlertOctagon size={16} />
Delete Integration
</div>
</Label>
<HelpText className={"max-w-lg mt-2"}>
Deleting this integration will remove the ability to sync users
and groups from your IdP to NetBird. If you delete the integration
you will need to reconfigure it again to enable the
synchronization.
</HelpText>
</div>
<Button
variant={"danger"}
size={"xs"}
className={"mt-3"}
onClick={deleteIntegration}
>
Delete Integration
</Button>
</TabsContent>
</Tabs>
<div className={"h-6"}></div>
<ModalFooter className={"items-center gap-4"}>
<ModalClose asChild={true}>
<Button variant={"secondary"} className={"w-full"}>
Cancel
</Button>
</ModalClose>
<Button
variant={"primary"}
className={"w-full"}
disabled={!hasChanges}
onClick={updateIntegration}
>
Save
</Button>
</ModalFooter>
</ModalContent>
);
}

View File

@@ -1,498 +0,0 @@
import Button from "@components/Button";
import HelpText from "@components/HelpText";
import InlineLink from "@components/InlineLink";
import { Input } from "@components/Input";
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
import { notify } from "@components/Notification";
import Steps from "@components/Steps";
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
import { Lightbox } from "@components/ui/Lightbox";
import { Mark } from "@components/ui/Mark";
import { MinimalList } from "@components/ui/MinimalList";
import { IconArrowLeft, IconArrowRight } from "@tabler/icons-react";
import { useApiCall } from "@utils/api";
import { cn } from "@utils/helpers";
import { isEmpty } from "lodash";
import {
Box,
Clock4,
Folder,
FolderGit2,
KeyRound,
PlusCircle,
Repeat,
Settings2,
Shield,
UserCircle,
} from "lucide-react";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import integrationImage from "@/assets/integrations/entra-id.png";
import { AzureADIntegration } from "@/interfaces/IdentityProvider";
import azureGrantAdmin from "@/modules/integrations/idp-sync/azure-ad/images/azure-grant-admin-conset.png";
import { IntegrationModalHeader } from "@/modules/integrations/IntegrationModalHeader";
import { GroupPrefixInput } from "../GroupPrefixInput";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
};
export default function AzureADSetup({ open, onOpenChange, onSuccess }: Props) {
return (
<>
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
<SetupContent
onSuccess={() => {
onOpenChange(false);
onSuccess && onSuccess();
}}
/>
</Modal>
</>
);
}
type ModalProps = {
onSuccess: () => void;
};
export function SetupContent({ onSuccess }: ModalProps) {
const { mutate } = useSWRConfig();
const azureRequest = useApiCall<AzureADIntegration>(
"/integrations/azure-idp",
);
const [step, setStep] = useState(0);
const maxSteps = 6;
const [clientSecret, setClientSecret] = useState("");
const [clientId, setClientId] = useState("");
const [tenantId, setTenantId] = useState("");
const clientSecretEntered = !isEmpty(clientSecret);
const clientIdEntered = !isEmpty(clientId);
const tenantIdEntered = !isEmpty(tenantId);
const allEntered = clientIdEntered && tenantIdEntered && clientSecretEntered;
const isDisabled =
(step == 8 && !clientSecretEntered) || (step == 9 && !allEntered);
const [groupPrefixes, setGroupPrefixes] = useState<string[]>([]);
const [userGroupPrefixes, setUserGroupPrefixes] = useState<string[]>([]);
const connect = async () => {
notify({
title: "Entra ID Integration",
description: `Entra ID was successfully connected to NetBird.`,
promise: azureRequest
.post({
client_secret: btoa(clientSecret), // Encode client secret to base64
client_id: clientId,
tenant_id: tenantId,
group_prefixes: groupPrefixes || [],
user_group_prefixes: userGroupPrefixes || [],
})
.then(() => {
mutate("/integrations/azure-idp");
onSuccess();
}),
loadingMessage: "Setting up integration...",
});
};
return (
<ModalContent
maxWidthClass={cn("relative", step == 0 ? "max-w-md" : "max-w-xl")}
showClose={true}
className={""}
onEscapeKeyDown={(e) => step > 0 && e.preventDefault()}
onInteractOutside={(e) => step > 0 && e.preventDefault()}
onPointerDownOutside={(e) => step > 0 && e.preventDefault()}
>
<GradientFadedBackground />
{step > 0 && (
<div className={"flex gap-2 w-full items-center justify-center mb-4"}>
{Array.from({ length: maxSteps }).map((_, index) => (
<div
key={index}
className={cn(
"w-8 h-1 rounded-full bg-nb-gray-800",
step >= index + 1 && "bg-netbird",
)}
/>
))}
</div>
)}
<IntegrationModalHeader
image={integrationImage}
title={"Connect NetBird with Entra ID (Azure AD)"}
description={
"Start syncing your users and groups from Entra ID to NetBird. Follow the steps below to get started."
}
/>
{step == 0 && (
<div
className={
"px-8 py-3 flex z-0 flex-col gap-0 text-sm mb-3 text-center justify-center items-center"
}
>
<div
className={
"mt-6 text-base font-medium text-nb-gray-100 flex gap-2 items-center justify-center"
}
>
<Shield size={16} />
Required Permissions
</div>
<p className={"mt-2 !text-nb-gray-300 !leading-[1.5]"}>
Ensure that you have an an{" "}
<span className={"text-nb-gray-100 font-semibold"}>
Azure AD user account
</span>{" "}
with the following{" "}
<span className={"text-nb-gray-100 font-semibold"}>
permissions
</span>
.{" "}
{
"If you don't have the required permissions, ask your Azure AD administrator to grant them to you."
}
</p>
<div
className={
"flex items-center flex-col gap-0 mt-2 w-full justify-center max-w-lg"
}
>
<div
className={
"py-2 px-6 flex items-center gap-2 rounded-md w-full justify-center bg-nb-gray-930/0 text-nb-gray-200"
}
>
<PlusCircle size={14} className={"text-sky-500"} />
Create Azure AD applications
</div>
<div
className={
"py-2 px-6 flex items-center gap-2 rounded-md w-full justify-center bg-nb-gray-930/0 text-nb-gray-200"
}
>
<Settings2 size={14} className={"text-sky-500"} />
Manage Azure AD applications
</div>
</div>
</div>
)}
{step == 1 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<Box size={20} />
Create and configure Azure AD application
</p>
<Steps>
<Steps.Step step={1}>
<p>
Navigate to{" "}
<InlineLink
className={"inline"}
target={"_blank"}
href={
"https://portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/Overview"
}
>
Azure Active Directory
</InlineLink>
</p>
</Steps.Step>
<Steps.Step step={2}>
<p className={"font-normal"}>
Click <Mark>App Registrations</Mark> in the left menu then click
on the <Mark>+ New registration</Mark> button to create a new
application.
</p>
</Steps.Step>
<Steps.Step step={3} line={false}>
<p className={"font-normal"}>
Fill in the form with the following values and click{" "}
<Mark>Register</Mark>
</p>
</Steps.Step>
</Steps>
<MinimalList
data={[
{
label: "Name",
value: "NetBird",
},
{
label: "Account Types",
value:
"Accounts in this organizational directory only (Default Directory only - Single tenant)",
},
{
label: "Redirect Type",
value: "Single-page application (SPA)",
},
{
label: "Redirect URI",
value: "https://app.netbird.io/silent-auth",
},
]}
/>
</div>
)}
{step == 2 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<Shield size={20} />
Add API permissions
</p>
<Steps>
<Steps.Step step={1}>
<p className={"font-normal"}>
Click <Mark>API permissions</Mark> on the left side menu
</p>
</Steps.Step>
<Steps.Step step={2}>
<p className={"font-normal"}>
Click <Mark>Add a permission</Mark> then{" "}
<Mark>Microsoft Graph</Mark> and then on the{" "}
<Mark>Application permissions</Mark> tab.
</p>
</Steps.Step>
<Steps.Step step={3}>
<p className={"font-normal"}>
In <Mark>Select permissions</Mark> select{" "}
<Mark>User.Read.All</Mark> and <Mark>Group.Read.All</Mark> and
click <Mark>Add permissions</Mark>
</p>
</Steps.Step>
<Steps.Step step={4} line={false}>
<p className={"font-normal"}>
Click <Mark>Grant admin conset for Default Directory</Mark> and
click <Mark>Yes</Mark>
</p>
<Lightbox image={azureGrantAdmin} />
</Steps.Step>
</Steps>
</div>
)}
{step == 3 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<KeyRound size={20} />
Generate client secret
</p>
<Steps>
<Steps.Step step={1}>
<p className={"font-normal"}>
Navigate to <Mark>Certificates & secrets</Mark> on left side
menu
</p>
</Steps.Step>
<Steps.Step step={2}>
<p className={"font-normal"}>
Click on <Mark>+ New client secret</Mark>
</p>
</Steps.Step>
<Steps.Step step={3}>
<p className={"font-normal"}>
Add <Mark copy>NetBird</Mark> as the description and click{" "}
<Mark>Add</Mark>
</p>
</Steps.Step>
<Steps.Step step={4} line={false}>
<p className={"font-normal"}>
Copy the <Mark>Value</Mark> and paste it here
</p>
</Steps.Step>
</Steps>
<div className={"mb-4"}>
<Input
type={"text"}
className={"w-full"}
customPrefix={
<div className={"flex items-center gap-2"}>
<KeyRound size={16} className={"text-nb-gray-300"} />
</div>
}
placeholder={"YdV7Q~JJ62Xl.LvYoBanxZR2sJA2va_3UbqvncY8"}
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
/>
</div>
</div>
)}
{step == 4 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<Box size={20} />
Enter Application ID and Directory ID
</p>
<Steps>
<Steps.Step step={1}>
<p className={"font-normal"}>
Navigate to{" "}
<InlineLink
target={"_blank"}
className={"inline"}
href={
"https://portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/RegisteredApps"
}
>
Owner applications
</InlineLink>
</p>
</Steps.Step>
<Steps.Step step={2} line={false}>
<p className={"font-normal"}>
Select <Mark>NetBird</Mark> application in overview page and
enter your <Mark>Application (client) ID</Mark> and{" "}
<Mark>Directory (tenant) ID</Mark>
</p>
</Steps.Step>
</Steps>
<div className={"mb-4 flex flex-col gap-3"}>
<Input
type={"text"}
className={"w-full"}
customPrefix={
<div className={"min-w-[165px] flex gap-2 items-center"}>
<Box size={16} />
Application (client) ID
</div>
}
placeholder={"62d3a656-c87d-4f30-a242-5b6347e29e9f"}
value={clientId}
onChange={(e) => setClientId(e.target.value)}
/>
<Input
type={"text"}
className={"w-full"}
customPrefix={
<div className={"min-w-[165px] flex gap-2 items-center"}>
<Folder size={16} />
Directory (tenant) ID
</div>
}
placeholder={"5d60468a-65b7-45eb-a61a-53ecfbcd1ea3"}
value={tenantId}
onChange={(e) => setTenantId(e.target.value)}
/>
</div>
</div>
)}
{step == 5 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<FolderGit2 size={20} />
Groups to be synchronized
</p>
<div className={"mb-4 flex flex-col gap-1"}>
<div>
<HelpText className={"max-w-lg mt-2 text-sm"}>
By default,{" "}
<span className={"text-netbird font-semibold"}>All Groups</span>{" "}
will be synchronized from your IdP to NetBird. <br />
If you want to synchronize only groups that start with a
specific prefix, you can add them below.
</HelpText>
</div>
<GroupPrefixInput
value={groupPrefixes}
onChange={setGroupPrefixes}
/>
</div>
</div>
)}
{step == 6 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<UserCircle size={18} />
Users to be synchronized
</p>
<div className={"mb-4 flex flex-col gap-1"}>
<div>
<HelpText className={"max-w-lg mt-2 text-sm"}>
By default,{" "}
<span className={"text-netbird font-semibold"}>All Users</span>{" "}
will be synchronized from your IdP to NetBird. <br />
If you want to synchronize only users that belong to a specific
group, you can add them below.
</HelpText>
</div>
<GroupPrefixInput
addText={"Add user group filter"}
text={"User group starts with..."}
value={userGroupPrefixes}
onChange={setUserGroupPrefixes}
/>
</div>
</div>
)}
<ModalFooter className={"items-center gap-4"}>
{step > 0 && (
<Button
variant={"secondary"}
className={"w-full"}
onClick={() => setStep(step - 1)}
>
<IconArrowLeft size={16} />
Back
</Button>
)}
{step >= 0 && step < maxSteps && (
<Button
variant={"primary"}
className={"w-full"}
disabled={isDisabled}
onClick={() => setStep(step + 1)}
>
{step == 0 ? "Get Started" : "Continue"}
<IconArrowRight size={16} />
</Button>
)}
{step == maxSteps && (
<Button
variant={"primary"}
className={"w-full"}
disabled={isDisabled}
onClick={connect}
>
<Repeat size={16} />
Connect
</Button>
)}
</ModalFooter>
{step == 0 && (
<div
className={
"text-center z-0 mt-2.5 text-xs text-nb-gray-300 flex items-center justify-center gap-2 font-normal"
}
>
<Clock4 size={12} />
<div>
Estimated setup time:
<span className={"font-medium"}> 10-20 Minutes</span>
</div>
</div>
)}
</ModalContent>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

View File

@@ -1,168 +0,0 @@
import Button from "@components/Button";
import FullTooltip from "@components/FullTooltip";
import { notify } from "@components/Notification";
import { SkeletonIntegration } from "@components/skeletons/SkeletonIntegration";
import useFetchApi, { useApiCall } from "@utils/api";
import dayjs from "dayjs";
import { isEmpty } from "lodash";
import { RefreshCw, Settings } from "lucide-react";
import * as React from "react";
import { useEffect, useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import integrationImage from "@/assets/integrations/google-workspace.png";
import {
AzureADIntegration,
GoogleWorkspaceIntegration,
IdentityProviderLog,
} from "@/interfaces/IdentityProvider";
import GoogleWorkspaceConfiguration from "@/modules/integrations/idp-sync/google-workspace/GoogleWorkspaceConfiguration";
import GoogleWorkspaceSetup from "@/modules/integrations/idp-sync/google-workspace/GoogleWorkspaceSetup";
import { useIntegrations } from "@/modules/integrations/idp-sync/useIntegrations";
import { IntegrationCard } from "@/modules/integrations/IntegrationCard";
export const GoogleWorkspace = () => {
const { mutate } = useSWRConfig();
const [setupModal, setSetupModal] = useState(false);
const {
google: integration,
isAnyIntegrationEnabled,
isGoogleLoading,
} = useIntegrations();
const googleRequest = useApiCall<AzureADIntegration>(
"/integrations/google-idp",
);
const [enabled, setEnabled] = useState(
integration ? integration.enabled : false,
);
useEffect(() => {
setEnabled(integration?.enabled ?? false);
}, [integration]);
const toggleSwitch = async (state: boolean) => {
if (!integration) return setSetupModal(true);
notify({
title: "Google Workspace Integration",
description: `Google Workspace was successfully ${
state ? "enabled" : "disabled"
}`,
promise: googleRequest
.put(
{
enabled: state,
},
"/" + integration.id,
)
.then(() => {
mutate("/integrations/google-idp");
setEnabled(state);
}),
loadingMessage: "Updating integration...",
});
};
return isGoogleLoading ? (
<SkeletonIntegration loadingHeight={197} />
) : (
<>
<IntegrationCard
name="Google Workspace"
description="A flexible, innovative solution for people and organizations to achieve more."
url={{
title: "workspace.google.com",
href: "https://workspace.google.com/",
}}
image={integrationImage}
data={integration}
disabled={enabled ? false : isAnyIntegrationEnabled}
switchState={enabled}
onEnabledChange={toggleSwitch}
onSetup={() => setSetupModal(true)}
>
{integration && <ConfigurationButton config={integration} />}
</IntegrationCard>
<GoogleWorkspaceSetup
open={setupModal}
onOpenChange={setSetupModal}
onSuccess={() => setEnabled(true)}
/>
</>
);
};
type ConfigurationProps = {
config: GoogleWorkspaceIntegration;
};
const ConfigurationButton = ({ config }: ConfigurationProps) => {
const { data: logs } = useFetchApi<IdentityProviderLog[]>(
`/integrations/google-idp/${config.id}/logs`,
);
const { mutate } = useSWRConfig();
const syncRequest = useApiCall<{ response: boolean }>(
`/integrations/google-idp/${config.id}/sync`,
);
const [configModal, setConfigModal] = useState(false);
const forceSync = async () => {
notify({
title: "Google Workspace Integration",
description: `Google Workspace was successfully synced`,
loadingMessage: "Syncing integration...",
promise: syncRequest.post({}).then(() => {
mutate(`/integrations/google-idp/${config.id}/logs`);
}),
});
};
const lastSync = useMemo(() => {
if (isEmpty(logs)) return "Not synchronized";
return "Synced " + dayjs().to(logs?.[0]?.timestamp);
}, [logs]);
return (
<>
<div className={"flex gap-2"}>
<FullTooltip
content={
<div className={"text-xs"}>
Force synchronization of users and groups
</div>
}
disabled={!config.enabled}
className={"w-full"}
interactive={false}
>
<Button
variant={"secondary"}
size={"xs"}
className={"w-full items-center"}
onClick={forceSync}
disabled={!config.enabled}
>
<RefreshCw size={14} />
{lastSync}
</Button>
</FullTooltip>
<Button
variant={"secondary"}
size={"xs"}
className={"items-center"}
onClick={() => {
setConfigModal(true);
}}
>
<Settings size={14} />
</Button>
</div>
<GoogleWorkspaceConfiguration
open={configModal}
onOpenChange={setConfigModal}
/>
</>
);
};

View File

@@ -1,361 +0,0 @@
import Button from "@components/Button";
import HelpText from "@components/HelpText";
import { Input } from "@components/Input";
import { JSONFileUpload } from "@components/JSONFileUpload";
import { Label } from "@components/Label";
import {
Modal,
ModalClose,
ModalContent,
ModalFooter,
} from "@components/modal/Modal";
import { notify } from "@components/Notification";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
import { useHasChanges } from "@hooks/useHasChanges";
import { useApiCall } from "@utils/api";
import { cn } from "@utils/helpers";
import {
AlertOctagon,
Box,
Cog,
FolderGit2,
KeyRound,
RefreshCw,
UserCircle,
} from "lucide-react";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import integrationImage from "@/assets/integrations/google-workspace.png";
import { useDialog } from "@/contexts/DialogProvider";
import { GoogleWorkspaceIntegration } from "@/interfaces/IdentityProvider";
import { GroupPrefixInput } from "@/modules/integrations/idp-sync/GroupPrefixInput";
import { useIntegrations } from "@/modules/integrations/idp-sync/useIntegrations";
import { IntegrationModalHeader } from "@/modules/integrations/IntegrationModalHeader";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
};
export default function GoogleWorkspaceConfiguration({
open,
onOpenChange,
onSuccess,
}: Props) {
const { google } = useIntegrations();
return (
<>
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
{google && (
<ConfigurationContent
onSuccess={() => {
onOpenChange(false);
onSuccess && onSuccess();
}}
config={google}
/>
)}
</Modal>
</>
);
}
type ModalProps = {
onSuccess: () => void;
config: GoogleWorkspaceIntegration;
};
export function ConfigurationContent({ onSuccess, config }: ModalProps) {
const { mutate } = useSWRConfig();
const { confirm } = useDialog();
const [tab, setTab] = useState<string>("settings");
const googleRequest = useApiCall<GoogleWorkspaceIntegration>(
"/integrations/google-idp",
);
const accountKeyPlaceholder = "******************************";
const [serviceAccountKey, setServiceAccountKey] = useState(
accountKeyPlaceholder,
);
const [customerID, setCustomerID] = useState(config.customerId);
const [interval, setInterval] = useState(config.syncInterval.toString());
const [groupPrefixes, setGroupPrefixes] = useState<string[]>(
config.group_prefixes || [],
);
const [userGroupPrefixes, setUserGroupPrefixes] = useState<string[]>(
config.user_group_prefixes || [],
);
const deleteIntegration = async () => {
const choice = await confirm({
title: `Delete integration?`,
description: "Are you sure you want to delete this integration?",
confirmText: "Delete",
cancelText: "Cancel",
type: "danger",
});
if (!choice) return;
notify({
title: "Google Workspace Integration",
description: `Google Workspace was successfully deleted`,
promise: googleRequest.del({}, `/${config.id}`).then(() => {
mutate("/integrations/google-idp");
onSuccess();
}),
loadingMessage: "Deleting integration...",
});
};
const updateIntegration = async () => {
notify({
title: "Google Workspace Integration",
description: `Google Workspace was successfully updated`,
promise: googleRequest
.put(
{
customerId: customerID,
service_account_key:
accountKeyPlaceholder == serviceAccountKey
? undefined
: serviceAccountKey,
sync_interval: interval ? parseInt(interval) : 300,
group_prefixes: groupPrefixes || [],
user_group_prefixes: userGroupPrefixes || [],
},
`/${config.id}`,
)
.then(() => {
mutate("/integrations/google-idp");
onSuccess();
}),
loadingMessage: "Updating integration...",
});
};
const { hasChanges } = useHasChanges([
customerID,
serviceAccountKey,
interval,
groupPrefixes,
userGroupPrefixes,
]);
return (
<ModalContent
maxWidthClass={cn("relative max-w-xl")}
showClose={true}
className={""}
autoFocus={false}
>
<GradientFadedBackground />
<IntegrationModalHeader
image={integrationImage}
title={"Google Workspace Configuration"}
description={"Sync your users and groups from Google Workspace."}
/>
<Tabs
defaultValue={tab}
onValueChange={(v) => setTab(v)}
className={"mt-6"}
>
<TabsList justify={"start"} className={"px-8"}>
<TabsTrigger value={"settings"}>
<Cog
size={16}
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
/>
Settings
</TabsTrigger>
<TabsTrigger value={"group-sync"}>
<FolderGit2
size={16}
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
/>
Group Sync
</TabsTrigger>
<TabsTrigger value={"user-sync"}>
<UserCircle
size={16}
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
/>
User Sync
</TabsTrigger>
<TabsTrigger value={"danger"}>
<AlertOctagon
size={16}
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
/>
Danger Zone
</TabsTrigger>
</TabsList>
<TabsContent value={"settings"} className={"px-8 text-sm"}>
<div className={"flex-col gap-3 flex"}>
<Input
type={"text"}
autoCorrect={"off"}
autoComplete={"off"}
className={"w-full"}
customPrefix={
<div className={"min-w-[165px] flex gap-2 items-center"}>
<Box size={16} />
Customer ID
</div>
}
placeholder={"62d3a656-c87d-4f30-a242-5b6347e29e9f"}
value={customerID}
onChange={(e) => setCustomerID(e.target.value)}
/>
<Input
autoCorrect={"off"}
type={"text"}
className={"w-full"}
customPrefix={
<div className={"min-w-[165px] flex gap-2 items-center"}>
<KeyRound size={16} />
Service Account Key
</div>
}
placeholder={"YdV7Q~JJ62Xl.LvYoBanxZR2sJA2va_3UbqvncY8"}
value={serviceAccountKey}
readOnly={true}
/>
<JSONFileUpload
value={serviceAccountKey}
onChange={(val) => setServiceAccountKey(btoa(val))}
/>
<div className={"flex justify-between mt-4"}>
<div>
<Label>Sync Interval</Label>
<HelpText className={"max-w-[300px]"}>
The interval in seconds when the synchronization should
happen.
</HelpText>
</div>
<Input
maxWidthClass={"max-w-[400px]"}
placeholder={"300"}
min={1}
max={99999}
value={interval}
type={"number"}
onChange={(e) => setInterval(e.target.value)}
customPrefix={
<RefreshCw size={16} className={"text-nb-gray-300"} />
}
customSuffix={"Seconds"}
/>
</div>
</div>
</TabsContent>
<TabsContent value={"group-sync"} className={"px-8"}>
<div>
<Label>
<div className={"flex gap-2 items-center"}>
<FolderGit2 size={16} />
Synchronize Groups
</div>
</Label>
<HelpText className={"max-w-lg mt-2"}>
By default,{" "}
<span className={"text-netbird font-semibold"}>All Groups</span>{" "}
will be synchronized from your IdP to NetBird. <br />
If you want to synchronize only groups that start with a specific
prefix, you can add them below.
</HelpText>
</div>
<GroupPrefixInput value={groupPrefixes} onChange={setGroupPrefixes} />
</TabsContent>
<TabsContent value={"user-sync"} className={"px-8"}>
<div>
<Label>
<div className={"flex gap-2 items-center"}>
<UserCircle size={16} />
Synchronize Users
</div>
</Label>
<HelpText className={"max-w-lg mt-2"}>
By default,{" "}
<span className={"text-netbird font-semibold"}>All Users</span>{" "}
will be synchronized from your IdP to NetBird. <br />
If you want to synchronize only users that belong to a specific
group, you can add them below.
</HelpText>
</div>
<GroupPrefixInput
addText={"Add user group filter"}
text={"User group starts with..."}
value={userGroupPrefixes}
onChange={setUserGroupPrefixes}
/>
</TabsContent>
<TabsContent value={"danger"} className={"px-8"}>
<div>
<Label>
<div className={"flex gap-2 items-center"}>
<AlertOctagon size={16} />
Delete Integration
</div>
</Label>
<HelpText className={"max-w-lg mt-2"}>
Deleting this integration will remove the ability to sync users
and groups from your IdP to NetBird. If you delete the integration
you will need to reconfigure it again to enable the
synchronization.
</HelpText>
</div>
<Button
variant={"danger"}
size={"xs"}
className={"mt-3"}
onClick={deleteIntegration}
>
Delete Integration
</Button>
</TabsContent>
</Tabs>
<div className={"h-6"}></div>
<ModalFooter className={"items-center gap-4"}>
<ModalClose asChild={true}>
<Button variant={"secondary"} className={"w-full"}>
Cancel
</Button>
</ModalClose>
<Button
variant={"primary"}
className={"w-full"}
disabled={!hasChanges}
onClick={updateIntegration}
>
Save
</Button>
</ModalFooter>
</ModalContent>
);
}

View File

@@ -1,632 +0,0 @@
import Button from "@components/Button";
import HelpText from "@components/HelpText";
import InlineLink from "@components/InlineLink";
import { Input } from "@components/Input";
import { JSONFileUpload } from "@components/JSONFileUpload";
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
import { notify } from "@components/Notification";
import Steps from "@components/Steps";
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
import { Lightbox } from "@components/ui/Lightbox";
import { Mark } from "@components/ui/Mark";
import { MinimalList } from "@components/ui/MinimalList";
import { IconArrowLeft, IconArrowRight } from "@tabler/icons-react";
import { useApiCall } from "@utils/api";
import { cn } from "@utils/helpers";
import { isEmpty } from "lodash";
import {
Box,
Clock4,
FolderCog2,
FolderGit2,
KeyRound,
Mail,
MailPlus,
PlusCircle,
Repeat,
Settings2,
Shield,
UserCircle,
} from "lucide-react";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import integrationImage from "@/assets/integrations/google-workspace.png";
import { GoogleWorkspaceIntegration } from "@/interfaces/IdentityProvider";
import googleAssignServiceAccount from "@/modules/integrations/idp-sync/google-workspace/images/google-assign-service-account.png";
import googleEditServiceAccount from "@/modules/integrations/idp-sync/google-workspace/images/google-edit-service-account.png";
import googlePrivilegesReview from "@/modules/integrations/idp-sync/google-workspace/images/google-privileges-review.png";
import { IntegrationModalHeader } from "@/modules/integrations/IntegrationModalHeader";
import { GroupPrefixInput } from "../GroupPrefixInput";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
};
export default function GoogleWorkspaceSetup({
open,
onOpenChange,
onSuccess,
}: Props) {
return (
<>
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
<SetupContent
onSuccess={() => {
onOpenChange(false);
onSuccess && onSuccess();
}}
/>
</Modal>
</>
);
}
type ModalProps = {
onSuccess: () => void;
};
export function SetupContent({ onSuccess }: ModalProps) {
const { mutate } = useSWRConfig();
const googleRequest = useApiCall<GoogleWorkspaceIntegration>(
"/integrations/google-idp",
);
const [step, setStep] = useState(0);
const maxSteps = 9;
const [serviceAccountKey, setServiceAccountKey] = useState("");
const [customerID, setCustomerID] = useState("");
const [serviceAccountMail, setServiceAccountMail] = useState("");
const clientSecretEntered = !isEmpty(serviceAccountKey);
const customerIDEntered = !isEmpty(customerID);
const serviceAccountMailEntered = !isEmpty(serviceAccountMail);
const allEntered =
clientSecretEntered && customerIDEntered && serviceAccountMailEntered;
const isDisabled =
(step == 2 && !serviceAccountMailEntered) ||
(step == 3 && !clientSecretEntered) ||
(step == 7 && !customerIDEntered);
const [groupPrefixes, setGroupPrefixes] = useState<string[]>([]);
const [userGroupPrefixes, setUserGroupPrefixes] = useState<string[]>([]);
const connect = async () => {
notify({
title: "Google Workspace Integration",
description: `Google Workspace was successfully connected to NetBird.`,
promise: googleRequest
.post({
service_account_key: btoa(serviceAccountKey), // Encode client secret to base64
customer_id: customerID,
group_prefixes: groupPrefixes || [],
user_group_prefixes: userGroupPrefixes || [],
})
.then(() => {
mutate("/integrations/google-idp");
onSuccess();
}),
loadingMessage: "Setting up integration...",
});
};
return (
<ModalContent
maxWidthClass={cn("relative", step == 0 ? "max-w-md" : "max-w-xl")}
showClose={true}
className={""}
onEscapeKeyDown={(e) => step > 0 && e.preventDefault()}
onInteractOutside={(e) => step > 0 && e.preventDefault()}
onPointerDownOutside={(e) => step > 0 && e.preventDefault()}
>
<GradientFadedBackground />
{step > 0 && (
<div className={"flex gap-2 w-full items-center justify-center mb-4"}>
{Array.from({ length: maxSteps }).map((_, index) => (
<div
key={index}
className={cn(
"w-8 h-1 rounded-full bg-nb-gray-800",
step >= index + 1 && "bg-netbird",
)}
/>
))}
</div>
)}
<IntegrationModalHeader
image={integrationImage}
title={"Connect NetBird with Google Workspace"}
description={
"Start syncing your users and groups from Google Workspace to NetBird. Follow the steps below to get started."
}
/>
{step == 0 && (
<div
className={
"px-8 py-3 flex z-0 flex-col gap-0 text-sm mb-3 text-center justify-center items-center"
}
>
<div
className={
"mt-6 text-base font-medium text-nb-gray-100 flex gap-2 items-center justify-center"
}
>
<Shield size={16} />
Required Permissions
</div>
<p className={"mt-2 !text-nb-gray-300 !leading-[1.5]"}>
Ensure that you have an an{" "}
<span className={"text-nb-gray-100 font-semibold"}>
Google Workspace user account
</span>{" "}
with the following{" "}
<span className={"text-nb-gray-100 font-semibold"}>
permissions
</span>
.{" "}
{
"If you don't have the required permissions, ask your workspace administrator to grant them to you."
}
</p>
<div
className={
"flex items-center flex-col gap-0 mt-2 w-full justify-center max-w-lg"
}
>
<div
className={
"py-2 px-6 flex items-center gap-2 rounded-md w-full justify-center bg-nb-gray-930/0 text-nb-gray-200"
}
>
<PlusCircle size={14} className={"text-sky-500"} />
Create Google Workspace applications
</div>
<div
className={
"py-2 px-6 flex items-center gap-2 rounded-md w-full justify-center bg-nb-gray-930/0 text-nb-gray-200"
}
>
<Settings2 size={14} className={"text-sky-500"} />
Manage Google Workspace applications
</div>
</div>
</div>
)}
{step == 1 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<UserCircle size={20} />
Create a service account
</p>
<Steps>
<Steps.Step step={1}>
<p>
Navigate to{" "}
<InlineLink
className={"inline"}
target={"_blank"}
href={"https://console.cloud.google.com/apis/credentials"}
>
API Credentials
</InlineLink>
</p>
</Steps.Step>
<Steps.Step step={2}>
<p className={"font-normal"}>
Click <Mark>CREATE CREDENTIALS</Mark> at the top and select{" "}
<Mark>Service account</Mark>
</p>
</Steps.Step>
<Steps.Step step={3} line={false}>
<p className={"font-normal"}>
Fill in the form with the following values and click{" "}
<Mark>DONE</Mark>
</p>
</Steps.Step>
</Steps>
<MinimalList
data={[
{
label: "Service account name",
value: "NetBird",
},
{
label: "Service account ID",
value: "netbird",
},
]}
/>
</div>
)}
{step == 2 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<Mail size={20} />
Get your service account email
</p>
<Steps>
<Steps.Step step={1}>
<p>
Navigate to{" "}
<InlineLink
className={"inline"}
target={"_blank"}
href={
"https://console.cloud.google.com/iam-admin/serviceaccounts"
}
>
Service Accounts
</InlineLink>
</p>
</Steps.Step>
<Steps.Step step={1}>
<p className={"font-normal"}>
Click <Mark>NetBird</Mark> to edit the service account. Copy the
service account email address.
</p>
<Lightbox image={googleEditServiceAccount} />
</Steps.Step>
<Steps.Step step={2} line={false}>
<p className={"font-normal"}>
Enter your service account email address
</p>
</Steps.Step>
</Steps>
<div className={"mb-4"}>
<Input
type={"text"}
className={"w-full"}
customPrefix={
<div className={"flex items-center gap-2"}>
<Mail size={16} className={"text-nb-gray-300"} />
</div>
}
placeholder={"netbird@loadtests-347817.iam.gserviceaccount.com"}
value={serviceAccountMail}
onChange={(e) => setServiceAccountMail(e.target.value)}
/>
</div>
</div>
)}
{step == 3 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<KeyRound size={20} />
Create service account key
</p>
<Steps>
<Steps.Step step={1}>
<p className={"font-normal"}>
On the same page, now click the <Mark>Keys</Mark> tab, open the{" "}
<Mark>Add key</Mark> dropdown and select{" "}
<Mark>Create new key</Mark>
</p>
</Steps.Step>
<Steps.Step step={3}>
<p className={"font-normal"}>
Select <Mark>JSON</Mark> as the key type and click{" "}
<Mark>Create</Mark>
</p>
</Steps.Step>
<Steps.Step step={4} line={false}>
<p className={"font-normal"}>
Most browsers immediately download the new key and save it in a
download folder on your computer. Read how to manage and secure
your service keys{" "}
<InlineLink
href={
"https://cloud.google.com/iam/docs/best-practices-for-managing-service-account-keys#temp-locations"
}
target={"_blank"}
>
here
</InlineLink>
.
</p>
</Steps.Step>
</Steps>
<div className={"mb-4 z-0 relative"}>
<JSONFileUpload
value={serviceAccountKey}
onChange={setServiceAccountKey}
/>
{serviceAccountKey && (
<div className={"mt-3"}>
<Input
type={"text"}
className={"w-full"}
customPrefix={
<div className={"flex items-center gap-2"}>
<KeyRound size={16} className={"text-nb-gray-300"} />
</div>
}
placeholder={"YdV7Q~JJ62Xl.LvYoBanxZR2sJA2va_3UbqvncY8"}
value={btoa(serviceAccountKey)}
readOnly={true}
/>
</div>
)}
</div>
</div>
)}
{step == 4 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<FolderCog2 size={20} />
Create admin role
</p>
<Steps>
<Steps.Step step={1}>
<p>
Navigate to{" "}
<InlineLink
className={"inline"}
target={"_blank"}
href={"https://admin.google.com/ac/home"}
>
Admin Console
</InlineLink>
</p>
</Steps.Step>
<Steps.Step step={2}>
<p className={"font-normal"}>
Select <Mark>Account</Mark> on the left menu and then click{" "}
<Mark>Admin Roles</Mark>
</p>
</Steps.Step>
<Steps.Step step={3} line={false}>
<p className={"font-normal"}>
Click <Mark>Create new role</Mark> and fill in the form with the
following values
</p>
</Steps.Step>
</Steps>
<MinimalList
data={[
{
label: "Name",
value: "User and Group Management ReadOnly",
},
{
label: "Description",
value: "User and Group Management ReadOnly",
},
]}
/>
</div>
)}
{step == 5 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<Shield size={20} />
Add role privileges
</p>
<Steps>
<Steps.Step step={1}>
<p className={"font-normal"}>
Scroll down to <Mark>Admin API privileges</Mark> and add the
following privileges to the role
</p>
<MinimalList
className={"mt-2 mb-0"}
data={[
{
label: "Users",
value: "Read",
},
{
label: "Groups",
value: "Read",
},
]}
/>
</Steps.Step>
<Steps.Step step={2} line={false}>
<p className={"font-normal"}>
Verify preview of assigned Admin API privileges to ensure that
everything is properly configured, and then click{" "}
<Mark>CREATE ROLE</Mark>
</p>
<Lightbox image={googlePrivilegesReview} />
</Steps.Step>
</Steps>
</div>
)}
{step == 6 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<MailPlus size={20} />
Assign service account
</p>
<Steps>
<Steps.Step step={1}>
<p className={"font-normal"}>
Click <Mark>Assign service accounts</Mark>
</p>
</Steps.Step>
<Steps.Step step={2}>
<p className={"font-normal"}>
Enter your <Mark>E-Mail</Mark> and then click <Mark>ADD</Mark>
</p>
<MinimalList
className={"mt-2 mb-0"}
data={[
{
label: "E-Mail",
value: serviceAccountMail,
},
]}
/>
</Steps.Step>
<Steps.Step step={3} line={false}>
<p className={"font-normal"}>
Click <Mark>ASSIGN ROLE</Mark>
</p>
<Lightbox image={googleAssignServiceAccount} />
</Steps.Step>
</Steps>
</div>
)}
{step == 7 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<Box size={20} />
Enter Customer ID
</p>
<Steps>
<Steps.Step step={1}>
<p className={"font-normal"}>
Navigate to{" "}
<InlineLink
target={"_blank"}
className={"inline"}
href={
"https://admin.google.com/ac/accountsettings/profile?hl=en_US"
}
>
Account Settings
</InlineLink>
</p>
</Steps.Step>
<Steps.Step step={2} line={false}>
<p className={"font-normal"}>
Take note of the <Mark>Customer ID</Mark> and enter it below
</p>
</Steps.Step>
</Steps>
<div className={"mb-4 flex flex-col gap-3"}>
<Input
type={"text"}
className={"w-full"}
customPrefix={
<div className={"min-w-[165px] flex gap-2 items-center"}>
<Box size={16} />
Customer ID
</div>
}
placeholder={"C03f4c3po"}
value={customerID}
onChange={(e) => setCustomerID(e.target.value)}
/>
</div>
</div>
)}
{step == 8 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<FolderGit2 size={20} />
Groups to be synchronized
</p>
<div className={"mb-4 flex flex-col gap-1"}>
<div>
<HelpText className={"max-w-lg mt-2 text-sm"}>
By default,{" "}
<span className={"text-netbird font-semibold"}>All Groups</span>{" "}
will be synchronized from your IdP to NetBird. <br />
If you want to synchronize only groups that start with a
specific prefix, you can add them below.
</HelpText>
</div>
<GroupPrefixInput
value={groupPrefixes}
onChange={setGroupPrefixes}
/>
</div>
</div>
)}
{step == 9 && (
<div className={"px-8 py-3 flex flex-col gap-0 mt-4"}>
<p className={"font-medium flex gap-3 items-center text-base"}>
<UserCircle size={18} />
Users to be synchronized
</p>
<div className={"mb-4 flex flex-col gap-1"}>
<div>
<HelpText className={"max-w-lg mt-2 text-sm"}>
By default,{" "}
<span className={"text-netbird font-semibold"}>All Users</span>{" "}
will be synchronized from your IdP to NetBird. <br />
If you want to synchronize only users that belong to a specific
group, you can add them below.
</HelpText>
</div>
<GroupPrefixInput
addText={"Add user group filter"}
text={"User group starts with..."}
value={userGroupPrefixes}
onChange={setUserGroupPrefixes}
/>
</div>
</div>
)}
<ModalFooter className={"items-center gap-4"}>
{step > 0 && (
<Button
variant={"secondary"}
className={"w-full"}
onClick={() => setStep(step - 1)}
>
<IconArrowLeft size={16} />
Back
</Button>
)}
{step >= 0 && step < maxSteps && (
<Button
variant={"primary"}
className={"w-full"}
disabled={isDisabled}
onClick={() => setStep(step + 1)}
>
{step == 0 ? "Get Started" : "Continue"}
<IconArrowRight size={16} />
</Button>
)}
{step == maxSteps && (
<Button
variant={"primary"}
className={"w-full"}
disabled={!allEntered}
onClick={connect}
>
<Repeat size={16} />
Connect
</Button>
)}
</ModalFooter>
{step == 0 && (
<div
className={
"text-center z-0 mt-2.5 text-xs text-nb-gray-300 flex items-center justify-center gap-2 font-normal"
}
>
<Clock4 size={12} />
<div>
Estimated setup time:
<span className={"font-medium"}> 10-20 Minutes</span>
</div>
</div>
)}
</ModalContent>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

View File

@@ -1,27 +0,0 @@
import useFetchApi from "@utils/api";
import {
AzureADIntegration,
GoogleWorkspaceIntegration,
} from "@/interfaces/IdentityProvider";
export const useIntegrations = () => {
const { data: azureIntegrations, isLoading: isAzureLoading } = useFetchApi<
AzureADIntegration[]
>("/integrations/azure-idp");
const { data: googleIntegrations, isLoading: isGoogleLoading } = useFetchApi<
GoogleWorkspaceIntegration[]
>("/integrations/google-idp");
const azure = azureIntegrations?.[0];
const google = googleIntegrations?.[0];
const isAnyIntegrationEnabled = azure?.enabled || google?.enabled;
return {
azure,
google,
isAnyIntegrationEnabled,
isAzureLoading,
isGoogleLoading,
};
};

View File

@@ -42,7 +42,7 @@ export function PeerOSCell({ os }: { os: string }) {
export function OSLogo({ os }: { os: string }) {
const icon = useMemo(() => {
return getOperatingSystem(os.toLowerCase());
return getOperatingSystem(os);
}, [os]);
if (icon === OperatingSystem.WINDOWS)

View File

@@ -17,6 +17,7 @@ import React from "react";
import { useSWRConfig } from "swr";
import PeerIcon from "@/assets/icons/PeerIcon";
import PeerProvider from "@/contexts/PeerProvider";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { Group } from "@/interfaces/Group";
import { Peer } from "@/interfaces/Peer";
@@ -133,6 +134,7 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
),
},
{
id: "actions",
accessorKey: "id",
header: "",
cell: ({ row }) => (
@@ -176,6 +178,8 @@ export default function PeersTable({ peers, isLoading }: Props) {
"name",
) as Group[]) || ([] as Group[]);
const { isUser } = useLoggedInUser();
return (
<DataTable
onRowClick={(row) => router.push("/peer?id=" + row.original.id)}
@@ -193,6 +197,7 @@ export default function PeersTable({ peers, isLoading }: Props) {
ip: false,
user_name: false,
user_email: false,
actions: !isUser,
}}
isLoading={isLoading}
getStartedCard={
@@ -229,6 +234,29 @@ export default function PeersTable({ peers, isLoading }: Props) {
{(table) => (
<>
<ButtonGroup disabled={peers?.length == 0}>
<ButtonGroup.Button
disabled={peers?.length == 0}
onClick={() => {
table.setPageIndex(0);
table.setColumnFilters([
{
id: "connected",
value: undefined,
},
{
id: "approval_required",
value: undefined,
},
]);
}}
variant={
table.getColumn("connected")?.getFilterValue() == undefined
? "tertiary"
: "secondary"
}
>
All
</ButtonGroup.Button>
<ButtonGroup.Button
onClick={() => {
table.setPageIndex(0);
@@ -253,13 +281,12 @@ export default function PeersTable({ peers, isLoading }: Props) {
Online
</ButtonGroup.Button>
<ButtonGroup.Button
disabled={peers?.length == 0}
onClick={() => {
table.setPageIndex(0);
table.setColumnFilters([
{
id: "connected",
value: undefined,
value: false,
},
{
id: "approval_required",
@@ -267,13 +294,14 @@ export default function PeersTable({ peers, isLoading }: Props) {
},
]);
}}
disabled={peers?.length == 0}
variant={
table.getColumn("connected")?.getFilterValue() == undefined
table.getColumn("connected")?.getFilterValue() == false
? "tertiary"
: "secondary"
}
>
All
Offline
</ButtonGroup.Button>
</ButtonGroup>

View File

@@ -15,9 +15,8 @@ import {
} from "@components/Select";
import * as Tabs from "@radix-ui/react-tabs";
import { useApiCall } from "@utils/api";
import { cn } from "@utils/helpers";
import { isLocalDev, isNetBirdHosted } from "@utils/netbird";
import { CalendarClock, ShieldIcon, TimerReset, VoteIcon } from "lucide-react";
import { cn, isInt } from "@utils/helpers";
import { CalendarClock, ShieldIcon, TimerReset } from "lucide-react";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import SettingsIcon from "@/assets/icons/SettingsIcon";
@@ -55,16 +54,21 @@ export default function AuthenticationTab({ account }: Props) {
const [expiresInSeconds] = useState(
account.settings.peer_login_expiration || 86400,
);
const [expiresIn, setExpiresIn] = useState(() => {
if (expiresInSeconds <= 86400) return "1";
return Math.round(expiresInSeconds / 86400).toString();
if (expiresInSeconds <= 172800) {
const hours = expiresInSeconds / 3600;
return isInt(hours) ? hours.toString() : hours.toFixed(2).toString();
}
const days = expiresInSeconds / 86400;
return isInt(days) ? days.toString() : days.toFixed(2).toString();
});
/**
* Interval
*/
const initialInterval = useMemo(() => {
if (Number(expiresIn) <= 86400) return "hours";
if (expiresInSeconds <= 172800) return "hours";
return "days";
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -138,22 +142,6 @@ export default function AuthenticationTab({ account }: Props) {
</div>
<div className={"flex flex-col gap-6 w-full mt-8"}>
{(isLocalDev() || isNetBirdHosted()) && (
<div>
<FancyToggleSwitch
value={peerApproval}
onChange={setPeerApproval}
label={
<>
<VoteIcon size={15} />
Peer approval
</>
}
helpText={"Require peers to be approved by an administrator."}
/>
</div>
)}
<div>
<FancyToggleSwitch
value={loginExpiration}

View File

@@ -50,7 +50,7 @@ export default function GroupsTab({ account }: Props) {
* JWT Group Sync
*/
const [jwtGroupSync, setJwtGroupSync] = useState<boolean>(
account.settings.groups_propagation_enabled,
account.settings.jwt_groups_enabled,
);
const [jwtGroupsClaimName, setJwtGroupsClaimName] = useState(
account.settings.jwt_groups_claim_name,

View File

@@ -0,0 +1,100 @@
import Breadcrumbs from "@components/Breadcrumbs";
import Button from "@components/Button";
import FancyToggleSwitch from "@components/FancyToggleSwitch";
import { notify } from "@components/Notification";
import * as Tabs from "@radix-ui/react-tabs";
import { useApiCall } from "@utils/api";
import { GaugeIcon, LockIcon } from "lucide-react";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import SettingsIcon from "@/assets/icons/SettingsIcon";
import { useHasChanges } from "@/hooks/useHasChanges";
import { Account } from "@/interfaces/Account";
type Props = {
account: Account;
};
export default function PermissionsTab({ account }: Props) {
const { mutate } = useSWRConfig();
const saveRequest = useApiCall<Account>("/accounts/" + account.id);
const [userViewBlocked, setUserViewBlocked] = useState<boolean>(
account?.settings.regular_users_view_blocked ?? false,
);
const { hasChanges, updateRef } = useHasChanges([userViewBlocked]);
const saveChanges = async () => {
notify({
title: "Permission Settings",
description: "Permissions were updated successfully.",
promise: saveRequest
.put({
id: account.id,
settings: {
regular_users_view_blocked: userViewBlocked,
groups_propagation_enabled:
account.settings?.groups_propagation_enabled,
peer_login_expiration_enabled:
account.settings?.peer_login_expiration_enabled,
peer_login_expiration: account.settings?.peer_login_expiration,
jwt_groups_enabled: account.settings?.jwt_groups_enabled,
jwt_groups_claim_name: account.settings?.jwt_groups_claim_name,
jwt_allow_groups: account.settings?.jwt_allow_groups,
},
})
.then(() => {
mutate("/accounts");
updateRef([userViewBlocked]);
}),
loadingMessage: "Updating permissions...",
});
};
return (
<Tabs.Content value={"permissions"} className={"w-full"}>
<div className={"p-default py-6 max-w-xl"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/settings"}
label={"Settings"}
icon={<SettingsIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/settings?tab=permissions"}
label={"Permissions"}
icon={<LockIcon size={14} />}
active
/>
</Breadcrumbs>
<div className={"flex items-start justify-between"}>
<h1>Permissions</h1>
<Button
variant={"primary"}
disabled={!hasChanges}
onClick={saveChanges}
>
Save Changes
</Button>
</div>
<div className={"flex flex-col gap-6 mt-8 mb-3"}>
<FancyToggleSwitch
value={userViewBlocked}
onChange={setUserViewBlocked}
label={
<>
<GaugeIcon size={15} />
Restrict dashboard for regular users
</>
}
helpText={
"Access to the dashboard will be limited and regular users will not be able to view any peers."
}
/>
</div>
</div>
</Tabs.Content>
);
}

View File

@@ -5,6 +5,7 @@ import { ModalContent, ModalFooter } from "@components/modal/Modal";
import Paragraph from "@components/Paragraph";
import SmallParagraph from "@components/SmallParagraph";
import { Tabs, TabsList, TabsTrigger } from "@components/Tabs";
import { ExternalLinkIcon } from "lucide-react";
import React from "react";
import AndroidIcon from "@/assets/icons/AndroidIcon";
import AppleIcon from "@/assets/icons/AppleIcon";
@@ -32,29 +33,49 @@ type Props = {
};
export default function SetupModal({ showClose = true, user }: Props) {
const os = useOperatingSystem();
return (
<ModalContent showClose={showClose}>
<SetupModalContent user={user} />
</ModalContent>
);
}
export function SetupModalContent({
user,
header = true,
footer = true,
tabAlignment = "center",
}: {
user?: OidcUserInfo;
header?: boolean;
footer?: boolean;
tabAlignment?: "center" | "start" | "end";
}) {
const os = useOperatingSystem();
const [isFirstRun] = useLocalStorage<boolean>("netbird-first-run", true);
return (
<ModalContent showClose={showClose}>
<div className={"text-center pb-8 pt-4 px-8"}>
<h2 className={"text-3xl max-w-lg mx-auto"}>
{isFirstRun ? (
<>
Hello {user?.given_name || "there"}! 👋 <br />
{`It's time to add your first device.`}
</>
) : (
<>Add new peer</>
)}
</h2>
<Paragraph className={"max-w-xs mx-auto mt-3"}>
To get started, install NetBird and log in using your email account.
</Paragraph>
</div>
<>
{header && (
<div className={"text-center pb-8 pt-4 px-8"}>
<h2 className={"text-3xl max-w-lg mx-auto"}>
{isFirstRun ? (
<>
Hello {user?.given_name || "there"}! 👋 <br />
{`It's time to add your first device.`}
</>
) : (
<>Add new peer</>
)}
</h2>
<Paragraph className={"max-w-xs mx-auto mt-3"}>
To get started, install NetBird and log in using your email account.
</Paragraph>
</div>
)}
<Tabs defaultValue={String(os)}>
<TabsList>
<TabsList justify={tabAlignment} className={"pt-2 px-3"}>
<TabsTrigger value={String(OperatingSystem.LINUX)}>
<ShellIcon
className={
@@ -111,23 +132,26 @@ export default function SetupModal({ showClose = true, user }: Props) {
<IOSTab />
<DockerTab />
</Tabs>
<ModalFooter variant={"setup"}>
<div>
<SmallParagraph>
After that you should be connected. Add more devices to your network
or manage your existing devices in the admin panel. If you have
further questions check out our{" "}
<InlineLink
href={
"https://docs.netbird.io/how-to/getting-started#installation"
}
target={"_blank"}
>
installation guide.
</InlineLink>
</SmallParagraph>
</div>
</ModalFooter>
</ModalContent>
{footer && (
<ModalFooter variant={"setup"}>
<div>
<SmallParagraph>
After that you should be connected. Add more devices to your
network or manage your existing devices in the admin panel. If you
have further questions check out our{" "}
<InlineLink
href={
"https://docs.netbird.io/how-to/getting-started#installation"
}
target={"_blank"}
>
Installation Guide
<ExternalLinkIcon size={12} />
</InlineLink>
</SmallParagraph>
</div>
</ModalFooter>
)}
</>
);
}

View File

@@ -100,7 +100,11 @@ export function ServiceUserModalContent({ onSuccess }: ModalProps) {
/>
</div>
<UserRoleSelector value={role as Role} onChange={setRole} />
<UserRoleSelector
value={role as Role}
onChange={setRole}
hideOwner={true}
/>
</div>
</div>

View File

@@ -20,7 +20,7 @@ export default function UserActionCell({ user, serviceUser = false }: Props) {
const deleteRule = async () => {
const name = user.name || "User";
notify({
title: name + "deleted",
title: `'${name}' deleted`,
description: "User was successfully deleted.",
promise: userRequest.del("", `/${user.id}`).then(() => {
mutate(`/users?service_user=${serviceUser}`);

View File

@@ -26,16 +26,28 @@ async function apiRequest<T>(
data?: any,
) {
const origin = config.apiOrigin;
const res = await oidcFetch(`${origin}/api${url}`, {
method,
body: JSON.stringify(data),
});
if (!res.ok) {
const error = (await res.json()) as ErrorResponse;
return Promise.reject(error);
}
return (await res.json()) as T;
try {
if (!res.ok) {
const error = (await res.json()) as ErrorResponse;
return Promise.reject(error);
}
return (await res.json()) as T;
} catch (e) {
if (!res.ok) {
const error = {
code: res.status,
message: res.statusText,
} as ErrorResponse;
return Promise.reject(error);
}
return res;
}
}
export function useNetBirdFetch(ignoreError: boolean = false) {
@@ -160,6 +172,9 @@ export function useApiErrorHandling(ignoreError = false) {
if (err.code == 500 && err.message == "internal server error") {
return setError(err);
}
if (err.code > 400 && err.code <= 500) {
return setError(err);
}
return Promise.reject(err);
};

View File

@@ -75,3 +75,7 @@ export const validator = {
return semverRegex.test(version);
},
};
export function isInt(n: number) {
return n % 1 === 0;
}

View File

@@ -27,7 +27,6 @@ const config: Config = {
"940": "#1b1f22",
"950": "#181a1d",
},
netbird: {
DEFAULT: "#f68330",
"50": "#fff6ed",
@@ -42,6 +41,20 @@ const config: Config = {
"900": "#7a2b14",
"950": "#421308",
},
"nb-blue": {
DEFAULT: "#31e4f5",
"50": "#ebffff",
"100": "#cefdff",
"200": "#a2f9ff",
"300": "#63f2fd",
"400": "#31e4f5",
"500": "#00c4da",
"600": "#039cb7",
"700": "#0a7c94",
"800": "#126478",
"900": "#145365",
"950": "#063746",
},
},
keyframes: {
"accordion-down": {