Compare commits

...

8 Commits

Author SHA1 Message Date
Misha Bragin
ea148545e8 Disable local users when LocalAuthDisabled = true (#546)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-02-01 14:31:57 +01:00
Misha Bragin
d2febbf27b Fix version comparison (#544)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-27 14:13:27 +01:00
Misha Bragin
615b4487ad Point to the right upgrade doc (#543)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-27 12:23:56 +01:00
Misha Bragin
a7c7800916 Add invite notification count badge (#542) 2026-01-27 10:44:39 +01:00
Eduard Gert
3d51e0893e Update announcement (#538)
* Update announcement

* Fix repeated fetches
2026-01-27 09:33:43 +01:00
Misha Bragin
d7d44b5817 Adjust Invites API (#541)
* Add API adjustments

* Invite_link renamed to invite_token
2026-01-26 19:25:56 +01:00
Misha Bragin
f67f39b68b Local user invites (#539) 2026-01-25 21:40:49 +01:00
dependabot[bot]
d2bc7a1f57 Bump lodash from 4.17.21 to 4.17.23 (#537)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.17.23
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-23 13:28:28 +01:00
19 changed files with 1528 additions and 111 deletions

View File

@@ -54,8 +54,19 @@ jobs:
fileName: "ironrdp_web_bg.wasm"
out-file-path: 'public/ironrdp-pkg'
- name: Get version from tag
id: version
run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
else
echo "version=development" >> $GITHUB_OUTPUT
fi
- name: Build
run: npm run build
env:
NEXT_PUBLIC_DASHBOARD_VERSION: ${{ steps.version.outputs.version }}
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2

12
announcements.json Normal file
View File

@@ -0,0 +1,12 @@
[
{
"tag": "New",
"text": "Custom DNS Zones for Private Network Resolution",
"link": "https://netbird.io/knowledge-hub/custom-dns-zones",
"linkText": "Read Release Article",
"variant": "important",
"isExternal": true,
"closeable": true,
"isCloudOnly": false
}
]

View File

@@ -7,6 +7,8 @@ const nextConfig = {
reactStrictMode: false,
env: {
APP_ENV: process.env.APP_ENV || "production",
NEXT_PUBLIC_DASHBOARD_VERSION:
process.env.NEXT_PUBLIC_DASHBOARD_VERSION || "development",
},
};

9
package-lock.json generated
View File

@@ -59,7 +59,7 @@
"ip-address": "^10.1.0",
"ip-cidr": "^3.1.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"lodash": "^4.17.23",
"lucide-react": "^0.539.0",
"next": "^14.2.35",
"next-themes": "^0.2.1",
@@ -7253,9 +7253,10 @@
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",

View File

@@ -64,7 +64,7 @@
"ip-address": "^10.1.0",
"ip-cidr": "^3.1.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"lodash": "^4.17.23",
"lucide-react": "^0.539.0",
"next": "^14.2.35",
"next-themes": "^0.2.1",

View 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: `Accept Invite - ${globalMetaTitle}`,
};
export default BlankLayout;

321
src/app/invite/page.tsx Normal file
View File

@@ -0,0 +1,321 @@
"use client";
import Button from "@components/Button";
import { Input } from "@components/Input";
import Paragraph from "@components/Paragraph";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { acceptInvite, fetchInviteInfo } from "@utils/unauthenticatedApi";
import {
AlertCircle,
CheckCircle2,
Clock,
KeyRound,
Mail,
User2,
} from "lucide-react";
import dayjs from "dayjs";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useMemo, useState } from "react";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import { UserInviteInfo } from "@/interfaces/User";
export default function InviteAcceptPage() {
return (
<Suspense fallback={<FullScreenLoading />}>
<InviteAcceptContent />
</Suspense>
);
}
function InviteAcceptContent() {
const searchParams = useSearchParams();
const router = useRouter();
const token = searchParams?.get("token");
const [loading, setLoading] = useState(true);
const [inviteInfo, setInviteInfo] = useState<UserInviteInfo | null>(null);
const [error, setError] = useState<string | null>(null);
const [isRateLimited, setIsRateLimited] = useState(false);
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
useEffect(() => {
if (!token) {
setError("No invite token provided");
setLoading(false);
return;
}
fetchInviteInfo(token)
.then((info) => {
setInviteInfo(info);
setLoading(false);
})
.catch((err) => {
if (err.code === 429) {
setError("Too many attempts. Please wait a moment and try again.");
setIsRateLimited(true);
} else {
setError(err.message || "Invalid or expired invite link");
setIsRateLimited(false);
}
setLoading(false);
});
}, [token]);
const passwordsMatch = password === confirmPassword;
const hasMinLength = password.length >= 8;
const hasUppercase = /[A-Z]/.test(password);
const hasLowercase = /[a-z]/.test(password);
const hasNumber = /[0-9]/.test(password);
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
const passwordValid = hasMinLength && hasUppercase && hasLowercase && hasNumber && hasSpecialChar;
const canSubmit = passwordValid && passwordsMatch && !submitting;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!canSubmit || !token) return;
setSubmitting(true);
setError(null);
try {
await acceptInvite(token, password);
setSuccess(true);
} catch (err: any) {
setError(err.message || "Failed to accept invite");
} finally {
setSubmitting(false);
}
};
const isExpired = useMemo(() => {
if (!inviteInfo) return false;
return new Date(inviteInfo.expires_at) < new Date();
}, [inviteInfo]);
if (loading) {
return <FullScreenLoading />;
}
if (error && !inviteInfo) {
if (isRateLimited) {
return (
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
<div className="max-w-md w-full text-center">
<div className="mb-6 flex justify-center">
<div className="w-16 h-16 bg-yellow-500/10 rounded-full flex items-center justify-center">
<Clock className="w-8 h-8 text-yellow-500" />
</div>
</div>
<h1 className="text-2xl font-semibold text-white mb-2">
Too Many Requests
</h1>
<Paragraph className="text-nb-gray-400 text-base">
You&apos;ve made too many requests. Please wait a moment and try
again.
</Paragraph>
<Button
variant="secondary"
className="mt-6"
onClick={() => window.location.reload()}
>
Try Again
</Button>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
<div className="max-w-md w-full text-center">
<div className="mb-6 flex justify-center">
<div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center">
<AlertCircle className="w-8 h-8 text-red-500" />
</div>
</div>
<h1 className="text-2xl font-semibold text-white mb-2">
Invalid Invite
</h1>
<Paragraph className="text-nb-gray-400 text-base">
This invite link is invalid or has expired. Please contact your
administrator to receive a new invitation.
</Paragraph>
<Button
variant="secondary"
className="mt-6"
onClick={() => router.push("/")}
>
Go to Login
</Button>
</div>
</div>
);
}
if (success) {
return (
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
<div className="max-w-md w-full text-center">
<div className="mb-6 flex justify-center">
<div className="w-16 h-16 bg-green-500/10 rounded-full flex items-center justify-center">
<CheckCircle2 className="w-8 h-8 text-green-500" />
</div>
</div>
<h1 className="text-2xl font-semibold text-white mb-2">
Account Created!
</h1>
<Paragraph className="text-nb-gray-400">
Your account has been created successfully. You can now log in with
your email and password.
</Paragraph>
<Button
variant="primary"
className="mt-6"
onClick={() => router.push("/")}
>
Go to Login
</Button>
</div>
</div>
);
}
if (isExpired || !inviteInfo?.valid) {
return (
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
<div className="max-w-md w-full text-center">
<div className="mb-6 flex justify-center">
<div className="w-16 h-16 bg-yellow-500/10 rounded-full flex items-center justify-center">
<AlertCircle className="w-8 h-8 text-yellow-500" />
</div>
</div>
<h1 className="text-2xl font-semibold text-white mb-2">
Invite Expired
</h1>
<Paragraph className="text-nb-gray-400">
This invite link has expired. Please contact your administrator to
receive a new invitation.
</Paragraph>
<Button
variant="secondary"
className="mt-6"
onClick={() => router.push("/")}
>
Go to Login
</Button>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
<div className="max-w-md w-full">
<div className="mb-8 flex justify-center">
<NetBirdIcon size={48} />
</div>
<div className="text-center mb-8">
<h1 className="text-2xl font-semibold text-white mb-2">
Welcome to NetBird
</h1>
<p className="dark:text-nb-gray-400 text-nb-gray-500 text-base">
You&apos;ve been invited by <span className="dark:text-white text-nb-gray-900 font-medium">{inviteInfo.invited_by}</span> to join the network. Set your password to complete your account setup.
</p>
</div>
<div className="bg-nb-gray-930 border border-nb-gray-900 rounded-lg p-6 mb-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-nb-gray-900 rounded-full flex items-center justify-center">
<User2 className="w-5 h-5 text-nb-gray-400" />
</div>
<div>
<div className="text-white font-medium">{inviteInfo.name}</div>
<div className="text-nb-gray-400 text-sm flex items-center gap-1">
<Mail className="w-3 h-3" />
{inviteInfo.email}
</div>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
customPrefix={
<KeyRound size={16} className="text-nb-gray-400" />
}
/>
{password && (
<div className="mt-2 space-y-1">
<PasswordRule met={hasMinLength} text="At least 8 characters" />
<PasswordRule met={hasUppercase} text="One uppercase letter" />
<PasswordRule met={hasLowercase} text="One lowercase letter" />
<PasswordRule met={hasNumber} text="One number" />
<PasswordRule met={hasSpecialChar} text="One special character (!@#$%^&*)" />
</div>
)}
</div>
<div>
<Input
type="password"
placeholder="Confirm Password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
customPrefix={
<KeyRound size={16} className="text-nb-gray-400" />
}
/>
{confirmPassword && !passwordsMatch && (
<p className="text-xs text-red-500 mt-1">
Passwords do not match
</p>
)}
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-3">
<p className="text-sm text-red-500">{error}</p>
</div>
)}
<Button
type="submit"
variant="primary"
className="w-full"
disabled={!canSubmit}
>
{submitting ? "Creating Account..." : "Create Account"}
</Button>
</form>
</div>
<p className="text-center text-xs text-nb-gray-500">
Invite expires on {dayjs(inviteInfo.expires_at).format("D MMMM, YYYY [at] h:mm A")}
</p>
</div>
</div>
);
}
function PasswordRule({ met, text }: { met: boolean; text: string }) {
return (
<div className="flex items-center gap-2 text-xs">
{met ? (
<CheckCircle2 className="w-3 h-3 text-green-500" />
) : (
<AlertCircle className="w-3 h-3 text-nb-gray-500" />
)}
<span className={met ? "text-green-500" : "text-nb-gray-500"}>{text}</span>
</div>
);
}

View File

@@ -104,7 +104,9 @@ 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.
// Or the instance setup wizard for first-time setup.
if (path === "/install" || path === "/setup") return children;
// Or the invite acceptance page for new users.
if (path === "/install" || path === "/setup" || path?.startsWith("/invite"))
return children;
return mounted && providerConfig ? (
<OidcProvider

View File

@@ -0,0 +1,148 @@
"use client";
import FullTooltip from "@components/FullTooltip";
import { cn } from "@utils/helpers";
import { ArrowUpCircle } from "lucide-react";
import * as React from "react";
import Skeleton from "react-loading-skeleton";
import useFetchApi from "@utils/api";
import { isNetBirdHosted } from "@utils/netbird";
import { useApplicationContext } from "@/contexts/ApplicationProvider";
import { VersionInfo as VersionInfoType } from "@/interfaces/Instance";
function formatVersion(version: string): string {
if (!version) return "";
// Add "v" prefix if version starts with a number
if (/^\d/.test(version)) return `v${version}`;
return version;
}
function compareVersions(current: string, latest: string): boolean {
// Returns true if latest is newer than current
if (!current || !latest) return false;
if (current === "development") return false;
// Strip "v" prefix if present
const normalizedCurrent = current.replace(/^v/, "");
const normalizedLatest = latest.replace(/^v/, "");
const currentParts = normalizedCurrent
.split(".")
.map((p) => parseInt(p, 10) || 0);
const latestParts = normalizedLatest
.split(".")
.map((p) => parseInt(p, 10) || 0);
for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
const c = currentParts[i] || 0;
const l = latestParts[i] || 0;
if (l > c) return true;
if (l < c) return false;
}
return false;
}
export const NavigationVersionInfo = () => {
const { isNavigationCollapsed, mobileNavOpen } = useApplicationContext();
// Only show for self-hosted, not cloud
if (isNetBirdHosted()) return null;
return (
<div
className={cn(
"px-4 py-4 animate-fade-in",
isNavigationCollapsed &&
!mobileNavOpen &&
"hidden md:group-hover/navigation:block",
)}
>
<NavigationVersionInfoContent />
</div>
);
};
const NavigationVersionInfoContent = () => {
const { data: versionInfo, isLoading } = useFetchApi<VersionInfoType>(
"/instance/version",
true, // ignore errors
false, // don't revalidate on focus
);
const dashboardVersion =
process.env.NEXT_PUBLIC_DASHBOARD_VERSION || "development";
if (isLoading)
return <Skeleton height={80} className={"rounded-lg opacity-60"} />;
if (!versionInfo) return null;
// Compare versions to detect updates (returns false for "development" versions)
const managementUpdateAvailable = compareVersions(
versionInfo.management_current_version,
versionInfo.management_available_version,
);
const dashboardUpdateAvailable = compareVersions(
dashboardVersion,
versionInfo.dashboard_available_version,
);
const hasUpdate = managementUpdateAvailable || dashboardUpdateAvailable;
return (
<div
className={cn(
"w-full rounded-md text-xs flex flex-col gap-2 whitespace-normal border text-left",
"bg-nb-gray-900/20 py-3 px-3 border-nb-gray-800/30",
)}
>
<div className="flex flex-col gap-1 text-nb-gray-400">
<FullTooltip
content={
<span className="text-xs">
Latest: {formatVersion(versionInfo.management_available_version)}
</span>
}
side="top"
className="w-full"
>
<div className="flex items-center justify-between w-full cursor-default">
<span>Management</span>
<span className="text-nb-gray-300 font-medium">
{formatVersion(versionInfo.management_current_version)}
</span>
</div>
</FullTooltip>
<FullTooltip
content={
<span className="text-xs">
Latest: {formatVersion(versionInfo.dashboard_available_version)}
</span>
}
side="top"
className="w-full"
>
<div className="flex items-center justify-between w-full cursor-default">
<span>Dashboard</span>
<span className="text-nb-gray-300 font-medium">
{formatVersion(dashboardVersion)}
</span>
</div>
</FullTooltip>
</div>
{hasUpdate && (
<a
href="https://docs.netbird.io/selfhosted/maintenance/upgrade"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 text-white font-medium bg-netbird hover:bg-netbird-500 transition-colors rounded-md py-1.5 px-2 mt-1"
>
<ArrowUpCircle size={12} />
<span>Update available</span>
</a>
)}
</div>
);
};
export default NavigationVersionInfo;

View File

@@ -1,21 +1,26 @@
import { AnnouncementVariant } from "@components/ui/AnnouncementBanner";
import { useLocalStorage } from "@hooks/useLocalStorage";
import md5 from "crypto-js/md5";
import React, { useEffect, useState } from "react";
import React, {
createContext,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { isNetBirdHosted } from "@utils/netbird";
const initialAnnouncements: Announcement[] = [
{
tag: "New",
text: "NetBird v0.62 Released - Local Users and Simplified IdP Integration",
link: "https://netbird.io/knowledge-hub/local-users-simplified-idp",
linkText: "Read Release Article",
variant: "important", // "default" or "important"
isExternal: true,
closeable: true,
isCloudOnly: false,
},
];
const ANNOUNCEMENTS_URL =
"https://raw.githubusercontent.com/netbirdio/dashboard/main/announcements.json";
const STORAGE_KEY = "netbird-announcements";
const CACHE_DURATION_MS = 30 * 60 * 1000;
const BANNER_HEIGHT = 40;
interface AnnouncementStore {
timestamp: number;
announcements: Announcement[];
closedAnnouncements: string[];
}
export interface Announcement extends AnnouncementVariant {
tag: string;
@@ -36,7 +41,7 @@ type Props = {
children: React.ReactNode;
};
const AnnouncementContext = React.createContext(
const AnnouncementContext = createContext(
{} as {
bannerHeight: number;
announcements?: AnnouncementInfo[];
@@ -47,59 +52,99 @@ const AnnouncementContext = React.createContext(
},
);
const bannerHeight = 40;
const getAnnouncements = async (): Promise<AnnouncementInfo[]> => {
try {
let stored: AnnouncementStore | null = null;
try {
const data = localStorage.getItem(STORAGE_KEY);
stored = data ? JSON.parse(data) : null;
} catch {}
const now = Date.now();
let raw: Announcement[];
if (stored && now - stored.timestamp < CACHE_DURATION_MS) {
raw = stored.announcements;
} else {
const response = await fetch(ANNOUNCEMENTS_URL);
if (!response.ok) return [];
raw = await response.json();
}
const isCloud = isNetBirdHosted();
const filtered = raw.filter((a) => !a.isCloudOnly || isCloud);
const hashes = new Set(filtered.map((a) => md5(a.text).toString()));
const closed = (stored?.closedAnnouncements ?? []).filter((h) =>
hashes.has(h),
);
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
timestamp: now,
announcements: raw,
closedAnnouncements: closed,
}),
);
} catch {}
return filtered.map((a) => {
const hash = md5(a.text).toString();
return { ...a, hash, isOpen: !closed.includes(hash) };
});
} catch {
return [];
}
};
const saveAnnouncements = (closedAnnouncements: string[]) => {
try {
const data = localStorage.getItem(STORAGE_KEY);
const stored: AnnouncementStore | null = data ? JSON.parse(data) : null;
if (stored) {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ ...stored, closedAnnouncements }),
);
}
} catch {}
};
export default function AnnouncementProvider({ children }: Readonly<Props>) {
const [height, setHeight] = useState(0);
const [closedAnnouncements, setClosedAnnouncements] = useLocalStorage<
string[]
>("netbird-closed-announcements", []);
const [announcements, setAnnouncements] = useState<AnnouncementInfo[]>();
const { isRestricted } = usePermissions();
const fetchingRef = useRef(false);
useEffect(() => {
if (announcements && announcements.length > 0) return;
if (isRestricted) return;
const initial = initialAnnouncements.map((announcement) => {
const hash = md5(announcement.text).toString();
const isOpen = !closedAnnouncements.some((h) => h === hash);
return {
...announcement,
hash,
isOpen,
} as AnnouncementInfo;
});
if (initial.length > 0) {
setAnnouncements(initial);
}
}, [closedAnnouncements, announcements]);
if (announcements !== undefined || isRestricted || fetchingRef.current)
return;
fetchingRef.current = true;
getAnnouncements()
.then((a) => setAnnouncements(a))
.finally(() => (fetchingRef.current = false));
}, [announcements, isRestricted]);
const closeAnnouncement = (hash: string) => {
setClosedAnnouncements([...closedAnnouncements, hash]);
setAnnouncements(() => {
return announcements?.map((a) => {
if (a.hash === hash) {
return { ...a, isOpen: false };
}
return a;
});
});
if (!announcements) return;
const updated = announcements.map((a) =>
a.hash === hash ? { ...a, isOpen: false } : a,
);
const closedAnnouncements = updated
.filter((a) => !a.isOpen)
.map((a) => a.hash);
saveAnnouncements(closedAnnouncements);
setAnnouncements(updated);
};
useEffect(() => {
const isAnnouncementOpen = announcements?.some((a) => a.isOpen);
if (isAnnouncementOpen) {
setHeight(bannerHeight);
} else {
setHeight(0);
}
}, [announcements]);
const bannerHeight = announcements?.some((a) => a.isOpen) ? BANNER_HEIGHT : 0;
return (
<AnnouncementContext.Provider
value={{
bannerHeight: height,
bannerHeight,
announcements,
closeAnnouncement,
setAnnouncements,
@@ -110,6 +155,4 @@ export default function AnnouncementProvider({ children }: Readonly<Props>) {
);
}
export const useAnnouncement = () => {
return React.useContext(AnnouncementContext);
};
export const useAnnouncement = () => useContext(AnnouncementContext);

View File

@@ -24,6 +24,7 @@ export interface Account {
lazy_connection_enabled: boolean;
embedded_idp_enabled?: boolean;
auto_update_version: string;
local_auth_disabled?: boolean;
};
onboarding?: AccountOnboarding;
}

View File

@@ -17,3 +17,9 @@ export interface ApiError {
code: number;
message: string;
}
export interface VersionInfo {
management_current_version: string;
management_available_version: string;
dashboard_available_version: string;
}

View File

@@ -17,6 +17,51 @@ export interface User {
idp_id?: string;
}
export interface UserInviteCreateRequest {
email: string;
name: string;
role: string;
auto_groups: string[];
expires_in?: number;
}
export interface UserInvite {
id: string;
email: string;
name: string;
role: string;
auto_groups: string[];
expires_at: string;
created_at: string;
expired: boolean;
invite_token?: string;
}
export interface UserInviteInfo {
email: string;
name: string;
expires_at: string;
valid: boolean;
invited_by: string;
}
export interface UserInviteAcceptRequest {
password: string;
}
export interface UserInviteAcceptResponse {
success: boolean;
}
export interface UserInviteRegenerateRequest {
expires_in?: number;
}
export interface UserInviteRegenerateResponse {
invite_token: string;
invite_expires_at: string;
}
export enum Role {
User = "user",
Admin = "admin",

View File

@@ -14,6 +14,7 @@ import SettingsIcon from "@/assets/icons/SettingsIcon";
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
import TeamIcon from "@/assets/icons/TeamIcon";
import SidebarItem from "@/components/SidebarItem";
import { NavigationVersionInfo } from "@/components/VersionInfo";
import { useAnnouncement } from "@/contexts/AnnouncementProvider";
import { useApplicationContext } from "@/contexts/ApplicationProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
@@ -201,6 +202,7 @@ export default function Navigation({
/>
</SidebarItemGroup>
</div>
<NavigationVersionInfo />
</div>
</ScrollArea>
</div>

View File

@@ -285,6 +285,42 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
/**
* User Invite Link
*/
if (event.activity_code == "user.invite.link.create")
return (
<div className={"inline"}>
Invite link was created for <Value>{event.meta.username}</Value>{" "}
<Value>{event.meta.email}</Value>
</div>
);
if (event.activity_code == "user.invite.link.accept")
return (
<div className={"inline"}>
Invite link was accepted by <Value>{event.meta.username}</Value>{" "}
<Value>{event.meta.email}</Value>
</div>
);
if (event.activity_code == "user.invite.link.regenerate")
return (
<div className={"inline"}>
Invite link was regenerated for <Value>{event.meta.username}</Value>{" "}
<Value>{event.meta.email}</Value>
</div>
);
if (event.activity_code == "user.invite.link.delete")
return (
<div className={"inline"}>
Invite link was deleted for <Value>{event.meta.username}</Value>{" "}
<Value>{event.meta.email}</Value>
</div>
);
/**
* Service User
*/

View File

@@ -12,10 +12,11 @@ import {
import { notify } from "@components/Notification";
import Paragraph from "@components/Paragraph";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import { IconMailForward } from "@tabler/icons-react";
import { SegmentedTabs } from "@components/SegmentedTabs";
import { IconMailForward, IconLink, IconUserPlus } from "@tabler/icons-react";
import { useApiCall } from "@utils/api";
import { cn, validator } from "@utils/helpers";
import { CopyIcon, MailIcon, User2 } from "lucide-react";
import { AlarmClock, CopyIcon, MailIcon, User2 } from "lucide-react";
import Image from "next/image";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
@@ -25,28 +26,50 @@ import Avatar2 from "@/assets/avatars/030.jpg";
import Avatar3 from "@/assets/avatars/063.jpg";
import Avatar4 from "@/assets/avatars/086.jpg";
import { Group } from "@/interfaces/Group";
import { Role, User } from "@/interfaces/User";
import { Role, User, UserInvite } from "@/interfaces/User";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { UserRoleSelector } from "@/modules/users/UserRoleSelector";
import {isNetBirdHosted} from "@utils/netbird";
import { isNetBirdHosted } from "@utils/netbird";
type UserCreationMode = "create" | "invite";
type Props = {
children: React.ReactNode;
groups?: Group[];
};
const copyMessage = "Password was copied to your clipboard!";
const passwordCopyMessage = "Password was copied to your clipboard!";
const inviteLinkCopyMessage = "Invite link was copied to your clipboard!";
type SuccessData =
| { type: "password"; user: User }
| { type: "invite"; invite: UserInvite };
export default function UserInviteModal({ children, groups }: Readonly<Props>) {
const [open, setOpen] = useState(false);
const [successModal, setSuccessModal] = useState(false);
const [createdUser, setCreatedUser] = useState<User>();
const [successData, setSuccessData] = useState<SuccessData | null>(null);
const { mutate } = useSWRConfig();
const [, copyToClipboard] = useCopyToClipboard(createdUser?.password);
const handleOnSuccess = (user: User) => {
const isPasswordSuccess = successData?.type === "password";
const isInviteSuccess = successData?.type === "invite";
const getInviteFullUrl = () => {
if (!isInviteSuccess) return "";
const origin = typeof window !== "undefined" ? window.location.origin : "";
return `${origin}/invite?token=${successData.invite.invite_token}`;
};
const getCopyValue = () => {
if (successData?.type === "password") return successData.user.password;
if (successData?.type === "invite") return getInviteFullUrl();
return undefined;
};
const [, copyToClipboard] = useCopyToClipboard(getCopyValue());
const handleUserCreated = (user: User) => {
if (user.password) {
setCreatedUser(user);
setSuccessData({ type: "password", user });
setSuccessModal(true);
} else {
setOpen(false);
@@ -56,9 +79,22 @@ export default function UserInviteModal({ children, groups }: Readonly<Props>) {
}, 1000);
};
const handleInviteCreated = (invite: UserInvite) => {
setSuccessData({ type: "invite", invite });
setSuccessModal(true);
setTimeout(() => {
mutate("/users?service_user=false");
mutate("/users/invites");
}, 1000);
};
const handleCopyAndClose = () => {
copyToClipboard(copyMessage).then(() => {
setCreatedUser(undefined);
const message =
successData?.type === "password"
? passwordCopyMessage
: inviteLinkCopyMessage;
copyToClipboard(message).then(() => {
setSuccessData(null);
setSuccessModal(false);
setOpen(false);
});
@@ -68,14 +104,18 @@ export default function UserInviteModal({ children, groups }: Readonly<Props>) {
<>
<Modal open={open} onOpenChange={setOpen} key={open ? 1 : 0}>
<ModalTrigger asChild={true}>{children}</ModalTrigger>
<UserInviteModalContent onSuccess={handleOnSuccess} groups={groups} />
<UserInviteModalContent
onUserCreated={handleUserCreated}
onInviteCreated={handleInviteCreated}
groups={groups}
/>
</Modal>
<Modal
open={successModal}
onOpenChange={(open) => {
if (!open) {
setCreatedUser(undefined);
setSuccessData(null);
}
setSuccessModal(open);
setOpen(open);
@@ -85,7 +125,7 @@ export default function UserInviteModal({ children, groups }: Readonly<Props>) {
onEscapeKeyDown={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
maxWidthClass={"max-w-md"}
maxWidthClass={isInviteSuccess ? "max-w-xl" : "max-w-md"}
className={"mt-20"}
showClose={false}
>
@@ -93,20 +133,41 @@ export default function UserInviteModal({ children, groups }: Readonly<Props>) {
<div className={"flex flex-col items-center justify-center gap-3"}>
<div>
<h2 className={"text-2xl text-center mb-2"}>
User created successfully!
{isPasswordSuccess && "User created successfully!"}
{isInviteSuccess && "Invite link created!"}
</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.
{isPasswordSuccess &&
"This password will not be shown again, so be sure to copy it and store in a secure location."}
{isInviteSuccess &&
"Share this link with the user. They will be able to set their own password."}
</Paragraph>
</div>
</div>
</div>
<div className={"px-8 pb-6"}>
<Code message={copyMessage}>
<Code.Line>{createdUser?.password || ""}</Code.Line>
<Code
message={
isPasswordSuccess ? passwordCopyMessage : inviteLinkCopyMessage
}
codeToCopy={getCopyValue()}
>
{isPasswordSuccess && (
<Code.Line>{successData.user.password}</Code.Line>
)}
{isInviteSuccess && (
<span className="break-all whitespace-normal block">
{getInviteFullUrl()}
</span>
)}
</Code>
{isInviteSuccess && (
<Paragraph className={"mt-3 text-xs text-nb-gray-400 text-center"}>
Expires on{" "}
{new Date(successData.invite.expires_at).toLocaleString()}
</Paragraph>
)}
</div>
<ModalFooter className={"items-center"}>
<Button
@@ -125,31 +186,38 @@ export default function UserInviteModal({ children, groups }: Readonly<Props>) {
}
type ModalProps = {
onSuccess: (user: User) => void;
onUserCreated: (user: User) => void;
onInviteCreated: (invite: UserInvite) => void;
groups?: Group[];
};
export function UserInviteModalContent({
onSuccess,
onUserCreated,
onInviteCreated,
groups = [],
}: Readonly<ModalProps>) {
const userRequest = useApiCall<User>("/users");
const inviteRequest = useApiCall<UserInvite>("/users/invites");
const { mutate } = useSWRConfig();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [role, setRole] = useState("user");
const [expiresIn, setExpiresIn] = useState("3");
const [selectedGroups, setSelectedGroups, { save: saveGroups }] =
useGroupHelper({
initial: groups,
});
const sendInvite = async () => {
const isCloud = isNetBirdHosted();
const [mode, setMode] = useState<UserCreationMode>("invite");
const createUser = async () => {
const groups = await saveGroups();
const groupIds = groups.map((group) => group.id) as string[];
notify({
title: "User Invitation",
description: `${name} was invited to join your network.`,
title: "Create User",
description: `Creating user account for ${name}...`,
promise: userRequest
.post({
name,
@@ -160,11 +228,46 @@ export function UserInviteModalContent({
})
.then((user) => {
mutate("/users?service_user=false");
onSuccess && onSuccess(user);
onUserCreated && onUserCreated(user);
}),
loadingMessage: "Sending invite...",
loadingMessage: "Creating user...",
});
};
const createInvite = async () => {
const groups = await saveGroups();
const groupIds = groups.map((group) => group.id) as string[];
notify({
title: "Create Invite",
description: `Creating invite link for ${name}...`,
promise: inviteRequest
.post({
name,
email,
role,
auto_groups: groupIds,
expires_in: parseInt(expiresIn || "3") * 24 * 60 * 60, // Days to seconds
})
.then((invite) => {
mutate("/users?service_user=false");
onInviteCreated && onInviteCreated(invite);
}),
loadingMessage: "Creating invite...",
});
};
const handleSubmit = async () => {
if (isCloud) {
await createUser();
} else {
if (mode === "create") {
await createUser();
} else {
await createInvite();
}
}
};
const isValidEmail = useMemo(() => {
return email.length > 0 && validator.isValidEmail(email);
}, [email]);
@@ -173,6 +276,33 @@ export function UserInviteModalContent({
return name.length === 0 || !isValidEmail;
}, [name, isValidEmail]);
const getTitle = () => {
if (isCloud) return "Invite User";
return mode === "create" ? "Create User" : "Invite User";
};
const getDescription = () => {
if (isCloud) return "Invite a user to your network and set their permissions.";
if (mode === "create") {
return "Create a NetBird user account with email and password.";
}
return "Generate an invite link that the user can use to set their own password.";
};
const getButtonText = () => {
if (isCloud) return "Send Invitation";
return mode === "create" ? "Create User" : "Create Invite Link";
};
const getButtonIcon = () => {
if (isCloud) return <IconMailForward size={16} />;
return mode === "create" ? (
<IconUserPlus size={16} />
) : (
<IconLink size={16} />
);
};
return (
<ModalContent maxWidthClass={"max-w-lg relative"} showClose={true}>
<div
@@ -193,15 +323,31 @@ export function UserInviteModalContent({
"mx-auto text-center flex flex-col items-center justify-center mt-6"
}
>
<h2 className={"text-lg my-0 leading-[1.5 text-center]"}>
{isNetBirdHosted() ? "Invite User" : "Create User"}
</h2>
<h2 className={"text-lg my-0 leading-[1.5 text-center]"}>{getTitle()}</h2>
<Paragraph className={cn("text-sm text-center max-w-xs")}>
{isNetBirdHosted() ? "Invite a user to your network and set their permissions." : "Create a NetBird user account with email and password."}
{getDescription()}
</Paragraph>
</div>
<div className={"px-8 py-3 flex flex-col gap-6 mt-4"}>
<div className={"px-8 py-3 flex flex-col gap-6 mt-4 relative z-10"}>
{!isCloud && (
<SegmentedTabs
value={mode}
onChange={(value) => setMode(value as UserCreationMode)}
>
<SegmentedTabs.List className="rounded-lg border">
<SegmentedTabs.Trigger value="invite">
<IconLink size={16} />
Invite User
</SegmentedTabs.Trigger>
<SegmentedTabs.Trigger value="create">
<IconUserPlus size={16} />
Create User
</SegmentedTabs.Trigger>
</SegmentedTabs.List>
</SegmentedTabs>
)}
<div className={"flex flex-col gap-4"}>
<Input
customPrefix={
@@ -230,6 +376,26 @@ export function UserInviteModalContent({
onChange={setRole}
hideOwner={true}
/>
{!isCloud && mode === "invite" && (
<div className={"flex justify-between mt-3"}>
<div>
<Label>Expires in</Label>
<HelpText>Days until the invite expires.</HelpText>
</div>
<Input
maxWidthClass={"max-w-[200px]"}
placeholder={"3"}
min={1}
value={expiresIn}
type={"number"}
onChange={(e) => setExpiresIn(e.target.value)}
customPrefix={
<AlarmClock size={16} className={"text-nb-gray-300"} />
}
customSuffix={"Day(s)"}
/>
</div>
)}
</div>
<div className={"mb-4"}>
@@ -252,10 +418,10 @@ export function UserInviteModalContent({
variant={"primary"}
className={"w-full"}
disabled={isDisabled}
onClick={sendInvite}
onClick={handleSubmit}
>
{isNetBirdHosted() ? "Send Invitation" : "Create User"}
<IconMailForward size={16} />
{getButtonText()}
{getButtonIcon()}
</Button>
</ModalFooter>
</ModalContent>

View File

@@ -0,0 +1,528 @@
import Button from "@components/Button";
import Code from "@components/Code";
import InlineLink from "@components/InlineLink";
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
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 MultipleGroups from "@components/ui/MultipleGroups";
import Skeleton from "react-loading-skeleton";
import { ColumnDef, SortingState } from "@tanstack/react-table";
import useFetchApi, { useApiCall } from "@utils/api";
import { notify } from "@components/Notification";
import { RefreshCw } from "lucide-react";
import { isNetBirdHosted } from "@utils/netbird";
import dayjs from "dayjs";
import {
Cog,
CopyIcon,
CreditCardIcon,
ExternalLinkIcon,
EyeIcon,
Link2,
MailPlus,
NetworkIcon,
Trash2,
User2,
} from "lucide-react";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import Badge from "@components/Badge";
import { usePathname } from "next/navigation";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
import { useGroups } from "@/contexts/GroupsProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import useCopyToClipboard from "@/hooks/useCopyToClipboard";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { cn, generateColorFromString } from "@utils/helpers";
import { Group } from "@/interfaces/Group";
import { Role, UserInvite, UserInviteRegenerateResponse } from "@/interfaces/User";
import UserInviteModal from "@/modules/users/UserInviteModal";
import { useAccount } from "@/modules/account/useAccount";
// Name cell for invites - same styling as UserNameCell but for invites
function InviteNameCell({ invite }: { invite: UserInvite }) {
return (
<div
className={cn("flex gap-4 px-2 py-1 items-center")}
data-cy={"invite-name-cell"}
>
<div
className={
"w-10 h-10 rounded-full relative flex items-center justify-center text-white uppercase text-md font-medium bg-nb-gray-900"
}
style={{
color: generateColorFromString(invite.name || invite.email),
}}
>
{invite?.name?.charAt(0) || invite?.email?.charAt(0)}
</div>
<div className={"flex flex-col justify-center"}>
<span className={cn("text-base font-medium flex items-center gap-3")}>
{invite.name}
</span>
<span className={cn("text-sm text-nb-gray-400")}>{invite.email}</span>
</div>
</div>
);
}
// Role cell for invites - same styling as UserRoleCell but for invites
function InviteRoleCell({ invite }: { invite: UserInvite }) {
const role = invite.role as Role;
return (
<div className={cn("flex gap-3 items-center text-nb-gray-200")}>
<Badge variant={role === "owner" ? "netbird" : "gray"}>
{role === Role.User && (
<>
<User2 size={14} />
User
</>
)}
{role === Role.Admin && (
<>
<Cog size={14} />
Admin
</>
)}
{role === Role.Owner && (
<>
<NetBirdIcon size={14} />
Owner
</>
)}
{role === Role.BillingAdmin && (
<>
<CreditCardIcon size={14} />
Billing Admin
</>
)}
{role === Role.Auditor && (
<>
<EyeIcon size={14} />
Auditor
</>
)}
{role === Role.NetworkAdmin && (
<>
<NetworkIcon size={14} />
Network Admin
</>
)}
</Badge>
</div>
);
}
// Regenerate cell for invites - button to regenerate invite link with modal
function InviteRegenerateCell({ invite }: { invite: UserInvite }) {
const { mutate } = useSWRConfig();
const { permission } = usePermissions();
const [modalOpen, setModalOpen] = useState(false);
const [regeneratedData, setRegeneratedData] = useState<UserInviteRegenerateResponse | null>(null);
const regenerateRequest = useApiCall<UserInviteRegenerateResponse>(
`/users/invites/${invite.id}/regenerate`,
);
const getInviteFullUrl = () => {
if (!regeneratedData) return "";
const origin = typeof window !== "undefined" ? window.location.origin : "";
return `${origin}/invite?token=${regeneratedData.invite_token}`;
};
const [, copyToClipboard] = useCopyToClipboard(getInviteFullUrl());
const handleRegenerate = async () => {
notify({
title: "Regenerate Invite",
description: `Regenerating invite link for ${invite.name}...`,
promise: regenerateRequest.post({}).then((response) => {
setRegeneratedData(response);
setModalOpen(true);
mutate("/users/invites");
}),
loadingMessage: "Regenerating...",
});
};
const handleCopyAndClose = () => {
copyToClipboard("Invite link was copied to your clipboard!").then(() => {
setRegeneratedData(null);
setModalOpen(false);
});
};
return (
<>
<div className={"flex"}>
<Button
variant="secondary"
size="xs"
onClick={handleRegenerate}
disabled={!permission.users.update}
>
<RefreshCw size={14} />
Regenerate
</Button>
</div>
<Modal
open={modalOpen}
onOpenChange={(open) => {
if (!open) {
setRegeneratedData(null);
}
setModalOpen(open);
}}
>
<ModalContent
maxWidthClass={"max-w-xl"}
className={"mt-20"}
showClose={true}
>
<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"}>
Invite link regenerated!
</h2>
<Paragraph className={"mt-0 text-sm text-center"}>
Share this link with the user. They will be able to set their own password.
</Paragraph>
</div>
</div>
</div>
<div className={"px-8 pb-6"}>
<Code
message={"Invite link was copied to your clipboard!"}
codeToCopy={getInviteFullUrl()}
>
<span className="break-all whitespace-normal block">
{getInviteFullUrl()}
</span>
</Code>
{regeneratedData && (
<Paragraph className={"mt-3 text-xs text-nb-gray-400 text-center"}>
Expires on{" "}
{new Date(regeneratedData.invite_expires_at).toLocaleString()}
</Paragraph>
)}
</div>
<ModalFooter className={"items-center"}>
<Button
variant={"primary"}
className={"w-full"}
onClick={handleCopyAndClose}
>
<CopyIcon size={14} />
Copy & Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
}
// Groups cell for invites - read-only display of auto_groups
function InviteGroupCell({ invite }: { invite: UserInvite }) {
const { groups, isLoading } = useGroups();
const foundGroups = useMemo(() => {
if (isLoading || !groups) return [];
return (invite.auto_groups || [])
.map((groupId) => groups.find((g) => g?.id === groupId))
.filter((g): g is Group => g !== undefined);
}, [invite.auto_groups, groups, isLoading]);
if (isLoading) {
return (
<div className={"flex gap-2"}>
<Skeleton height={34} width={90} />
<Skeleton height={34} width={45} />
</div>
);
}
return (
<MultipleGroups
groups={foundGroups}
label={"Auto-assigned Groups"}
/>
);
}
// Status cell for invites - shows Valid/Expired based on expired field
function InviteStatusCell({ invite }: { invite: UserInvite }) {
const isExpired = invite.expired;
const text = isExpired ? "Expired" : "Valid";
const color = isExpired ? "bg-red-500" : "bg-green-500";
return (
<div
className={cn("flex gap-2.5 items-center text-nb-gray-300 text-sm")}
data-cy={"invite-status-cell"}
>
<span className={cn("h-2 w-2 rounded-full", color)}></span>
{text}
</div>
);
}
// Action cell for invites - delete invite
function InviteActionCell({ invite }: { invite: UserInvite }) {
const { confirm } = useDialog();
const { permission } = usePermissions();
const inviteRequest = useApiCall<UserInvite>("/users/invites");
const { mutate } = useSWRConfig();
const deleteInvite = async () => {
const name = invite.name || invite.email || "Invite";
notify({
title: `'${name}' deleted`,
description: "Invite was successfully deleted.",
promise: inviteRequest.del("", `/${invite.id}`).then(() => {
mutate("/users/invites");
}),
loadingMessage: "Deleting the invite...",
});
};
const openConfirm = async () => {
const name = invite.name || invite.email || "Invite";
const choice = await confirm({
title: `Delete invite for '${name}'?`,
description:
"Deleting this invite will revoke the invite link. The user will no longer be able to join using this invite.",
confirmText: "Delete",
cancelText: "Cancel",
maxWidthClass: "max-w-md",
type: "danger",
});
if (!choice) return;
deleteInvite().then();
};
return (
<div className={"flex justify-end pr-4 items-center gap-2"}>
<Button
variant={"danger-outline"}
size={"sm"}
onClick={openConfirm}
data-cy={"delete-invite"}
disabled={!permission.users.delete}
>
<Trash2 size={16} />
Delete
</Button>
</div>
);
}
export const InvitesTableColumns: ColumnDef<UserInvite>[] = [
{
accessorKey: "name",
header: ({ column }) => {
return <DataTableHeader column={column}>Name</DataTableHeader>;
},
accessorFn: (row) => row.name + " " + row.email,
sortingFn: "text",
cell: ({ row }) => <InviteNameCell invite={row.original} />,
},
{
accessorKey: "role",
header: ({ column }) => {
return <DataTableHeader column={column}>Role</DataTableHeader>;
},
sortingFn: "text",
cell: ({ row }) => <InviteRoleCell invite={row.original} />,
},
{
accessorKey: "expired",
header: ({ column }) => {
return <DataTableHeader column={column}>Status</DataTableHeader>;
},
sortingFn: "basic",
cell: ({ row }) => <InviteStatusCell invite={row.original} />,
},
{
accessorKey: "auto_groups",
header: ({ column }) => {
return <DataTableHeader column={column}>Groups</DataTableHeader>;
},
sortingFn: "text",
cell: ({ row }) => <InviteGroupCell invite={row.original} />,
},
{
id: "regenerate",
header: ({ column }) => {
return <DataTableHeader column={column}>Regenerate</DataTableHeader>;
},
cell: ({ row }) => <InviteRegenerateCell invite={row.original} />,
},
{
accessorKey: "expires_at",
header: ({ column }) => {
return <DataTableHeader column={column}>Expires</DataTableHeader>;
},
sortingFn: "datetime",
cell: ({ row }) => (
<span className="text-nb-gray-400">
{dayjs(row.original.expires_at).format("D MMM, YYYY")}
</span>
),
},
{
accessorKey: "id",
header: "",
sortingFn: "text",
cell: ({ row }) => <InviteActionCell invite={row.original} />,
},
];
type Props = {
headingTarget?: HTMLHeadingElement | null;
onShowUsers?: () => void;
};
export default function UserInvitesTable({
headingTarget,
onShowUsers,
}: Readonly<Props>) {
useFetchApi("/groups");
const { data: invites, isLoading } = useFetchApi<UserInvite[]>("/users/invites");
const { mutate } = useSWRConfig();
const path = usePathname();
// Default sorting state of the table
const [sorting, setSorting] = useLocalStorage<SortingState>(
"netbird-table-sort-invites" + path,
[
{
id: "is_current",
desc: true,
},
{
id: "name",
desc: true,
},
],
);
return (
<DataTable
headingTarget={headingTarget}
isLoading={isLoading}
text={"Invites"}
sorting={sorting}
setSorting={setSorting}
columns={InvitesTableColumns}
data={invites}
searchPlaceholder={"Search by name or email..."}
getStartedCard={
<GetStartedTest
icon={
<SquareIcon
icon={<Link2 className={"fill-nb-gray-200"} size={20} />}
color={"gray"}
size={"large"}
/>
}
title={"No Pending Invites"}
description={
"There are no pending invites. Create an invite to add users to your network."
}
button={
<div className={"flex flex-col items-center justify-center"}>
<InviteUserButton show={true} />
</div>
}
learnMore={
<>
Learn more about
<InlineLink
href={
"https://docs.netbird.io/how-to/add-users-to-your-network"
}
target={"_blank"}
>
Users
<ExternalLinkIcon size={12} />
</InlineLink>
</>
}
/>
}
rightSide={() => (
<InviteUserButton
show={invites && invites?.length > 0}
className={"ml-auto"}
/>
)}
>
{(table) => {
return (
<>
<DataTableRowsPerPage table={table} disabled={invites?.length == 0} />
<DataTableRefreshButton
isDisabled={invites?.length == 0}
onClick={() => {
mutate("/users/invites");
}}
/>
<Button variant={"secondary"} onClick={onShowUsers}>
<User2 size={14} />
Show Users
</Button>
</>
);
}}
</DataTable>
);
}
type InviteUserButtonProps = {
show?: boolean;
className?: string;
groups?: Group[];
};
export const InviteUserButton = ({
show = false,
className,
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 (
<UserInviteModal groups={groups}>
<Button
variant={"primary"}
className={className}
disabled={!permission.users.create}
>
<MailPlus size={16} />
{isCloud ? "Invite User" : "Add User"}
</Button>
</UserInviteModal>
);
};

View File

@@ -1,5 +1,6 @@
import Button from "@components/Button";
import Card from "@components/Card";
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import SquareIcon from "@components/SquareIcon";
import { DataTable } from "@components/table/DataTable";
@@ -7,6 +8,7 @@ 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 { NotificationCountBadge } from "@components/ui/NotificationCountBadge";
import {
ColumnDef,
Row,
@@ -17,15 +19,15 @@ import {
import useFetchApi from "@utils/api";
import { isNetBirdHosted } from "@utils/netbird";
import dayjs from "dayjs";
import { ExternalLinkIcon, MailPlus } from "lucide-react";
import { ExternalLinkIcon, Link2, MailPlus } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import React from "react";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import TeamIcon from "@/assets/icons/TeamIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { Group } from "@/interfaces/Group";
import { User } from "@/interfaces/User";
import { User, UserInvite } from "@/interfaces/User";
import LastTimeRow from "@/modules/common-table-rows/LastTimeRow";
import { PendingApprovalFilter } from "@/modules/users/PendingApprovalFilter";
import UserActionCell from "@/modules/users/table-cells/UserActionCell";
@@ -35,6 +37,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 UserInvitesTable from "@/modules/users/UserInvitesTable";
import { useAccount } from "@/modules/account/useAccount";
export const UsersTableColumns: ColumnDef<User>[] = [
@@ -142,6 +145,21 @@ export default function UsersTable({
useFetchApi("/groups");
const { mutate } = useSWRConfig();
const path = usePathname();
const account = useAccount();
const isCloud = isNetBirdHosted();
const embeddedIdpEnabled = account?.settings.embedded_idp_enabled;
const showInvitesToggle = !isCloud && embeddedIdpEnabled;
const { data: invites } = useFetchApi<UserInvite[]>(
"/users/invites",
false,
true,
showInvitesToggle,
);
const validInvitesCount = invites?.filter((i) => !i.expired).length ?? 0;
const [showInvites, setShowInvites] = useState(false);
// Default sorting state of the table
const [sorting, setSorting] = useLocalStorage<SortingState>(
@@ -162,6 +180,15 @@ export default function UsersTable({
const router = useRouter();
const { permission } = usePermissions();
if (showInvites) {
return (
<UserInvitesTable
headingTarget={headingTarget}
onShowUsers={() => setShowInvites(false)}
/>
);
}
return (
<DataTable
headingTarget={headingTarget}
@@ -256,6 +283,16 @@ export default function UsersTable({
mutate("/groups");
}}
/>
{showInvitesToggle && (
<Button
variant={"secondary"}
onClick={() => setShowInvites(true)}
>
<Link2 size={14} />
Show Invites
<NotificationCountBadge count={validInvitesCount} />
</Button>
)}
</>
);
}}
@@ -283,19 +320,51 @@ export const InviteUserButton = ({
// On self-hosted: only show when embedded_idp_enabled is true
const isCloud = isNetBirdHosted();
const embeddedIdpEnabled = account?.settings.embedded_idp_enabled;
const localAuthDisabled = account?.settings.local_auth_disabled;
if (!isCloud && !embeddedIdpEnabled) return null;
return (
<UserInviteModal groups={groups}>
<Button
variant={"primary"}
className={className}
disabled={!permission.users.create}
>
<MailPlus size={16} />
{isCloud ? "Invite User" : "Create User"}
</Button>
</UserInviteModal>
const isDisabled = !permission.users.create || localAuthDisabled;
const button = (
<Button
variant={"primary"}
className={className}
disabled={isDisabled}
>
<MailPlus size={16} />
{isCloud ? "Invite User" : "Add User"}
</Button>
);
if (localAuthDisabled) {
return (
<FullTooltip
className={className}
interactive={true}
content={
<div className={"flex flex-col"}>
<p className={"max-w-[200px] text-xs"}>
Local authentication is disabled. Use your IdP for authentication.
</p>
<div className={"text-xs mt-1.5"}>
<InlineLink
href={"https://docs.netbird.io/selfhosted/identity-providers/disable-local-authentication"}
target={"_blank"}
className={"flex gap-1 items-center"}
>
Learn more
<ExternalLinkIcon size={12} />
</InlineLink>
</div>
</div>
}
>
{button}
</FullTooltip>
);
}
return <UserInviteModal groups={groups}>{button}</UserInviteModal>;
};

View File

@@ -5,6 +5,7 @@ import {
SetupRequest,
SetupResponse,
} from "@/interfaces/Instance";
import { UserInviteInfo, UserInviteAcceptResponse } from "@/interfaces/User";
const config = loadConfig();
@@ -52,3 +53,18 @@ export async function fetchInstanceStatus(): Promise<InstanceStatus> {
export async function submitSetup(data: SetupRequest): Promise<SetupResponse> {
return unauthenticatedRequest<SetupResponse>("POST", "/setup", data);
}
export async function fetchInviteInfo(token: string): Promise<UserInviteInfo> {
return unauthenticatedRequest<UserInviteInfo>("GET", `/users/invites/${token}`);
}
export async function acceptInvite(
token: string,
password: string,
): Promise<UserInviteAcceptResponse> {
return unauthenticatedRequest<UserInviteAcceptResponse>(
"POST",
`/users/invites/${token}/accept`,
{ password },
);
}