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 ? (
+ setShowPassword(!showPassword)}
+ className={"hover:text-white transition-all"}
+ aria-label={"Toggle password visibility"}
+ >
+ {showPassword ? : }
+
+ ) : 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 ...
+
+
+ (window.location.href = "/")}
+ variant={"primary"}
+ className={"mx-auto w-full"}
+ >
+ Go to Login
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ Welcome to NetBird
+
+
+ Create the first admin account to get started
+
+
+
+
+
+
+
+ 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"}
+ />
+
+
+
+
+
+
Provider Type
+
Select the type of identity provider
+
{
+ const newType = v as SSOIdentityProviderType;
+ setType(newType);
+ if (!isEditing) {
+ setName(defaultNames[newType]);
+ }
+ }}
+ >
+
+
+
+
+ {SSOIdentityProviderOptions.map((idp) => (
+
+
+ {idpIcon(idp.value)}
+ {idp.label}
+
+
+ ))}
+
+
+
+
+
+ Name
+ A friendly name to identify this provider
+ setName(e.target.value)}
+ customPrefix={
+
+ }
+ />
+
+
+ {requiresIssuer && (
+
+ Issuer URL
+ The OIDC issuer URL for this provider
+ setIssuer(e.target.value)}
+ customPrefix={
+
+ }
+ />
+
+ )}
+
+
+ Client ID
+ The OAuth2 confidential client ID
+ setClientId(e.target.value)}
+ customPrefix={ }
+ />
+
+
+
+ Client Secret
+
+ {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={
+
+ }
+ />
+
+
+
+
+
+ Redirect / Callback URL
+
+ Copy this URL to your identity provider configuration
+
+
+ {redirectUrl}
+
+
+
+
+
+
+
+ Cancel
+
+
+
+ {isEditing ? (
+ <>
+
+ Save Changes
+ >
+ ) : (
+ <>
+
+ Add Provider
+ >
+ )}
+
+
+
+
+
+ >
+ );
+}
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={
+ setModalOpen(true)}
+ disabled={!permission.identity_providers.create}
+ >
+
+ Add Identity Provider
+
+ }
+ />
+ }
+ rightSide={() => (
+ <>
+ {providers && providers.length > 0 && (
+ setModalOpen(true)}
+ disabled={!permission.identity_providers.create}
+ >
+
+ Add Identity Provider
+
+ )}
+ >
+ )}
+ >
+ {(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 || ""}
+
+
+
+
+
+ Copy & Close
+
+
+
+
+ >
);
}
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()) && (
-
-
-
- Invite User
-
-
- )
+
+
+
+ {isCloud ? "Invite User" : "Create User"}
+
+
);
};
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);
+}