From af768148eff7fdb5545826b87b5c1710620834b4 Mon Sep 17 00:00:00 2001 From: braginini Date: Fri, 23 Jan 2026 10:21:00 +0100 Subject: [PATCH 01/13] Add local user invites --- src/interfaces/User.ts | 19 +++ src/modules/users/UserInviteModal.tsx | 209 ++++++++++++++++++++++---- src/modules/users/UsersTable.tsx | 2 +- 3 files changed, 197 insertions(+), 33 deletions(-) diff --git a/src/interfaces/User.ts b/src/interfaces/User.ts index 6dbe9e7d..5aa1fc97 100644 --- a/src/interfaces/User.ts +++ b/src/interfaces/User.ts @@ -17,6 +17,25 @@ export interface User { idp_id?: string; } +export interface UserInviteCreateRequest { + email: string; + name: string; + role: string; + auto_groups: string[]; + expires_in?: number; +} + +export interface UserInviteCreateResponse { + id: string; + email: string; + name: string; + role: string; + auto_groups: string[]; + status: string; + invite_link: string; + invite_expires_at: string; +} + export enum Role { User = "user", Admin = "admin", diff --git a/src/modules/users/UserInviteModal.tsx b/src/modules/users/UserInviteModal.tsx index b3941579..899a03d9 100644 --- a/src/modules/users/UserInviteModal.tsx +++ b/src/modules/users/UserInviteModal.tsx @@ -12,7 +12,7 @@ import { import { notify } from "@components/Notification"; import Paragraph from "@components/Paragraph"; import { PeerGroupSelector } from "@components/PeerGroupSelector"; -import { IconMailForward } from "@tabler/icons-react"; +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"; @@ -25,28 +25,42 @@ 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, UserInviteCreateResponse } 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: UserInviteCreateResponse }; export default function UserInviteModal({ children, groups }: Readonly) { const [open, setOpen] = useState(false); const [successModal, setSuccessModal] = useState(false); - const [createdUser, setCreatedUser] = useState(); + const [successData, setSuccessData] = useState(null); const { mutate } = useSWRConfig(); - const [, copyToClipboard] = useCopyToClipboard(createdUser?.password); - const handleOnSuccess = (user: User) => { + const copyValue = + successData?.type === "password" + ? successData.user.password + : successData?.type === "invite" + ? successData.invite.invite_link + : undefined; + const [, copyToClipboard] = useCopyToClipboard(copyValue); + + const handleUserCreated = (user: User) => { if (user.password) { - setCreatedUser(user); + setSuccessData({ type: "password", user }); setSuccessModal(true); } else { setOpen(false); @@ -56,26 +70,45 @@ export default function UserInviteModal({ children, groups }: Readonly) { }, 1000); }; + const handleInviteCreated = (invite: UserInviteCreateResponse) => { + setSuccessData({ type: "invite", invite }); + setSuccessModal(true); + setTimeout(() => { + mutate("/users?service_user=false"); + }, 1000); + }; + const handleCopyAndClose = () => { - copyToClipboard(copyMessage).then(() => { - setCreatedUser(undefined); + const message = + successData?.type === "password" + ? passwordCopyMessage + : inviteLinkCopyMessage; + copyToClipboard(message).then(() => { + setSuccessData(null); setSuccessModal(false); setOpen(false); }); }; + const isPasswordSuccess = successData?.type === "password"; + const isInviteSuccess = successData?.type === "invite"; + return ( <> {children} - + { if (!open) { - setCreatedUser(undefined); + setSuccessData(null); } setSuccessModal(open); setOpen(open); @@ -93,20 +126,36 @@ export default function UserInviteModal({ children, groups }: Readonly) {

- User created successfully! + {isPasswordSuccess && "User created successfully!"} + {isInviteSuccess && "Invite link created!"}

- 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."}
- - {createdUser?.password || ""} + + + {isPasswordSuccess && successData.user.password} + {isInviteSuccess && successData.invite.invite_link} + + {isInviteSuccess && ( + + Expires on{" "} + {new Date(successData.invite.invite_expires_at).toLocaleString()} + + )}
+ + + )} +
- {isNetBirdHosted() ? "Send Invitation" : "Create User"} - + {getButtonText()} + {getButtonIcon()} diff --git a/src/modules/users/UsersTable.tsx b/src/modules/users/UsersTable.tsx index 4866de09..991ffdbf 100644 --- a/src/modules/users/UsersTable.tsx +++ b/src/modules/users/UsersTable.tsx @@ -294,7 +294,7 @@ export const InviteUserButton = ({ disabled={!permission.users.create} > - {isCloud ? "Invite User" : "Create User"} + {isCloud ? "Invite User" : "Add User"} ); From 304f16a0880083c9acae30f17db1fd24ea6cec02 Mon Sep 17 00:00:00 2001 From: braginini Date: Fri, 23 Jan 2026 10:55:38 +0100 Subject: [PATCH 02/13] Fix modal width when generating invite link --- src/modules/users/UserInviteModal.tsx | 40 +++++++++++++++++---------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/src/modules/users/UserInviteModal.tsx b/src/modules/users/UserInviteModal.tsx index 899a03d9..342468a9 100644 --- a/src/modules/users/UserInviteModal.tsx +++ b/src/modules/users/UserInviteModal.tsx @@ -50,13 +50,21 @@ export default function UserInviteModal({ children, groups }: Readonly) { const [successData, setSuccessData] = useState(null); const { mutate } = useSWRConfig(); - const copyValue = - successData?.type === "password" - ? successData.user.password - : successData?.type === "invite" - ? successData.invite.invite_link - : undefined; - const [, copyToClipboard] = useCopyToClipboard(copyValue); + 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/${successData.invite.invite_link}`; + }; + + 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) { @@ -90,9 +98,6 @@ export default function UserInviteModal({ children, groups }: Readonly) { }); }; - const isPasswordSuccess = successData?.type === "password"; - const isInviteSuccess = successData?.type === "invite"; - return ( <> @@ -118,7 +123,7 @@ export default function UserInviteModal({ children, groups }: Readonly) { 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} > @@ -144,11 +149,16 @@ export default function UserInviteModal({ children, groups }: Readonly) { message={ isPasswordSuccess ? passwordCopyMessage : inviteLinkCopyMessage } + codeToCopy={getCopyValue()} > - - {isPasswordSuccess && successData.user.password} - {isInviteSuccess && successData.invite.invite_link} - + {isPasswordSuccess && ( + {successData.user.password} + )} + {isInviteSuccess && ( + + {getInviteFullUrl()} + + )} {isInviteSuccess && ( From 42cc562770d0864872e620da037c91161b84ade0 Mon Sep 17 00:00:00 2001 From: braginini Date: Fri, 23 Jan 2026 11:32:01 +0100 Subject: [PATCH 03/13] Fix SegmentedTabs of the user invite modal --- src/modules/users/UserInviteModal.tsx | 44 ++++++++++----------------- 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/src/modules/users/UserInviteModal.tsx b/src/modules/users/UserInviteModal.tsx index 342468a9..6c1a8a7f 100644 --- a/src/modules/users/UserInviteModal.tsx +++ b/src/modules/users/UserInviteModal.tsx @@ -12,6 +12,7 @@ import { import { notify } from "@components/Notification"; import Paragraph from "@components/Paragraph"; import { PeerGroupSelector } from "@components/PeerGroupSelector"; +import { SegmentedTabs } from "@components/SegmentedTabs"; import { IconMailForward, IconLink, IconUserPlus } from "@tabler/icons-react"; import { useApiCall } from "@utils/api"; import { cn, validator } from "@utils/helpers"; @@ -327,34 +328,21 @@ export function UserInviteModalContent({
{!isCloud && ( -
- - -
+ setMode(value as UserCreationMode)} + > + + + + Invite User + + + + Create User + + + )}
From 4b2dd97e2d779f2079b69754d782eb00fa8c8d74 Mon Sep 17 00:00:00 2001 From: braginini Date: Fri, 23 Jan 2026 14:36:40 +0100 Subject: [PATCH 04/13] Add invite accept window --- src/app/invite/layout.tsx | 8 + src/app/invite/page.tsx | 260 ++++++++++++++++++++++++++ src/auth/OIDCProvider.tsx | 4 +- src/interfaces/User.ts | 15 ++ src/modules/users/UserInviteModal.tsx | 2 +- src/utils/unauthenticatedApi.ts | 16 ++ 6 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 src/app/invite/layout.tsx create mode 100644 src/app/invite/page.tsx diff --git a/src/app/invite/layout.tsx b/src/app/invite/layout.tsx new file mode 100644 index 00000000..55739b24 --- /dev/null +++ b/src/app/invite/layout.tsx @@ -0,0 +1,8 @@ +import { globalMetaTitle } from "@utils/meta"; +import type { Metadata } from "next"; +import BlankLayout from "@/layouts/BlankLayout"; + +export const metadata: Metadata = { + title: `Accept Invite - ${globalMetaTitle}`, +}; +export default BlankLayout; \ No newline at end of file diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx new file mode 100644 index 00000000..56779c2f --- /dev/null +++ b/src/app/invite/page.tsx @@ -0,0 +1,260 @@ +"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, + KeyRound, + Mail, + User2, +} from "lucide-react"; +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 ( + }> + + + ); +} + +function InviteAcceptContent() { + const searchParams = useSearchParams(); + const router = useRouter(); + const token = searchParams?.get("token"); + + const [loading, setLoading] = useState(true); + const [inviteInfo, setInviteInfo] = useState(null); + const [error, setError] = useState(null); + + 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) => { + setError(err.message || "Invalid or expired invite link"); + setLoading(false); + }); + }, [token]); + + const passwordsMatch = password === confirmPassword; + const passwordValid = password.length >= 8; + 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 ; + } + + if (error && !inviteInfo) { + return ( +
+
+
+
+ +
+
+

+ Invalid Invite +

+ {error} + +
+
+ ); + } + + if (success) { + return ( +
+
+
+
+ +
+
+

+ Account Created! +

+ + Your account has been created successfully. You can now log in with + your email and password. + + +
+
+ ); + } + + if (isExpired || !inviteInfo?.valid) { + return ( +
+
+
+
+ +
+
+

+ Invite Expired +

+ + This invite link has expired. Please contact your administrator to + receive a new invitation. + + +
+
+ ); + } + + return ( +
+
+
+ +
+ +
+

+ Welcome to NetBird +

+ + You've been invited to join the network. Set your password to + complete your account setup. + +
+ +
+
+
+ +
+
+
{inviteInfo.name}
+
+ + {inviteInfo.email} +
+
+
+ +
+
+ setPassword(e.target.value)} + customPrefix={ + + } + /> + {password && !passwordValid && ( +

+ Password must be at least 8 characters +

+ )} +
+ +
+ setConfirmPassword(e.target.value)} + customPrefix={ + + } + /> + {confirmPassword && !passwordsMatch && ( +

+ Passwords do not match +

+ )} +
+ + {error && ( +
+

{error}

+
+ )} + + +
+
+ +

+ Invite expires on {new Date(inviteInfo.expires_at).toLocaleString()} +

+
+
+ ); +} \ No newline at end of file diff --git a/src/auth/OIDCProvider.tsx b/src/auth/OIDCProvider.tsx index a19d49e4..6314f0ae 100644 --- a/src/auth/OIDCProvider.tsx +++ b/src/auth/OIDCProvider.tsx @@ -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 ? ( ) { const getInviteFullUrl = () => { if (!isInviteSuccess) return ""; const origin = typeof window !== "undefined" ? window.location.origin : ""; - return `${origin}/invite/${successData.invite.invite_link}`; + return `${origin}/invite?token=${successData.invite.invite_link}`; }; const getCopyValue = () => { diff --git a/src/utils/unauthenticatedApi.ts b/src/utils/unauthenticatedApi.ts index c65f210c..79164eaa 100644 --- a/src/utils/unauthenticatedApi.ts +++ b/src/utils/unauthenticatedApi.ts @@ -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 { export async function submitSetup(data: SetupRequest): Promise { return unauthenticatedRequest("POST", "/setup", data); } + +export async function fetchInviteInfo(token: string): Promise { + return unauthenticatedRequest("GET", `/users/invites/${token}`); +} + +export async function acceptInvite( + token: string, + password: string, +): Promise { + return unauthenticatedRequest( + "POST", + `/users/invites/${token}/accept`, + { password }, + ); +} From 8cbb7cdeb1e20ad828e07ab57496ece9bf660037 Mon Sep 17 00:00:00 2001 From: braginini Date: Fri, 23 Jan 2026 15:53:14 +0100 Subject: [PATCH 05/13] Improve accept invite modal --- src/app/invite/page.tsx | 42 +++++++++++++++++++++++++++++++---------- src/interfaces/User.ts | 1 + 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx index 56779c2f..5c2069a4 100644 --- a/src/app/invite/page.tsx +++ b/src/app/invite/page.tsx @@ -12,6 +12,7 @@ import { 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"; @@ -58,7 +59,12 @@ function InviteAcceptContent() { }, [token]); const passwordsMatch = password === confirmPassword; - const passwordValid = password.length >= 8; + 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) => { @@ -179,10 +185,9 @@ function InviteAcceptContent() {

Welcome to NetBird

- - You've been invited to join the network. Set your password to - complete your account setup. - +

+ You've been invited by {inviteInfo.invited_by} to join the network. Set your password to complete your account setup. +

@@ -210,10 +215,14 @@ function InviteAcceptContent() { } /> - {password && !passwordValid && ( -

- Password must be at least 8 characters -

+ {password && ( +
+ + + + + +
)}
@@ -252,9 +261,22 @@ function InviteAcceptContent() {

- Invite expires on {new Date(inviteInfo.expires_at).toLocaleString()} + Invite expires on {dayjs(inviteInfo.expires_at).format("D MMMM, YYYY [at] h:mm A")}

); +} + +function PasswordRule({ met, text }: { met: boolean; text: string }) { + return ( +
+ {met ? ( + + ) : ( + + )} + {text} +
+ ); } \ No newline at end of file diff --git a/src/interfaces/User.ts b/src/interfaces/User.ts index 0efbcd07..1ec7fdc5 100644 --- a/src/interfaces/User.ts +++ b/src/interfaces/User.ts @@ -41,6 +41,7 @@ export interface UserInviteInfo { name: string; expires_at: string; valid: boolean; + invited_by: string; } export interface UserInviteAcceptRequest { From 638254a25cfa16268ca858818f5f123be880e668 Mon Sep 17 00:00:00 2001 From: braginini Date: Fri, 23 Jan 2026 17:26:22 +0100 Subject: [PATCH 06/13] Add the invites view (draft) --- src/interfaces/User.ts | 11 ++ src/modules/users/UserInviteModal.tsx | 1 + src/modules/users/UserInvitesTable.tsx | 253 +++++++++++++++++++++++++ src/modules/users/UsersTable.tsx | 31 ++- 4 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 src/modules/users/UserInvitesTable.tsx diff --git a/src/interfaces/User.ts b/src/interfaces/User.ts index 1ec7fdc5..328dc449 100644 --- a/src/interfaces/User.ts +++ b/src/interfaces/User.ts @@ -52,6 +52,17 @@ export interface UserInviteAcceptResponse { success: boolean; } +export interface UserInviteListItem { + id: string; + email: string; + name: string; + role: string; + auto_groups: string[]; + expires_at: string; + created_at: string; + expired: boolean; +} + export enum Role { User = "user", Admin = "admin", diff --git a/src/modules/users/UserInviteModal.tsx b/src/modules/users/UserInviteModal.tsx index fa0aba3f..f692ca9e 100644 --- a/src/modules/users/UserInviteModal.tsx +++ b/src/modules/users/UserInviteModal.tsx @@ -84,6 +84,7 @@ export default function UserInviteModal({ children, groups }: Readonly) { setSuccessModal(true); setTimeout(() => { mutate("/users?service_user=false"); + mutate("/users/invites"); }, 1000); }; diff --git a/src/modules/users/UserInvitesTable.tsx b/src/modules/users/UserInvitesTable.tsx new file mode 100644 index 00000000..cf5302a5 --- /dev/null +++ b/src/modules/users/UserInvitesTable.tsx @@ -0,0 +1,253 @@ +import Button from "@components/Button"; +import InlineLink from "@components/InlineLink"; +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 { ColumnDef, SortingState } from "@tanstack/react-table"; +import useFetchApi from "@utils/api"; +import { isNetBirdHosted } from "@utils/netbird"; +import dayjs from "dayjs"; +import { ExternalLinkIcon, Link2, MailPlus, User2 } from "lucide-react"; +import { usePathname } from "next/navigation"; +import React from "react"; +import { useSWRConfig } from "swr"; +import { usePermissions } from "@/contexts/PermissionsProvider"; +import { useLocalStorage } from "@/hooks/useLocalStorage"; +import { cn, generateColorFromString } from "@utils/helpers"; +import { Group } from "@/interfaces/Group"; +import { User, UserInviteListItem } from "@/interfaces/User"; +import LastTimeRow from "@/modules/common-table-rows/LastTimeRow"; +import UserActionCell from "@/modules/users/table-cells/UserActionCell"; +import UserBlockCell from "@/modules/users/table-cells/UserBlockCell"; +import UserGroupCell from "@/modules/users/table-cells/UserGroupCell"; +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"; + +// Name cell for invites - same styling as UserNameCell but for invites +function InviteNameCell({ invite }: { invite: UserInviteListItem }) { + return ( +
+
+ {invite?.name?.charAt(0) || invite?.email?.charAt(0)} +
+
+ + {invite.name} + + {invite.email} +
+
+ ); +} + +export const InvitesTableColumns: ColumnDef[] = [ + { + accessorKey: "name", + header: ({ column }) => { + return Name; + }, + accessorFn: (row) => row.name + " " + row.email, + sortingFn: "text", + cell: ({ row }) => , + }, + { + accessorKey: "role", + header: ({ column }) => { + return Role; + }, + sortingFn: "text", + cell: ({ row }) => , + }, + { + accessorKey: "status", + header: ({ column }) => { + return Status; + }, + sortingFn: "text", + cell: ({ row }) => , + }, + { + accessorKey: "auto_groups", + header: ({ column }) => { + return Groups; + }, + sortingFn: "text", + cell: ({ row }) => , + }, + { + accessorKey: "is_blocked", + header: ({ column }) => { + return Block User; + }, + sortingFn: "text", + cell: ({ row }) => , + }, + { + accessorKey: "last_login", + header: ({ column }) => { + return Last Login; + }, + sortingFn: "text", + cell: ({ row }) => ( + + ), + }, + { + accessorKey: "id", + header: "", + sortingFn: "text", + cell: ({ row }) => , + }, +]; + +type Props = { + headingTarget?: HTMLHeadingElement | null; + onShowUsers?: () => void; +}; + +export default function UserInvitesTable({ + headingTarget, + onShowUsers, +}: Readonly) { + useFetchApi("/groups"); + const { data: invites, isLoading } = useFetchApi("/users/invites"); + const { mutate } = useSWRConfig(); + const path = usePathname(); + + // Default sorting state of the table + const [sorting, setSorting] = useLocalStorage( + "netbird-table-sort-invites" + path, + [ + { + id: "is_current", + desc: true, + }, + { + id: "name", + desc: true, + }, + ], + ); + + return ( + } + color={"gray"} + size={"large"} + /> + } + title={"No Pending Invites"} + description={ + "There are no pending invites. Create an invite to add users to your network." + } + learnMore={ + <> + Learn more about + + Users + + + + } + /> + } + rightSide={() => ( + 0} + className={"ml-auto"} + /> + )} + > + {(table) => { + return ( + <> + + { + mutate("/users/invites"); + }} + /> + + + ); + }} + + ); +} + +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 ( + + + + ); +}; + diff --git a/src/modules/users/UsersTable.tsx b/src/modules/users/UsersTable.tsx index 991ffdbf..0f96e48e 100644 --- a/src/modules/users/UsersTable.tsx +++ b/src/modules/users/UsersTable.tsx @@ -17,9 +17,9 @@ 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"; @@ -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 UserInvitesTable from "@/modules/users/UserInvitesTable"; import { useAccount } from "@/modules/account/useAccount"; export const UsersTableColumns: ColumnDef[] = [ @@ -142,6 +143,13 @@ 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 [showInvites, setShowInvites] = useState(false); // Default sorting state of the table const [sorting, setSorting] = useLocalStorage( @@ -162,6 +170,15 @@ export default function UsersTable({ const router = useRouter(); const { permission } = usePermissions(); + if (showInvites) { + return ( + setShowInvites(false)} + /> + ); + } + return ( + {showInvitesToggle && ( + + )} ); }} @@ -299,3 +325,4 @@ export const InviteUserButton = ({ ); }; + From 8504198ae84131fcfd07389f5d7a670d94e03a40 Mon Sep 17 00:00:00 2001 From: braginini Date: Sat, 24 Jan 2026 15:35:03 +0100 Subject: [PATCH 07/13] Fix invites table --- src/modules/users/UserInvitesTable.tsx | 329 +++++++++++++++++++++++-- 1 file changed, 302 insertions(+), 27 deletions(-) diff --git a/src/modules/users/UserInvitesTable.tsx b/src/modules/users/UserInvitesTable.tsx index cf5302a5..08e94fd2 100644 --- a/src/modules/users/UserInvitesTable.tsx +++ b/src/modules/users/UserInvitesTable.tsx @@ -1,30 +1,47 @@ 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 from "@utils/api"; +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 { ExternalLinkIcon, Link2, MailPlus, User2 } from "lucide-react"; +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 from "react"; +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 { User, UserInviteListItem } from "@/interfaces/User"; -import LastTimeRow from "@/modules/common-table-rows/LastTimeRow"; -import UserActionCell from "@/modules/users/table-cells/UserActionCell"; -import UserBlockCell from "@/modules/users/table-cells/UserBlockCell"; -import UserGroupCell from "@/modules/users/table-cells/UserGroupCell"; -import UserRoleCell from "@/modules/users/table-cells/UserRoleCell"; -import UserStatusCell from "@/modules/users/table-cells/UserStatusCell"; +import { Role, UserInviteListItem } from "@/interfaces/User"; import UserInviteModal from "@/modules/users/UserInviteModal"; import { useAccount } from "@/modules/account/useAccount"; @@ -55,6 +72,266 @@ function InviteNameCell({ invite }: { invite: UserInviteListItem }) { ); } +// Role cell for invites - same styling as UserRoleCell but for invites +function InviteRoleCell({ invite }: { invite: UserInviteListItem }) { + const role = invite.role as Role; + + return ( +
+ + {role === Role.User && ( + <> + + User + + )} + {role === Role.Admin && ( + <> + + Admin + + )} + {role === Role.Owner && ( + <> + + Owner + + )} + {role === Role.BillingAdmin && ( + <> + + Billing Admin + + )} + {role === Role.Auditor && ( + <> + + Auditor + + )} + {role === Role.NetworkAdmin && ( + <> + + Network Admin + + )} + +
+ ); +} + +// Regenerate cell for invites - button to regenerate invite link with modal +type RegenerateResponse = { + invite_link: string; + invite_expires_at: string; +}; + +function InviteRegenerateCell({ invite }: { invite: UserInviteListItem }) { + const { mutate } = useSWRConfig(); + const { permission } = usePermissions(); + const [modalOpen, setModalOpen] = useState(false); + const [regeneratedData, setRegeneratedData] = useState(null); + + const regenerateRequest = useApiCall( + `/users/invites/${invite.id}/regenerate`, + ); + + const getInviteFullUrl = () => { + if (!regeneratedData) return ""; + const origin = typeof window !== "undefined" ? window.location.origin : ""; + return `${origin}/invite?token=${regeneratedData.invite_link}`; + }; + + 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 ( + <> +
+ +
+ + { + if (!open) { + setRegeneratedData(null); + } + setModalOpen(open); + }} + > + +
+
+
+

+ Invite link regenerated! +

+ + Share this link with the user. They will be able to set their own password. + +
+
+
+ +
+ + + {getInviteFullUrl()} + + + {regeneratedData && ( + + Expires on{" "} + {new Date(regeneratedData.invite_expires_at).toLocaleString()} + + )} +
+ + + +
+
+ + ); +} + +// Groups cell for invites - read-only display of auto_groups +function InviteGroupCell({ invite }: { invite: UserInviteListItem }) { + 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 ( +
+ + +
+ ); + } + + return ( + + ); +} + +// Status cell for invites - shows Valid/Expired based on expired field +function InviteStatusCell({ invite }: { invite: UserInviteListItem }) { + const isExpired = invite.expired; + const text = isExpired ? "Expired" : "Valid"; + const color = isExpired ? "bg-red-500" : "bg-green-500"; + + return ( +
+ + {text} +
+ ); +} + +// Action cell for invites - delete invite +function InviteActionCell({ invite }: { invite: UserInviteListItem }) { + const { confirm } = useDialog(); + const { permission } = usePermissions(); + const inviteRequest = useApiCall("/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 ( +
+ +
+ ); +} + export const InvitesTableColumns: ColumnDef[] = [ { accessorKey: "name", @@ -71,15 +348,15 @@ export const InvitesTableColumns: ColumnDef[] = [ return Role; }, sortingFn: "text", - cell: ({ row }) => , + cell: ({ row }) => , }, { - accessorKey: "status", + accessorKey: "expired", header: ({ column }) => { return Status; }, - sortingFn: "text", - cell: ({ row }) => , + sortingFn: "basic", + cell: ({ row }) => , }, { accessorKey: "auto_groups", @@ -87,34 +364,32 @@ export const InvitesTableColumns: ColumnDef[] = [ return Groups; }, sortingFn: "text", - cell: ({ row }) => , + cell: ({ row }) => , }, { - accessorKey: "is_blocked", + id: "regenerate", header: ({ column }) => { - return Block User; + return Regenerate; }, - sortingFn: "text", - cell: ({ row }) => , + cell: ({ row }) => , }, { - accessorKey: "last_login", + accessorKey: "expires_at", header: ({ column }) => { - return Last Login; + return Expires; }, - sortingFn: "text", + sortingFn: "datetime", cell: ({ row }) => ( - + + {dayjs(row.original.expires_at).format("D MMM, YYYY")} + ), }, { accessorKey: "id", header: "", sortingFn: "text", - cell: ({ row }) => , + cell: ({ row }) => , }, ]; From c16c09eb6a1aaba48c1eee140897820987c2fc33 Mon Sep 17 00:00:00 2001 From: braginini Date: Sat, 24 Jan 2026 15:38:49 +0100 Subject: [PATCH 08/13] Add Add User to the invites view --- src/modules/users/UserInvitesTable.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/modules/users/UserInvitesTable.tsx b/src/modules/users/UserInvitesTable.tsx index 08e94fd2..3e63d255 100644 --- a/src/modules/users/UserInvitesTable.tsx +++ b/src/modules/users/UserInvitesTable.tsx @@ -445,6 +445,11 @@ export default function UserInvitesTable({ description={ "There are no pending invites. Create an invite to add users to your network." } + button={ +
+ +
+ } learnMore={ <> Learn more about From 9317de767d3c0233d8695e55705054da04ccaef3 Mon Sep 17 00:00:00 2001 From: braginini Date: Sat, 24 Jan 2026 16:37:05 +0100 Subject: [PATCH 09/13] Add expires in --- src/modules/users/UserInviteModal.tsx | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/modules/users/UserInviteModal.tsx b/src/modules/users/UserInviteModal.tsx index f692ca9e..62e3ff94 100644 --- a/src/modules/users/UserInviteModal.tsx +++ b/src/modules/users/UserInviteModal.tsx @@ -16,7 +16,7 @@ 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"; @@ -203,6 +203,7 @@ export function UserInviteModalContent({ 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, @@ -245,6 +246,7 @@ export function UserInviteModalContent({ email, role, auto_groups: groupIds, + expires_in: parseInt(expiresIn || "3") * 24 * 60 * 60, // Days to seconds }) .then((invite) => { mutate("/users?service_user=false"); @@ -374,6 +376,26 @@ export function UserInviteModalContent({ onChange={setRole} hideOwner={true} /> + {!isCloud && mode === "invite" && ( +
+
+ + Days until the invite expires. +
+ setExpiresIn(e.target.value)} + customPrefix={ + + } + customSuffix={"Day(s)"} + /> +
+ )}
From 5e8adbf0b8d03a2c65ca61d3331e5a6ecf08f5c1 Mon Sep 17 00:00:00 2001 From: braginini Date: Sat, 24 Jan 2026 17:57:47 +0100 Subject: [PATCH 10/13] Handle new activity events --- src/modules/activity/ActivityDescription.tsx | 36 ++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/modules/activity/ActivityDescription.tsx b/src/modules/activity/ActivityDescription.tsx index 613a82a1..6d81f08b 100644 --- a/src/modules/activity/ActivityDescription.tsx +++ b/src/modules/activity/ActivityDescription.tsx @@ -285,6 +285,42 @@ export default function ActivityDescription({ event }: Props) {
); + /** + * User Invite Link + */ + + if (event.activity_code == "user.invite.link.create") + return ( +
+ Invite link was created for {event.meta.username}{" "} + {event.meta.email} +
+ ); + + if (event.activity_code == "user.invite.link.accept") + return ( +
+ Invite link was accepted by {event.meta.username}{" "} + {event.meta.email} +
+ ); + + if (event.activity_code == "user.invite.link.regenerate") + return ( +
+ Invite link was regenerated for {event.meta.username}{" "} + {event.meta.email} +
+ ); + + if (event.activity_code == "user.invite.link.delete") + return ( +
+ Invite link was deleted for {event.meta.username}{" "} + {event.meta.email} +
+ ); + /** * Service User */ From 5a926edab9a4fe928badcc4a1fdfb122e9b26b18 Mon Sep 17 00:00:00 2001 From: braginini Date: Sun, 25 Jan 2026 08:44:58 +0100 Subject: [PATCH 11/13] Fix invalid invite --- src/app/invite/page.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx index 5c2069a4..44c19b6c 100644 --- a/src/app/invite/page.tsx +++ b/src/app/invite/page.tsx @@ -105,7 +105,10 @@ function InviteAcceptContent() {

Invalid Invite

- {error} + + This invite link is invalid or has expired. Please contact your + administrator to receive a new invitation. + + + + ); + } + return (
From c68693917daa0c9b036f531f424eb92e0d81840f Mon Sep 17 00:00:00 2001 From: Misha Bragin Date: Sun, 25 Jan 2026 21:27:45 +0100 Subject: [PATCH 13/13] Show Versions (#540) * Add version component * Add version update * Add version update * Show version only for self-hosted * Add version to dashboard on CI/CD --- .github/workflows/build_and_push.yml | 11 +++ next.config.js | 2 + src/components/VersionInfo.tsx | 140 +++++++++++++++++++++++++++ src/interfaces/Instance.ts | 6 ++ src/layouts/Navigation.tsx | 2 + 5 files changed, 161 insertions(+) create mode 100644 src/components/VersionInfo.tsx diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml index cfad79d9..8bdb9afb 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -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 diff --git a/next.config.js b/next.config.js index b270871a..6ce436c3 100644 --- a/next.config.js +++ b/next.config.js @@ -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", }, }; diff --git a/src/components/VersionInfo.tsx b/src/components/VersionInfo.tsx new file mode 100644 index 00000000..cd071894 --- /dev/null +++ b/src/components/VersionInfo.tsx @@ -0,0 +1,140 @@ +"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; + + const currentParts = current.split(".").map((p) => parseInt(p, 10) || 0); + const latestParts = latest.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 ( +
+ +
+ ); +}; + +const NavigationVersionInfoContent = () => { + const { data: versionInfo, isLoading } = useFetchApi( + "/instance/version", + true, // ignore errors + false, // don't revalidate on focus + ); + + const dashboardVersion = + process.env.NEXT_PUBLIC_DASHBOARD_VERSION || "development"; + + if (isLoading) + return ; + + 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 ( +
+
+ + Latest: {formatVersion(versionInfo.management_available_version)} + + } + side="top" + className="w-full" + > +
+ Management + + {formatVersion(versionInfo.management_current_version)} + +
+
+ + Latest: {formatVersion(versionInfo.dashboard_available_version)} + + } + side="top" + className="w-full" + > +
+ Dashboard + + {formatVersion(dashboardVersion)} + +
+
+
+ + {hasUpdate && ( + + + Update available + + )} +
+ ); +}; + +export default NavigationVersionInfo; \ No newline at end of file diff --git a/src/interfaces/Instance.ts b/src/interfaces/Instance.ts index 1f9f6a4f..8ba5e5eb 100644 --- a/src/interfaces/Instance.ts +++ b/src/interfaces/Instance.ts @@ -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; +} diff --git a/src/layouts/Navigation.tsx b/src/layouts/Navigation.tsx index 38e1da74..8c51178d 100644 --- a/src/layouts/Navigation.tsx +++ b/src/layouts/Navigation.tsx @@ -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({ />
+