mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-01-26 01:21:04 +00:00
Configure Identity Providers in the UI (#523)
* Add user creation with password copy * Add initial identity provider view * Add IdP logos * Add IdP id to user * Add IdP logo to user obj * Fix okta icon * Return callback URL when creating an IdP * Create user for self-hosted * Clear up password from the state * Show IdPs and create user when enabled * Fetch IdPs only when embedded idp is enabled * Update src/app/(dashboard)/settings/page.tsx Co-authored-by: Eduard Gert <kontakt@eduardgert.de> * Update src/app/(dashboard)/settings/page.tsx Co-authored-by: Eduard Gert <kontakt@eduardgert.de> * Update src/modules/settings/IdentityProvidersTab.tsx Co-authored-by: Eduard Gert <kontakt@eduardgert.de> * Update src/modules/settings/IdentityProviderModal.tsx Co-authored-by: Eduard Gert <kontakt@eduardgert.de> * Update src/modules/settings/IdentityProvidersTab.tsx Co-authored-by: Eduard Gert <kontakt@eduardgert.de> * Update src/modules/settings/IdentityProviderModal.tsx Co-authored-by: Eduard Gert <kontakt@eduardgert.de> * Rename IdentityProvider to SSOIdentityProvider * Fix build and extract icons * Fix initial onboarding * Add icons * Move name to the top * Fix setup wizard background color * Update instance setup ui * Update instance setup ui * Use input component * Move idp label and icons * Fix setup wizard width * Add authentik and keycloak * Add idp hints * Handle idp permissions * Consider selfhosted instances when checking if netbird is hosted * Update redirect * Add max retries to redirect * Require new secret when clientid changed * Add callback URL on the idp creation step * Add idp activity events --------- Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
This commit is contained in:
@@ -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() {
|
||||
<ShieldIcon size={14} />
|
||||
Authentication
|
||||
</VerticalTabs.Trigger>
|
||||
{account?.settings?.embedded_idp_enabled &&
|
||||
permission.identity_providers.read && (
|
||||
<VerticalTabs.Trigger value="identity-providers">
|
||||
<FingerprintIcon size={14} />
|
||||
Identity Providers
|
||||
</VerticalTabs.Trigger>
|
||||
)}
|
||||
<VerticalTabs.Trigger value="groups">
|
||||
<FolderGit2Icon size={14} />
|
||||
Groups
|
||||
@@ -80,6 +89,8 @@ export default function NetBirdSettings() {
|
||||
>
|
||||
<div className={"border-l border-nb-gray-930 w-full"}>
|
||||
{account && <AuthenticationTab account={account} />}
|
||||
{account?.settings?.embedded_idp_enabled &&
|
||||
permission.identity_providers.read && <IdentityProvidersTab />}
|
||||
{account && <PermissionsTab account={account} />}
|
||||
{account && <GroupsSettings account={account} />}
|
||||
{account && <NetworkSettingsTab account={account} />}
|
||||
|
||||
@@ -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 <FullScreenLoading />;
|
||||
};
|
||||
|
||||
@@ -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 <FullScreenLoading />;
|
||||
};
|
||||
|
||||
8
src/app/setup/layout.tsx
Normal file
8
src/app/setup/layout.tsx
Normal file
@@ -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;
|
||||
7
src/app/setup/page.tsx
Normal file
7
src/app/setup/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import InstanceSetupWizard from "@/modules/instance-setup/InstanceSetupWizard";
|
||||
|
||||
export default function SetupPage() {
|
||||
return <InstanceSetupWizard />;
|
||||
}
|
||||
28
src/assets/icons/AuthentikIcon.tsx
Normal file
28
src/assets/icons/AuthentikIcon.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function AuthentikIcon(props: Readonly<IconProps>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="-0.03 59.9 512.03 392.1"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<path
|
||||
d="M279.9 141h17.9v51.2h-17.9zm46.6-2.2h17.9v40h-17.9zM65.3 197.3c-24 0-46 13.2-57.4 34.3h30.4c13.5-11.6 33-15 47.1 0h32.2c-12.6-17.1-31.4-34.3-52.3-34.3"
|
||||
fill="#fd4b2d"
|
||||
/>
|
||||
<path
|
||||
d="M108.7 262.4C66.8 350-6.6 275.3 38.3 231.5H7.9C-15.9 273 17 329 65.3 327.8c37.4 0 68.2-55.5 68.2-65.3 0-4.3-6-17.6-16-31H85.4c10.7 9.7 20 23.7 23.3 30.9m1.1-2.6"
|
||||
fill="#fd4b2d"
|
||||
/>
|
||||
<path
|
||||
d="M512 140.3v231.3c0 44.3-36.1 80.4-80.4 80.4h-34.1v-78.8h-163V452h-34.1c-44.4 0-80.4-36.1-80.4-80.4v-72.8h258.4v-139H253.6V238H119.9v-97.6c0-3.1.2-6.2.5-9.2.4-3.7 1.1-7.3 2-10.8.3-1.1.6-2.3 1-3.4.1-.3.2-.6.3-.8.2-.6.4-1.1.5-1.7.2-.5.4-1.1.6-1.7s.5-1.2.7-1.8.5-1.2.8-1.8c2-4.7 4.4-9.3 7.3-13.6l.1-.1c.7-1.1 1.5-2.1 2.3-3.2.7-.9 1.3-1.7 2-2.6.8-.9 1.6-1.9 2.4-2.8s1.6-1.8 2.4-2.6l.1-.1c.4-.5.9-.9 1.4-1.4 3-2.9 6.2-5.6 9.6-8 .9-.7 1.9-1.3 2.8-1.9 1.1-.7 2.2-1.4 3.3-2 2.1-1.2 4.2-2.4 6.5-3.4.7-.3 1.4-.7 2.1-1 3.1-1.3 6.2-2.5 9.4-3.4 1.2-.4 2.5-.7 3.7-1 .6-.2 1.2-.3 1.8-.4 3.6-.8 7.2-1.3 10.9-1.6l1.6-.1h.8c1.2-.1 2.4-.1 3.7-.1h231.3c1.2 0 2.5 0 3.7.1h.8l1.6.1c3.7.3 7.3.8 10.9 1.6.6.1 1.2.3 1.8.4 1.3.3 2.5.6 3.7 1 3.2.9 6.3 2.1 9.4 3.4.7.3 1.4.6 2.1 1 2.2 1 4.4 2.2 6.5 3.4 1.1.7 2.2 1.3 3.3 2 1 .6 1.9 1.3 2.8 1.9 3.9 2.8 7.6 6 11 9.4.8.8 1.7 1.7 2.4 2.6.8.9 1.6 1.9 2.4 2.8.7.8 1.3 1.7 2 2.6.8 1.1 1.5 2.1 2.3 3.2l.1.1c2.9 4.3 5.3 8.8 7.3 13.6.2.6.5 1.2.8 1.8.2.6.5 1.2.7 1.8.2.5.4 1.1.6 1.7s.4 1.1.5 1.7c.1.3.2.6.3.8.3 1.1.7 2.3 1 3.4.9 3.6 1.6 7.2 2 10.8 0 3.1.2 6.1.2 9.2"
|
||||
fill="#fd4b2d"
|
||||
/>
|
||||
<path
|
||||
d="M498.3 95.5H133.5c14.9-22.2 40-35.6 66.7-35.6h231.3c26.9 0 51.9 13.4 66.8 35.6m13.2 35.6H120.4c1.4-12.8 6-25 13.1-35.6h364.8c7.2 10.6 11.7 22.9 13.2 35.6m.5 9.2v26.4H378.3v-6.9H253.6v6.9H119.9v-26.4c0-3.1.2-6.2.5-9.2h391.1c.3 3.1.5 6.1.5 9.2M119.9 166.7h133.7v35.6H119.9zm258.4 0H512v35.6H378.3zm-258.4 35.6h133.7v35.6H119.9zm258.4 0H512v35.6H378.3z"
|
||||
fill="#fd4b2d"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
30
src/assets/icons/IdentityProviderIcons.tsx
Normal file
30
src/assets/icons/IdentityProviderIcons.tsx
Normal file
@@ -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<SSOIdentityProviderType, React.ReactNode> = {
|
||||
google: <GoogleIcon size={size} />,
|
||||
microsoft: <MicrosoftIcon size={size} />,
|
||||
entra: <EntraIcon size={size} />,
|
||||
okta: <OktaIcon size={size} className="text-nb-gray-300" />,
|
||||
pocketid: <PocketIdIcon size={size} />,
|
||||
zitadel: <ZitadelIcon size={size} />,
|
||||
authentik: <AuthentikIcon size={size} />,
|
||||
keycloak: <KeycloakIcon size={size} />,
|
||||
oidc: <KeyRound size={size} className="text-nb-gray-400" />,
|
||||
};
|
||||
|
||||
return icons[type];
|
||||
};
|
||||
88
src/assets/icons/KeycloakIcon.tsx
Normal file
88
src/assets/icons/KeycloakIcon.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function KeycloakIcon(props: Readonly<IconProps>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 512 512"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<g transform="translate(.714 .07)">
|
||||
<path
|
||||
d="M432.9 149.2c-1.4 0-2.7-.7-3.4-2L370.1 44.1c-.7-1.2-2-2-3.5-2H124.2c-1.4 0-2.7.7-3.4 2L58.9 150.9l23.9 34.9c-.7 1.2-6.2 24-5.5 25.2L58.9 360.9l61.9 106.9c.7 1.2 2 2 3.4 2h242.4c1.4 0 2.7-.7 3.5-2l59.4-103.2c.7-1.2 2-2 3.4-2h73.8c2.4 0 4.4-2 4.4-4.4V153.6c0-2.4-2-4.4-4.4-4.4z"
|
||||
fill="#4d4d4d"
|
||||
/>
|
||||
<path d="M72.7 245.3 6.4 269.4l-6.6-11.3c-.7-1.2-.7-2.7 0-3.9l30-52z" fill="#e1e1e1" />
|
||||
<path d="M511.3 258.3V309l-43.7-44.5z" fill="#c8c8c8" />
|
||||
<path
|
||||
d="m467.5 264.5 43.7 44.5v49.6c0 2.4-2 4.4-4.4 4.4H456z"
|
||||
fill="#c2c2c2"
|
||||
/>
|
||||
<path d="M467.5 264.5 456 362.9h-61.2l-18.5-44.7z" fill="#c7c7c7" />
|
||||
<path d="M511.3 211.2v47l-43.7 6.2z" fill="#cecece" />
|
||||
<path
|
||||
d="M511.3 153.6v57.6l-43.7 53.2-33.1-115.3h72.2c2.4-.1 4.5 1.8 4.6 4.3z"
|
||||
fill="#d3d3d3"
|
||||
/>
|
||||
<path d="M394.8 362.9h-32.3l-8.4-12 22.1-32.7z" fill="#c6c6c6" />
|
||||
<path d="m467.5 264.5-121.1-51.2 63.7-64.1h24.4z" fill="#d5d5d5" />
|
||||
<path d="m346.5 213.3 29.8 105 91.2-53.8z" fill="#d0d0d0" />
|
||||
<path d="m353.8 362.9.4-12 8.4 12z" fill="#bfbfbf" />
|
||||
<path d="m410.1 149.2-63.7 64.1-11.4-57.4 24.6-6.8h50.5z" fill="#d9d9d9" />
|
||||
<path d="m346.5 213.3-147 33.9 154.7 103.7z" fill="#d4d4d4" />
|
||||
<path d="m346.5 213.3 7.7 137.6 22.1-32.7z" fill="#d0d0d0" />
|
||||
<path d="m335 155.9-135.5 91.2 147-33.9z" fill="#d9d9d9" />
|
||||
<path d="m199.5 247.2-63.7 115.7H99.6L72.7 245.3z" fill="#d8d8d8" />
|
||||
<path
|
||||
d="m134.3 149.2-61.5 96.1L57.3 155l2.2-3.8c.7-1.2 2-1.9 3.4-1.9z"
|
||||
fill="#e2e2e2"
|
||||
/>
|
||||
<path
|
||||
d="M99.6 362.9H62.7c-1.4 0-2.8-.8-3.5-2L6.4 269.4l66.4-24.1z"
|
||||
fill="#d8d8d8"
|
||||
/>
|
||||
<path d="M29.9 202.1 57.1 155l15.7 90.3z" fill="#e4e4e4" />
|
||||
<path d="m335 155.9-40.8-6.8H159.4l40.1 98z" fill="#dedede" />
|
||||
<path d="m199.5 247.2-40.1-98h-25.1l-61.5 96.1z" fill="#dedede" />
|
||||
<path d="M324.7 362.9h29.1l.4-12z" fill="#c5c5c5" />
|
||||
<path d="M266.7 362.9h58l29.5-12-154.7-103.7 27.9 115.7z" fill="#d0d0d0" />
|
||||
<path d="m227.4 362.9-27.9-115.7-63.7 115.7z" fill="#d1d1d1" />
|
||||
<path d="m335.4 149.2-.4 6.8 24.6-6.8z" fill="#ddd" />
|
||||
<path d="m335 155.9-3.8-6.8h-37z" fill="#e3e3e3" />
|
||||
<path d="m335 155.9.4-6.8h-4.2z" fill="#e2e2e2" />
|
||||
<path
|
||||
d="m223.9 151-59.7 103.4c-.3.5-.4 1.1-.4 1.7h-41.7l82-142q.75.45 1.2 1.2l18.6 32.3c.5 1.1.5 2.4 0 3.4"
|
||||
fill="#00b8e3"
|
||||
/>
|
||||
<path
|
||||
d="M223.8 364.9 205.3 397q-.45.75-1.2 1.2l-82-142.2h41.7c0 .6.1 1.1.4 1.6l59.6 103.2c.8 1.2.9 2.9 0 4.1"
|
||||
fill="#33c6e9"
|
||||
/>
|
||||
<path
|
||||
d="m204 114.2-82 141.9-20.6 35.6-19.6-34c-.3-.5-.4-1-.4-1.6s.1-1.2.4-1.7l19.9-34.4 60.4-104.5c.6-1.1 1.8-1.8 3-1.8h37.2c.6 0 1.2.2 1.7.5"
|
||||
fill="#008aaa"
|
||||
/>
|
||||
<path
|
||||
d="M204 398.2c-.5.3-1.1.5-1.8.5h-37.1c-1.3 0-2.4-.7-3-1.8l-55.2-95.6-5.5-9.5 20.6-35.6z"
|
||||
fill="#00b8e3"
|
||||
/>
|
||||
<path
|
||||
d="m368.9 256.1-82 142q-.75-.45-1.2-1.2L267 364.7c-.5-1-.5-2.3 0-3.3L326.7 258c.3-.5.5-1.2.5-1.8z"
|
||||
fill="#008aaa"
|
||||
/>
|
||||
<path
|
||||
d="M409.4 256.1c0 .6-.2 1.3-.5 1.8l-80.3 139.3c-.6 1-1.8 1.7-3 1.6h-37c-.6 0-1.2-.2-1.8-.5L368.9 256l20.6-35.6 19.5 33.8c.3.7.4 1.3.4 1.9"
|
||||
fill="#00b8e3"
|
||||
/>
|
||||
<path
|
||||
d="M368.9 256.1h-41.7c0-.6-.2-1.2-.5-1.8L267 151.2c-.6-1.1-.6-2.5 0-3.6l18.6-32.2q.45-.75 1.2-1.2z"
|
||||
fill="#00b8e3"
|
||||
/>
|
||||
<path
|
||||
d="m389.4 220.5-20.6 35.6-82-142c.6-.3 1.2-.5 1.8-.5h37.1c1.2 0 2.3.6 3 1.6z"
|
||||
fill="#33c6e9"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
16
src/assets/icons/MicrosoftIcon.tsx
Normal file
16
src/assets/icons/MicrosoftIcon.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function MicrosoftIcon(props: Readonly<IconProps>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 221 221"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<path fill="#F1511B" d="M104.868 104.868H0V0h104.868z" />
|
||||
<path fill="#80CC28" d="M220.654 104.868H115.788V0h104.866z" />
|
||||
<path fill="#00ADEF" d="M104.865 220.695H0V115.828h104.865z" />
|
||||
<path fill="#FBBC09" d="M220.654 220.695H115.788V115.828h104.866z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
17
src/assets/icons/PocketIdIcon.tsx
Normal file
17
src/assets/icons/PocketIdIcon.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function PocketIdIcon(props: Readonly<IconProps>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 512 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<circle cx="256" cy="256" r="256" fill="#fff" />
|
||||
<path
|
||||
d="M268.6 102.4c64.4 0 116.8 52.4 116.8 116.7 0 25.3-8 49.4-23 69.6-14.8 19.9-35 34.3-58.4 41.7l-6.5 2-15.5-76.2 4.3-2c14-6.7 23-21.1 23-36.6 0-22.4-18.2-40.6-40.6-40.6S228 195.2 228 217.6c0 15.5 9 29.8 23 36.6l4.2 2-25 153.4h-69.5V102.4z"
|
||||
fill="#191919"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
32
src/assets/icons/ZitadelIcon.tsx
Normal file
32
src/assets/icons/ZitadelIcon.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
|
||||
|
||||
export default function ZitadelIcon(props: Readonly<IconProps>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 80 79"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...iconProperties(props)}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="zitadel-grad"
|
||||
x1="3.86"
|
||||
x2="76.88"
|
||||
y1="47.89"
|
||||
y2="47.89"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#FF8F00" />
|
||||
<stop offset="1" stopColor="#FE00FF" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
fill="url(#zitadel-grad)"
|
||||
fillRule="evenodd"
|
||||
d="M17.12 39.17l1.42 5.32-6.68 6.68 9.12 2.44 1.43 5.32-19.77-5.3L17.12 39.17zM58.82 22.41l-5.32-1.43-2.44-9.12-6.68 6.68-5.32-1.43 14.47-14.47 5.3 19.77zM52.65 67.11l3.89-3.89 9.12 2.44-2.44-9.12 3.9-3.9 5.29 19.77-19.76-5.3zM36.43 69.54l-1.18-12.07 8.23 2.21-7.05 9.86zM23 23.84l5.02 11.04 6.02-6.02L23 23.84zM69.32 36.2l-12.07-1.18 2.2 8.23 9.87-7.05z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
<OidcProvider
|
||||
configuration={providerConfig}
|
||||
//withCustomHistory={withCustomHistory}
|
||||
withCustomHistory={withCustomHistory}
|
||||
authenticatingComponent={FullScreenLoading}
|
||||
authenticatingErrorComponent={OIDCError}
|
||||
loadingComponent={FullScreenLoading}
|
||||
callbackSuccessComponent={CallBackSuccess}
|
||||
callbackSuccessComponent={FullScreenLoading}
|
||||
onEvent={onEvent}
|
||||
onSessionLost={() => void 0}
|
||||
//sessionLostComponent={SessionLost}
|
||||
@@ -125,11 +124,3 @@ export default function OIDCProvider({ children }: Props) {
|
||||
<FullScreenLoading />
|
||||
);
|
||||
}
|
||||
|
||||
const CallBackSuccess = () => {
|
||||
const params = useSearchParams();
|
||||
const errorParam = params.get("error");
|
||||
const currentPath = usePathname();
|
||||
useRedirect(currentPath, true, !errorParam);
|
||||
return <FullScreenLoading />;
|
||||
};
|
||||
|
||||
@@ -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<typeof inputVariants>;
|
||||
|
||||
@@ -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<HTMLInputElement, InputProps>(
|
||||
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 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className={"hover:text-white transition-all"}
|
||||
aria-label={"Toggle password visibility"}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
const suffix = passwordToggle || customSuffix;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("flex relative h-[42px]", maxWidthClass)}>
|
||||
@@ -94,7 +115,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
</div>
|
||||
|
||||
<input
|
||||
type={type}
|
||||
type={inputType}
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn(
|
||||
@@ -103,7 +124,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
"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<HTMLInputElement, InputProps>(
|
||||
props.disabled && "opacity-30",
|
||||
)}
|
||||
>
|
||||
{customSuffix}
|
||||
{suffix}
|
||||
</div>
|
||||
{error && errorTooltip && (
|
||||
<div
|
||||
|
||||
93
src/contexts/InstanceSetupProvider.tsx
Normal file
93
src/contexts/InstanceSetupProvider.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
import FullScreenLoading from "@/components/ui/FullScreenLoading";
|
||||
import { fetchInstanceStatus } from "@/utils/unauthenticatedApi";
|
||||
import { isNetBirdHosted } from "@utils/netbird";
|
||||
|
||||
interface InstanceSetupContextType {
|
||||
setupRequired: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const InstanceSetupContext = createContext<InstanceSetupContextType>({
|
||||
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 <FullScreenLoading />;
|
||||
}
|
||||
|
||||
// If setup required and not on setup page, wait for redirect
|
||||
if (setupRequired && !shouldBypass) {
|
||||
return <FullScreenLoading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<InstanceSetupContext.Provider value={{ setupRequired, loading }}>
|
||||
{children}
|
||||
</InstanceSetupContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -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<NodeJS.Timeout | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(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);
|
||||
}
|
||||
|
||||
retryCountRef.current += 1;
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
performRedirect();
|
||||
|
||||
// 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();
|
||||
}
|
||||
}, 1250);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
retryCountRef.current = 0;
|
||||
};
|
||||
}, [replace, router, url, enable, currentPath]);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
19
src/interfaces/Instance.ts
Normal file
19
src/interfaces/Instance.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export interface Permissions {
|
||||
settings: Permission;
|
||||
accounts: Permission;
|
||||
billing: Permission;
|
||||
identity_providers: Permission;
|
||||
|
||||
edr: Permission;
|
||||
event_streaming: Permission;
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface User {
|
||||
pending_approval?: boolean;
|
||||
last_login?: Date;
|
||||
permissions: Permissions;
|
||||
password?: string;
|
||||
idp_id?: string;
|
||||
}
|
||||
|
||||
export enum Role {
|
||||
|
||||
@@ -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({
|
||||
<DialogProvider>
|
||||
<GlobalThemeProvider>
|
||||
<ErrorBoundaryProvider>
|
||||
<OIDCProvider>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</OIDCProvider>
|
||||
<InstanceSetupProvider>
|
||||
<OIDCProvider>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</OIDCProvider>
|
||||
</InstanceSetupProvider>
|
||||
</ErrorBoundaryProvider>
|
||||
</GlobalThemeProvider>
|
||||
</DialogProvider>
|
||||
|
||||
@@ -707,6 +707,31 @@ export default function ActivityDescription({ event }: Props) {
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Identity Provider
|
||||
*/
|
||||
|
||||
if (event.activity_code == "identityprovider.create")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Identity provider <Value>{m.name}</Value> was created
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "identityprovider.update")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Identity provider <Value>{m.name}</Value> was updated
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "identityprovider.delete")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Identity provider <Value>{m.name}</Value> was deleted
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={"flex gap-2.5 items-center"}>
|
||||
<span className={"mb-[1px]"}>{event.activity}</span>
|
||||
|
||||
@@ -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<string, LucideIcon>;
|
||||
|
||||
export default function ActivityTypeIcon({
|
||||
|
||||
294
src/modules/instance-setup/InstanceSetupWizard.tsx
Normal file
294
src/modules/instance-setup/InstanceSetupWizard.tsx
Normal file
@@ -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<FormData>({
|
||||
email: "",
|
||||
password: "",
|
||||
name: "",
|
||||
});
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
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<HTMLInputElement>) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: e.target.value }));
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<div className="mt-20">
|
||||
<div className={"flex items-center justify-center"}>
|
||||
<NetBirdLogo size={"large"} mobile={false} />
|
||||
</div>
|
||||
<Card className={"max-w-[360px] mt-8 mx-auto"}>
|
||||
<div className="w-10 h-10 rounded-full bg-green-500/10 flex items-center justify-center mb-4 mx-auto">
|
||||
<CheckCircle2 className="text-green-500" size={22} />
|
||||
</div>
|
||||
<h1 className={"text-xl text-center z-10 relative"}>
|
||||
Account Created!
|
||||
</h1>
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center z-10 relative"
|
||||
}
|
||||
>
|
||||
You are being redirected to login in{" "}
|
||||
<span className={"text-white font-medium"}>{countdown}s</span>...
|
||||
</div>
|
||||
<div className={"flex items-center justify-center mt-4"}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => (window.location.href = "/")}
|
||||
variant={"primary"}
|
||||
className={"mx-auto w-full"}
|
||||
>
|
||||
Go to Login
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-20">
|
||||
<div className={"flex items-center justify-center"}>
|
||||
<NetBirdLogo size={"large"} mobile={false} />
|
||||
</div>
|
||||
<Card className={"max-w-[420px] mt-8 mx-auto"}>
|
||||
<h1 className={"text-xl text-center z-10 relative"}>
|
||||
Welcome to NetBird
|
||||
</h1>
|
||||
<div
|
||||
className={
|
||||
"text-sm text-nb-gray-300 font-light mt-2 block text-center z-10 relative"
|
||||
}
|
||||
>
|
||||
Create the first admin account to get started
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className={"flex flex-col gap-5 mt-7 z-10 relative"}
|
||||
>
|
||||
{errors.general && <ErrorMessage error={errors.general} />}
|
||||
<div>
|
||||
<Label htmlFor={"name"}>Name</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={handleInputChange("name")}
|
||||
placeholder="Your name"
|
||||
disabled={isSubmitting}
|
||||
autoFocus
|
||||
error={errors.name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor={"email"}>Email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange("email")}
|
||||
placeholder="admin@example.com"
|
||||
disabled={isSubmitting}
|
||||
error={errors.email}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor={"password"}>Password</Label>
|
||||
<Input
|
||||
type={"password"}
|
||||
id="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange("password")}
|
||||
placeholder="Enter a strong password"
|
||||
disabled={isSubmitting}
|
||||
error={errors.password}
|
||||
showPasswordToggle={true}
|
||||
/>
|
||||
<HelpText className={"mt-2"}>
|
||||
Must be at least 8 characters
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type={"submit"}
|
||||
disabled={isSubmitting}
|
||||
variant={"primary"}
|
||||
className={"w-full"}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
Creating Account...
|
||||
</>
|
||||
) : (
|
||||
"Create Admin Account"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<div className={"flex items-center justify-center mt-6"}>
|
||||
<span
|
||||
className={"text-sm text-nb-gray-400 font-light pb-10 text-center"}
|
||||
>
|
||||
This is a one-time setup for your NetBird instance.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Card = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"px-6 sm:px-10 py-8 pt-6",
|
||||
"bg-nb-gray-940 border border-nb-gray-910 rounded-lg relative",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<GradientFadedBackground />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ErrorMessage = ({ error }: { error?: string }) => {
|
||||
return (
|
||||
<div className="text-red-400 bg-red-800/20 border border-red-800/50 rounded-lg px-4 py-3 whitespace-break-spaces my-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
309
src/modules/settings/IdentityProviderModal.tsx
Normal file
309
src/modules/settings/IdentityProviderModal.tsx
Normal file
@@ -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<Record<SSOIdentityProviderType, string>> = {
|
||||
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<SSOIdentityProviderType, string> = {
|
||||
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<Props>) {
|
||||
const { mutate } = useSWRConfig();
|
||||
const { permission } = usePermissions();
|
||||
const isEditing = !!provider;
|
||||
|
||||
const createRequest = useApiCall<SSOIdentityProvider>("/identity-providers");
|
||||
const updateRequest = useApiCall<SSOIdentityProvider>(
|
||||
"/identity-providers/" + provider?.id,
|
||||
);
|
||||
|
||||
const [type, setType] = useState<SSOIdentityProviderType>(
|
||||
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 (
|
||||
<>
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={(state) => !state && onClose()}
|
||||
key={open ? 1 : 0}
|
||||
>
|
||||
<ModalContent maxWidthClass={"max-w-xl"}>
|
||||
<ModalHeader
|
||||
icon={<FingerprintIcon size={20} />}
|
||||
title={
|
||||
isEditing ? "Edit Identity Provider" : "Add Identity Provider"
|
||||
}
|
||||
description={
|
||||
isEditing
|
||||
? "Update the identity provider configuration"
|
||||
: "Configure a new identity provider for authentication"
|
||||
}
|
||||
color={"netbird"}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className={"px-8 py-6 flex flex-col gap-6"}>
|
||||
<div>
|
||||
<Label>Provider Type</Label>
|
||||
<HelpText>Select the type of identity provider</HelpText>
|
||||
<Select
|
||||
value={type}
|
||||
onValueChange={(v) => {
|
||||
const newType = v as SSOIdentityProviderType;
|
||||
setType(newType);
|
||||
if (!isEditing) {
|
||||
setName(defaultNames[newType]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select provider type..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SSOIdentityProviderOptions.map((idp) => (
|
||||
<SelectItem key={idp.value} value={idp.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
{idpIcon(idp.value)}
|
||||
<span>{idp.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Name</Label>
|
||||
<HelpText>A friendly name to identify this provider</HelpText>
|
||||
<Input
|
||||
placeholder={"e.g., Corporate SSO"}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
customPrefix={
|
||||
<TagIcon size={16} className="text-nb-gray-300" />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{requiresIssuer && (
|
||||
<div>
|
||||
<Label>Issuer URL</Label>
|
||||
<HelpText>The OIDC issuer URL for this provider</HelpText>
|
||||
<Input
|
||||
placeholder={issuerHints[type] ?? "https://login.example.com"}
|
||||
value={issuer}
|
||||
onChange={(e) => setIssuer(e.target.value)}
|
||||
customPrefix={
|
||||
<GlobeIcon size={16} className="text-nb-gray-300" />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label>Client ID</Label>
|
||||
<HelpText>The OAuth2 confidential client ID</HelpText>
|
||||
<Input
|
||||
placeholder={"Enter client ID"}
|
||||
value={clientId}
|
||||
onChange={(e) => setClientId(e.target.value)}
|
||||
customPrefix={<IdCard size={16} className="text-nb-gray-300" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Client Secret</Label>
|
||||
<HelpText>
|
||||
{isEditing
|
||||
? clientIdChanged
|
||||
? "Required when client ID is changed"
|
||||
: "Leave empty to keep the existing secret, or enter a new one"
|
||||
: "The OAuth2 client secret"}
|
||||
</HelpText>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={isEditing ? "••••••••" : "Enter client secret"}
|
||||
value={clientSecret}
|
||||
onChange={(e) => setClientSecret(e.target.value)}
|
||||
customPrefix={
|
||||
<KeyIcon size={16} className="text-nb-gray-300" />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<Label>Redirect / Callback URL</Label>
|
||||
<HelpText>
|
||||
Copy this URL to your identity provider configuration
|
||||
</HelpText>
|
||||
<Code codeToCopy={redirectUrl} message={copyMessage}>
|
||||
<Code.Line>{redirectUrl}</Code.Line>
|
||||
</Code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalFooter className={"items-center"}>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={submit}
|
||||
disabled={
|
||||
isDisabled ||
|
||||
(isEditing
|
||||
? !permission.identity_providers.update
|
||||
: !permission.identity_providers.create)
|
||||
}
|
||||
>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<SaveIcon size={16} />
|
||||
Save Changes
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlusCircle size={16} />
|
||||
Add Provider
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
287
src/modules/settings/IdentityProvidersTab.tsx
Normal file
287
src/modules/settings/IdentityProvidersTab.tsx
Normal file
@@ -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<SSOIdentityProviderType, string> = {
|
||||
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<SSOIdentityProvider>(
|
||||
"/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 (
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" className="p-2">
|
||||
<MoreVertical size={16} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => onEdit(provider)}
|
||||
disabled={!permission.identity_providers.update}
|
||||
>
|
||||
<PencilIcon size={14} className="mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={handleDelete}
|
||||
disabled={!permission.identity_providers.delete}
|
||||
className="text-red-500 focus:text-red-500"
|
||||
>
|
||||
<Trash2 size={14} className="mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IdentityProvidersTab() {
|
||||
const { permission } = usePermissions();
|
||||
const { mutate } = useSWRConfig();
|
||||
const { data: providers, isLoading } = useFetchApi<SSOIdentityProvider[]>(
|
||||
"/identity-providers",
|
||||
);
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editProvider, setEditProvider] = useState<SSOIdentityProvider | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [sorting, setSorting] = useLocalStorage<SortingState>(
|
||||
"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<SSOIdentityProvider>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Name</DataTableHeader>
|
||||
),
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-3">
|
||||
{idpIcon(row.original.type) || (
|
||||
<KeyRound size={16} className="text-nb-gray-400" />
|
||||
)}
|
||||
<span className="font-medium">{row.original.name}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Type</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-nb-gray-400">
|
||||
{getSSOIdentityProviderLabelByType(row.original.type)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
accessorKey: "id",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<ActionCell provider={row.original} onEdit={handleEdit} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Tabs.Content value={"identity-providers"} className={"w-full"}>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/settings"}
|
||||
label={"Settings"}
|
||||
icon={<SettingsIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/settings?tab=identity-providers"}
|
||||
label={"Identity Providers"}
|
||||
icon={<FingerprintIcon size={14} />}
|
||||
active
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<div className={"flex items-start justify-between"}>
|
||||
<div>
|
||||
<h1>Identity Providers</h1>
|
||||
<Paragraph>
|
||||
Configure identity providers for user authentication in your
|
||||
network.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IdentityProviderModal
|
||||
open={modalOpen}
|
||||
key={modalOpen ? 1 : 0}
|
||||
onClose={handleCloseModal}
|
||||
provider={editProvider}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
isLoading={isLoading}
|
||||
text={"Identity Providers"}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
columns={columns}
|
||||
data={providers}
|
||||
onRowClick={(row) => handleEdit(row.original)}
|
||||
searchPlaceholder={"Search by name or type..."}
|
||||
getStartedCard={
|
||||
<GetStartedTest
|
||||
icon={
|
||||
<SquareIcon
|
||||
icon={<FingerprintIcon size={20} />}
|
||||
color={"gray"}
|
||||
size={"large"}
|
||||
/>
|
||||
}
|
||||
title={"Add Identity Provider"}
|
||||
description={
|
||||
"Configure an identity provider to enable SSO authentication for your users."
|
||||
}
|
||||
button={
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => setModalOpen(true)}
|
||||
disabled={!permission.identity_providers.create}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Add Identity Provider
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
rightSide={() => (
|
||||
<>
|
||||
{providers && providers.length > 0 && (
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"ml-auto"}
|
||||
onClick={() => setModalOpen(true)}
|
||||
disabled={!permission.identity_providers.create}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Add Identity Provider
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{(table) => (
|
||||
<>
|
||||
<DataTableRowsPerPage
|
||||
table={table}
|
||||
disabled={!providers || providers.length === 0}
|
||||
/>
|
||||
<DataTableRefreshButton
|
||||
isDisabled={!providers || providers.length === 0}
|
||||
onClick={() => mutate("/identity-providers")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DataTable>
|
||||
</Tabs.Content>
|
||||
);
|
||||
}
|
||||
@@ -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<Props>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [successModal, setSuccessModal] = useState(false);
|
||||
const [createdUser, setCreatedUser] = useState<User>();
|
||||
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 (
|
||||
<Modal open={open} onOpenChange={setOpen} key={open ? 1 : 0}>
|
||||
<ModalTrigger asChild={true}>{children}</ModalTrigger>
|
||||
<UserInviteModalContent onSuccess={handleOnSuccess} groups={groups} />
|
||||
</Modal>
|
||||
<>
|
||||
<Modal open={open} onOpenChange={setOpen} key={open ? 1 : 0}>
|
||||
<ModalTrigger asChild={true}>{children}</ModalTrigger>
|
||||
<UserInviteModalContent onSuccess={handleOnSuccess} groups={groups} />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={successModal}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCreatedUser(undefined);
|
||||
}
|
||||
setSuccessModal(open);
|
||||
setOpen(open);
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
maxWidthClass={"max-w-md"}
|
||||
className={"mt-20"}
|
||||
showClose={false}
|
||||
>
|
||||
<div className={"pb-6 px-8"}>
|
||||
<div className={"flex flex-col items-center justify-center gap-3"}>
|
||||
<div>
|
||||
<h2 className={"text-2xl text-center mb-2"}>
|
||||
User created successfully!
|
||||
</h2>
|
||||
<Paragraph className={"mt-0 text-sm text-center"}>
|
||||
This password will not be shown again, so be sure to copy it
|
||||
and store in a secure location.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"px-8 pb-6"}>
|
||||
<Code message={copyMessage}>
|
||||
<Code.Line>{createdUser?.password || ""}</Code.Line>
|
||||
</Code>
|
||||
</div>
|
||||
<ModalFooter className={"items-center"}>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"w-full"}
|
||||
onClick={handleCopyAndClose}
|
||||
>
|
||||
<CopyIcon size={14} />
|
||||
Copy & Close
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
}
|
||||
>
|
||||
<h2 className={"text-lg my-0 leading-[1.5 text-center]"}>
|
||||
Invite User
|
||||
{isNetBirdHosted() ? "Invite User" : "Create User"}
|
||||
</h2>
|
||||
<Paragraph className={cn("text-sm text-center max-w-xs")}>
|
||||
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."}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
@@ -181,7 +254,7 @@ export function UserInviteModalContent({
|
||||
disabled={isDisabled}
|
||||
onClick={sendInvite}
|
||||
>
|
||||
Send Invitation
|
||||
{isNetBirdHosted() ? "Send Invitation" : "Create User"}
|
||||
<IconMailForward size={16} />
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -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<User>[] = [
|
||||
{
|
||||
@@ -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()) && (
|
||||
<UserInviteModal groups={groups}>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={className}
|
||||
disabled={!permission.users.create}
|
||||
>
|
||||
<MailPlus size={16} />
|
||||
Invite User
|
||||
</Button>
|
||||
</UserInviteModal>
|
||||
)
|
||||
<UserInviteModal groups={groups}>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={className}
|
||||
disabled={!permission.users.create}
|
||||
>
|
||||
<MailPlus size={16} />
|
||||
{isCloud ? "Invite User" : "Create User"}
|
||||
</Button>
|
||||
</UserInviteModal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<Props>) {
|
||||
const account = useAccount();
|
||||
const embeddedIdpEnabled = account?.settings.embedded_idp_enabled;
|
||||
|
||||
const { data: identityProviders } = useFetchApi<SSOIdentityProvider[]>(
|
||||
"/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<Props>) {
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
{userIdp && status !== "invited" && status !== "blocked" && (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="w-5 h-5 absolute -right-1 -bottom-1 bg-nb-gray-930 rounded-full flex items-center justify-center border-2 border-nb-gray-950 text-nb-gray-50">
|
||||
{idpIcon(userIdp.type, 14)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={5}>
|
||||
<span className="text-xs">{userIdp.name}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
<div className={"flex flex-col justify-center"}>
|
||||
<span className={cn("text-base font-medium flex items-center gap-3")}>
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
54
src/utils/unauthenticatedApi.ts
Normal file
54
src/utils/unauthenticatedApi.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import loadConfig from "@utils/config";
|
||||
import {
|
||||
ApiError,
|
||||
InstanceStatus,
|
||||
SetupRequest,
|
||||
SetupResponse,
|
||||
} from "@/interfaces/Instance";
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
async function unauthenticatedRequest<T>(
|
||||
method: "GET" | "POST",
|
||||
endpoint: string,
|
||||
data?: unknown,
|
||||
): Promise<T> {
|
||||
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<InstanceStatus> {
|
||||
return unauthenticatedRequest<InstanceStatus>("GET", "/instance");
|
||||
}
|
||||
|
||||
export async function submitSetup(data: SetupRequest): Promise<SetupResponse> {
|
||||
return unauthenticatedRequest<SetupResponse>("POST", "/setup", data);
|
||||
}
|
||||
Reference in New Issue
Block a user