diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index a710bfe2..63217362 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -4,6 +4,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess"; import { VerticalTabs } from "@components/VerticalTabs"; import { AlertOctagonIcon, + FingerprintIcon, FolderGit2Icon, LockIcon, MonitorSmartphoneIcon, @@ -19,6 +20,7 @@ import { useAccount } from "@/modules/account/useAccount"; import AuthenticationTab from "@/modules/settings/AuthenticationTab"; import ClientSettingsTab from "@/modules/settings/ClientSettingsTab"; import DangerZoneTab from "@/modules/settings/DangerZoneTab"; +import IdentityProvidersTab from "@/modules/settings/IdentityProvidersTab"; import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab"; import PermissionsTab from "@/modules/settings/PermissionsTab"; import GroupsSettings from "@/modules/settings/GroupsSettings"; @@ -53,6 +55,13 @@ export default function NetBirdSettings() { Authentication + {account?.settings?.embedded_idp_enabled && + permission.identity_providers.read && ( + + + Identity Providers + + )} Groups @@ -80,6 +89,8 @@ export default function NetBirdSettings() { >
{account && } + {account?.settings?.embedded_idp_enabled && + permission.identity_providers.read && } {account && } {account && } {account && } diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index 902c1e1e..a77a5e58 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -36,6 +36,6 @@ export default function NotFound() { const Redirect = ({ url, queryParams }: Props) => { const params = queryParams && `?${queryParams}`; - useRedirect(url == "/" ? `/peers${params}` : `${url}${params}`); + useRedirect(url == "/" ? `/peers${params}` : `${url}${params}`, true); return ; }; diff --git a/src/app/page.tsx b/src/app/page.tsx index aca8eae9..f603b3ed 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -37,6 +37,6 @@ export default function Home() { const Redirect = ({ url, queryParams }: Props) => { const params = queryParams && `?${queryParams}`; - useRedirect(url == "/" ? `/peers${params}` : `${url}${params}`); + useRedirect(url == "/" ? `/peers${params}` : `${url}${params}`, true); return ; }; diff --git a/src/app/setup/layout.tsx b/src/app/setup/layout.tsx new file mode 100644 index 00000000..4a809f0a --- /dev/null +++ b/src/app/setup/layout.tsx @@ -0,0 +1,8 @@ +import { globalMetaTitle } from "@utils/meta"; +import type { Metadata } from "next"; +import BlankLayout from "@/layouts/BlankLayout"; + +export const metadata: Metadata = { + title: `Instance Setup - ${globalMetaTitle}`, +}; +export default BlankLayout; diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx new file mode 100644 index 00000000..762e05f1 --- /dev/null +++ b/src/app/setup/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import InstanceSetupWizard from "@/modules/instance-setup/InstanceSetupWizard"; + +export default function SetupPage() { + return ; +} diff --git a/src/assets/icons/AuthentikIcon.tsx b/src/assets/icons/AuthentikIcon.tsx new file mode 100644 index 00000000..51b1a42f --- /dev/null +++ b/src/assets/icons/AuthentikIcon.tsx @@ -0,0 +1,28 @@ +import { iconProperties, IconProps } from "@/assets/icons/IconProperties"; + +export default function AuthentikIcon(props: Readonly) { + return ( + + + + + + + ); +} \ No newline at end of file diff --git a/src/assets/icons/IdentityProviderIcons.tsx b/src/assets/icons/IdentityProviderIcons.tsx new file mode 100644 index 00000000..bf969b65 --- /dev/null +++ b/src/assets/icons/IdentityProviderIcons.tsx @@ -0,0 +1,30 @@ +import { SSOIdentityProviderType } from "@/interfaces/IdentityProvider"; +import React from "react"; +import GoogleIcon from "@/assets/icons/GoogleIcon"; +import MicrosoftIcon from "@/assets/icons/MicrosoftIcon"; +import EntraIcon from "@/assets/icons/EntraIcon"; +import OktaIcon from "@/assets/icons/OktaIcon"; +import PocketIdIcon from "@/assets/icons/PocketIdIcon"; +import ZitadelIcon from "@/assets/icons/ZitadelIcon"; +import AuthentikIcon from "@/assets/icons/AuthentikIcon"; +import KeycloakIcon from "@/assets/icons/KeycloakIcon"; +import { KeyRound } from "lucide-react"; + +export const idpIcon = ( + type: SSOIdentityProviderType, + size: number = 16, +): React.ReactNode => { + const icons: Record = { + google: , + microsoft: , + entra: , + okta: , + pocketid: , + zitadel: , + authentik: , + keycloak: , + oidc: , + }; + + return icons[type]; +}; diff --git a/src/assets/icons/KeycloakIcon.tsx b/src/assets/icons/KeycloakIcon.tsx new file mode 100644 index 00000000..288abd0d --- /dev/null +++ b/src/assets/icons/KeycloakIcon.tsx @@ -0,0 +1,88 @@ +import { iconProperties, IconProps } from "@/assets/icons/IconProperties"; + +export default function KeycloakIcon(props: Readonly) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/src/assets/icons/MicrosoftIcon.tsx b/src/assets/icons/MicrosoftIcon.tsx new file mode 100644 index 00000000..de79d6b2 --- /dev/null +++ b/src/assets/icons/MicrosoftIcon.tsx @@ -0,0 +1,16 @@ +import { iconProperties, IconProps } from "@/assets/icons/IconProperties"; + +export default function MicrosoftIcon(props: Readonly) { + return ( + + + + + + + ); +} \ No newline at end of file diff --git a/src/assets/icons/PocketIdIcon.tsx b/src/assets/icons/PocketIdIcon.tsx new file mode 100644 index 00000000..dcab032d --- /dev/null +++ b/src/assets/icons/PocketIdIcon.tsx @@ -0,0 +1,17 @@ +import { iconProperties, IconProps } from "@/assets/icons/IconProperties"; + +export default function PocketIdIcon(props: Readonly) { + return ( + + + + + ); +} \ No newline at end of file diff --git a/src/assets/icons/ZitadelIcon.tsx b/src/assets/icons/ZitadelIcon.tsx new file mode 100644 index 00000000..e947df3e --- /dev/null +++ b/src/assets/icons/ZitadelIcon.tsx @@ -0,0 +1,32 @@ +import { iconProperties, IconProps } from "@/assets/icons/IconProperties"; + +export default function ZitadelIcon(props: Readonly) { + return ( + + + + + + + + + + ); +} \ No newline at end of file diff --git a/src/auth/OIDCProvider.tsx b/src/auth/OIDCProvider.tsx index 69175fbc..a19d49e4 100644 --- a/src/auth/OIDCProvider.tsx +++ b/src/auth/OIDCProvider.tsx @@ -7,7 +7,6 @@ import { } from "@axa-fr/react-oidc"; import FullScreenLoading from "@components/ui/FullScreenLoading"; import { useLocalStorage } from "@hooks/useLocalStorage"; -import { useRedirect } from "@hooks/useRedirect"; import loadConfig, { buildExtras } from "@utils/config"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import React, { useEffect, useState } from "react"; @@ -75,8 +74,7 @@ export default function OIDCProvider({ children }: Props) { const withCustomHistory = () => { return { replaceState: (url: any) => { - router.replace(url); - window.dispatchEvent(new Event("popstate")); + window?.location?.replace(url); }, }; }; @@ -105,16 +103,17 @@ export default function OIDCProvider({ children }: Props) { // We bypass authentication for pages that do not require auth. // E.g., when we just want to show installation steps for public. - if (path === "/install") return children; + // Or the instance setup wizard for first-time setup. + if (path === "/install" || path === "/setup") return children; return mounted && providerConfig ? ( void 0} //sessionLostComponent={SessionLost} @@ -125,11 +124,3 @@ export default function OIDCProvider({ children }: Props) { ); } - -const CallBackSuccess = () => { - const params = useSearchParams(); - const errorParam = params.get("error"); - const currentPath = usePathname(); - useRedirect(currentPath, true, !errorParam); - return ; -}; diff --git a/src/components/Input.tsx b/src/components/Input.tsx index f4bd9391..5309044a 100644 --- a/src/components/Input.tsx +++ b/src/components/Input.tsx @@ -2,8 +2,9 @@ import FullTooltip from "@components/FullTooltip"; import Paragraph from "@components/Paragraph"; import { cn } from "@utils/helpers"; import { cva, VariantProps } from "class-variance-authority"; -import { AlertCircle } from "lucide-react"; +import { AlertCircle, Eye, EyeOff } from "lucide-react"; import * as React from "react"; +import { useState } from "react"; type InputVariants = VariantProps; @@ -18,6 +19,7 @@ export interface InputProps errorTooltip?: boolean; errorTooltipPosition?: "top" | "top-right"; prefixClassName?: string; + showPasswordToggle?: boolean; } const inputVariants = cva("", { @@ -61,10 +63,29 @@ const Input = React.forwardRef( errorTooltipPosition = "top", variant = "default", prefixClassName, + showPasswordToggle = false, ...props }, ref, ) => { + const [showPassword, setShowPassword] = useState(false); + const isPasswordType = type === "password"; + const inputType = isPasswordType && showPassword ? "text" : type; + + const passwordToggle = + isPasswordType && showPasswordToggle ? ( + + ) : null; + + const suffix = passwordToggle || customSuffix; + return ( <>
@@ -94,7 +115,7 @@ const Input = React.forwardRef(
( "file:border-0", "focus-visible:ring-2 focus-visible:ring-offset-2", customPrefix && "!border-l-0 !rounded-l-none", - customSuffix && "!pr-16", + suffix && "!pr-16", icon && "!pl-10", "border", className, @@ -116,7 +137,7 @@ const Input = React.forwardRef( props.disabled && "opacity-30", )} > - {customSuffix} + {suffix}
{error && errorTooltip && (
({ + setupRequired: false, + loading: true, +}); + +export const useInstanceSetup = () => useContext(InstanceSetupContext); + +// Check if we're in an OIDC callback flow (hash-based routing) +const isOIDCCallback = () => { + if (typeof window === "undefined") return false; + const hash = window.location.hash; + return hash.startsWith("#callback") || hash.startsWith("#silent-callback"); +}; + +export default function InstanceSetupProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [setupRequired, setSetupRequired] = useState(false); + const [loading, setLoading] = useState(true); + const router = useRouter(); + const pathname = usePathname(); + + // Routes that don't need setup check + const bypassRoutes = ["/setup", "/install"]; + const shouldBypass = + bypassRoutes.includes(pathname) || isOIDCCallback(); + + // Skip setup check for NetBird hosted (cloud) deployments + const isCloud = isNetBirdHosted(); + + // Check instance status on mount + useEffect(() => { + // Skip check for cloud deployments or bypass routes + if (isCloud || shouldBypass) { + setLoading(false); + return; + } + + // Check if instance setup is required + fetchInstanceStatus() + .then((status) => { + if (status.setup_required) { + setSetupRequired(true); + } + }) + .catch((err) => { + // If API fails (e.g., endpoint doesn't exist on older versions), + // assume setup is not required and continue normally + console.warn("Instance status check failed:", err); + }) + .finally(() => { + setLoading(false); + }); + }, [shouldBypass, isCloud]); + + // Handle redirect separately to avoid setState during render conflicts + useEffect(() => { + if (setupRequired && !shouldBypass) { + router.replace("/setup"); + } + }, [setupRequired, shouldBypass, router]); + + // Show loading while checking (only for non-cloud, non-bypass routes) + if (loading && !shouldBypass && !isCloud) { + return ; + } + + // If setup required and not on setup page, wait for redirect + if (setupRequired && !shouldBypass) { + return ; + } + + return ( + + {children} + + ); +} diff --git a/src/hooks/useRedirect.tsx b/src/hooks/useRedirect.tsx index 90edd900..f691dc59 100644 --- a/src/hooks/useRedirect.tsx +++ b/src/hooks/useRedirect.tsx @@ -4,6 +4,9 @@ import { useEffect, useRef } from "react"; const config = loadConfig(); +const RETRY_DELAY = 1250; +const MAX_RETRIES = 10; + export const useRedirect = ( url: string, replace: boolean = false, @@ -12,40 +15,51 @@ export const useRedirect = ( const router = useRouter(); const currentPath = usePathname(); const callBackUrls = useRef([config.redirectURI, config.silentRedirectURI]); - const isRedirecting = useRef(false); - const intervalRef = useRef(null); + const timeoutRef = useRef(null); + const retryCountRef = useRef(0); useEffect(() => { + // Parse URL to separate path and query params + const [targetPath] = url.split("?"); + const currentFullPath = window.location.pathname; + // If redirect is disabled or the url is already in the callback urls then do not redirect - if (!enable || callBackUrls.current.includes(url) || url === currentPath) + if (!enable || callBackUrls.current.includes(url)) { return; + } + + // Check if we're already on the target path + if (targetPath === currentFullPath || targetPath === currentPath) { + return; + } const performRedirect = () => { - if (!isRedirecting.current) { - isRedirecting.current = true; - router.refresh(); - if (replace) { - router.replace(url); - } else { - router.push(url); - } - isRedirecting.current = false; + if (replace) { + router.replace(url); + } else { + router.push(url); } - }; - performRedirect(); + retryCountRef.current += 1; - // Try to redirect after 1.25 seconds if for whatever reason the redirect did not happen (network change, browser tab open but not focused etc.) - intervalRef.current = setInterval(() => { - if (!isRedirecting.current) { - performRedirect(); + // Retry if navigation hasn't occurred and we haven't exceeded max retries + if (retryCountRef.current < MAX_RETRIES) { + timeoutRef.current = setTimeout(() => { + // Check again if we're still not on the target path + if (window.location.pathname !== targetPath) { + performRedirect(); + } + }, RETRY_DELAY); } - }, 1250); + }; + + performRedirect(); return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); } + retryCountRef.current = 0; }; }, [replace, router, url, enable, currentPath]); }; diff --git a/src/interfaces/Account.ts b/src/interfaces/Account.ts index 4ddb87f8..49d55d03 100644 --- a/src/interfaces/Account.ts +++ b/src/interfaces/Account.ts @@ -22,6 +22,7 @@ export interface Account { dns_domain: string; network_range?: string; lazy_connection_enabled: boolean; + embedded_idp_enabled?: boolean; auto_update_version: string; }; onboarding?: AccountOnboarding; diff --git a/src/interfaces/IdentityProvider.ts b/src/interfaces/IdentityProvider.ts index 9f426c1e..126faeac 100644 --- a/src/interfaces/IdentityProvider.ts +++ b/src/interfaces/IdentityProvider.ts @@ -30,3 +30,55 @@ export interface IdentityProviderLog { level: string; timestamp: Date; } + +export type SSOIdentityProviderType = + | "oidc" + | "zitadel" + | "entra" + | "google" + | "okta" + | "pocketid" + | "microsoft" + | "authentik" + | "keycloak"; + +export const SSOIdentityProviderOptions: { + value: SSOIdentityProviderType; + label: string; +}[] = [ + { value: "oidc", label: "OIDC (Generic)" }, + { value: "google", label: "Google" }, + { value: "microsoft", label: "Microsoft" }, + { value: "entra", label: "Microsoft Entra" }, + { value: "okta", label: "Okta" }, + { value: "zitadel", label: "Zitadel" }, + { value: "pocketid", label: "PocketID" }, + { value: "authentik", label: "Authentik" }, + { value: "keycloak", label: "Keycloak" }, +]; + +export const getSSOIdentityProviderLabelByType = ( + type: SSOIdentityProviderType, +) => { + return ( + SSOIdentityProviderOptions.find((option) => option.value === type)?.label ?? + type + ); +}; + +export interface SSOIdentityProvider { + id: string; + type: SSOIdentityProviderType; + name: string; + issuer: string; + client_id: string; + redirect_url?: string; +} + +export interface SSOIdentityProviderRequest { + type: SSOIdentityProviderType; + name: string; + issuer: string; + client_id: string; + client_secret: string; +} diff --git a/src/interfaces/Instance.ts b/src/interfaces/Instance.ts new file mode 100644 index 00000000..1f9f6a4f --- /dev/null +++ b/src/interfaces/Instance.ts @@ -0,0 +1,19 @@ +export interface InstanceStatus { + setup_required: boolean; +} + +export interface SetupRequest { + email: string; + password: string; + name: string; +} + +export interface SetupResponse { + user_id: string; + email: string; +} + +export interface ApiError { + code: number; + message: string; +} diff --git a/src/interfaces/Permission.ts b/src/interfaces/Permission.ts index a0b80602..ddae72db 100644 --- a/src/interfaces/Permission.ts +++ b/src/interfaces/Permission.ts @@ -22,6 +22,7 @@ export interface Permissions { settings: Permission; accounts: Permission; billing: Permission; + identity_providers: Permission; edr: Permission; event_streaming: Permission; diff --git a/src/interfaces/User.ts b/src/interfaces/User.ts index 1182740b..6dbe9e7d 100644 --- a/src/interfaces/User.ts +++ b/src/interfaces/User.ts @@ -13,6 +13,8 @@ export interface User { pending_approval?: boolean; last_login?: Date; permissions: Permissions; + password?: string; + idp_id?: string; } export enum Role { diff --git a/src/layouts/AppLayout.tsx b/src/layouts/AppLayout.tsx index 35d3cd6c..cee06a9f 100644 --- a/src/layouts/AppLayout.tsx +++ b/src/layouts/AppLayout.tsx @@ -18,6 +18,7 @@ import AnalyticsProvider, { import DialogProvider from "@/contexts/DialogProvider"; import ErrorBoundaryProvider from "@/contexts/ErrorBoundary"; import { GlobalThemeProvider } from "@/contexts/GlobalThemeProvider"; +import InstanceSetupProvider from "@/contexts/InstanceSetupProvider"; import { NavigationEvents } from "@/contexts/NavigationEvents"; const inter = localFont({ @@ -47,11 +48,13 @@ export default function AppLayout({ - - - {children} - - + + + + {children} + + + diff --git a/src/modules/activity/ActivityDescription.tsx b/src/modules/activity/ActivityDescription.tsx index d795a941..2b9bda5e 100644 --- a/src/modules/activity/ActivityDescription.tsx +++ b/src/modules/activity/ActivityDescription.tsx @@ -707,6 +707,31 @@ export default function ActivityDescription({ event }: Props) {
); + /** + * Identity Provider + */ + + if (event.activity_code == "identityprovider.create") + return ( +
+ Identity provider {m.name} was created +
+ ); + + if (event.activity_code == "identityprovider.update") + return ( +
+ Identity provider {m.name} was updated +
+ ); + + if (event.activity_code == "identityprovider.delete") + return ( +
+ Identity provider {m.name} was deleted +
+ ); + return (
{event.activity} diff --git a/src/modules/activity/ActivityTypeIcon.tsx b/src/modules/activity/ActivityTypeIcon.tsx index 2ae40794..77546d2f 100644 --- a/src/modules/activity/ActivityTypeIcon.tsx +++ b/src/modules/activity/ActivityTypeIcon.tsx @@ -4,6 +4,7 @@ import { Blocks, Cog, CreditCardIcon, + FingerprintIcon, FolderGit2, Globe, HelpCircleIcon, @@ -52,6 +53,7 @@ const ActivityTypeMappings = { transferred: RefreshCcw, resource: Layers3Icon, network: NetworkIcon, + identityprovider: FingerprintIcon, } as const satisfies Record; export default function ActivityTypeIcon({ diff --git a/src/modules/instance-setup/InstanceSetupWizard.tsx b/src/modules/instance-setup/InstanceSetupWizard.tsx new file mode 100644 index 00000000..36fbe4a2 --- /dev/null +++ b/src/modules/instance-setup/InstanceSetupWizard.tsx @@ -0,0 +1,294 @@ +"use client"; + +import { cn } from "@utils/helpers"; +import { CheckCircle2, Loader2 } from "lucide-react"; +import React, { useCallback, useEffect, useState } from "react"; +import { ApiError, SetupRequest } from "@/interfaces/Instance"; +import { submitSetup } from "@/utils/unauthenticatedApi"; +import { NetBirdLogo } from "@components/NetBirdLogo"; +import Button from "@components/Button"; +import { Label } from "@components/Label"; +import { Input } from "@components/Input"; +import HelpText from "@components/HelpText"; +import { GradientFadedBackground } from "@components/ui/GradientFadedBackground"; + +interface FormData { + email: string; + password: string; + name: string; +} + +interface FormErrors { + email?: string; + password?: string; + name?: string; + general?: string; +} + +const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$/i; + +export default function InstanceSetupWizard() { + const [formData, setFormData] = useState({ + email: "", + password: "", + name: "", + }); + const [errors, setErrors] = useState({}); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + const [countdown, setCountdown] = useState(3); + + const validateForm = useCallback((): boolean => { + const newErrors: FormErrors = {}; + + if (!formData.email.trim()) { + newErrors.email = "Email is required"; + } else if (!emailRegex.test(formData.email)) { + newErrors.email = "Please enter a valid email address"; + } + + if (!formData.password) { + newErrors.password = "Password is required"; + } else if (formData.password.length < 8) { + newErrors.password = "Password must be at least 8 characters"; + } + + if (!formData.name.trim()) { + newErrors.name = "Name is required"; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }, [formData]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) return; + + setIsSubmitting(true); + setErrors({}); + + try { + const request: SetupRequest = { + email: formData.email.trim(), + password: formData.password, + name: formData.name.trim(), + }; + + await submitSetup(request); + setIsSuccess(true); + } catch (err) { + const error = err as ApiError; + let message = "An error occurred. Please try again."; + + switch (error.code) { + case 400: + message = "Invalid request. Please check your input."; + break; + case 412: + message = "Setup has already been completed. Redirecting to login..."; + setTimeout(() => (window.location.href = "/"), 2000); + break; + case 422: + message = + error.message || "Validation error. Please check your input."; + break; + case 500: + message = "An error occurred. Please try again."; + break; + default: + message = error.message || message; + } + + setErrors({ general: message }); + } finally { + setIsSubmitting(false); + } + }; + + useEffect(() => { + if (!isSuccess) return; + + const timer = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + clearInterval(timer); + // Full page reload to get fresh instance status from API + window.location.href = "/"; + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(timer); + }, [isSuccess]); + + const handleInputChange = + (field: keyof FormData) => (e: React.ChangeEvent) => { + setFormData((prev) => ({ ...prev, [field]: e.target.value })); + if (errors[field]) { + setErrors((prev) => ({ ...prev, [field]: undefined })); + } + }; + + if (isSuccess) { + return ( +
+
+ +
+ +
+ +
+

+ Account Created! +

+
+ You are being redirected to login in{" "} + {countdown}s... +
+
+ +
+
+
+ ); + } + + return ( +
+
+ +
+ +

+ Welcome to NetBird +

+
+ Create the first admin account to get started +
+ +
+ {errors.general && } +
+ + +
+ +
+ + +
+ +
+ + + + Must be at least 8 characters + +
+ + + +
+ +
+ + This is a one-time setup for your NetBird instance. + +
+
+ ); +} + +const Card = ({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) => { + return ( +
+ + {children} +
+ ); +}; + +const ErrorMessage = ({ error }: { error?: string }) => { + return ( +
+ {error} +
+ ); +}; diff --git a/src/modules/settings/IdentityProviderModal.tsx b/src/modules/settings/IdentityProviderModal.tsx new file mode 100644 index 00000000..288e1822 --- /dev/null +++ b/src/modules/settings/IdentityProviderModal.tsx @@ -0,0 +1,309 @@ +import Button from "@components/Button"; +import Code from "@components/Code"; +import HelpText from "@components/HelpText"; +import { Input } from "@components/Input"; +import { Label } from "@components/Label"; +import { + Modal, + ModalClose, + ModalContent, + ModalFooter, +} from "@components/modal/Modal"; +import ModalHeader from "@components/modal/ModalHeader"; +import { notify } from "@components/Notification"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@components/Select"; +import Separator from "@components/Separator"; +import { useApiCall } from "@utils/api"; +import loadConfig from "@utils/config"; +import { trim } from "lodash"; +import { + FingerprintIcon, + GlobeIcon, + IdCard, + KeyIcon, + PlusCircle, + SaveIcon, + TagIcon, +} from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { useSWRConfig } from "swr"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { + SSOIdentityProvider, + SSOIdentityProviderOptions, + SSOIdentityProviderRequest, + SSOIdentityProviderType, +} from "@/interfaces/IdentityProvider"; +import { idpIcon } from "@/assets/icons/IdentityProviderIcons"; + +const issuerHints: Partial> = { + keycloak: "https://keycloak.example.com/realms/{REALM}", + authentik: "https://authentik.example.com/application/o/{APP_SLUG}/", + zitadel: "https://{INSTANCE}.zitadel.cloud", + okta: "https://{ORG}.okta.com", + entra: "https://login.microsoftonline.com/{TENANT_ID}/v2.0", + pocketid: "https://pocketid.example.com", +}; + +const defaultNames: Record = { + oidc: "Generic OIDC", + google: "Google", + microsoft: "Microsoft", + entra: "Microsoft Entra", + okta: "Okta", + zitadel: "Zitadel", + pocketid: "PocketID", + authentik: "Authentik", + keycloak: "Keycloak", +}; + +type Props = { + open: boolean; + onClose: () => void; + provider?: SSOIdentityProvider | null; +}; + +const copyMessage = "Redirect URL was copied to your clipboard!"; +const config = loadConfig(); +const redirectUrl = `${config.apiOrigin}/oauth2/callback`; + +export default function IdentityProviderModal({ + open, + onClose, + provider, +}: Readonly) { + const { mutate } = useSWRConfig(); + const { permission } = usePermissions(); + const isEditing = !!provider; + + const createRequest = useApiCall("/identity-providers"); + const updateRequest = useApiCall( + "/identity-providers/" + provider?.id, + ); + + const [type, setType] = useState( + provider?.type ?? "oidc", + ); + const [name, setName] = useState(provider?.name ?? ""); + const [issuer, setIssuer] = useState(provider?.issuer ?? ""); + const [clientId, setClientId] = useState(provider?.client_id ?? ""); + const [clientSecret, setClientSecret] = useState(""); + + const requiresIssuer = type !== "google" && type !== "microsoft"; + + const clientIdChanged = isEditing && trim(clientId) !== provider?.client_id; + + const isDisabled = useMemo(() => { + const trimmedName = trim(name); + const trimmedIssuer = trim(issuer); + const trimmedClientId = trim(clientId); + const trimmedClientSecret = trim(clientSecret); + + if (trimmedName.length === 0) return true; + if (requiresIssuer && trimmedIssuer.length === 0) return true; + if (trimmedClientId.length === 0) return true; + // Client secret required for new providers, or when client ID changed during edit + if ((!isEditing || clientIdChanged) && trimmedClientSecret.length === 0) + return true; + + return false; + }, [name, issuer, clientId, clientSecret, isEditing, clientIdChanged, requiresIssuer]); + + const submit = () => { + const payload: SSOIdentityProviderRequest = { + type, + name: trim(name), + issuer: trim(issuer), + client_id: trim(clientId), + client_secret: trim(clientSecret), + }; + + if (isEditing) { + notify({ + title: "Update Identity Provider", + description: "Identity provider was updated successfully.", + promise: updateRequest.put(payload).then(() => { + mutate("/identity-providers"); + onClose(); + }), + loadingMessage: "Updating identity provider...", + }); + } else { + notify({ + title: "Create Identity Provider", + description: "Identity provider was created successfully.", + promise: createRequest.post(payload).then(() => { + mutate("/identity-providers"); + onClose(); + }), + loadingMessage: "Creating identity provider...", + }); + } + }; + + return ( + <> + !state && onClose()} + key={open ? 1 : 0} + > + + } + title={ + isEditing ? "Edit Identity Provider" : "Add Identity Provider" + } + description={ + isEditing + ? "Update the identity provider configuration" + : "Configure a new identity provider for authentication" + } + color={"netbird"} + /> + + + +
+
+ + Select the type of identity provider + +
+ +
+ + A friendly name to identify this provider + setName(e.target.value)} + customPrefix={ + + } + /> +
+ + {requiresIssuer && ( +
+ + The OIDC issuer URL for this provider + setIssuer(e.target.value)} + customPrefix={ + + } + /> +
+ )} + +
+ + The OAuth2 confidential client ID + setClientId(e.target.value)} + customPrefix={} + /> +
+ +
+ + + {isEditing + ? clientIdChanged + ? "Required when client ID is changed" + : "Leave empty to keep the existing secret, or enter a new one" + : "The OAuth2 client secret"} + + setClientSecret(e.target.value)} + customPrefix={ + + } + /> +
+ + + +
+ + + Copy this URL to your identity provider configuration + + + {redirectUrl} + +
+
+ + +
+ + + + + +
+
+
+
+ + ); +} diff --git a/src/modules/settings/IdentityProvidersTab.tsx b/src/modules/settings/IdentityProvidersTab.tsx new file mode 100644 index 00000000..347c2338 --- /dev/null +++ b/src/modules/settings/IdentityProvidersTab.tsx @@ -0,0 +1,287 @@ +import Breadcrumbs from "@components/Breadcrumbs"; +import Button from "@components/Button"; +import { notify } from "@components/Notification"; +import Paragraph from "@components/Paragraph"; +import SquareIcon from "@components/SquareIcon"; +import { DataTable } from "@components/table/DataTable"; +import DataTableHeader from "@components/table/DataTableHeader"; +import DataTableRefreshButton from "@components/table/DataTableRefreshButton"; +import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage"; +import GetStartedTest from "@components/ui/GetStartedTest"; +import * as Tabs from "@radix-ui/react-tabs"; +import useFetchApi, { useApiCall } from "@utils/api"; +import { ColumnDef, SortingState } from "@tanstack/react-table"; +import { + FingerprintIcon, + KeyRound, + MoreVertical, + PencilIcon, + PlusCircle, + Trash2, +} from "lucide-react"; +import React, { useState } from "react"; +import { useSWRConfig } from "swr"; +import SettingsIcon from "@/assets/icons/SettingsIcon"; +import { useDialog } from "@/contexts/DialogProvider"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { useLocalStorage } from "@/hooks/useLocalStorage"; +import { + getSSOIdentityProviderLabelByType, + SSOIdentityProvider, + SSOIdentityProviderType, +} from "@/interfaces/IdentityProvider"; +import IdentityProviderModal from "@/modules/settings/IdentityProviderModal"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@components/DropdownMenu"; +import { idpIcon } from "@/assets/icons/IdentityProviderIcons"; + +export const idpTypeLabels: Record = { + oidc: "OIDC", + zitadel: "Zitadel", + entra: "Microsoft Entra", + google: "Google", + okta: "Okta", + pocketid: "PocketID", + microsoft: "Microsoft", + authentik: "Authentik", + keycloak: "Keycloak", +}; + +type ActionCellProps = { + provider: SSOIdentityProvider; + onEdit: (provider: SSOIdentityProvider) => void; +}; + +function ActionCell({ provider, onEdit }: ActionCellProps) { + const { confirm } = useDialog(); + const { mutate } = useSWRConfig(); + const deleteRequest = useApiCall( + "/identity-providers/" + provider.id, + ); + const { permission } = usePermissions(); + + const handleDelete = async () => { + const choice = await confirm({ + title: `Delete '${provider.name}'?`, + description: + "Are you sure you want to delete this identity provider? This action cannot be undone.", + confirmText: "Delete", + cancelText: "Cancel", + type: "danger", + }); + + if (!choice) return; + + notify({ + title: "Delete Identity Provider", + description: "Identity provider was deleted successfully.", + promise: deleteRequest.del().then(() => { + mutate("/identity-providers"); + }), + loadingMessage: "Deleting identity provider...", + }); + }; + + return ( +
+ + + + + + onEdit(provider)} + disabled={!permission.identity_providers.update} + > + + Edit + + + + Delete + + + +
+ ); +} + +export default function IdentityProvidersTab() { + const { permission } = usePermissions(); + const { mutate } = useSWRConfig(); + const { data: providers, isLoading } = useFetchApi( + "/identity-providers", + ); + + const [modalOpen, setModalOpen] = useState(false); + const [editProvider, setEditProvider] = useState( + null, + ); + + const [sorting, setSorting] = useLocalStorage( + "netbird-table-sort-identity-providers", + [ + { + id: "name", + desc: false, + }, + ], + ); + + const handleEdit = (provider: SSOIdentityProvider) => { + setEditProvider(provider); + setModalOpen(true); + }; + + const handleCloseModal = () => { + setModalOpen(false); + setEditProvider(null); + }; + + const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: ({ column }) => ( + Name + ), + sortingFn: "text", + cell: ({ row }) => ( +
+ {idpIcon(row.original.type) || ( + + )} + {row.original.name} +
+ ), + }, + { + accessorKey: "type", + header: ({ column }) => ( + Type + ), + cell: ({ row }) => ( + + {getSSOIdentityProviderLabelByType(row.original.type)} + + ), + }, + { + id: "actions", + accessorKey: "id", + header: "", + cell: ({ row }) => ( + + ), + }, + ]; + + return ( + +
+ + } + /> + } + active + /> + +
+
+

Identity Providers

+ + Configure identity providers for user authentication in your + network. + +
+
+
+ + + + handleEdit(row.original)} + searchPlaceholder={"Search by name or type..."} + getStartedCard={ + } + color={"gray"} + size={"large"} + /> + } + title={"Add Identity Provider"} + description={ + "Configure an identity provider to enable SSO authentication for your users." + } + button={ + + } + /> + } + rightSide={() => ( + <> + {providers && providers.length > 0 && ( + + )} + + )} + > + {(table) => ( + <> + + mutate("/identity-providers")} + /> + + )} + +
+ ); +} diff --git a/src/modules/users/UserInviteModal.tsx b/src/modules/users/UserInviteModal.tsx index 3c48ad68..b3941579 100644 --- a/src/modules/users/UserInviteModal.tsx +++ b/src/modules/users/UserInviteModal.tsx @@ -1,4 +1,5 @@ import Button from "@components/Button"; +import Code from "@components/Code"; import HelpText from "@components/HelpText"; import { Input } from "@components/Input"; import { Label } from "@components/Label"; @@ -14,10 +15,11 @@ import { PeerGroupSelector } from "@components/PeerGroupSelector"; import { IconMailForward } from "@tabler/icons-react"; import { useApiCall } from "@utils/api"; import { cn, validator } from "@utils/helpers"; -import { MailIcon, User2 } from "lucide-react"; +import { CopyIcon, MailIcon, User2 } from "lucide-react"; import Image from "next/image"; import React, { useMemo, useState } from "react"; import { useSWRConfig } from "swr"; +import useCopyToClipboard from "@/hooks/useCopyToClipboard"; import Avatar1 from "@/assets/avatars/009.jpg"; import Avatar2 from "@/assets/avatars/030.jpg"; import Avatar3 from "@/assets/avatars/063.jpg"; @@ -26,33 +28,104 @@ import { Group } from "@/interfaces/Group"; import { Role, User } from "@/interfaces/User"; import useGroupHelper from "@/modules/groups/useGroupHelper"; import { UserRoleSelector } from "@/modules/users/UserRoleSelector"; +import {isNetBirdHosted} from "@utils/netbird"; type Props = { children: React.ReactNode; groups?: Group[]; }; +const copyMessage = "Password was copied to your clipboard!"; + export default function UserInviteModal({ children, groups }: Readonly) { const [open, setOpen] = useState(false); + const [successModal, setSuccessModal] = useState(false); + const [createdUser, setCreatedUser] = useState(); const { mutate } = useSWRConfig(); + const [, copyToClipboard] = useCopyToClipboard(createdUser?.password); - const handleOnSuccess = () => { - setOpen(false); + const handleOnSuccess = (user: User) => { + if (user.password) { + setCreatedUser(user); + setSuccessModal(true); + } else { + setOpen(false); + } setTimeout(() => { mutate("/users?service_user=false"); }, 1000); }; + const handleCopyAndClose = () => { + copyToClipboard(copyMessage).then(() => { + setCreatedUser(undefined); + setSuccessModal(false); + setOpen(false); + }); + }; + return ( - - {children} - - + <> + + {children} + + + + { + if (!open) { + setCreatedUser(undefined); + } + setSuccessModal(open); + setOpen(open); + }} + > + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + onPointerDownOutside={(e) => e.preventDefault()} + maxWidthClass={"max-w-md"} + className={"mt-20"} + showClose={false} + > +
+
+
+

+ User created successfully! +

+ + This password will not be shown again, so be sure to copy it + and store in a secure location. + +
+
+
+ +
+ + {createdUser?.password || ""} + +
+ + + +
+
+ ); } type ModalProps = { - onSuccess: () => void; + onSuccess: (user: User) => void; groups?: Group[]; }; @@ -85,9 +158,9 @@ export function UserInviteModalContent({ auto_groups: groupIds, is_service_user: false, }) - .then(() => { + .then((user) => { mutate("/users?service_user=false"); - onSuccess && onSuccess(); + onSuccess && onSuccess(user); }), loadingMessage: "Sending invite...", }); @@ -121,10 +194,10 @@ export function UserInviteModalContent({ } >

- Invite User + {isNetBirdHosted() ? "Invite User" : "Create User"}

- Invite a user to your network and set their permissions. + {isNetBirdHosted() ? "Invite a user to your network and set their permissions." : "Create a NetBird user account with email and password."}
@@ -181,7 +254,7 @@ export function UserInviteModalContent({ disabled={isDisabled} onClick={sendInvite} > - Send Invitation + {isNetBirdHosted() ? "Send Invitation" : "Create User"} diff --git a/src/modules/users/UsersTable.tsx b/src/modules/users/UsersTable.tsx index 07ace53f..4866de09 100644 --- a/src/modules/users/UsersTable.tsx +++ b/src/modules/users/UsersTable.tsx @@ -15,7 +15,7 @@ import { Table, } from "@tanstack/react-table"; import useFetchApi from "@utils/api"; -import { isLocalDev, isNetBirdHosted } from "@utils/netbird"; +import { isNetBirdHosted } from "@utils/netbird"; import dayjs from "dayjs"; import { ExternalLinkIcon, MailPlus } from "lucide-react"; import { usePathname, useRouter } from "next/navigation"; @@ -35,6 +35,7 @@ import UserNameCell from "@/modules/users/table-cells/UserNameCell"; import UserRoleCell from "@/modules/users/table-cells/UserRoleCell"; import UserStatusCell from "@/modules/users/table-cells/UserStatusCell"; import UserInviteModal from "@/modules/users/UserInviteModal"; +import { useAccount } from "@/modules/account/useAccount"; export const UsersTableColumns: ColumnDef[] = [ { @@ -274,20 +275,27 @@ export const InviteUserButton = ({ groups, }: InviteUserButtonProps) => { const { permission } = usePermissions(); + const account = useAccount(); + if (!show) return null; + // On cloud: always show "Invite User" + // On self-hosted: only show when embedded_idp_enabled is true + const isCloud = isNetBirdHosted(); + const embeddedIdpEnabled = account?.settings.embedded_idp_enabled; + + if (!isCloud && !embeddedIdpEnabled) return null; + return ( - (isLocalDev() || isNetBirdHosted()) && ( - - - - ) + + + ); }; diff --git a/src/modules/users/table-cells/UserNameCell.tsx b/src/modules/users/table-cells/UserNameCell.tsx index 6be7e1c0..b1a58fa8 100644 --- a/src/modules/users/table-cells/UserNameCell.tsx +++ b/src/modules/users/table-cells/UserNameCell.tsx @@ -1,12 +1,37 @@ import { cn, generateColorFromUser } from "@utils/helpers"; +import useFetchApi from "@utils/api"; import { Ban, Clock, Cog } from "lucide-react"; -import React from "react"; +import React, { useMemo } from "react"; import { User } from "@/interfaces/User"; +import { SSOIdentityProvider } from "@/interfaces/IdentityProvider"; +import { useAccount } from "@/modules/account/useAccount"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@components/Tooltip"; +import { idpIcon } from "@/assets/icons/IdentityProviderIcons"; type Props = { user: User; }; + export default function UserNameCell({ user }: Readonly) { + const account = useAccount(); + const embeddedIdpEnabled = account?.settings.embedded_idp_enabled; + + const { data: identityProviders } = useFetchApi( + "/identity-providers", + false, + true, + embeddedIdpEnabled === true, + ); + + const userIdp = useMemo(() => { + if (!user.idp_id || !identityProviders) return null; + return identityProviders.find((idp) => idp.id === user.idp_id); + }, [user.idp_id, identityProviders]); const status = user.status; const isCurrent = user.is_current; @@ -56,6 +81,20 @@ export default function UserNameCell({ user }: Readonly) { {icon} )} + {userIdp && status !== "invited" && status !== "blocked" && ( + + + +
+ {idpIcon(userIdp.type, 14)} +
+
+ + {userIdp.name} + +
+
+ )}
diff --git a/src/utils/netbird.ts b/src/utils/netbird.ts index 773d80fe..ce1c877c 100644 --- a/src/utils/netbird.ts +++ b/src/utils/netbird.ts @@ -16,10 +16,9 @@ export const getInstallUrl = () => { }; export const isNetBirdHosted = () => { - return ( - window.location.hostname.endsWith(".netbird.io") || - window.location.hostname.endsWith(".wiretrustee.com") - ); + const hostname = window.location.hostname; + if (hostname.includes("selfhosted")) return false; + return hostname.endsWith(".netbird.io") || hostname.endsWith(".wiretrustee.com"); }; export const isLocalDev = () => { diff --git a/src/utils/unauthenticatedApi.ts b/src/utils/unauthenticatedApi.ts new file mode 100644 index 00000000..c65f210c --- /dev/null +++ b/src/utils/unauthenticatedApi.ts @@ -0,0 +1,54 @@ +import loadConfig from "@utils/config"; +import { + ApiError, + InstanceStatus, + SetupRequest, + SetupResponse, +} from "@/interfaces/Instance"; + +const config = loadConfig(); + +async function unauthenticatedRequest( + method: "GET" | "POST", + endpoint: string, + data?: unknown, +): Promise { + const url = `${config.apiOrigin}/api${endpoint}`; + + const res = await fetch(url, { + method, + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: data ? JSON.stringify(data) : undefined, + }); + + if (!res.ok) { + let error: ApiError; + try { + const errorBody = await res.json(); + error = { + code: res.status, + message: errorBody.message || res.statusText, + }; + } catch { + error = { code: res.status, message: res.statusText }; + } + return Promise.reject(error); + } + + // Handle empty response + const text = await res.text(); + if (!text) return {} as T; + + return JSON.parse(text) as T; +} + +export async function fetchInstanceStatus(): Promise { + return unauthenticatedRequest("GET", "/instance"); +} + +export async function submitSetup(data: SetupRequest): Promise { + return unauthenticatedRequest("POST", "/setup", data); +}