diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml index b7b2a311..cfad79d9 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -2,7 +2,6 @@ name: build and push on: push: branches: - - "feature/**" - main tags: - "**" diff --git a/src/assets/onboarding/acl.png b/src/assets/onboarding/acl.png new file mode 100644 index 00000000..7ad77dc5 Binary files /dev/null and b/src/assets/onboarding/acl.png differ diff --git a/src/assets/onboarding/activity.png b/src/assets/onboarding/activity.png new file mode 100644 index 00000000..78694def Binary files /dev/null and b/src/assets/onboarding/activity.png differ diff --git a/src/assets/onboarding/posture.png b/src/assets/onboarding/posture.png new file mode 100644 index 00000000..b5a88150 Binary files /dev/null and b/src/assets/onboarding/posture.png differ diff --git a/src/contexts/AnalyticsProvider.tsx b/src/contexts/AnalyticsProvider.tsx index d629bf0a..38d94c45 100644 --- a/src/contexts/AnalyticsProvider.tsx +++ b/src/contexts/AnalyticsProvider.tsx @@ -17,6 +17,12 @@ declare global { } } +export type HubspotFormField = { + objectTypeId?: string; + name: string; + value: string; +}; + const AnalyticsContext = React.createContext( {} as { initialized: boolean; diff --git a/src/interfaces/Account.ts b/src/interfaces/Account.ts index 210d6ff2..88e17142 100644 --- a/src/interfaces/Account.ts +++ b/src/interfaces/Account.ts @@ -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; } diff --git a/src/interfaces/Peer.ts b/src/interfaces/Peer.ts index 74f55776..a29e1e2d 100644 --- a/src/interfaces/Peer.ts +++ b/src/interfaces/Peer.ts @@ -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; diff --git a/src/layouts/DashboardLayout.tsx b/src/layouts/DashboardLayout.tsx index 3655ddb3..e9c95d99 100644 --- a/src/layouts/DashboardLayout.tsx +++ b/src/layouts/DashboardLayout.tsx @@ -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({ + {!isNetBirdHosted() && } {children} diff --git a/src/modules/onboarding/Onboarding.tsx b/src/modules/onboarding/Onboarding.tsx new file mode 100644 index 00000000..ae3234af --- /dev/null +++ b/src/modules/onboarding/Onboarding.tsx @@ -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("/networks", true, false); + const { data: policies } = useFetchApi("/policies", true); + const router = useRouter(); + + const resourceRequest = useApiCall("/networks", true); + const routerRequest = useApiCall("/networks", true); + const policyRequest = useApiCall("/policies", true); + const { mutate } = useSWRConfig(); + + const [onboarding, dispatch] = useReducer(onboardingReducer, initial); + const { step, intent } = onboarding; + + const [resource, setResource] = useState(); + const [firstRoutingPeer, setFirstRoutingPeer] = useState(); + 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 ( + + + 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" + } + > +
+
+ + +
+ + {isOnboardingPending && ( + + )} + + {step === 1 && domainCategory && ( + { + 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 && ( + { + dispatch({ + type: "SET_INTENT", + payload: val, + }); + dispatch({ + type: "SET_STEP", + payload: 3, + }); + }} + /> + )} + {intent === Intent.P2P && ( + <> + {step === 3 && ( + { + dispatch({ + type: "SET_STEP", + payload: 4, + }); + }} + onBack={() => { + dispatch({ + type: "SET_STEP", + payload: 2, + }); + }} + /> + )} + {step === 4 && ( + { + dispatch({ + type: "SET_STEP", + payload: 5, + }); + }} + /> + )} + {step === 5 && ( + + onTroubleshootingClick?.(intent) + } + onNext={() => { + dispatch({ + type: "SET_STEP", + payload: 6, + }); + }} + /> + )} + {step === 6 && ( + { + dispatch({ + type: "SET_STEP", + payload: 7, + }); + }} + /> + )} + + )} + {intent === Intent.NETWORKS && ( + <> + {step === 3 && ( + { + setResource(res); + dispatch({ + type: "SET_STEP", + payload: 4, + }); + mutate("/networks"); + }} + onBack={() => { + dispatch({ + type: "SET_STEP", + payload: 2, + }); + }} + /> + )} + {step === 4 && ( + { + setFirstRoutingPeer(p); + dispatch({ + type: "SET_STEP", + payload: 5, + }); + }} + /> + )} + {step === 5 && ( + { + dispatch({ + type: "SET_STEP", + payload: 6, + }); + }} + /> + )} + {step === 6 && ( + + onTroubleshootingClick?.(intent) + } + onNext={() => { + dispatch({ + type: "SET_STEP", + payload: 7, + }); + }} + /> + )} + {step === 7 && ( + { + dispatch({ + type: "SET_STEP", + payload: 8, + }); + disableDefaultPolicy().then(); + }} + /> + )} + + )} + {step === maxSteps && ( + { + dispatch({ + type: "SET_FINISHED_AT", + payload: new Date().toISOString(), + }); + + if (intent === Intent.NETWORKS) { + onFinish(firstNetwork); + } else { + onFinish(); + } + }} + /> + )} + + + {showWaitingForDevices && ( + + + + )} +
+ + {step !== 1 && step !== maxSteps && ( + + Already know how NetBird works? + { + dispatch({ + type: "SKIP", + }); + onSkip(intent, step); + }} + > + Skip to Dashboard + + + )} +
+
+
+
+
+ ); +}; + +const Stepper = ({ step, maxSteps }: { step: number; maxSteps: number }) => { + if (step <= 0) return; + + return ( +
+ {Array.from({ length: maxSteps }).map((_, index) => ( +
= index + 1 && "bg-netbird", + )} + /> + ))} +
+ ); +}; + +const Card = ({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) => { + return ( +
+ + {children} +
+ ); +}; diff --git a/src/modules/onboarding/OnboardingDevices.tsx b/src/modules/onboarding/OnboardingDevices.tsx new file mode 100644 index 00000000..3cf1aa58 --- /dev/null +++ b/src/modules/onboarding/OnboardingDevices.tsx @@ -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 ? ( +
+ + {firstDevice && secondDevice && ( +
+ )} + + {firstDevice && secondDevice && ( +
+ {enabled ? ( + + ) : ( + + )} +
+ )} + + + {(!firstDevice || !secondDevice) && ( + + )} +
+ ) : ( +
+ {firstRoutingPeer && resource && ( + Network + )} + +
+ + {resource && ( + + )} + +
+ +
+ {firstRoutingPeer && ( + + )} + + {(!firstDevice || !firstRoutingPeer) && ( + + )} + {firstDevice && firstRoutingPeer && ( +
+ {enabled ? ( + + ) : ( + + )} +
+ )} +
+
+ ); +}; + +export const WaitingForDevice = ({ + text = "Waiting for your first device to connect", +}: { + text: string; +}) => { + return ( +
+
+
+
+
+
+
+
{text}
+
+ ); +}; + +type DeviceCardProps = { + device?: Peer; + resource?: NetworkResource; +}; + +export const DeviceCard = ({ device, resource }: DeviceCardProps) => { + if (!device && !resource) return; + return ( +
+
+ {device && } + {resource?.type && } + + {device?.country_code && ( +
+
+ +
+
+ )} +
+
+ + + + + {device?.ip || resource?.address} + +
+
+ ); +}; + +const PeerOSIcon = ({ os }: { os: string }) => { + const osType = getOperatingSystem(os); + return ( +
+ +
+ ); +}; + +const ResourceIcon = ({ + type, + size = 15, +}: { + type: "domain" | "host" | "subnet"; + size?: number; +}) => { + switch (type) { + case "domain": + return ; + case "subnet": + return ; + case "host": + return ; + default: + return ; + } +}; + +const Line = ({ + className, + height = "100%", + bg = "#1c1d21", + config = ["2px", "3px", "6px", "8.2px"], +}: { + className?: string; + height?: string; + bg?: string; + config?: string[]; +}) => { + return ( +
+
+
+ ); +}; diff --git a/src/modules/onboarding/OnboardingEnd.tsx b/src/modules/onboarding/OnboardingEnd.tsx new file mode 100644 index 00000000..8b39a973 --- /dev/null +++ b/src/modules/onboarding/OnboardingEnd.tsx @@ -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 ( +
+
+

+ {title}
+ You’ve completed the onboarding. +

+
+ 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. +
+ +
+ + + +
+ +
+ +
+
+
+ ); +}; + +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 ( +
+ + +
+ +
+
+ {title} + +
+
{title}
+
+ {description} +
+
+
+ ); +}; diff --git a/src/modules/onboarding/OnboardingIntent.tsx b/src/modules/onboarding/OnboardingIntent.tsx new file mode 100644 index 00000000..6308470e --- /dev/null +++ b/src/modules/onboarding/OnboardingIntent.tsx @@ -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 ( +
+
+

Get started with NetBird

+
+ 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. +
+
+ } + onClick={() => onSelect(Intent.P2P)} + /> + } + onClick={() => onSelect(Intent.NETWORKS)} + /> +
+
+
+ ); +}; + +type IntentCardProps = { + title: string; + description: string; + icon: React.ReactNode; + onClick: () => void; + recommended?: boolean; +}; + +const IntentCard = ({ + title, + description, + icon, + onClick, + recommended, + }: IntentCardProps) => { + return ( +
+ + ); +}; diff --git a/src/modules/onboarding/OnboardingPolicy.tsx b/src/modules/onboarding/OnboardingPolicy.tsx new file mode 100644 index 00000000..a033b9e7 --- /dev/null +++ b/src/modules/onboarding/OnboardingPolicy.tsx @@ -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 ( + + ); +}; diff --git a/src/modules/onboarding/OnboardingProvider.tsx b/src/modules/onboarding/OnboardingProvider.tsx new file mode 100644 index 00000000..04221d47 --- /dev/null +++ b/src/modules/onboarding/OnboardingProvider.tsx @@ -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("/peers"); + const accountRequest = useApiCall("/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( + 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) => { + 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 && ( + + )} + + ); +}; diff --git a/src/modules/onboarding/OnboardingSurvey.tsx b/src/modules/onboarding/OnboardingSurvey.tsx new file mode 100644 index 00000000..a44adcba --- /dev/null +++ b/src/modules/onboarding/OnboardingSurvey.tsx @@ -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(""); + 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(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 ( + <> +
+

{welcomeMessage}

+
+ Share a few details about your use case to help us get you started + smoothly. +
+
+ + + + + Business + + + + Personal + + + + + {personalOrBusiness === "business" && ( +
+
+ +
+ + {companySizes.map((size) => ( + setCompanySize(size.value)} + variant={ + isCompanySizeSelected(size.value) + ? "tertiary" + : "secondary" + } + > + {size.label} + + ))} + +
+ )} + +
+ + +
+ +
+
+ + + You can also select multiple use cases. + +
+ +
+ {isBusiness ? ( + <> + + + Zero Trust Security + + + + Employee Remote Access + + + + Business VPN + + + + Site-to-Site Connectivity + + + + IoT (Internet of Things) + + + + MSP (Managed Service Provider) + + + ) : ( + <> + + + Homelab Automation + + + + Home Remote Access + + + + File Access + + + + Gaming + + + )} + + +
+ +
+ setOtherUseCase(e.target.value)} + /> +
+
+
+
+ + + + ); +}; + +const OnboardingCheckbox = ({ + value, + setValue, + children, +}: { + value: boolean; + setValue: (value: boolean) => void; + children: React.ReactNode; +}) => { + return ( + + ); +}; + +const RequiredAsterisk = () => ( + * +); diff --git a/src/modules/onboarding/networks/OnboardingAddResource.tsx b/src/modules/onboarding/networks/OnboardingAddResource.tsx new file mode 100644 index 00000000..a827b331 --- /dev/null +++ b/src/modules/onboarding/networks/OnboardingAddResource.tsx @@ -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(); + const { mutate } = useSWRConfig(); + const { groups } = useGroups(); + + const networkRequest = useApiCall("/networks", true); + const resourceRequest = useApiCall("/networks", true); + const policyRequest = useApiCall("/policies", true); + const groupRequest = useApiCall("/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 ( +
+
+
+

Add your first resource

+
+ Resources are your subnets, services, or machines inside your network. + Pick the type you want to connect to. +
+
+ + + } + description={"IPv4 address like 192.168.31.45"} + /> + } + description={"CIDR range like 192.168.0.0/24"} + /> + } + description={ + "A domain like service.internal or a wildcard like *.services.internal" + } + /> + + + {resourceType && ( + + )} + +
+ + +
+
+
+ ); +}; diff --git a/src/modules/onboarding/networks/OnboardingAddRoutingPeer.tsx b/src/modules/onboarding/networks/OnboardingAddRoutingPeer.tsx new file mode 100644 index 00000000..68a18c0f --- /dev/null +++ b/src/modules/onboarding/networks/OnboardingAddRoutingPeer.tsx @@ -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(); + const { groups } = useGroups(); + const setupKeyRequest = useApiCall("/setup-keys", true); + const groupRequest = useApiCall("/groups", true); + const routerRequest = useApiCall("/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 ( +
+
+

+ Add a routing peer and get the traffic flowing +

+
+ 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. +
+
+ Generate a setup key and install NetBird on that machine. +
+
+ +
+
+
+ + Setup-Key +
+
+ {setupKey?.key || "Not yet generated"} +
+
+ {setupKey ? ( + + ) : ( + + )} +
+ + + + {setupKey && ( + + + + + + )} +
+ ); +}; diff --git a/src/modules/onboarding/networks/OnboardingAddUserDevice.tsx b/src/modules/onboarding/networks/OnboardingAddUserDevice.tsx new file mode 100644 index 00000000..91db3269 --- /dev/null +++ b/src/modules/onboarding/networks/OnboardingAddUserDevice.tsx @@ -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("/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 ( +
+
+

+ {"Time to add your client device"} +

+
+ {`Your first resource and routing peer are all set. Now, take your device, install NetBird, and let's get you connected.`} +
+
+ +
+ +
+ + + + + + +
+ ); +}; diff --git a/src/modules/onboarding/networks/OnboardingExplainPolicy.tsx b/src/modules/onboarding/networks/OnboardingExplainPolicy.tsx new file mode 100644 index 00000000..bf7a9279 --- /dev/null +++ b/src/modules/onboarding/networks/OnboardingExplainPolicy.tsx @@ -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 ( +
+
+

+ {`Set the rules. You're in control`} +

+
+ {`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.`} +
+ + {policy && ( +
+ Flip the switch, then try pinging your resource again to see how it affects the connection. +
+ )} +
+ +
+ +
+ + +
+ ); +}; diff --git a/src/modules/onboarding/networks/OnboardingTestResource.tsx b/src/modules/onboarding/networks/OnboardingTestResource.tsx new file mode 100644 index 00000000..4c901a9f --- /dev/null +++ b/src/modules/onboarding/networks/OnboardingTestResource.tsx @@ -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 ( +
+
+

+ {`Let's put that connection to the test`} +

+
+ {`Nice work connecting your client device! Now, let’s have a little fun and test if it can reach your resource.`} +
+
+ + + +

+ Open your command line and run this command from{" "} + + {device?.name || "your device"} + {" "} + to ping your resource. +

+ + ping {pingAddress} + +
+ +

+ Everything working? Great! You can now continue with the onboarding. + If something isn’t right, please check our{" "} + + troubleshooting guide + + +

+
+ +
+
+
+ + + + + + +
+ ); +}; diff --git a/src/modules/onboarding/p2p/OnboardingExplainDefaultPolicy.tsx b/src/modules/onboarding/p2p/OnboardingExplainDefaultPolicy.tsx new file mode 100644 index 00000000..39f0a4cf --- /dev/null +++ b/src/modules/onboarding/p2p/OnboardingExplainDefaultPolicy.tsx @@ -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 ( +
+
+

+ {`Set the rules. You're in control`} +

+
+ {`With NetBird, you decide who gets access to what. + We've already set up an access policy for your devices.`} +
+ + {policy && ( +
+ Flip the switch, then try pinging your other device again to see how it affects the connection. +
+ )} +
+ +
+ +
+ + +
+ ); +}; diff --git a/src/modules/onboarding/p2p/OnboardingFirstDevice.tsx b/src/modules/onboarding/p2p/OnboardingFirstDevice.tsx new file mode 100644 index 00000000..42568f0d --- /dev/null +++ b/src/modules/onboarding/p2p/OnboardingFirstDevice.tsx @@ -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 ( +
+
+

+ {`Let's get your first device online`} +

+
+ {`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.`} +
+
+ +
+ + +
+ + + + + + +
+ ); +}; diff --git a/src/modules/onboarding/p2p/OnboardingSecondDevice.tsx b/src/modules/onboarding/p2p/OnboardingSecondDevice.tsx new file mode 100644 index 00000000..0758ab86 --- /dev/null +++ b/src/modules/onboarding/p2p/OnboardingSecondDevice.tsx @@ -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("/setup-keys", true); + const [setupKey, setSetupKey] = useState(); + 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 ( +
+
+

+ {`Time to bring in your second device`} +

+
+ Each device (a.k.a. peer) in your NetBird network gets its own private IP and name to communicate securely in the network. +
+
+ To complete the setup, just share this link or email it to yourself to set up your next device + with ease. +
+
+ +
+
+ + {getInstallUrl()} + +
+ {isShareSupported && ( + + )} +
+
+ Use the headless setup to register a peer without a browser or user interaction.{" "} + + Install with a setup key + + {" "} +
+ + {setupKey && ( + + + + + + )} +
+ ); +}; diff --git a/src/modules/onboarding/p2p/OnboardingTestP2P.tsx b/src/modules/onboarding/p2p/OnboardingTestP2P.tsx new file mode 100644 index 00000000..dbcfaa52 --- /dev/null +++ b/src/modules/onboarding/p2p/OnboardingTestP2P.tsx @@ -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 ( +
+
+

+ {`Let's put that connection to the test`} +

+
+ { + "Nice work connecting your devices! Now, let’s have a little fun and test if they can talk to each other." + } +
+
+ + + +

+ Run this command from{" "} + {firstDevice?.name} to ping{" "} + {secondDevice?.name}. + You should receive a response if the connection is working. +

+ + ping {secondDevice?.ip} + +
+ +

+ Everything working? Great! You can now continue with the onboarding. + If something isn’t right, please check our{" "} + + troubleshooting guide + + +

+
+ +
+
+
+
+ ); +};