mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-01-26 01:21:04 +00:00
Add onboarding for new accounts (#514)
This commit is contained in:
1
.github/workflows/build_and_push.yml
vendored
1
.github/workflows/build_and_push.yml
vendored
@@ -2,7 +2,6 @@ name: build and push
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "feature/**"
|
||||
- main
|
||||
tags:
|
||||
- "**"
|
||||
|
||||
BIN
src/assets/onboarding/acl.png
Normal file
BIN
src/assets/onboarding/acl.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
BIN
src/assets/onboarding/activity.png
Normal file
BIN
src/assets/onboarding/activity.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 261 KiB |
BIN
src/assets/onboarding/posture.png
Normal file
BIN
src/assets/onboarding/posture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 283 KiB |
@@ -17,6 +17,12 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export type HubspotFormField = {
|
||||
objectTypeId?: string;
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const AnalyticsContext = React.createContext(
|
||||
{} as {
|
||||
initialized: boolean;
|
||||
|
||||
@@ -23,4 +23,10 @@ export interface Account {
|
||||
network_range?: string;
|
||||
lazy_connection_enabled: boolean;
|
||||
};
|
||||
onboarding?: AccountOnboarding;
|
||||
}
|
||||
|
||||
export interface AccountOnboarding {
|
||||
onboarding_flow_pending: boolean;
|
||||
signup_form_pending: boolean;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface Peer {
|
||||
login_expiration_enabled: boolean;
|
||||
inactivity_expiration_enabled: boolean;
|
||||
approval_required: boolean;
|
||||
disapproval_reason?: string;
|
||||
city_name: string;
|
||||
country_code: string;
|
||||
connection_ip: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useOidcUser } from "@axa-fr/react-oidc";
|
||||
import Button from "@components/Button";
|
||||
import { UserAvatar } from "@components/ui/UserAvatar";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { isNetBirdHosted } from "@utils/netbird";
|
||||
import { useIsSm, useIsXs } from "@utils/responsive";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { XIcon } from "lucide-react";
|
||||
@@ -20,6 +21,7 @@ import GroupsProvider from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import UsersProvider from "@/contexts/UsersProvider";
|
||||
import Navigation from "@/layouts/Navigation";
|
||||
import { OnboardingProvider } from "@/modules/onboarding/OnboardingProvider";
|
||||
import Header, { headerHeight } from "./Header";
|
||||
|
||||
export default function DashboardLayout({
|
||||
@@ -33,6 +35,7 @@ export default function DashboardLayout({
|
||||
<AnnouncementProvider>
|
||||
<GroupsProvider>
|
||||
<CountryProvider>
|
||||
{!isNetBirdHosted() && <OnboardingProvider />}
|
||||
<DashboardPageContent>{children}</DashboardPageContent>
|
||||
</CountryProvider>
|
||||
</GroupsProvider>
|
||||
|
||||
643
src/modules/onboarding/Onboarding.tsx
Normal file
643
src/modules/onboarding/Onboarding.tsx
Normal file
@@ -0,0 +1,643 @@
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { Modal, ModalPortal } from "@components/modal/Modal";
|
||||
import { NetBirdLogo } from "@components/NetBirdLogo";
|
||||
import { notify } from "@components/Notification";
|
||||
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
|
||||
import { DialogContent } from "@radix-ui/react-dialog";
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { isNetBirdHosted } from "@utils/netbird";
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { useEffect, useMemo, useReducer, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { HubspotFormField } from "@/contexts/AnalyticsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Network, NetworkResource, NetworkRouter } from "@/interfaces/Network";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { OnboardingAddResource } from "@/modules/onboarding/networks/OnboardingAddResource";
|
||||
import { OnboardingAddRoutingPeer } from "@/modules/onboarding/networks/OnboardingAddRoutingPeer";
|
||||
import { OnboardingAddUserDevice } from "@/modules/onboarding/networks/OnboardingAddUserDevice";
|
||||
import { OnboardingExplainPolicy } from "@/modules/onboarding/networks/OnboardingExplainPolicy";
|
||||
import { OnboardingTestResource } from "@/modules/onboarding/networks/OnboardingTestResource";
|
||||
import { OnboardingDevices } from "@/modules/onboarding/OnboardingDevices";
|
||||
import { OnboardingEnd } from "@/modules/onboarding/OnboardingEnd";
|
||||
import { OnboardingIntent } from "@/modules/onboarding/OnboardingIntent";
|
||||
import { OnboardingSurvey } from "@/modules/onboarding/OnboardingSurvey";
|
||||
import { OnboardingExplainDefaultPolicy } from "@/modules/onboarding/p2p/OnboardingExplainDefaultPolicy";
|
||||
import { OnboardingFirstDevice } from "@/modules/onboarding/p2p/OnboardingFirstDevice";
|
||||
import { OnboardingSecondDevice } from "@/modules/onboarding/p2p/OnboardingSecondDevice";
|
||||
import { OnboardingTestP2P } from "@/modules/onboarding/p2p/OnboardingTestP2P";
|
||||
|
||||
export interface OnboardingState {
|
||||
intent: Intent;
|
||||
step: number;
|
||||
finished_at?: string;
|
||||
survey_submitted_at?: string;
|
||||
skipped?: boolean;
|
||||
}
|
||||
|
||||
export enum Intent {
|
||||
P2P = "p2p",
|
||||
NETWORKS = "networks",
|
||||
}
|
||||
|
||||
type OnboardingAction =
|
||||
| { type: "SET_INTENT"; payload: OnboardingState["intent"] }
|
||||
| { type: "SET_FINISHED_AT"; payload: string }
|
||||
| { type: "SET_STEP"; payload: number }
|
||||
| { type: "SET_SURVEY_SUBMITTED_AT"; payload: string }
|
||||
| { type: "RESET" }
|
||||
| { type: "SKIP" };
|
||||
|
||||
const onboardingReducer = (
|
||||
state: OnboardingState,
|
||||
action: OnboardingAction,
|
||||
): OnboardingState => {
|
||||
switch (action.type) {
|
||||
case "SET_INTENT":
|
||||
return { ...state, intent: action.payload };
|
||||
case "SET_STEP":
|
||||
return { ...state, step: action.payload };
|
||||
case "SET_FINISHED_AT":
|
||||
return { ...state, finished_at: action.payload };
|
||||
case "SET_SURVEY_SUBMITTED_AT":
|
||||
return { ...state, survey_submitted_at: action.payload };
|
||||
case "RESET":
|
||||
return { intent: Intent.P2P, step: 1 };
|
||||
case "SKIP":
|
||||
return { ...state, skipped: true };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initial: OnboardingState;
|
||||
setLocalOnboarding: (onboarding: OnboardingState) => void;
|
||||
peers: Peer[];
|
||||
onSurveySubmit?: (fields: HubspotFormField[]) => void;
|
||||
onSkip: (intent: Intent, step: number) => void;
|
||||
onFinish: (n?: Network) => void;
|
||||
formSubmitted: boolean;
|
||||
onTroubleshootingClick?: (intent: Intent) => void;
|
||||
isOnboardingPending: boolean;
|
||||
domainCategory?: string;
|
||||
};
|
||||
|
||||
export const Onboarding = ({
|
||||
initial,
|
||||
setLocalOnboarding,
|
||||
peers,
|
||||
onSurveySubmit,
|
||||
onSkip,
|
||||
onFinish,
|
||||
formSubmitted,
|
||||
onTroubleshootingClick,
|
||||
isOnboardingPending,
|
||||
domainCategory,
|
||||
}: Props) => {
|
||||
const { data: networks } = useFetchApi<Network[]>("/networks", true, false);
|
||||
const { data: policies } = useFetchApi<Policy[]>("/policies", true);
|
||||
const router = useRouter();
|
||||
|
||||
const resourceRequest = useApiCall<NetworkResource>("/networks", true);
|
||||
const routerRequest = useApiCall<NetworkRouter>("/networks", true);
|
||||
const policyRequest = useApiCall<Policy>("/policies", true);
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const [onboarding, dispatch] = useReducer(onboardingReducer, initial);
|
||||
const { step, intent } = onboarding;
|
||||
|
||||
const [resource, setResource] = useState<NetworkResource>();
|
||||
const [firstRoutingPeer, setFirstRoutingPeer] = useState<Peer>();
|
||||
const [useCases, setUseCases] = useState("");
|
||||
const [isBusiness, setIsBusiness] = useState(false);
|
||||
|
||||
const firstNetwork = useMemo(() => {
|
||||
return networks?.find((n) => n.name === "My First Network") ?? undefined;
|
||||
}, [networks]);
|
||||
|
||||
const firstDevice = useMemo(() => {
|
||||
return (
|
||||
peers?.find((p) => p.id !== firstRoutingPeer?.id && p.user_id !== "") ??
|
||||
undefined
|
||||
);
|
||||
}, [firstRoutingPeer?.id, peers]);
|
||||
|
||||
const secondDevice = useMemo(() => {
|
||||
return (
|
||||
peers?.find(
|
||||
(p) => p.id !== firstDevice?.id && p.id !== firstRoutingPeer?.id,
|
||||
) ?? undefined
|
||||
);
|
||||
}, [peers, firstDevice, firstRoutingPeer]);
|
||||
|
||||
const maxSteps = useMemo(() => {
|
||||
if (intent === Intent.P2P) return 7;
|
||||
return 8;
|
||||
}, [intent]);
|
||||
|
||||
const showWaitingForDevices = useMemo(() => {
|
||||
if (intent === Intent.NETWORKS) {
|
||||
return step === 4 || step === 5 || step === 6 || step === 7;
|
||||
} else {
|
||||
return step === 3 || step === 4 || step === 5 || step === 6;
|
||||
}
|
||||
}, [intent, step]);
|
||||
|
||||
const policy = useMemo(() => {
|
||||
if (intent === Intent.P2P) {
|
||||
return policies?.find((p) => p.name === "Default");
|
||||
} else if (resource) {
|
||||
return policies?.find((p) => p.name.includes(resource?.name));
|
||||
}
|
||||
}, [intent, policies, resource]);
|
||||
|
||||
const defaultPolicy = useMemo(() => {
|
||||
return policies?.find((p) => p.name === "Default");
|
||||
}, [policies]);
|
||||
|
||||
const disableDefaultPolicy = async () => {
|
||||
if (!defaultPolicy) return;
|
||||
if (defaultPolicy.enabled) return await togglePolicy(defaultPolicy, true);
|
||||
};
|
||||
|
||||
const togglePolicy = async (p: Policy, ignoreNotification = false) => {
|
||||
if (!p) return;
|
||||
const rule = p?.rules?.[0];
|
||||
if (!rule) return;
|
||||
|
||||
const enabled = p?.enabled || false;
|
||||
|
||||
const sources = rule.sources
|
||||
?.map((group) => {
|
||||
const g = group as Group;
|
||||
return g?.id;
|
||||
})
|
||||
.filter((x) => x !== undefined);
|
||||
const destinations = rule.destinations
|
||||
?.map((group) => {
|
||||
const g = group as Group;
|
||||
return g?.id;
|
||||
})
|
||||
.filter((x) => x !== undefined);
|
||||
|
||||
const request = policyRequest.put(
|
||||
{
|
||||
...p,
|
||||
rules: [
|
||||
{
|
||||
...rule,
|
||||
sources: sources || [],
|
||||
destinations: rule.destinationResource
|
||||
? undefined
|
||||
: destinations || [],
|
||||
},
|
||||
],
|
||||
enabled: !enabled,
|
||||
},
|
||||
`/${p.id}`,
|
||||
);
|
||||
|
||||
if (ignoreNotification) {
|
||||
return request.then(() => mutate("/policies"));
|
||||
} else {
|
||||
notify({
|
||||
title: p.name + " Policy",
|
||||
description: `Policy was successfully ${
|
||||
!enabled ? "enabled" : "disabled"
|
||||
}`,
|
||||
loadingMessage: "Updating policy...",
|
||||
promise: request.then(() => mutate("/policies")),
|
||||
duration: 800,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (firstNetwork && intent === Intent.NETWORKS && !firstRoutingPeer) {
|
||||
const firstRouterId = firstNetwork?.routers?.[0];
|
||||
if (firstRouterId) {
|
||||
routerRequest
|
||||
.get(`/${firstNetwork?.id}/routers/${firstRouterId}`)
|
||||
.then((r) => {
|
||||
const routingPeer =
|
||||
peers?.find((p) => p.id === r.peer) ?? undefined;
|
||||
if (!routingPeer) return;
|
||||
setFirstRoutingPeer(routingPeer);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [intent, firstNetwork, peers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (firstNetwork && intent === Intent.NETWORKS) {
|
||||
const firstResourceId = firstNetwork?.resources?.[0];
|
||||
if (firstResourceId) {
|
||||
resourceRequest
|
||||
.get(`/${firstNetwork?.id}/resources/${firstResourceId}`)
|
||||
.then((r) => {
|
||||
setResource(r);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [intent, firstNetwork]);
|
||||
|
||||
/**
|
||||
* Polling every 5s if we are still waiting for devices to connect, in case browser focus does not trigger a refresh
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (
|
||||
(firstDevice && secondDevice) ||
|
||||
(firstDevice && firstRoutingPeer) ||
|
||||
!(step === 3 || step === 4 || step === 5)
|
||||
) {
|
||||
return; // Stop polling if condition is met
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
mutate("/peers");
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval); // Clean up when dependencies change
|
||||
}, [firstDevice, secondDevice, firstRoutingPeer, step, mutate]);
|
||||
|
||||
/**
|
||||
* Skip form if already submitted
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (formSubmitted && step === 1) {
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 2,
|
||||
});
|
||||
}
|
||||
}, [formSubmitted, step]);
|
||||
|
||||
/**
|
||||
* Sync state with local storage
|
||||
*/
|
||||
useEffect(() => {
|
||||
setLocalOnboarding(onboarding);
|
||||
}, [onboarding, setLocalOnboarding]);
|
||||
|
||||
/**
|
||||
* Prefetch the first network page if it exists for faster navigation
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!firstNetwork) return;
|
||||
router.prefetch(`/network?id=${firstNetwork.id}`);
|
||||
}, [firstNetwork, router]);
|
||||
|
||||
return (
|
||||
<Modal open={true}>
|
||||
<ModalPortal>
|
||||
<DialogContent
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
asChild={true}
|
||||
className={
|
||||
"h-full w-screen fixed z-[50] left-0 top-0 bg-nb-gray-950 flex overflow-y-auto"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"sm:px-4 py-10 max-w-6xl mx-auto flex flex-col items-center",
|
||||
intent === Intent.P2P && step === 3 && "max-w-4xl",
|
||||
intent === Intent.NETWORKS && step === 7 && "max-w-5xl",
|
||||
)}
|
||||
>
|
||||
<NetBirdLogo size={"large"} mobile={false} />
|
||||
|
||||
<div
|
||||
className={
|
||||
"grid grid-cols-1 md:grid-cols-12 gap-4 pb-10 mt-8 sm:mt-10"
|
||||
}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
"max-w-2xl md:col-span-12",
|
||||
step === 1 && "max-w-lg",
|
||||
step === 3 &&
|
||||
intent == "p2p" &&
|
||||
"md:col-span-7 lg:col-span-6",
|
||||
step === 4 &&
|
||||
intent == "p2p" &&
|
||||
"md:col-span-7 lg:col-span-6",
|
||||
step === 5 &&
|
||||
intent == "p2p" &&
|
||||
"md:col-span-7 lg:col-span-6",
|
||||
step === 6 &&
|
||||
intent == "p2p" &&
|
||||
"md:col-span-7 lg:col-span-6",
|
||||
step === 3 && intent == "networks" && "max-w-xl ",
|
||||
step === 4 &&
|
||||
intent == "networks" &&
|
||||
"md:col-span-7 lg:col-span-6",
|
||||
step === 5 &&
|
||||
intent == "networks" &&
|
||||
"md:col-span-7 lg:col-span-6",
|
||||
step === 6 &&
|
||||
intent == "networks" &&
|
||||
"md:col-span-7 lg:col-span-6",
|
||||
step === 7 &&
|
||||
intent == "networks" &&
|
||||
"md:col-span-7 lg:col-span-6",
|
||||
step === maxSteps && "max-w-2xl",
|
||||
)}
|
||||
>
|
||||
{isOnboardingPending && (
|
||||
<Stepper
|
||||
step={isNetBirdHosted() ? step : step - 1}
|
||||
maxSteps={isNetBirdHosted() ? maxSteps : maxSteps - 1}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 1 && domainCategory && (
|
||||
<OnboardingSurvey
|
||||
domainCategory={domainCategory}
|
||||
onSubmit={(fields) => {
|
||||
dispatch({
|
||||
type: "SET_SURVEY_SUBMITTED_AT",
|
||||
payload: new Date().toISOString(),
|
||||
});
|
||||
onSurveySubmit?.(fields);
|
||||
|
||||
let u = fields?.find((f) => f.name === "use_case");
|
||||
if (u) setUseCases(u.value);
|
||||
|
||||
let businessOrPersonal = fields?.find(
|
||||
(f) => f.name === "is_company",
|
||||
);
|
||||
if (businessOrPersonal)
|
||||
setIsBusiness(
|
||||
businessOrPersonal.value === "Business",
|
||||
);
|
||||
|
||||
if (isOnboardingPending) {
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 2,
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "SET_FINISHED_AT",
|
||||
payload: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<OnboardingIntent
|
||||
useCases={useCases}
|
||||
isBusiness={isBusiness}
|
||||
onSelect={(val) => {
|
||||
dispatch({
|
||||
type: "SET_INTENT",
|
||||
payload: val,
|
||||
});
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 3,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{intent === Intent.P2P && (
|
||||
<>
|
||||
{step === 3 && (
|
||||
<OnboardingFirstDevice
|
||||
firstDevice={firstDevice}
|
||||
onFinish={() => {
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 4,
|
||||
});
|
||||
}}
|
||||
onBack={() => {
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 2,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === 4 && (
|
||||
<OnboardingSecondDevice
|
||||
secondDevice={secondDevice}
|
||||
onFinish={() => {
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 5,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === 5 && (
|
||||
<OnboardingTestP2P
|
||||
firstDevice={firstDevice}
|
||||
secondDevice={secondDevice}
|
||||
onTroubleshootingClick={() =>
|
||||
onTroubleshootingClick?.(intent)
|
||||
}
|
||||
onNext={() => {
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 6,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === 6 && (
|
||||
<OnboardingExplainDefaultPolicy
|
||||
policy={policy}
|
||||
onToggle={togglePolicy}
|
||||
onNext={() => {
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 7,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{intent === Intent.NETWORKS && (
|
||||
<>
|
||||
{step === 3 && (
|
||||
<OnboardingAddResource
|
||||
onResourceCreation={(res) => {
|
||||
setResource(res);
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 4,
|
||||
});
|
||||
mutate("/networks");
|
||||
}}
|
||||
onBack={() => {
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 2,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === 4 && (
|
||||
<OnboardingAddRoutingPeer
|
||||
network={firstNetwork}
|
||||
peers={peers}
|
||||
onRoutingPeerAdded={(p) => {
|
||||
setFirstRoutingPeer(p);
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 5,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === 5 && (
|
||||
<OnboardingAddUserDevice
|
||||
device={firstDevice}
|
||||
policy={policy}
|
||||
onNext={() => {
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 6,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === 6 && (
|
||||
<OnboardingTestResource
|
||||
resource={resource}
|
||||
device={firstDevice}
|
||||
onTroubleshootingClick={() =>
|
||||
onTroubleshootingClick?.(intent)
|
||||
}
|
||||
onNext={() => {
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 7,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{step === 7 && (
|
||||
<OnboardingExplainPolicy
|
||||
policy={policy}
|
||||
onToggle={togglePolicy}
|
||||
onNext={() => {
|
||||
dispatch({
|
||||
type: "SET_STEP",
|
||||
payload: 8,
|
||||
});
|
||||
disableDefaultPolicy().then();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{step === maxSteps && (
|
||||
<OnboardingEnd
|
||||
onFinish={() => {
|
||||
dispatch({
|
||||
type: "SET_FINISHED_AT",
|
||||
payload: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (intent === Intent.NETWORKS) {
|
||||
onFinish(firstNetwork);
|
||||
} else {
|
||||
onFinish();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{showWaitingForDevices && (
|
||||
<Card className={"md:col-span-5 lg:col-span-6"}>
|
||||
<OnboardingDevices
|
||||
intent={intent}
|
||||
resource={resource}
|
||||
firstDevice={firstDevice}
|
||||
secondDevice={secondDevice}
|
||||
firstRoutingPeer={firstRoutingPeer}
|
||||
enabled={policy?.enabled}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{step !== 1 && step !== maxSteps && (
|
||||
<span
|
||||
className={
|
||||
"text-sm text-nb-gray-400 font-light pb-10 text-center px-4"
|
||||
}
|
||||
>
|
||||
Already know how NetBird works?
|
||||
<InlineLink
|
||||
href={"#"}
|
||||
className={"!text-nb-gray-200 ml-1"}
|
||||
onClick={() => {
|
||||
dispatch({
|
||||
type: "SKIP",
|
||||
});
|
||||
onSkip(intent, step);
|
||||
}}
|
||||
>
|
||||
Skip to Dashboard
|
||||
</InlineLink>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</ModalPortal>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const Stepper = ({ step, maxSteps }: { step: number; maxSteps: number }) => {
|
||||
if (step <= 0) return;
|
||||
|
||||
return (
|
||||
<div className={"flex gap-2 w-full items-center justify-center mb-6 mt-2"}>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
const Card = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"px-6 sm:px-8 py-8 pt-6",
|
||||
"bg-nb-gray-940 border border-nb-gray-910 rounded-lg relative",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<GradientFadedBackground className={"opacity-0"} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
297
src/modules/onboarding/OnboardingDevices.tsx
Normal file
297
src/modules/onboarding/OnboardingDevices.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import TruncatedText from "@components/ui/TruncatedText";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { cn } from "@utils/helpers";
|
||||
import {
|
||||
GlobeIcon,
|
||||
NetworkIcon,
|
||||
ShieldCheckIcon,
|
||||
ShieldXIcon,
|
||||
WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import RoundedFlag from "@/assets/countries/RoundedFlag";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { Intent } from "@/modules/onboarding/Onboarding";
|
||||
import { OSLogo } from "@/modules/peers/PeerOSCell";
|
||||
|
||||
type Props = {
|
||||
intent?: Intent;
|
||||
resource?: NetworkResource;
|
||||
firstDevice?: Peer;
|
||||
secondDevice?: Peer;
|
||||
firstRoutingPeer?: Peer;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export const OnboardingDevices = ({
|
||||
intent,
|
||||
resource,
|
||||
firstDevice,
|
||||
secondDevice,
|
||||
firstRoutingPeer,
|
||||
enabled = false,
|
||||
}: Props) => {
|
||||
return intent === Intent.P2P ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full flex-col items-center justify-center text-center text-nb-gray-300 py-8 w-full relative",
|
||||
!firstDevice && !secondDevice ? "gap-y-8" : "gap-y-2",
|
||||
)}
|
||||
>
|
||||
<DeviceCard device={firstDevice} />
|
||||
{firstDevice && secondDevice && (
|
||||
<div
|
||||
className={cn(
|
||||
"h-[70px] w-[2px] rounded-full border-l border-dashed border-green-400 relative",
|
||||
!enabled && "border-nb-gray-600",
|
||||
)}
|
||||
></div>
|
||||
)}
|
||||
|
||||
{firstDevice && secondDevice && (
|
||||
<div
|
||||
className={
|
||||
"absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 bg-nb-gray-940 p-2 "
|
||||
}
|
||||
>
|
||||
{enabled ? (
|
||||
<ShieldCheckIcon size={16} className={"text-green-500"} />
|
||||
) : (
|
||||
<ShieldXIcon size={16} className={"text-red-500"} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DeviceCard device={secondDevice} />
|
||||
{(!firstDevice || !secondDevice) && (
|
||||
<WaitingForDevice
|
||||
text={
|
||||
!firstDevice
|
||||
? "Waiting for your first device to connect"
|
||||
: "Waiting for your second device to connect"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full flex-col items-center justify-center text-center text-nb-gray-300 w-full",
|
||||
"gap-y-2",
|
||||
firstRoutingPeer && "h-full",
|
||||
)}
|
||||
>
|
||||
{firstRoutingPeer && resource && (
|
||||
<span className={"text-xs text-nb-gray-500"}>Network</span>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-y-1",
|
||||
resource &&
|
||||
firstRoutingPeer &&
|
||||
"border px-4 py-5 bg-nb-gray-940 border-nb-gray-900 rounded-lg border-dashed",
|
||||
)}
|
||||
>
|
||||
<DeviceCard resource={resource} />
|
||||
{resource && (
|
||||
<Line
|
||||
className={cn(
|
||||
firstRoutingPeer && firstDevice && enabled
|
||||
? "bg-green-400 animate-bg-scroll-faster"
|
||||
: "bg-nb-gray-600",
|
||||
)}
|
||||
height={"30px"}
|
||||
bg={"#1c1d21"}
|
||||
config={["4px", "4px", "8px", "7.5px"]}
|
||||
/>
|
||||
)}
|
||||
<DeviceCard device={firstRoutingPeer} />
|
||||
</div>
|
||||
|
||||
<div className={"flex flex-col items-center justify-center relative"}>
|
||||
{firstRoutingPeer && (
|
||||
<Line
|
||||
className={cn(
|
||||
firstRoutingPeer && firstDevice && enabled
|
||||
? "bg-green-400 animate-bg-scroll"
|
||||
: "bg-nb-gray-600",
|
||||
)}
|
||||
height={firstDevice && firstRoutingPeer ? "65px" : "25px"}
|
||||
bg={"#1c1d21"}
|
||||
/>
|
||||
)}
|
||||
<DeviceCard device={firstDevice} />
|
||||
{(!firstDevice || !firstRoutingPeer) && (
|
||||
<WaitingForDevice
|
||||
text={
|
||||
!firstRoutingPeer
|
||||
? "Waiting for your routing peer to connect"
|
||||
: "Waiting for your own device to connect"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{firstDevice && firstRoutingPeer && (
|
||||
<div
|
||||
className={
|
||||
"absolute top-0 left-1/2 -translate-x-1/2 bg-nb-gray-940 p-1 mt-[20px]"
|
||||
}
|
||||
>
|
||||
{enabled ? (
|
||||
<ShieldCheckIcon size={16} className={"text-green-500"} />
|
||||
) : (
|
||||
<ShieldXIcon size={16} className={"text-red-500"} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const WaitingForDevice = ({
|
||||
text = "Waiting for your first device to connect",
|
||||
}: {
|
||||
text: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className={"flex flex-col items-center justify-center mt-3"}>
|
||||
<div className="relative h-10 w-10 mt-4">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-10 w-10 rounded-full bg-netbird/10 border border-netbird/60 animate-slow-ping "></div>
|
||||
</div>
|
||||
<div className="absolute top-1/2 left-1/2 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full bg-netbird z-10" />
|
||||
</div>
|
||||
<div className="text-sm font-light animate-slow-pulse mt-6">{text}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type DeviceCardProps = {
|
||||
device?: Peer;
|
||||
resource?: NetworkResource;
|
||||
};
|
||||
|
||||
export const DeviceCard = ({ device, resource }: DeviceCardProps) => {
|
||||
if (!device && !resource) return;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-2.5 text-nb-gray-300 text-left py-1 pl-3 pr-4 rounded-md group/machine my-0 w-[200px]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-md h-9 w-9 shrink-0 bg-nb-gray-900 transition-all",
|
||||
"group-hover:bg-nb-gray-800 relative",
|
||||
)}
|
||||
>
|
||||
{device && <PeerOSIcon os={device.os} />}
|
||||
{resource?.type && <ResourceIcon type={resource.type} />}
|
||||
|
||||
{device?.country_code && (
|
||||
<div className={"absolute -bottom-[4px] -right-[4px]"}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-full border-[3px] shrink-0",
|
||||
"border-nb-gray-940",
|
||||
)}
|
||||
>
|
||||
<RoundedFlag country={device?.country_code} size={10} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={"flex flex-col gap-0 justify-center mt-2 leading-tight"}>
|
||||
<span
|
||||
className={
|
||||
"mb-1.5 font-normal text-[0.85rem] text-nb-gray-100 flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
<TruncatedText
|
||||
text={device?.name || resource?.name || "Unknown"}
|
||||
maxWidth={"150px"}
|
||||
hideTooltip={true}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"text-sm font-normal text-nb-gray-400 -top-[0.3rem] relative"
|
||||
}
|
||||
>
|
||||
{device?.ip || resource?.address}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PeerOSIcon = ({ os }: { os: string }) => {
|
||||
const osType = getOperatingSystem(os);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center grayscale brightness-[100%] contrast-[40%]",
|
||||
"w-4 h-4 shrink-0",
|
||||
osType === OperatingSystem.WINDOWS && "p-[2.5px]",
|
||||
osType === OperatingSystem.APPLE && "p-[2.7px]",
|
||||
osType === OperatingSystem.FREEBSD && "p-[1.5px]",
|
||||
)}
|
||||
>
|
||||
<OSLogo os={os} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ResourceIcon = ({
|
||||
type,
|
||||
size = 15,
|
||||
}: {
|
||||
type: "domain" | "host" | "subnet";
|
||||
size?: number;
|
||||
}) => {
|
||||
switch (type) {
|
||||
case "domain":
|
||||
return <GlobeIcon size={size} />;
|
||||
case "subnet":
|
||||
return <NetworkIcon size={size} />;
|
||||
case "host":
|
||||
return <WorkflowIcon size={size} />;
|
||||
default:
|
||||
return <WorkflowIcon size={size} />;
|
||||
}
|
||||
};
|
||||
|
||||
const Line = ({
|
||||
className,
|
||||
height = "100%",
|
||||
bg = "#1c1d21",
|
||||
config = ["2px", "3px", "6px", "8.2px"],
|
||||
}: {
|
||||
className?: string;
|
||||
height?: string;
|
||||
bg?: string;
|
||||
config?: string[];
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
className,
|
||||
"w-[1px] overflow-hidden relative -left-[0.5px]",
|
||||
)}
|
||||
style={{
|
||||
height: height,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn("absolute inset-0 w-full", className)}
|
||||
style={{
|
||||
backgroundImage: `repeating-linear-gradient(to bottom, transparent 0%, transparent ${config?.[0]}, ${bg} ${config?.[1]}, ${bg} ${config?.[2]})`,
|
||||
backgroundSize: `100% ${config?.[3]}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
131
src/modules/onboarding/OnboardingEnd.tsx
Normal file
131
src/modules/onboarding/OnboardingEnd.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useOidcUser } from "@axa-fr/react-oidc";
|
||||
import Button from "@components/Button";
|
||||
import { ArrowRightIcon, PlayIcon } from "lucide-react";
|
||||
import Image, { StaticImageData } from "next/image";
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
import ACLImage from "@/assets/onboarding/acl.png";
|
||||
import ActivityImage from "@/assets/onboarding/activity.png";
|
||||
import PostureCheckImage from "@/assets/onboarding/posture.png";
|
||||
|
||||
type Props = {
|
||||
onFinish?: () => void;
|
||||
};
|
||||
|
||||
export const OnboardingEnd = ({ onFinish }: Props) => {
|
||||
const { oidcUser: user } = useOidcUser();
|
||||
const name = user?.given_name || user?.name || user?.preferred_username;
|
||||
|
||||
const title = name ? `Congratulations, ${name}!` : "Congratulations!";
|
||||
|
||||
return (
|
||||
<div className={"relative flex flex-col h-full justify-between"}>
|
||||
<div>
|
||||
<h1 className={"text-xl text-center max-w-sm mx-auto"}>
|
||||
{title} <br />
|
||||
You’ve completed the onboarding.
|
||||
</h1>
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
|
||||
}
|
||||
>
|
||||
What’s next? Check out these guides to get the most out of NetBird. To
|
||||
learn more, explore the dashboard, visit our documentation, or browse
|
||||
our YouTube channel.
|
||||
</div>
|
||||
|
||||
<div className={"mt-8 flex flex-col gap-8"}>
|
||||
<VideoGuide
|
||||
title={"Access Control in Under 5 Minutes"}
|
||||
src={ACLImage}
|
||||
description={
|
||||
"Learn how to manage access for your network resources effectively. Whether you want to restrict access to specific machines or allow certain users to connect."
|
||||
}
|
||||
href={"https://www.youtube.com/watch?v=WtZD_q-g_Jc"}
|
||||
/>
|
||||
<VideoGuide
|
||||
title={"Provision Users & Groups From Your IdP"}
|
||||
src={PostureCheckImage}
|
||||
description={
|
||||
"Learn how to provision users and groups from your identity provider, such as Okta, Azure AD, or Google Workspace, to manage access control in NetBird and automate onboarding and offboarding processes."
|
||||
}
|
||||
href={"https://www.youtube.com/watch?v=RxYWTpf7cgY"}
|
||||
/>
|
||||
<VideoGuide
|
||||
title={"How NetBird Works"}
|
||||
description={
|
||||
"Learn more about how NetBird works, its architecture, and how it can help you build secure networks."
|
||||
}
|
||||
src={ActivityImage}
|
||||
href={"https://www.youtube.com/watch?v=CFa7SY4Up9k&t=261s"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={"mt-10 flex items-center justify-center"}>
|
||||
<Button variant={"secondaryLighter"} onClick={onFinish}>
|
||||
Go to Dashboard
|
||||
<ArrowRightIcon size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type VideoGuideProps = {
|
||||
src?: string | StaticImageData;
|
||||
title?: string;
|
||||
description?: string;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
const VideoGuide = ({
|
||||
src = ACLImage,
|
||||
title = "Access Control in Under 5 Minutes",
|
||||
description = "Learn how to manage access for your network resources effectively. Whether you want to restrict access to specific machines or allow certain users to connect.",
|
||||
href = "#",
|
||||
}: VideoGuideProps) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"flex flex-col sm:flex-row gap-3 items-center text-center sm:text-left sm:gap-6"
|
||||
}
|
||||
>
|
||||
<Link
|
||||
className={
|
||||
"border border-nb-gray-900 rounded-lg p-[2px] bg-nb-gray-920 min-w-[160px] max-w-[160px] relative group hover:bg-nb-gray-900 transition-all"
|
||||
}
|
||||
target={"_blank"}
|
||||
href={href}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"flex items-center justify-center absolute left-0 top-0 h-full w-full"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"bg-nb-gray-900/50 group-hover:bg-nb-gray-600/50 backdrop-blur h-8 w-8 flex items-center justify-center rounded-full"
|
||||
}
|
||||
>
|
||||
<PlayIcon size={14} />
|
||||
</div>
|
||||
</span>
|
||||
<Image
|
||||
src={src}
|
||||
alt={title}
|
||||
className={"border border-nb-gray-900 rounded-md"}
|
||||
/>
|
||||
</Link>
|
||||
<div>
|
||||
<div className={"text-md"}>{title}</div>
|
||||
<div
|
||||
className={"text-[0.8rem] text-nb-gray-300 font-light mt-1.5 block"}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
176
src/modules/onboarding/OnboardingIntent.tsx
Normal file
176
src/modules/onboarding/OnboardingIntent.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import {IconArrowRight} from "@tabler/icons-react";
|
||||
import {cn} from "@utils/helpers";
|
||||
import {HelpCircle} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import {useMemo} from "react";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import {Intent} from "@/modules/onboarding/Onboarding";
|
||||
|
||||
type Props = {
|
||||
onSelect: (intent: Intent) => void,
|
||||
useCases?: string,
|
||||
isBusiness?: boolean
|
||||
};
|
||||
|
||||
export const OnboardingIntent = ({onSelect, useCases, isBusiness}: Props) => {
|
||||
/**
|
||||
* Recommend Networks if users ticks any of these use cases
|
||||
*/
|
||||
const isNetworksRecommended = useMemo(() => {
|
||||
if (!useCases) return false;
|
||||
const intents = [
|
||||
"Zero Trust Security",
|
||||
"Employee Remote Access",
|
||||
"Business VPN",
|
||||
"Site-to-Site Connectivity",
|
||||
"IoT (Internet of Things)",
|
||||
"MSP (Managed Service Provider)",
|
||||
];
|
||||
for (const intent of intents) {
|
||||
if (useCases.toLowerCase().includes(intent.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [useCases]);
|
||||
|
||||
/**
|
||||
* Recommend P2P if users ticks any of these use cases
|
||||
*/
|
||||
const isP2PRecommended = useMemo(() => {
|
||||
if (!useCases) return false;
|
||||
const intents = [
|
||||
"Homelab Automation",
|
||||
"Home Remote Access",
|
||||
"File Access",
|
||||
"Gaming",
|
||||
];
|
||||
for (const intent of intents) {
|
||||
if (useCases.toLowerCase().includes(intent.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [useCases]);
|
||||
|
||||
return (
|
||||
<div className={"relative flex flex-col h-full justify-between"}>
|
||||
<div>
|
||||
<h1 className={"text-xl text-center"}>Get started with NetBird</h1>
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
|
||||
}
|
||||
>
|
||||
NetBird provides the flexibility of both a peer-to-peer overlay network and a remote network access
|
||||
solution.
|
||||
Choose what fits your needs, you can always combine both.
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 mt-8",
|
||||
"border border-nb-gray-900 rounded-lg flex items-start flex-col relative bg-nb-gray-930/60 transition-all ",
|
||||
)}
|
||||
>
|
||||
<IntentCard
|
||||
title={"Peer-to-Peer Network"}
|
||||
description={
|
||||
isBusiness ? "Install NetBird on two or more devices to create secure, direct WireGuard connections, like laptop to server or server to database. Add at least two machines to get started." :"Install NetBird on two or more devices in your homelab, such as your laptop, NAS, or Raspberry Pi, to create secure, direct WireGuard connections."
|
||||
}
|
||||
recommended={isP2PRecommended}
|
||||
icon={<PeerIcon size={18} className={"fill-netbird"}/>}
|
||||
onClick={() => onSelect(Intent.P2P)}
|
||||
/>
|
||||
<IntentCard
|
||||
title={"Remote Network Access"}
|
||||
description={
|
||||
isBusiness ? "Enable employee remote access to VMs, Kubernetes clusters, and cloud or on-prem resources without installing NetBird on every machine." : "Securely access your homelab remotely from anywhere without installing NetBird on every device."
|
||||
}
|
||||
recommended={isNetworksRecommended}
|
||||
icon={<NetworkRoutesIcon size={18} className={"fill-netbird"}/>}
|
||||
onClick={() => onSelect(Intent.NETWORKS)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type IntentCardProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
recommended?: boolean;
|
||||
};
|
||||
|
||||
const IntentCard = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
onClick,
|
||||
recommended,
|
||||
}: IntentCardProps) => {
|
||||
return (
|
||||
<button
|
||||
className={
|
||||
"px-6 py-6 flex items-start flex-col relative hover:bg-nb-gray-920 transition-all group first:border-b border-nb-gray-900"
|
||||
}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className={"flex gap-6"}>
|
||||
<div
|
||||
className={cn(
|
||||
"h-10 w-10 flex items-center justify-center rounded-md shrink-0 mt-2",
|
||||
"bg-nb-gray-900 border border-nb-gray-800 ",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className={"flex gap-4 items-center"}>
|
||||
<div className={"text-left"}>
|
||||
<h2
|
||||
className={
|
||||
"text-base font-medium mb-.5 group-hover:text-netbird transition-all inline-flex gap-x-2 gap-y-1 flex-wrap"
|
||||
}
|
||||
>
|
||||
{title}
|
||||
{recommended && (
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"text-xs max-w-xs"}>
|
||||
Based on your previous choices, we recommend starting with{" "}
|
||||
{title}. You can always combine both options later.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"relative",
|
||||
"inline-flex text-[0.7rem] font-light bg-netbird/10 border border-netbird-400/30 text-netbird-400 rounded-full px-2 py-1 pb-0.5 leading-none",
|
||||
"hover:bg-netbird/20 cursor-help transition-all self-center",
|
||||
)}
|
||||
>
|
||||
Recommended
|
||||
<HelpCircle size={10} className={"ml-1"}/>
|
||||
</span>
|
||||
</FullTooltip>
|
||||
)}
|
||||
</h2>
|
||||
<p className={"!text-nb-gray-300 text-[.85rem]"}>{description}</p>
|
||||
</div>
|
||||
<div
|
||||
className={"h-full items-center text-nb-gray-400 hidden sm:flex"}
|
||||
>
|
||||
<IconArrowRight
|
||||
size={24}
|
||||
className={"shrink-0 group-hover:text-netbird"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
39
src/modules/onboarding/OnboardingPolicy.tsx
Normal file
39
src/modules/onboarding/OnboardingPolicy.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ToggleSwitch } from "@components/ToggleSwitch";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ShieldIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
|
||||
type Props = {
|
||||
policy?: Policy;
|
||||
onToggle?: (policy: Policy) => void;
|
||||
};
|
||||
|
||||
export const OnboardingPolicy = ({ policy, onToggle }: Props) => {
|
||||
if (!policy) return;
|
||||
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
"relative block rounded-lg border border-nb-gray-900 px-5 py-3 transition-all",
|
||||
"flex justify-between items-center mt-3 cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<div className="text-nb-gray-100 font-normal text-sm text-left gap-2 flex items-center">
|
||||
<ShieldIcon size={12} className={"shrink-0"} />
|
||||
{policy?.name} Policy
|
||||
</div>
|
||||
<div className={"text-nb-gray-300 text-[0.8rem] text-left mt-0.5"}>
|
||||
{policy?.name.includes("Default")
|
||||
? "Allows connections between all your devices"
|
||||
: policy?.description}
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
onCheckedChange={() => onToggle?.(policy)}
|
||||
checked={policy?.enabled || false}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
170
src/modules/onboarding/OnboardingProvider.tsx
Normal file
170
src/modules/onboarding/OnboardingProvider.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useLocalStorage } from "@hooks/useLocalStorage";
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
import { isLocalDev, isNetBirdHosted } from "@utils/netbird";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { HubspotFormField, useAnalytics } from "@/contexts/AnalyticsProvider";
|
||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
import { Account } from "@/interfaces/Account";
|
||||
import { Network } from "@/interfaces/Network";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { useAccount } from "@/modules/account/useAccount";
|
||||
import {
|
||||
Intent,
|
||||
Onboarding,
|
||||
OnboardingState,
|
||||
} from "@/modules/onboarding/Onboarding";
|
||||
|
||||
type Props = {
|
||||
onSurveySubmit?: (data: {
|
||||
fields: HubspotFormField[];
|
||||
hsId: string;
|
||||
gaId: string;
|
||||
accountId?: string;
|
||||
userId?: string;
|
||||
}) => void;
|
||||
domainCategory?: string;
|
||||
};
|
||||
|
||||
export const OnboardingProvider = ({
|
||||
onSurveySubmit,
|
||||
domainCategory,
|
||||
}: Props) => {
|
||||
const { data: peers } = useFetchApi<Peer[]>("/peers");
|
||||
const accountRequest = useApiCall<Account>("/accounts", true);
|
||||
const account = useAccount();
|
||||
const router = useRouter();
|
||||
const { isOwner, loggedInUser } = useLoggedInUser();
|
||||
const { mutate } = useSWRConfig();
|
||||
const { trackEventV2 } = useAnalytics();
|
||||
const params = useSearchParams();
|
||||
const hsId = params?.get("hs_id") ?? "";
|
||||
const gaId = params?.get("ga_id") ?? "";
|
||||
|
||||
const accountId = account?.id ?? "unknown";
|
||||
const onboardingKey = `netbird-onboarding-flow:${accountId}`;
|
||||
|
||||
// Migrate old onboarding state to new key if needed
|
||||
if (typeof window !== "undefined" && account?.id) {
|
||||
const oldKey = "netbird-onboarding-flow";
|
||||
const oldValue = window.localStorage.getItem(oldKey);
|
||||
const newValue = window.localStorage.getItem(onboardingKey);
|
||||
if (oldValue && !newValue) {
|
||||
window.localStorage.setItem(onboardingKey, oldValue);
|
||||
window.localStorage.removeItem(oldKey);
|
||||
}
|
||||
}
|
||||
|
||||
const [onboarding, setOnboarding] = useLocalStorage<OnboardingState>(
|
||||
onboardingKey,
|
||||
{
|
||||
intent: Intent.P2P,
|
||||
step: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const showOnboarding = useMemo(() => {
|
||||
if (process.env.APP_ENV === "test") return false;
|
||||
if (!account) return false;
|
||||
const isSignupFormPending = isNetBirdHosted()
|
||||
? !!account?.onboarding?.signup_form_pending
|
||||
: false;
|
||||
const show =
|
||||
!!account?.onboarding?.onboarding_flow_pending || isSignupFormPending;
|
||||
return isOwner && show;
|
||||
}, [account, isOwner]);
|
||||
|
||||
const updateAccountMeta = async (meta: Partial<Account["onboarding"]>) => {
|
||||
if (!account) return;
|
||||
await accountRequest
|
||||
.put(
|
||||
{
|
||||
...account,
|
||||
id: account.id,
|
||||
onboarding: {
|
||||
...account.onboarding,
|
||||
...meta,
|
||||
},
|
||||
},
|
||||
`/${account.id}`,
|
||||
)
|
||||
.then(() => mutate("/accounts"));
|
||||
};
|
||||
|
||||
const onSkip = async (intent: Intent, step: number) => {
|
||||
await updateAccountMeta({
|
||||
onboarding_flow_pending: false,
|
||||
});
|
||||
trackEventV2(
|
||||
"Onboarding",
|
||||
`Skipped Onboarding - ${intent} (Step ${step})`,
|
||||
account?.id,
|
||||
loggedInUser?.id,
|
||||
);
|
||||
};
|
||||
|
||||
const onFinish = async (n?: Network) => {
|
||||
await updateAccountMeta({
|
||||
onboarding_flow_pending: false,
|
||||
});
|
||||
trackEventV2(
|
||||
"Onboarding",
|
||||
"Finished Onboarding",
|
||||
account?.id,
|
||||
loggedInUser?.id,
|
||||
);
|
||||
if (n) {
|
||||
// router.push(`/network?id=${n.id}`);
|
||||
router.push("/control-center?tab=networks");
|
||||
} else {
|
||||
router.push("/control-center");
|
||||
}
|
||||
};
|
||||
|
||||
const onTroubleshootingClick = (intent: Intent) => {
|
||||
trackEventV2(
|
||||
"Onboarding",
|
||||
`Troubleshooting - ${intent}`,
|
||||
account?.id,
|
||||
loggedInUser?.id,
|
||||
);
|
||||
};
|
||||
|
||||
const submitSurvey = async (fields: HubspotFormField[]) => {
|
||||
await updateAccountMeta({
|
||||
signup_form_pending: false,
|
||||
});
|
||||
if (isLocalDev()) return;
|
||||
onSurveySubmit?.({
|
||||
fields,
|
||||
hsId,
|
||||
gaId,
|
||||
accountId: account?.id,
|
||||
userId: loggedInUser?.id,
|
||||
});
|
||||
};
|
||||
|
||||
const formSubmitted = isNetBirdHosted()
|
||||
? !account?.onboarding?.signup_form_pending
|
||||
: true;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showOnboarding && peers && (
|
||||
<Onboarding
|
||||
formSubmitted={formSubmitted}
|
||||
isOnboardingPending={!!account?.onboarding?.onboarding_flow_pending}
|
||||
initial={onboarding}
|
||||
setLocalOnboarding={setOnboarding}
|
||||
peers={peers}
|
||||
onSurveySubmit={submitSurvey}
|
||||
onTroubleshootingClick={onTroubleshootingClick}
|
||||
onSkip={onSkip}
|
||||
onFinish={onFinish}
|
||||
domainCategory={domainCategory}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
516
src/modules/onboarding/OnboardingSurvey.tsx
Normal file
516
src/modules/onboarding/OnboardingSurvey.tsx
Normal file
@@ -0,0 +1,516 @@
|
||||
import { useOidcUser } from "@axa-fr/react-oidc";
|
||||
import Button from "@components/Button";
|
||||
import ButtonGroup from "@components/ButtonGroup";
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
import { SegmentedTabs } from "@components/SegmentedTabs";
|
||||
import { SelectDropdown } from "@components/select/SelectDropdown";
|
||||
import { cn } from "@utils/helpers";
|
||||
import {
|
||||
BriefcaseIcon,
|
||||
FolderIcon,
|
||||
Gamepad2,
|
||||
HomeIcon,
|
||||
Laptop,
|
||||
Layers,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
UserIcon,
|
||||
Waypoints,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
import { HubspotFormField } from "@/contexts/AnalyticsProvider";
|
||||
|
||||
type Props = {
|
||||
domainCategory: string;
|
||||
onSubmit?: (fields: HubspotFormField[]) => void;
|
||||
};
|
||||
|
||||
export const companySizes = [
|
||||
{
|
||||
label: "1-5",
|
||||
value: "1",
|
||||
},
|
||||
{
|
||||
label: "5-50",
|
||||
value: "5",
|
||||
},
|
||||
{
|
||||
label: "50-300",
|
||||
value: "50",
|
||||
},
|
||||
{
|
||||
label: "300-1000",
|
||||
value: "300",
|
||||
},
|
||||
{
|
||||
label: "1000+",
|
||||
value: "1000",
|
||||
},
|
||||
];
|
||||
|
||||
export const referralSourceOptions = [
|
||||
{
|
||||
label: "Search Engines (Google, Bing etc.)",
|
||||
value: "Search Engines (Google, Bing etc.)",
|
||||
},
|
||||
{
|
||||
label: "Coworker or Friend",
|
||||
value: "Coworker or Friend",
|
||||
},
|
||||
{
|
||||
label: "Trade Show or Event",
|
||||
value: "Trade Show or Event",
|
||||
},
|
||||
{
|
||||
label: "Blogs",
|
||||
value: "Blogs",
|
||||
},
|
||||
{
|
||||
label: "Comparison Sites",
|
||||
value: "Comparison Sites",
|
||||
},
|
||||
{
|
||||
label: "Slack",
|
||||
value: "Slack",
|
||||
},
|
||||
{
|
||||
label: "Other",
|
||||
value: "Other",
|
||||
},
|
||||
{
|
||||
label: "NetBird YouTube Channel",
|
||||
value: "NetBird YouTube Channel",
|
||||
},
|
||||
{
|
||||
label: "Other YouTube Channel",
|
||||
value: "Other YouTube Channel",
|
||||
},
|
||||
{
|
||||
label: "NetBird SubReddit",
|
||||
value: "NetBird SubReddit",
|
||||
},
|
||||
{
|
||||
label: "Other Reddit Thread",
|
||||
value: "Other Reddit Thread",
|
||||
},
|
||||
{
|
||||
label: "GitHub",
|
||||
value: "GitHub",
|
||||
},
|
||||
];
|
||||
|
||||
export const OnboardingSurvey = ({ domainCategory, onSubmit }: Props) => {
|
||||
const { oidcUser: user } = useOidcUser();
|
||||
const name = user?.given_name || user?.name || user?.preferred_username;
|
||||
const welcomeMessage = name
|
||||
? `Welcome to NetBird, ${name}!`
|
||||
: "Welcome to NetBird!";
|
||||
|
||||
const isPrivate = domainCategory === "private";
|
||||
const [personalOrBusiness, setPersonalOrBusiness] = useState(
|
||||
isPrivate ? "business" : "personal",
|
||||
);
|
||||
const [companySize, setCompanySize] = useState<string>("");
|
||||
const isCompanySizeSelected = (size: string) => companySize === size;
|
||||
const isBusiness = personalOrBusiness === "business";
|
||||
|
||||
const [homelab, setHomelab] = useState(false);
|
||||
const [remoteAccess, setRemoteAccess] = useState(false);
|
||||
const [homeRemoteAccess, setHomeRemoteAccess] = useState(false);
|
||||
const [fileAccess, setFileAccess] = useState(false);
|
||||
const [gaming, setGaming] = useState(false);
|
||||
const [zeroTrust, setZeroTrust] = useState(false);
|
||||
const [ioT, setIoT] = useState(false);
|
||||
const [siteToSite, setSiteToSite] = useState(false);
|
||||
const [businessVPN, setBusinessVPN] = useState(false);
|
||||
const [referralSource, setReferralSource] = useState("");
|
||||
const [msp, setMsp] = useState(false);
|
||||
|
||||
const [other, setOther] = useState(false);
|
||||
const [otherUseCase, setOtherUseCase] = useState("");
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const { loggedInUser } = useLoggedInUser();
|
||||
|
||||
const getUseCases = () => {
|
||||
const hl = homelab && !isBusiness ? "Homelab Automation" : "";
|
||||
const hra = homeRemoteAccess && !isBusiness ? "Home Remote Access" : "";
|
||||
const fa = fileAccess && !isBusiness ? "File Access" : "";
|
||||
const g = gaming && !isBusiness ? "Gaming" : "";
|
||||
|
||||
const zt = zeroTrust && isBusiness ? "Zero Trust Security" : "";
|
||||
const ra = remoteAccess && isBusiness ? "Employee Remote Access" : "";
|
||||
const bv = businessVPN && isBusiness ? "Business VPN" : "";
|
||||
const st = siteToSite && isBusiness ? "Site-to-Site Connectivity" : "";
|
||||
const iot = ioT && isBusiness ? "IoT (Internet of Things)" : "";
|
||||
const mp = msp && isBusiness ? "MSP (Managed Service Provider)" : "";
|
||||
|
||||
const ou = other ? otherUseCase : "";
|
||||
return [hl, hra, fa, g, zt, ra, bv, st, iot, mp, ou]
|
||||
.filter((s) => s != "")
|
||||
.join(", ");
|
||||
};
|
||||
|
||||
const hasSelectedUseCase = useMemo(() => {
|
||||
if (isBusiness) {
|
||||
return (
|
||||
zeroTrust ||
|
||||
remoteAccess ||
|
||||
businessVPN ||
|
||||
siteToSite ||
|
||||
ioT ||
|
||||
msp ||
|
||||
(other && otherUseCase !== "")
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
homelab ||
|
||||
homeRemoteAccess ||
|
||||
fileAccess ||
|
||||
gaming ||
|
||||
(other && otherUseCase !== "")
|
||||
);
|
||||
}
|
||||
}, [
|
||||
businessVPN,
|
||||
fileAccess,
|
||||
gaming,
|
||||
homeRemoteAccess,
|
||||
homelab,
|
||||
ioT,
|
||||
isBusiness,
|
||||
other,
|
||||
otherUseCase,
|
||||
remoteAccess,
|
||||
siteToSite,
|
||||
zeroTrust,
|
||||
msp,
|
||||
]);
|
||||
|
||||
const hasCompanySizeSelected = useMemo(() => {
|
||||
return companySize !== "";
|
||||
}, [companySize]);
|
||||
|
||||
const hasHowDidYouHearAboutUsSelected = useMemo(() => {
|
||||
return referralSource !== "";
|
||||
}, [referralSource]);
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
if (isBusiness) {
|
||||
return (
|
||||
hasCompanySizeSelected &&
|
||||
hasSelectedUseCase &&
|
||||
hasHowDidYouHearAboutUsSelected
|
||||
);
|
||||
} else {
|
||||
return hasSelectedUseCase && hasHowDidYouHearAboutUsSelected;
|
||||
}
|
||||
}, [
|
||||
hasSelectedUseCase,
|
||||
isBusiness,
|
||||
hasCompanySizeSelected,
|
||||
hasHowDidYouHearAboutUsSelected,
|
||||
]);
|
||||
|
||||
const randomizedOptions = useMemo(() => {
|
||||
return referralSourceOptions.sort(() => Math.random() - 0.5);
|
||||
}, []);
|
||||
|
||||
const submitForm = () => {
|
||||
let fields: HubspotFormField[] = [];
|
||||
try {
|
||||
// Fallback: use OIDC user email if loggedInUser?.email is missing
|
||||
const email = loggedInUser?.email || user?.email || "";
|
||||
if (loggedInUser || user) {
|
||||
fields = [
|
||||
{
|
||||
name: "email",
|
||||
value: email,
|
||||
},
|
||||
{
|
||||
name: "is_company",
|
||||
value: personalOrBusiness === "business" ? "Business" : "Personal",
|
||||
},
|
||||
{
|
||||
name: "use_case",
|
||||
value: getUseCases(),
|
||||
},
|
||||
{
|
||||
name: "how_did_you_hear_about_us",
|
||||
value: referralSource || "Other",
|
||||
},
|
||||
];
|
||||
|
||||
let accountCategory;
|
||||
switch (personalOrBusiness) {
|
||||
case "business":
|
||||
accountCategory = "business";
|
||||
break;
|
||||
case "personal":
|
||||
accountCategory = "personal";
|
||||
break;
|
||||
default:
|
||||
accountCategory = "unknown";
|
||||
}
|
||||
|
||||
fields.push({
|
||||
name: "account_category",
|
||||
value: accountCategory,
|
||||
});
|
||||
|
||||
if (domainCategory) {
|
||||
if (domainCategory === "business") {
|
||||
fields.push({
|
||||
name: "0-2/domain",
|
||||
value: email.split("@")[1] || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (personalOrBusiness === "business" && companySize !== "") {
|
||||
fields.push({
|
||||
name: "planned_users",
|
||||
value: companySize,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
onSubmit?.(fields);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={"relative"}>
|
||||
<h1 className={"text-xl text-center"}>{welcomeMessage}</h1>
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center max-w-md px-10"
|
||||
}
|
||||
>
|
||||
Share a few details about your use case to help us get you started
|
||||
smoothly.
|
||||
</div>
|
||||
<div className={"flex flex-col mt-8 z-0 gap-8"}>
|
||||
<SegmentedTabs
|
||||
value={personalOrBusiness}
|
||||
onChange={setPersonalOrBusiness}
|
||||
>
|
||||
<SegmentedTabs.List className={"rounded-lg border"}>
|
||||
<SegmentedTabs.Trigger value={"business"}>
|
||||
<BriefcaseIcon size={16} />
|
||||
Business
|
||||
</SegmentedTabs.Trigger>
|
||||
<SegmentedTabs.Trigger value={"personal"}>
|
||||
<UserIcon size={16} />
|
||||
Personal
|
||||
</SegmentedTabs.Trigger>
|
||||
</SegmentedTabs.List>
|
||||
</SegmentedTabs>
|
||||
|
||||
{personalOrBusiness === "business" && (
|
||||
<div className={"flex w-full flex-col gap-2"}>
|
||||
<div>
|
||||
<Label>
|
||||
How many people in your company will use NetBird?
|
||||
<RequiredAsterisk />
|
||||
</Label>
|
||||
</div>
|
||||
<ButtonGroup>
|
||||
{companySizes.map((size) => (
|
||||
<ButtonGroup.Button
|
||||
key={size.value}
|
||||
className={"w-full"}
|
||||
onClick={() => setCompanySize(size.value)}
|
||||
variant={
|
||||
isCompanySizeSelected(size.value)
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{size.label}
|
||||
</ButtonGroup.Button>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={"flex w-full flex-col gap-2"}>
|
||||
<Label>
|
||||
How did you hear about NetBird?
|
||||
<RequiredAsterisk />
|
||||
</Label>
|
||||
<SelectDropdown
|
||||
value={referralSource}
|
||||
onChange={setReferralSource}
|
||||
options={randomizedOptions}
|
||||
showValues={false}
|
||||
placeholder={"Please select an option..."}
|
||||
variant={"dropdown"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={"flex w-full flex-col gap-2"}>
|
||||
<div>
|
||||
<Label>
|
||||
How do you plan to use NetBird?
|
||||
<RequiredAsterisk />
|
||||
</Label>
|
||||
<HelpText className={"mt-1.5"}>
|
||||
You can also select multiple use cases.
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<div className={"flex flex-col gap-3"}>
|
||||
{isBusiness ? (
|
||||
<>
|
||||
<OnboardingCheckbox value={zeroTrust} setValue={setZeroTrust}>
|
||||
<ShieldCheck size={16} />
|
||||
Zero Trust Security
|
||||
</OnboardingCheckbox>
|
||||
<OnboardingCheckbox
|
||||
value={remoteAccess}
|
||||
setValue={setRemoteAccess}
|
||||
>
|
||||
<Laptop size={16} />
|
||||
Employee Remote Access
|
||||
</OnboardingCheckbox>
|
||||
<OnboardingCheckbox
|
||||
value={businessVPN}
|
||||
setValue={setBusinessVPN}
|
||||
>
|
||||
<BriefcaseIcon size={16} />
|
||||
Business VPN
|
||||
</OnboardingCheckbox>
|
||||
<OnboardingCheckbox
|
||||
value={siteToSite}
|
||||
setValue={setSiteToSite}
|
||||
>
|
||||
<Layers size={16} />
|
||||
Site-to-Site Connectivity
|
||||
</OnboardingCheckbox>
|
||||
<OnboardingCheckbox value={ioT} setValue={setIoT}>
|
||||
<Waypoints size={16} />
|
||||
IoT (Internet of Things)
|
||||
</OnboardingCheckbox>
|
||||
<OnboardingCheckbox value={msp} setValue={setMsp}>
|
||||
<Server size={15} />
|
||||
MSP (Managed Service Provider)
|
||||
</OnboardingCheckbox>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<OnboardingCheckbox value={homelab} setValue={setHomelab}>
|
||||
<HomeIcon size={16} />
|
||||
Homelab Automation
|
||||
</OnboardingCheckbox>
|
||||
<OnboardingCheckbox
|
||||
value={homeRemoteAccess}
|
||||
setValue={setHomeRemoteAccess}
|
||||
>
|
||||
<Laptop size={16} />
|
||||
Home Remote Access
|
||||
</OnboardingCheckbox>
|
||||
<OnboardingCheckbox
|
||||
value={fileAccess}
|
||||
setValue={setFileAccess}
|
||||
>
|
||||
<FolderIcon size={16} />
|
||||
File Access
|
||||
</OnboardingCheckbox>
|
||||
<OnboardingCheckbox value={gaming} setValue={setGaming}>
|
||||
<Gamepad2 size={16} />
|
||||
Gaming
|
||||
</OnboardingCheckbox>
|
||||
</>
|
||||
)}
|
||||
|
||||
<label
|
||||
className={
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-normal flex items-center gap-4 cursor-pointer"
|
||||
}
|
||||
>
|
||||
<Checkbox
|
||||
checked={other}
|
||||
onCheckedChange={(v) => {
|
||||
setOther(!other);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-1.5 whitespace-nowrap text-sm select-none"
|
||||
}
|
||||
>
|
||||
Other (Please specify)
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
!other && "!h-0 opacity-0",
|
||||
"mt-2",
|
||||
other && "mb-3",
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={
|
||||
isBusiness
|
||||
? "e.g. DNS Management, File Access"
|
||||
: "e.g. DNS Management, IoT"
|
||||
}
|
||||
value={otherUseCase}
|
||||
onChange={(e) => setOtherUseCase(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"w-full mt-4"}
|
||||
onClick={submitForm}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const OnboardingCheckbox = ({
|
||||
value,
|
||||
setValue,
|
||||
children,
|
||||
}: {
|
||||
value: boolean;
|
||||
setValue: (value: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<label
|
||||
className={
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-normal flex items-center gap-4 cursor-pointer"
|
||||
}
|
||||
>
|
||||
<Checkbox checked={value} onCheckedChange={setValue} />
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-1.5 whitespace-nowrap text-sm select-none"
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
const RequiredAsterisk = () => (
|
||||
<span className={"text-red-500 relative -top-[2.5px]"}>*</span>
|
||||
);
|
||||
259
src/modules/onboarding/networks/OnboardingAddResource.tsx
Normal file
259
src/modules/onboarding/networks/OnboardingAddResource.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import Button from "@components/Button";
|
||||
import { notify } from "@components/Notification";
|
||||
import { RadioCard, RadioCardGroup } from "@components/RadioCard";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { GlobeIcon, NetworkIcon, WorkflowIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Network, NetworkResource } from "@/interfaces/Network";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { ResourceSingleAddressInput } from "@/modules/networks/resources/ResourceSingleAddressInput";
|
||||
|
||||
type Props = {
|
||||
onNetworkCreation?: (network: Network) => void;
|
||||
onResourceCreation?: (resource: NetworkResource) => void;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
export const OnboardingAddResource = ({
|
||||
onNetworkCreation,
|
||||
onResourceCreation,
|
||||
onBack,
|
||||
}: Props) => {
|
||||
const [resourceType, setResourceType] = useState("");
|
||||
const [resourceAddress, setResourceAddress] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [network, setNetwork] = useState<Network>();
|
||||
const { mutate } = useSWRConfig();
|
||||
const { groups } = useGroups();
|
||||
|
||||
const networkRequest = useApiCall<Network>("/networks", true);
|
||||
const resourceRequest = useApiCall<NetworkResource>("/networks", true);
|
||||
const policyRequest = useApiCall<Policy>("/policies", true);
|
||||
const groupRequest = useApiCall<Group>("/groups", true);
|
||||
|
||||
const allGroupId = groups?.find((g) => g.name === "All")?.id;
|
||||
|
||||
/**
|
||||
* Create a new network and add a resource to it
|
||||
*/
|
||||
const createResource = async () => {
|
||||
let myNetwork = network;
|
||||
|
||||
if (!network) {
|
||||
await networkRequest
|
||||
.post({
|
||||
name: "My First Network",
|
||||
description: "Created during onboarding",
|
||||
})
|
||||
.then((n) => {
|
||||
myNetwork = n;
|
||||
onNetworkCreation?.(n);
|
||||
setNetwork(n);
|
||||
});
|
||||
}
|
||||
|
||||
if (!myNetwork) return;
|
||||
|
||||
notify({
|
||||
title: "My First Network",
|
||||
description: "Network & Resource created successfully",
|
||||
loadingMessage: "Creating your resource...",
|
||||
promise: resourceRequest
|
||||
.post(
|
||||
{
|
||||
name: resourceType === "subnet" ? "My Subnet" : "My Resource",
|
||||
description: "Created during onboarding",
|
||||
address: resourceAddress,
|
||||
enabled: true,
|
||||
groups: [],
|
||||
},
|
||||
`/${myNetwork.id}/resources`,
|
||||
)
|
||||
.then((r) => {
|
||||
onResourceCreation?.(r);
|
||||
createOnboardingGroups().then(({ usersGroup, routingPeersGroup }) => {
|
||||
createUsersToResourcePolicy(r, usersGroup);
|
||||
createUsersToRoutingPeersPolicy(r, usersGroup, routingPeersGroup);
|
||||
});
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create Users and Routing Peers groups if they do not exist
|
||||
*/
|
||||
const createOnboardingGroups = async () => {
|
||||
let usersGroup = groups?.find((group) => group.name === "Users");
|
||||
let routingPeersGroup = groups?.find(
|
||||
(group) => group.name === "Routing Peers",
|
||||
);
|
||||
if (!usersGroup) {
|
||||
usersGroup = await groupRequest.post({
|
||||
name: "Users",
|
||||
});
|
||||
}
|
||||
if (!routingPeersGroup) {
|
||||
routingPeersGroup = await groupRequest.post({
|
||||
name: "Routing Peers",
|
||||
});
|
||||
}
|
||||
return {
|
||||
usersGroup,
|
||||
routingPeersGroup,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a policy that allows users to access the resource
|
||||
*/
|
||||
const createUsersToResourcePolicy = async (
|
||||
r: NetworkResource,
|
||||
usersGroup: Group,
|
||||
) => {
|
||||
const isSubnet = r.type === "subnet";
|
||||
|
||||
await policyRequest.post({
|
||||
name: `Users to ${r.name}`,
|
||||
description: `Allows access to this ${
|
||||
isSubnet ? `subnet ${r.address}` : `resource ${r.address}`
|
||||
}`,
|
||||
enabled: true,
|
||||
rules: [
|
||||
{
|
||||
name: `Users to ${r.name}`,
|
||||
description: `Allows access to this ${
|
||||
isSubnet ? `subnet ${r.address}` : `resource ${r.address}`
|
||||
}`,
|
||||
enabled: true,
|
||||
action: "accept",
|
||||
bidirectional: true,
|
||||
protocol: "all",
|
||||
sources: usersGroup ? [usersGroup.id] : [allGroupId],
|
||||
destinationResource: {
|
||||
type: r.type,
|
||||
id: r.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a policy that allows users to access routing peers
|
||||
*/
|
||||
const createUsersToRoutingPeersPolicy = async (
|
||||
r: NetworkResource,
|
||||
usersGroup: Group,
|
||||
routingPeersGroup: Group,
|
||||
) => {
|
||||
await policyRequest
|
||||
.post({
|
||||
name: `Users to Routing Peers`,
|
||||
description: `Allows users to access routing peers`,
|
||||
enabled: true,
|
||||
rules: [
|
||||
{
|
||||
name: `Users to Routing Peers`,
|
||||
description: `Allows users to access routing peers`,
|
||||
enabled: true,
|
||||
action: "accept",
|
||||
bidirectional: true,
|
||||
protocol: "all",
|
||||
sources: usersGroup ? [usersGroup.id] : [allGroupId],
|
||||
destinations: routingPeersGroup
|
||||
? [routingPeersGroup.id]
|
||||
: [allGroupId],
|
||||
},
|
||||
],
|
||||
})
|
||||
.then(() => {
|
||||
mutate("/policies");
|
||||
mutate("/groups");
|
||||
});
|
||||
};
|
||||
|
||||
const description = useMemo(() => {
|
||||
if (resourceType === "ip")
|
||||
return "Enter a single IPv4 address of your resource";
|
||||
if (resourceType === "subnet") return "Enter a CIDR range of your network";
|
||||
if (resourceType === "domain")
|
||||
return "Enter a domain name of your resource";
|
||||
}, [resourceType]);
|
||||
|
||||
const placeholder = useMemo(() => {
|
||||
if (resourceType === "ip") return "e.g., 192.168.31.45";
|
||||
if (resourceType === "subnet") return "e.g., 192.168.1.0/24";
|
||||
if (resourceType === "domain")
|
||||
return "e.g., service.internal or *.services.internal";
|
||||
}, [resourceType]);
|
||||
|
||||
return (
|
||||
<div className={"relative flex flex-col h-full gap-4"}>
|
||||
<div className={"flex flex-col gap-8"}>
|
||||
<div>
|
||||
<h1 className={"text-xl text-center"}>Add your first resource</h1>
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
|
||||
}
|
||||
>
|
||||
Resources are your subnets, services, or machines inside your network.
|
||||
Pick the type you want to connect to.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RadioCardGroup value={resourceType} onValueChange={setResourceType}>
|
||||
<RadioCard
|
||||
value={"ip"}
|
||||
title={"Single IP Address"}
|
||||
icon={<WorkflowIcon size={12} />}
|
||||
description={"IPv4 address like 192.168.31.45"}
|
||||
/>
|
||||
<RadioCard
|
||||
value={"subnet"}
|
||||
title={"Entire Subnet"}
|
||||
icon={<NetworkIcon size={12} />}
|
||||
description={"CIDR range like 192.168.0.0/24"}
|
||||
/>
|
||||
<RadioCard
|
||||
value={"domain"}
|
||||
title={"Domain"}
|
||||
icon={<GlobeIcon size={12} />}
|
||||
description={
|
||||
"A domain like service.internal or a wildcard like *.services.internal"
|
||||
}
|
||||
/>
|
||||
</RadioCardGroup>
|
||||
|
||||
{resourceType && (
|
||||
<ResourceSingleAddressInput
|
||||
label={"What is the address of your resource?"}
|
||||
value={resourceAddress}
|
||||
onChange={setResourceAddress}
|
||||
onError={setError}
|
||||
description={description}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={"flex gap-4"}>
|
||||
<Button variant={"secondary"} className={"w-full"} onClick={onBack}>
|
||||
Go Back
|
||||
</Button>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"w-full"}
|
||||
onClick={createResource}
|
||||
disabled={resourceAddress === "" || error !== ""}
|
||||
>
|
||||
Create Resource
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
185
src/modules/onboarding/networks/OnboardingAddRoutingPeer.tsx
Normal file
185
src/modules/onboarding/networks/OnboardingAddRoutingPeer.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import Button from "@components/Button";
|
||||
import { Modal, ModalContent } from "@components/modal/Modal";
|
||||
import { notify } from "@components/Notification";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { CopyIcon, DownloadIcon, KeyRoundIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Network, NetworkRouter } from "@/interfaces/Network";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { SetupKey } from "@/interfaces/SetupKey";
|
||||
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
type Props = {
|
||||
network?: Network;
|
||||
peers?: Peer[];
|
||||
onRoutingPeerAdded: (peer: Peer) => void;
|
||||
};
|
||||
|
||||
export const OnboardingAddRoutingPeer = ({
|
||||
network,
|
||||
peers,
|
||||
onRoutingPeerAdded,
|
||||
}: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [setupKey, setSetupKey] = useState<SetupKey>();
|
||||
const { groups } = useGroups();
|
||||
const setupKeyRequest = useApiCall<SetupKey>("/setup-keys", true);
|
||||
const groupRequest = useApiCall<Group>("/groups", true);
|
||||
const routerRequest = useApiCall<NetworkRouter>("/networks", true);
|
||||
|
||||
/**
|
||||
* Generate a new setup key for the routing peer
|
||||
*/
|
||||
const generateSetupKey = async () => {
|
||||
let routingPeerGroup = groups?.find(
|
||||
(group) => group.name === "Routing Peers",
|
||||
);
|
||||
if (!routingPeerGroup) {
|
||||
routingPeerGroup = await groupRequest.post({
|
||||
name: "Routing Peers",
|
||||
});
|
||||
}
|
||||
|
||||
notify({
|
||||
title: "Setup Key Created",
|
||||
description: "Successfully copied to clipboard.",
|
||||
loadingMessage: "Generating setup key...",
|
||||
promise: setupKeyRequest
|
||||
.post({
|
||||
name: "Routing Peer (My First Network)",
|
||||
type: "one-off",
|
||||
expires_in: 24 * 60 * 60, // 1 day expiration
|
||||
revoked: false,
|
||||
auto_groups: routingPeerGroup ? [routingPeerGroup.id] : [],
|
||||
usage_limit: 1,
|
||||
ephemeral: false,
|
||||
allow_extra_dns_labels: false,
|
||||
})
|
||||
.then((setupKey) => {
|
||||
setSetupKey(setupKey);
|
||||
copySetupKey(setupKey.key);
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect routing peer based on group and add it to the network
|
||||
*/
|
||||
useEffect(() => {
|
||||
const routingPeer = peers?.find(
|
||||
(p) => p.groups?.some((g) => g.name === "Routing Peers"),
|
||||
);
|
||||
const hasNetworkRoutingPeer =
|
||||
network?.routers?.find((r) => r === routingPeer?.id) !== undefined;
|
||||
if (routingPeer && network && !hasNetworkRoutingPeer) {
|
||||
routerRequest
|
||||
.post(
|
||||
{
|
||||
peer: routingPeer.id,
|
||||
metric: 9999,
|
||||
masquerade: true,
|
||||
enabled: true,
|
||||
},
|
||||
`/${network.id}/routers`,
|
||||
)
|
||||
.then(() => {
|
||||
onRoutingPeerAdded(routingPeer);
|
||||
});
|
||||
}
|
||||
}, [network, peers]);
|
||||
|
||||
/**
|
||||
* Copy the setup key to clipboard
|
||||
*/
|
||||
const copySetupKey = async (key: string, showMessage = false) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(key || "");
|
||||
if (showMessage) {
|
||||
notify({
|
||||
title: "Setup Key Copied",
|
||||
description: "Successfully copied to clipboard.",
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"relative flex flex-col h-full gap-4"}>
|
||||
<div>
|
||||
<h1 className={"text-xl text-center"}>
|
||||
Add a routing peer and get the traffic flowing
|
||||
</h1>
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
|
||||
}
|
||||
>
|
||||
Think of a routing peer as a connector to your internal network.
|
||||
It runs NetBird and lets your remote devices access internal resources, while enforcing access control policies.
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
|
||||
}
|
||||
>
|
||||
Generate a setup key and install NetBird on that machine.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative block rounded-lg border border-nb-gray-900 px-5 py-3 transition-all",
|
||||
"flex justify-between items-center mt-3",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<div className="text-nb-gray-100 font-normal text-sm text-left gap-2 flex items-center">
|
||||
<KeyRoundIcon size={12} />
|
||||
Setup-Key
|
||||
</div>
|
||||
<div className={"text-nb-gray-300 text-[0.8rem] text-left mt-0.5"}>
|
||||
{setupKey?.key || "Not yet generated"}
|
||||
</div>
|
||||
</div>
|
||||
{setupKey ? (
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => copySetupKey(setupKey.key, true)}
|
||||
>
|
||||
<CopyIcon size={14} />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant={"primary"} onClick={generateSetupKey} size={"xs"}>
|
||||
Generate Setup Key
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={""}
|
||||
disabled={!setupKey}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<DownloadIcon size={16} />
|
||||
Install Routing Peer
|
||||
</Button>
|
||||
|
||||
{setupKey && (
|
||||
<Modal open={open} onOpenChange={setOpen}>
|
||||
<ModalContent>
|
||||
<SetupModalContent
|
||||
hostname={"routing-peer"}
|
||||
title={"Install NetBird"}
|
||||
setupKey={setupKey.key}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
95
src/modules/onboarding/networks/OnboardingAddUserDevice.tsx
Normal file
95
src/modules/onboarding/networks/OnboardingAddUserDevice.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import Button from "@components/Button";
|
||||
import { Modal, ModalContent } from "@components/modal/Modal";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { Group, GroupPeer } from "@/interfaces/Group";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
type Props = {
|
||||
device?: Peer;
|
||||
policy?: Policy;
|
||||
onNext?: () => void;
|
||||
};
|
||||
|
||||
export const OnboardingAddUserDevice = ({ device, policy, onNext }: Props) => {
|
||||
const groupRequest = useApiCall<Group>("/groups", true);
|
||||
const { mutate } = useSWRConfig();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const usersGroup = useMemo(() => {
|
||||
let rule = policy?.rules?.[0];
|
||||
const sourceGroups = rule?.sources as Group[];
|
||||
return sourceGroups?.find((g) => g.name === "Users");
|
||||
}, [policy]);
|
||||
|
||||
const hasDeviceUsersGroup = device?.groups?.find((g) => g.name === "Users");
|
||||
|
||||
/**
|
||||
* Detect the device and add it to the "Users" group
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!hasDeviceUsersGroup && usersGroup && device) {
|
||||
let peersOfGroup = (usersGroup.peers as GroupPeer[]) || [];
|
||||
let newPeers = peersOfGroup
|
||||
.map((p) => p.id)
|
||||
.filter((x) => x !== undefined);
|
||||
if (device?.id) newPeers.push(device.id);
|
||||
groupRequest
|
||||
.put(
|
||||
{
|
||||
...usersGroup,
|
||||
peers: newPeers,
|
||||
},
|
||||
`/${usersGroup.id}`,
|
||||
)
|
||||
.then(() => {
|
||||
mutate("/peers");
|
||||
mutate("/groups");
|
||||
});
|
||||
}
|
||||
}, [usersGroup, device, hasDeviceUsersGroup]);
|
||||
|
||||
/**
|
||||
* Continue to next step once device is recognized
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (device && hasDeviceUsersGroup) {
|
||||
onNext?.();
|
||||
}
|
||||
}, [device, hasDeviceUsersGroup]);
|
||||
|
||||
return (
|
||||
<div className={"relative flex flex-col h-full gap-4"}>
|
||||
<div>
|
||||
<h1 className={"text-xl text-center max-w-sm mx-auto"}>
|
||||
{"Time to add your client device"}
|
||||
</h1>
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
|
||||
}
|
||||
>
|
||||
{`Your first resource and routing peer are all set. Now, take your device, install NetBird, and let's get you connected.`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"flex items-center justify-center mt-3"}>
|
||||
<Button variant={"primary"} onClick={() => setOpen(true)}>
|
||||
<DownloadIcon size={16} />
|
||||
Install NetBird
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal open={open} onOpenChange={setOpen}>
|
||||
<ModalContent>
|
||||
<SetupModalContent title={"Install NetBird"} hideDocker={true} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
52
src/modules/onboarding/networks/OnboardingExplainPolicy.tsx
Normal file
52
src/modules/onboarding/networks/OnboardingExplainPolicy.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import Button from "@components/Button";
|
||||
import * as React from "react";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { OnboardingPolicy } from "@/modules/onboarding/OnboardingPolicy";
|
||||
|
||||
type Props = {
|
||||
policy?: Policy;
|
||||
onNext?: () => void;
|
||||
onToggle?: (policy: Policy) => void;
|
||||
};
|
||||
|
||||
export const OnboardingExplainPolicy = ({
|
||||
policy,
|
||||
onNext,
|
||||
onToggle,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className={"relative flex flex-col h-full gap-4"}>
|
||||
<div>
|
||||
<h1 className={"text-xl text-center max-w-sm mx-auto"}>
|
||||
{`Set the rules. You're in control`}
|
||||
</h1>
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
|
||||
}
|
||||
>
|
||||
{`NetBird makes it easy for admins to enforce least-privilege access with access control policies.
|
||||
We've already created one for your resource during onboarding.`}
|
||||
</div>
|
||||
|
||||
{policy && (
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
|
||||
}
|
||||
>
|
||||
Flip the switch, then try pinging your resource again to see how it affects the connection.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<OnboardingPolicy policy={policy} onToggle={onToggle} />
|
||||
</div>
|
||||
|
||||
<Button variant={"primary"} onClick={onNext}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
102
src/modules/onboarding/networks/OnboardingTestResource.tsx
Normal file
102
src/modules/onboarding/networks/OnboardingTestResource.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import Button from "@components/Button";
|
||||
import Code from "@components/Code";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { Modal, ModalContent } from "@components/modal/Modal";
|
||||
import Steps from "@components/Steps";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
type Props = {
|
||||
resource?: NetworkResource;
|
||||
device?: Peer;
|
||||
onNext?: () => void;
|
||||
onTroubleshootingClick?: () => void;
|
||||
};
|
||||
|
||||
export const OnboardingTestResource = ({
|
||||
resource,
|
||||
device,
|
||||
onNext,
|
||||
onTroubleshootingClick,
|
||||
}: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const isSubnet = resource?.type === "subnet";
|
||||
const isWildCard = resource?.address.includes("*");
|
||||
const isHost = resource?.type === "host";
|
||||
|
||||
const pingAddress = useMemo(() => {
|
||||
let a = resource?.address || "";
|
||||
if (isHost && a.endsWith("/32")) {
|
||||
a = a.slice(0, -3);
|
||||
}
|
||||
if (isWildCard) return `(any subdomain of ${a})`;
|
||||
return isSubnet ? `(resource ip in your subnet)` : a;
|
||||
}, [isWildCard, isHost, isSubnet, resource?.address]);
|
||||
|
||||
return (
|
||||
<div className={"relative flex flex-col h-full gap-4"}>
|
||||
<div>
|
||||
<h1 className={"text-xl text-center max-w-sm mx-auto"}>
|
||||
{`Let's put that connection to the test`}
|
||||
</h1>
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
|
||||
}
|
||||
>
|
||||
{`Nice work connecting your client device! Now, let’s have a little fun and test if it can reach your resource.`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Steps className={"stepper-bg-variant"}>
|
||||
<Steps.Step step={1}>
|
||||
<p className={"!text-nb-gray-300"}>
|
||||
Open your command line and run this command from{" "}
|
||||
<span className={cn(device && "text-white")}>
|
||||
{device?.name || "your device"}
|
||||
</span>{" "}
|
||||
to ping your resource.
|
||||
</p>
|
||||
<Code showCopyIcon={!isSubnet && !isWildCard}>
|
||||
ping {pingAddress}
|
||||
</Code>
|
||||
</Steps.Step>
|
||||
<Steps.Step step={2} line={false} className={"pb-0"} disabled={!device}>
|
||||
<p className={"!text-nb-gray-300"}>
|
||||
Everything working? Great! You can now continue with the onboarding.
|
||||
If something isn’t right, please check our{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/troubleshooting-client"}
|
||||
target={"_blank"}
|
||||
onClick={onTroubleshootingClick}
|
||||
>
|
||||
troubleshooting guide
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</p>
|
||||
<div className={"mt-2"}>
|
||||
<Button
|
||||
variant={"secondaryLighter"}
|
||||
onClick={onNext}
|
||||
className={"w-full"}
|
||||
>
|
||||
It works! - Continue
|
||||
</Button>
|
||||
</div>
|
||||
</Steps.Step>
|
||||
</Steps>
|
||||
|
||||
<Modal open={open} onOpenChange={setOpen}>
|
||||
<ModalContent>
|
||||
<SetupModalContent title={"Install NetBird"} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import Button from "@components/Button";
|
||||
import * as React from "react";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { OnboardingPolicy } from "@/modules/onboarding/OnboardingPolicy";
|
||||
|
||||
type Props = {
|
||||
policy?: Policy;
|
||||
onNext?: () => void;
|
||||
onToggle?: (policy: Policy) => void;
|
||||
};
|
||||
|
||||
export const OnboardingExplainDefaultPolicy = ({
|
||||
policy,
|
||||
onNext,
|
||||
onToggle,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className={"relative flex flex-col h-full gap-4"}>
|
||||
<div>
|
||||
<h1 className={"text-xl text-center max-w-sm mx-auto"}>
|
||||
{`Set the rules. You're in control`}
|
||||
</h1>
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
|
||||
}
|
||||
>
|
||||
{`With NetBird, you decide who gets access to what.
|
||||
We've already set up an access policy for your devices.`}
|
||||
</div>
|
||||
|
||||
{policy && (
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
|
||||
}
|
||||
>
|
||||
Flip the switch, then try pinging your other device again to see how it affects the connection.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<OnboardingPolicy policy={policy} onToggle={onToggle} />
|
||||
</div>
|
||||
|
||||
<Button variant={"primary"} onClick={onNext}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
62
src/modules/onboarding/p2p/OnboardingFirstDevice.tsx
Normal file
62
src/modules/onboarding/p2p/OnboardingFirstDevice.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import Button from "@components/Button";
|
||||
import { Modal, ModalContent } from "@components/modal/Modal";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
type Props = {
|
||||
onBack: () => void;
|
||||
firstDevice?: Peer;
|
||||
onFinish?: () => void;
|
||||
};
|
||||
|
||||
export const OnboardingFirstDevice = ({
|
||||
onBack,
|
||||
firstDevice,
|
||||
onFinish,
|
||||
}: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
/**
|
||||
* Continue to next step once first device is recognized
|
||||
*/
|
||||
useEffect(() => {
|
||||
firstDevice && onFinish?.();
|
||||
}, [firstDevice]);
|
||||
|
||||
return (
|
||||
<div className={"relative flex flex-col h-full gap-4"}>
|
||||
<div>
|
||||
<h1 className={"text-xl text-center"}>
|
||||
{`Let's get your first device online`}
|
||||
</h1>
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
|
||||
}
|
||||
>
|
||||
{`To access other machines, install NetBird, sign in, and your device joins the network.
|
||||
Every device you add becomes a NetBird peer in your network. It's that simple.`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"flex items-center justify-center mt-4 gap-3"}>
|
||||
<Button variant={"secondary"} onClick={onBack}>
|
||||
Go Back
|
||||
</Button>
|
||||
<Button variant={"primary"} onClick={() => setOpen(true)}>
|
||||
<DownloadIcon size={16} />
|
||||
Install NetBird
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal open={open} onOpenChange={setOpen}>
|
||||
<ModalContent className={"!z-[70]"}>
|
||||
<SetupModalContent title={"Install NetBird"} hideDocker={true} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
133
src/modules/onboarding/p2p/OnboardingSecondDevice.tsx
Normal file
133
src/modules/onboarding/p2p/OnboardingSecondDevice.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import Button from "@components/Button";
|
||||
import Code from "@components/Code";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { Modal, ModalContent } from "@components/modal/Modal";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { getInstallUrl } from "@utils/netbird";
|
||||
import { ArrowUpRightIcon, ShareIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { SetupKey } from "@/interfaces/SetupKey";
|
||||
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
type Props = {
|
||||
secondDevice?: Peer;
|
||||
onFinish?: () => void;
|
||||
};
|
||||
|
||||
export const OnboardingSecondDevice = ({ secondDevice, onFinish }: Props) => {
|
||||
const setupKeyRequest = useApiCall<SetupKey>("/setup-keys", true);
|
||||
const [setupKey, setSetupKey] = useState<SetupKey>();
|
||||
const { confirm } = useDialog();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const isShareSupported = navigator.share !== undefined;
|
||||
|
||||
/**
|
||||
* Continue to next step once second device is recognized
|
||||
*/
|
||||
useEffect(() => {
|
||||
secondDevice && onFinish?.();
|
||||
}, [secondDevice]);
|
||||
|
||||
const openNavigatorShare = () => {
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: "Install NetBird",
|
||||
text: "Install NetBird on another device using this link.",
|
||||
url: getInstallUrl(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const installUsingSetupKey = async () => {
|
||||
const choice = await confirm({
|
||||
title: `Create a Setup Key?`,
|
||||
description:
|
||||
"If you continue, a one-off setup key will be automatically created and you will be able to install NetBird.",
|
||||
confirmText: "Continue",
|
||||
cancelText: "Cancel",
|
||||
type: "default",
|
||||
});
|
||||
if (!choice) return;
|
||||
|
||||
await setupKeyRequest
|
||||
.post({
|
||||
name: "Onboarding (Second Device)",
|
||||
type: "one-off",
|
||||
expires_in: 24 * 60 * 60, // 1 day expiration
|
||||
revoked: false,
|
||||
auto_groups: [],
|
||||
usage_limit: 1,
|
||||
ephemeral: false,
|
||||
allow_extra_dns_labels: false,
|
||||
})
|
||||
.then((setupKey) => {
|
||||
setOpen(true);
|
||||
setSetupKey(setupKey);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"relative flex flex-col h-full gap-4"}>
|
||||
<div>
|
||||
<h1 className={"text-xl text-center max-w-sm mx-auto"}>
|
||||
{`Time to bring in your second device`}
|
||||
</h1>
|
||||
<div className="text-sm text-nb-gray-300 font-light mt-2 block text-center">
|
||||
Each device (a.k.a. peer) in your NetBird network gets its own private IP and name to communicate securely in the network.
|
||||
</div>
|
||||
<div className="text-sm text-nb-gray-300 font-light mt-2 block text-center">
|
||||
To complete the setup, just share this link or email it to yourself to set up your next device
|
||||
with ease.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap sm:flex-nowrap md:!flex-wrap gap-3 items-center justify-center",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<Code
|
||||
message={"Installation link successfully copied"}
|
||||
className={"text-[0.8rem]"}
|
||||
>
|
||||
{getInstallUrl()}
|
||||
</Code>
|
||||
</div>
|
||||
{isShareSupported && (
|
||||
<Button
|
||||
variant={"input"}
|
||||
onClick={openNavigatorShare}
|
||||
className={"h-[42px]"}
|
||||
>
|
||||
<ShareIcon size={16} />
|
||||
<span className={"lg:hidden"}>Share Link</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4">
|
||||
Use the headless setup to register a peer without a browser or user interaction.{" "}
|
||||
<InlineLink onClick={installUsingSetupKey} href={"#"}>
|
||||
Install with a setup key
|
||||
<ArrowUpRightIcon size={12} />
|
||||
</InlineLink>{" "}
|
||||
</div>
|
||||
|
||||
{setupKey && (
|
||||
<Modal open={open} onOpenChange={setOpen}>
|
||||
<ModalContent>
|
||||
<SetupModalContent
|
||||
title={"Install NetBird"}
|
||||
setupKey={setupKey.key}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
79
src/modules/onboarding/p2p/OnboardingTestP2P.tsx
Normal file
79
src/modules/onboarding/p2p/OnboardingTestP2P.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import Button from "@components/Button";
|
||||
import Code from "@components/Code";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Steps from "@components/Steps";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
|
||||
type Props = {
|
||||
firstDevice?: Peer;
|
||||
secondDevice?: Peer;
|
||||
policy?: Policy;
|
||||
onNext?: () => void;
|
||||
onTroubleshootingClick?: () => void;
|
||||
};
|
||||
|
||||
export const OnboardingTestP2P = ({
|
||||
firstDevice,
|
||||
secondDevice,
|
||||
onNext,
|
||||
onTroubleshootingClick,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className={"relative flex flex-col h-full gap-4"}>
|
||||
<div>
|
||||
<h1 className={"text-xl text-center max-w-sm mx-auto"}>
|
||||
{`Let's put that connection to the test`}
|
||||
</h1>
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center sm:px-4"
|
||||
}
|
||||
>
|
||||
{
|
||||
"Nice work connecting your devices! Now, let’s have a little fun and test if they can talk to each other."
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Steps className={"stepper-bg-variant"}>
|
||||
<Steps.Step step={1}>
|
||||
<p className={"!text-nb-gray-300"}>
|
||||
Run this command from{" "}
|
||||
<span className={"text-white"}>{firstDevice?.name}</span> to ping{" "}
|
||||
<span className={"text-white"}>{secondDevice?.name}</span>.
|
||||
You should receive a response if the connection is working.
|
||||
</p>
|
||||
<Code message={"Command has been copied successfully"}>
|
||||
ping {secondDevice?.ip}
|
||||
</Code>
|
||||
</Steps.Step>
|
||||
<Steps.Step step={2} line={false} className={"pb-0"}>
|
||||
<p className={"!text-nb-gray-300"}>
|
||||
Everything working? Great! You can now continue with the onboarding.
|
||||
If something isn’t right, please check our{" "}
|
||||
<InlineLink
|
||||
onClick={onTroubleshootingClick}
|
||||
href={"https://docs.netbird.io/how-to/troubleshooting-client"}
|
||||
target={"_blank"}
|
||||
>
|
||||
troubleshooting guide
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</p>
|
||||
<div className={"mt-2"}>
|
||||
<Button
|
||||
variant={"secondaryLighter"}
|
||||
className={"w-full"}
|
||||
onClick={onNext}
|
||||
>
|
||||
It works! - Continue
|
||||
</Button>
|
||||
</div>
|
||||
</Steps.Step>
|
||||
</Steps>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user