Compare commits

...

15 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
Eduard Gert
818ba5daa4 Allow wildcard dns zone records (#536)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-20 17:32:14 +01:00
Ali Amer
3a30f76629 Add Frontend Support for Peer Debug Bundle Trigger and History (#485)
* implement debug ui

* update job ui

* Add type cell, show tooltip if peer is offline, add copy to clipboard for upload key, show error reason in tooltip

* update job event description

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-01-20 17:12:33 +01:00
Misha Bragin
34dc21c89d Add password change (embedded Idp) (#535) 2026-01-20 15:00:14 +01:00
Eduard Gert
2e37703622 Update CONTRIBUTOR_LICENSE_AGREEMENT.md (#534) 2026-01-19 14:55:04 +01:00
Eduard Gert
8aec338c43 Fix dns doc link (#533)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-19 10:01:55 +01:00
Viktor Liu
f4f0c240fd Bump wasm to v0.63.0 (#531) 2026-01-19 09:49:26 +01:00
Viktor Liu
04e22a3c7e Enable SSH for Windows and Android peers (#532)
* Enable SSH for Windows and Android peers, hide update badge for temporary peers

* Fix RDP to use tcp protocol instead of netbird-ssh
2026-01-19 09:49:08 +01:00
42 changed files with 2528 additions and 137 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

View File

@@ -1,7 +1,7 @@
## Contributor License Agreement
This Contributor License Agreement (referred to as the "Agreement") is entered into by the individual
submitting this Agreement and NetBird GmbH, c/o Max-Beer-Straße 2-4 Münzstraße 12 10178 Berlin, Germany,
submitting this Agreement and NetBird GmbH, Brunnenstraße 196, 10119 Berlin, Germany,
referred to as "NetBird" (collectively, the "Parties"). The Agreement outlines the terms and conditions
under which NetBird may utilize software contributions provided by the Contributor for inclusion in
its software development projects. By submitting this Agreement, the Contributor confirms their acceptance

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

@@ -40,6 +40,8 @@ import {
MonitorSmartphoneIcon,
NetworkIcon,
PencilIcon,
RadioTowerIcon,
TimerResetIcon,
} from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { toASCII } from "punycode";
@@ -60,6 +62,7 @@ import PageContainer from "@/layouts/PageContainer";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection";
import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection";
import { PeerRemoteJobsSection } from "@/modules/peer/PeerRemoteJobsSection";
import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
@@ -326,6 +329,13 @@ const PeerOverviewTabs = () => {
Accessible Peers
</TabsTrigger>
)}
{peer?.id && permission.peers.delete && (
<TabsTrigger value={"peer-job"}>
<RadioTowerIcon size={16} />
Remote Jobs
</TabsTrigger>
)}
</TabsList>
{permission.routes.read && (
@@ -339,6 +349,11 @@ const PeerOverviewTabs = () => {
<AccessiblePeersSection peerID={peer.id} />
</TabsContent>
)}
{peer.id && permission.peers.delete && (
<TabsContent value={"peer-job"} className={"pb-8"}>
<PeerRemoteJobsSection peerID={peer.id} />
</TabsContent>
)}
</Tabs>
);
};
@@ -526,9 +541,9 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
peer.connected
? "just now"
: dayjs(peer.last_seen).format("D MMMM, YYYY [at] h:mm A") +
" (" +
dayjs().to(peer.last_seen) +
")"
" (" +
dayjs().to(peer.last_seen) +
")"
}
/>

View File

@@ -20,7 +20,6 @@ import {
useNetBirdClient,
} from "@/modules/remote-access/useNetBirdClient";
import { cn } from "@utils/helpers";
import { isNetbirdSSHProtocolSupported } from "@utils/version";
export default function RDPPage() {
const { peerId } = useRDPQueryParams();
@@ -85,11 +84,8 @@ function RDPSession({ peer }: Props) {
try {
setCredentials(rdpCredentials);
setIsNetBirdConnecting(true);
const protocol = isNetbirdSSHProtocolSupported(peer.version)
? "netbird-ssh"
: "tcp";
await client.connectTemporary(peer.id, [
`${protocol}/${rdpCredentials.port}`,
`tcp/${rdpCredentials.port}`,
]);
setIsNetBirdConnecting(false);
} catch (error) {

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,36 @@
import { cn } from "@utils/helpers";
import * as React from "react";
export const TooltipListItem = ({
icon,
label,
value,
className,
labelClassName,
}: {
icon?: React.ReactNode;
label: string;
value: string | React.ReactNode;
className?: string;
labelClassName?: string;
}) => {
return (
<div
className={cn(
"flex justify-between gap-12 border-b border-nb-gray-920 py-2 px-4 last:border-b-0",
className,
)}
>
<div
className={cn(
"flex items-center gap-2 text-nb-gray-100 font-medium",
labelClassName,
)}
>
{icon}
{label}
</div>
<div className={"text-nb-gray-300"}>{value}</div>
</div>
);
};

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

@@ -11,7 +11,7 @@ import {
} from "@components/DropdownMenu";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import { UserAvatar } from "@components/ui/UserAvatar";
import { LogOutIcon, User2 } from "lucide-react";
import { KeyRound, LogOutIcon, User2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
@@ -19,9 +19,13 @@ import { useApplicationContext } from "@/contexts/ApplicationProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLoggedInUser } from "@/contexts/UsersProvider";
import useOSDetection from "@/hooks/useOperatingSystem";
import { ChangePasswordModalContent } from "@/modules/users/ChangePasswordModal";
import { isNetBirdHosted } from "@utils/netbird";
import { Modal } from "@components/modal/Modal";
export default function UserDropdown() {
const [dropdownOpen, setDropdownOpen] = useState(false);
const [changePasswordModal, setChangePasswordModal] = useState(false);
const { user } = useApplicationContext();
const { loggedInUser, logout } = useLoggedInUser();
const { isRestricted, permission } = usePermissions();
@@ -31,11 +35,22 @@ export default function UserDropdown() {
useHotkeys("shift+mod+l", () => logout(), []);
return (
<DropdownMenu
modal={false}
open={dropdownOpen}
onOpenChange={setDropdownOpen}
>
<>
<Modal
open={changePasswordModal}
onOpenChange={setChangePasswordModal}
key={changePasswordModal ? 1 : 0}
>
<ChangePasswordModalContent
userId={loggedInUser?.id}
onSuccess={() => setChangePasswordModal(false)}
/>
</Modal>
<DropdownMenu
modal={false}
open={dropdownOpen}
onOpenChange={setDropdownOpen}
>
<DropdownMenuTrigger>
<UserAvatar size={"medium"} />
</DropdownMenuTrigger>
@@ -72,6 +87,20 @@ export default function UserDropdown() {
/>
)}
{!isNetBirdHosted() && loggedInUser?.idp_id === "local" && (
<DropdownMenuItem
onClick={() => {
setDropdownOpen(false);
setChangePasswordModal(true);
}}
>
<div className={"flex gap-3 items-center"}>
<KeyRound size={14} />
Change Password
</div>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={logout}>
<div className={"flex gap-3 items-center"}>
<LogOutIcon size={14} />
@@ -81,6 +110,7 @@ export default function UserDropdown() {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
}

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

@@ -19,5 +19,7 @@ export interface DNSRecord {
export type DNSRecordType = "A" | "AAAA" | "CNAME";
export const DNS_ZONE_DOCS_LINK = "https://docs.netbird.io/manage/dns/zones";
export const DNS_RECORDS_DOCS_LINK = "https://docs.netbird.io/manage/dns/zones";
export const DNS_ZONE_DOCS_LINK =
"https://docs.netbird.io/manage/dns/custom-zones";
export const DNS_RECORDS_DOCS_LINK =
"https://docs.netbird.io/manage/dns/custom-zones#adding-records-to-a-zone";

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;
}

23
src/interfaces/Job.ts Normal file
View File

@@ -0,0 +1,23 @@
export interface Job {
id: string;
triggered_by: string;
completed_at: Date | null;
created_at: Date;
failed_reason: string | null;
workload: Workload;
status: "pending" | "succeeded" | "failed";
}
export interface Workload {
type: "bundle";
parameters: BundleJobParameters;
result: string | null;
}
// Parameters for bundle job
export interface BundleJobParameters {
anonymize: boolean;
bundle_for: boolean;
bundle_for_time: number;
log_file_count: number;
}

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

@@ -277,6 +277,50 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
if (event.activity_code == "user.password.change")
return (
<div className={"inline"}>
Password was changed for user <Value>{event.meta.username}</Value>{" "}
<Value>{event.meta.email}</Value>
</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
*/
@@ -693,6 +737,16 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
/**
* Jobs
*/
if (event.activity_code == "peer.job.create")
return (<div className={"inline"}>
Remote job <Value>{m.job_type}</Value> created for peer <Value>{m.for_peer_name}</Value>
</div>
)
if (event.activity_code == "account.settings.extra.flow.group.remove")
return (
<div className={"inline"}>

View File

@@ -33,6 +33,7 @@ const ACTION_COLOR_MAPPING: Record<string, ActionStatus> = {
rename: ActionStatus.INFO,
unblock: ActionStatus.INFO,
login: ActionStatus.INFO,
change: ActionStatus.INFO,
};
export function getColorFromCode(code: string): string {

View File

@@ -101,8 +101,9 @@ export function DNSRecordModalContent({
const domainError = useMemo(() => {
if (domain == "") return "";
if (domain === "*") return "";
const valid = validator.isValidDomain(domain, {
allowWildcard: false,
allowWildcard: true,
allowOnlyTld: true,
});
if (!valid) {
@@ -210,12 +211,13 @@ export function DNSRecordModalContent({
<div className={"w-full mb-3"}>
<Label>Hostname</Label>
<HelpText>
Enter a subdomain or leave empty to use the primary domain.
Enter a subdomain, wildcard or leave empty to use the primary
domain.
</HelpText>
<div className={"flex w-full"}>
<Input
autoFocus={true}
placeholder={"Subdomain (leave empty for primary domain)"}
placeholder={"E.g., dev, * or leave empty for primary domain"}
errorTooltip={true}
errorTooltipPosition={"bottom"}
error={domainError}

View File

@@ -0,0 +1,194 @@
import {
AlarmClock,
BugPlay,
FileText,
PlusCircle,
Shield,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import Button from "@/components/Button";
import FancyToggleSwitch from "@/components/FancyToggleSwitch";
import HelpText from "@/components/HelpText";
import { Input } from "@/components/Input";
import { Label } from "@/components/Label";
import {
ModalClose,
ModalContent,
ModalFooter,
} from "@/components/modal/Modal";
import ModalHeader from "@/components/modal/ModalHeader";
import { notify } from "@/components/Notification";
import Separator from "@/components/Separator";
import { Workload } from "@/interfaces/Job";
import { useApiCall } from "@/utils/api";
type Props = {
peerID: string;
onSuccess: () => void;
};
export function CreateDebugJobModalContent({ peerID, onSuccess }: Props) {
const jobRequest = useApiCall<Workload>(`/peers/${peerID}/jobs`, true);
const { mutate } = useSWRConfig();
const [bundleForTimeEnabled, setBundleForTimeEnabled] = useState(false);
const [bundleForTime, setBundleForTime] = useState<string>("");
const [logFileCount, setLogFileCount] = useState<string>("10");
const [anonymize, setAnonymize] = useState<boolean>(false);
const isValid = useMemo(() => {
let validBundleFor = true;
let validLogFileCount = true;
const logFileCountNumber = Number(logFileCount);
const bundleForTimeNumber = Number(bundleForTime);
if (bundleForTime) {
validBundleFor = bundleForTimeNumber >= 1 && bundleForTimeNumber <= 5;
}
validLogFileCount = logFileCountNumber >= 1 && logFileCountNumber <= 1000;
return validLogFileCount && validBundleFor;
}, [bundleForTime, logFileCount]);
const createDebugJob = async () => {
notify({
title: "Create Debug Job",
description: "Debug job triggered successfully.",
loadingMessage: "Creating job...",
promise: jobRequest
.post({
workload: {
type: "bundle",
parameters: {
anonymize,
bundle_for: bundleForTimeEnabled,
bundle_for_time: bundleForTimeEnabled
? Number(bundleForTime)
: undefined,
log_file_count: logFileCount ? Number(logFileCount) : 10,
},
},
})
.then((job) => {
mutate(`/peers/${peerID}/jobs`);
onSuccess();
return job;
}),
});
};
return (
<ModalContent maxWidthClass="max-w-xl">
<ModalHeader
icon={<BugPlay size={20} />}
title="Debug Bundle"
description="Generate a debug bundle on this peer with logs and diagnostics. Useful for troubleshooting without CLI access."
color="netbird"
/>
<Separator />
<div className={"px-8 py-6 flex flex-col gap-4"}>
{/* Log File Count */}
<div className="flex justify-between gap-6">
<div className={"max-w-[300px]"}>
<Label>Log File Count</Label>
<HelpText>
Sets the limit for how many individual log files will be included
in the debug bundle.
</HelpText>
</div>
<Input
type="number"
min={1}
placeholder={"10"}
max={50}
value={logFileCount}
onChange={(e) => setLogFileCount(e.target.value)}
maxWidthClass="w-[220px]"
customPrefix={<FileText size={16} className="text-nb-gray-300" />}
customSuffix="File(s)"
/>
</div>
{/* Bundle Duration */}
<div>
<FancyToggleSwitch
value={bundleForTimeEnabled}
onChange={(enabled) => {
setBundleForTimeEnabled(enabled);
if (!enabled) {
setBundleForTime("");
} else {
setBundleForTime("2");
}
}}
label={
<>
<AlarmClock size={15} />
Enable Bundle Duration
</>
}
helpText="When enabled, allows you to specify a time period for log collection before generating the debug bundle."
/>
{bundleForTimeEnabled && (
<div className="flex justify-between gap-6 mt-6 mb-3">
<div className={"max-w-[300px]"}>
<Label>Duration</Label>
<HelpText>
Time period for which logs should be collected before creating
the debug bundle.
</HelpText>
</div>
<Input
type="number"
min={1}
max={60}
value={bundleForTime}
onChange={(e) => setBundleForTime(e.target.value)}
maxWidthClass="w-[220px]"
placeholder={"2"}
customPrefix={
<AlarmClock size={16} className="text-nb-gray-300" />
}
customSuffix="Minute(s)"
/>
</div>
)}
</div>
{/* Anonymize Data */}
<FancyToggleSwitch
value={anonymize}
onChange={setAnonymize}
label={
<>
<Shield size={15} />
Anonymize Log Data
</>
}
helpText="Remove sensitive information (IP addresses, domains etc.) before creating the debug bundle."
/>
</div>
<ModalFooter className="items-center">
<div className="flex gap-3 w-full justify-end">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
</ModalClose>
<Button
variant="primary"
disabled={!isValid}
onClick={createDebugJob}
>
<PlusCircle size={16} />
Create Debug Bundle
</Button>
</div>
</ModalFooter>
</ModalContent>
);
}

View File

@@ -0,0 +1,60 @@
import Badge from "@components/Badge";
import CopyToClipboardText from "@components/CopyToClipboardText";
import FullTooltip from "@components/FullTooltip";
import { Input } from "@components/Input";
import * as React from "react";
import { Job } from "@/interfaces/Job";
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
type Props = {
job: Job;
};
export const JobOutputCell = ({ job }: Props) => {
if (job.status === "succeeded" && job.workload.result) {
return (
<div className="flex flex-col gap-1 items-start justify-center pb-1">
{Object.entries(job.workload.result).map(([key, value]) => (
<div key={key} className="text-sm max-w-[200px]">
<span className="font-normal capitalize text-nb-gray-300 text-xs">
{key.replaceAll("_", " ")}
</span>
<br />
<span className="text-nb-gray-200 truncate">
<CopyToClipboardText
message={"Upload key has been copied to your clipboard"}
alwaysShowIcon={true}
>
<span className={"font-mono truncate"}>
{typeof value === "boolean"
? value
? "Yes"
: "No"
: String(value)}
</span>
</CopyToClipboardText>
</span>
</div>
))}
</div>
);
}
if (job.status === "failed" && job.failed_reason) {
return (
<div className={"flex"}>
<FullTooltip
content={
<div className={"max-w-xs text-xs"}>{job.failed_reason}</div>
}
>
<Badge variant={"red"} className={"px-3 max-w-[200px]"}>
<div className={"truncate"}>{job.failed_reason}</div>
</Badge>
</FullTooltip>
</div>
);
}
return <EmptyRow />;
};

View File

@@ -0,0 +1,56 @@
import Badge from "@components/Badge";
import FullTooltip from "@components/FullTooltip";
import { TooltipListItem } from "@components/TooltipListItem";
import { InfoIcon } from "lucide-react";
import React from "react";
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
export const JobParametersCell = ({ parameters }: { parameters: any }) => {
if (!parameters || Object.keys(parameters).length === 0) {
return <EmptyRow />;
}
const entries = Object.entries(parameters);
return (
<FullTooltip
side={"top"}
interactive={true}
delayDuration={250}
skipDelayDuration={100}
contentClassName={"p-0"}
content={
<div
className={"text-xs flex flex-col"}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
{entries.map(([key, value]) => (
<TooltipListItem
label={key.replaceAll("_", " ")}
labelClassName={"capitalize"}
value={
typeof value === "boolean"
? value
? "Yes"
: "No"
: String(value)
}
key={key}
/>
))}
</div>
}
>
<Badge
variant="gray"
className="flex items-center gap-1.5 cursor-default"
>
<InfoIcon size={12} />
{entries.length} Parameters
</Badge>
</FullTooltip>
);
};

View File

@@ -0,0 +1,30 @@
import { cn } from "@utils/helpers";
import React from "react";
import { Job } from "@/interfaces/Job";
type Props = {
job: Job;
};
export default function JobStatusCell({ job }: Readonly<Props>) {
const status = job.status;
return (
<div
className={cn("flex gap-2.5 items-center text-nb-gray-300 text-sm")}
data-cy={"job-status-cell"}
>
<span
className={cn(
"h-2 w-2 rounded-full",
status == "pending" && "bg-yellow-400",
status == "failed" && "bg-red-500",
status == "succeeded" && "bg-green-500",
)}
></span>
{status == "pending" && "Pending"}
{status == "failed" && "Failed"}
{status == "succeeded" && "Completed"}
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { BugIcon } from "lucide-react";
import * as React from "react";
import { Job } from "@/interfaces/Job";
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
type Props = {
job: Job;
};
export const JobTypeCell = ({ job }: Props) => {
if (job.workload.type === "bundle") {
return (
<div
className={"flex items-center gap-2 whitespace-nowrap text-nb-gray-200"}
>
<BugIcon size={14} />
<span>Debug Bundle</span>
</div>
);
}
return <EmptyRow />;
};

View File

@@ -0,0 +1,141 @@
import Card from "@components/Card";
import { DataTable } from "@components/table/DataTable";
import DataTableHeader from "@components/table/DataTableHeader";
import NoResults from "@components/ui/NoResults";
import { ColumnDef, SortingState } from "@tanstack/react-table";
import { ClipboardList } from "lucide-react";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import DataTableRefreshButton from "@/components/table/DataTableRefreshButton";
import { DataTableRowsPerPage } from "@/components/table/DataTableRowsPerPage";
import { Job } from "@/interfaces/Job";
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
import LastTimeRow from "@/modules/common-table-rows/LastTimeRow";
import { JobOutputCell } from "@/modules/jobs/table/JobOutputCell";
import { JobParametersCell } from "@/modules/jobs/table/JobParametersCell";
import JobStatusCell from "@/modules/jobs/table/JobStatusCell";
import { JobTypeCell } from "@/modules/jobs/table/JobTypeCell";
import { RemoteJobDropdownButton } from "@/modules/peer/RemoteJobDropdownButton";
type Props = {
jobs?: Job[];
peerID: string;
isLoading: boolean;
headingTarget?: HTMLHeadingElement | null;
};
const PeerRemoteJobsColumns: ColumnDef<Job>[] = [
{
accessorKey: "Type",
header: ({ column }) => (
<DataTableHeader column={column}>Type</DataTableHeader>
),
cell: ({ row }) => <JobTypeCell job={row.original} />,
},
{
accessorKey: "CreatedAt",
header: ({ column }) => (
<DataTableHeader column={column}>Created</DataTableHeader>
),
sortingFn: "datetime",
cell: ({ row }) => (
<LastTimeRow date={row.original.created_at} text="Created at" />
),
},
{
accessorKey: "Status",
header: ({ column }) => (
<DataTableHeader column={column}>Status</DataTableHeader>
),
cell: ({ row }) => <JobStatusCell job={row.original} />,
},
{
accessorKey: "CompletedAt",
header: ({ column }) => (
<DataTableHeader column={column}>Completed</DataTableHeader>
),
sortingFn: "datetime",
cell: ({ row }) =>
row.original.completed_at ? (
<LastTimeRow date={row.original.completed_at} text="Completed at" />
) : (
<EmptyRow />
),
},
{
accessorKey: "Parameters",
header: ({ column }) => (
<DataTableHeader column={column}>Parameters</DataTableHeader>
),
cell: ({ row }) => (
<JobParametersCell parameters={row.original.workload.parameters} />
),
},
{
id: "ResultOrReason",
header: ({ column }) => (
<DataTableHeader column={column}>Output</DataTableHeader>
),
cell: ({ row }) => <JobOutputCell job={row.original} />,
},
];
export default function PeerRemoteJobsTable({
jobs,
isLoading,
headingTarget,
peerID,
}: Props) {
const { mutate } = useSWRConfig();
const [sorting, setSorting] = useState<SortingState>([
{ id: "CreatedAt", desc: true },
]);
return (
<DataTable
rightSide={() => (
<div className={"gap-x-4 ml-auto flex"}>
<RemoteJobDropdownButton />
</div>
)}
wrapperComponent={Card}
wrapperProps={{ className: "mt-6 w-full" }}
headingTarget={headingTarget}
useRowId={true}
sorting={sorting}
setSorting={setSorting}
minimal={true}
showSearchAndFilters={true}
inset={false}
tableClassName="mt-0"
text="Jobs"
columns={PeerRemoteJobsColumns}
keepStateInLocalStorage={false}
data={jobs}
searchPlaceholder="Search by type, status, or parameters..."
isLoading={isLoading}
getStartedCard={
<NoResults
className="py-4"
title="This peer has no remote jobs"
description="Create a debug bundle or trigger other remote jobs to see them listed here."
icon={<ClipboardList size={20} className="text-nb-gray-300" />}
/>
}
paginationPaddingClassName="px-0 pt-8"
>
{(table) => (
<>
<DataTableRowsPerPage table={table} disabled={jobs?.length == 0} />
<DataTableRefreshButton
isDisabled={jobs?.length == 0}
onClick={() => {
mutate(`/peers/${peerID}/jobs`).then();
}}
/>
</>
)}
</DataTable>
);
}

View File

@@ -58,6 +58,7 @@ export default function AddRouteDropdownButton() {
icon={<PlusCircle size={14} />}
color={"green"}
margin={""}
size={"small"}
/>
<div className={"flex flex-col text-left"}>
<div className={"text-left text-white"}>New Network Route</div>
@@ -79,6 +80,7 @@ export default function AddRouteDropdownButton() {
}
color={"netbird"}
margin={""}
size={"small"}
/>
<div className={"flex flex-col text-left"}>
<div className={"text-left text-white"}>Existing Network</div>

View File

@@ -0,0 +1,64 @@
import { ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense } from "react";
import InlineLink from "@/components/InlineLink";
import Paragraph from "@/components/Paragraph";
import SkeletonTable, {
SkeletonTableHeader,
} from "@/components/skeletons/SkeletonTable";
import { usePortalElement } from "@/hooks/usePortalElement";
import { Job } from "@/interfaces/Job";
import useFetchApi from "@/utils/api";
const PeerRemoteJobsTable = lazy(
() => import("@/modules/jobs/table/PeerRemoteJobsTable"),
);
type Props = {
peerID: string;
};
export const PeerRemoteJobsSection = ({ peerID }: Props) => {
const { data: jobs, isLoading } = useFetchApi<Job[]>(`/peers/${peerID}/jobs`);
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<div className="pb-10 px-8">
<div className="max-w-6xl">
<div className="flex justify-between items-center mb-5">
<div>
<h2 ref={headingRef}>Remote Jobs</h2>
<Paragraph>
Remotely trigger actions such as debug bundles or other tasks on
this peer, without requiring CLI access.
</Paragraph>
<Paragraph>
Learn more about{" "}
<InlineLink href={"https://docs.netbird.io"} target={"_blank"}>
Remote Jobs <ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
</div>
<Suspense
fallback={
<div>
<SkeletonTableHeader className="!p-0" />
<div className="mt-8 w-full">
<SkeletonTable withHeader={false} />
</div>
</div>
}
>
<PeerRemoteJobsTable
peerID={peerID}
jobs={jobs}
isLoading={isLoading}
headingTarget={portalTarget}
/>
</Suspense>
</div>
</div>
);
};

View File

@@ -0,0 +1,87 @@
import Button from "@components/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@components/DropdownMenu";
import { Modal } from "@components/modal/Modal";
import SquareIcon from "@components/SquareIcon";
import { BugPlay, ChevronDown } from "lucide-react";
import React, { useState } from "react";
import { usePeer } from "@/contexts/PeerProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { CreateDebugJobModalContent } from "../jobs/CreateDebugJobModal";
export const RemoteJobDropdownButton = () => {
const [modal, setModal] = useState(false);
const { peer } = usePeer();
const { permission } = usePermissions();
const isConnected = peer?.connected;
const disabled = !permission.peers.delete;
return (
<>
<Modal open={modal} onOpenChange={setModal} key={modal ? 1 : 0}>
<CreateDebugJobModalContent
peerID={peer.id!}
onSuccess={() => setModal(false)}
/>
</Modal>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
asChild={true}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Button variant={"primary"} disabled={disabled}>
Run Remote Job
<ChevronDown size={16} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-auto" align="end" sideOffset={10}>
{!isConnected && (
<>
<div
className={
"text-xs flex items-center w-full justify-center max-w-xs px-3 py-3 text-nb-gray-200 font-light"
}
>
<div>
Peer{" "}
<span className={"text-white font-medium"}>{peer.name}</span>{" "}
is currently offline. Please connect the peer to run remote
jobs.
</div>
</div>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() => setModal(true)}
disabled={disabled || !isConnected}
>
<div className={"flex gap-3 items-center justify-center pr-3"}>
<SquareIcon
icon={<BugPlay size={14} />}
margin={""}
size={"small"}
/>
<div className={"flex flex-col text-left"}>
<div className={"text-left text-white"}>Debug Bundle</div>
<div className={"text-xs"}>
Collect debug information for troubleshooting
</div>
</div>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
};

View File

@@ -20,11 +20,13 @@ type Props = {
version: string;
os: string;
serial?: string;
ephemeral?: boolean;
};
export default function PeerVersionCell({ version, os, serial }: Props) {
export default function PeerVersionCell({ version, os, serial, ephemeral }: Props) {
const { latestVersion, latestUrl } = useApplicationContext();
const updateAvailable = useMemo(() => {
if (ephemeral) return false;
const operatingSystem = getOperatingSystem(os);
if (
operatingSystem === OperatingSystem.IOS ||
@@ -33,7 +35,7 @@ export default function PeerVersionCell({ version, os, serial }: Props) {
return false;
if (!latestVersion) return false;
return !compareVersions(version, latestVersion);
}, [os, version, latestVersion]);
}, [os, version, latestVersion, ephemeral]);
const updateIcon = useMemo(() => {
return <ArrowUpCircleIcon size={15} className={"text-netbird"} />;

View File

@@ -170,6 +170,7 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
version={row.original.version}
os={row.original.os}
serial={row.original.serial_number}
ephemeral={row.original.ephemeral}
/>
),
},

View File

@@ -26,9 +26,7 @@ export const SSHButton = ({ peer, isDropdown = false }: Props) => {
const hasPermission = permission.peers.update;
const os = getOperatingSystem(peer?.os);
const isWindows = os === OperatingSystem.WINDOWS;
const isMobile = os === OperatingSystem.ANDROID || os === OperatingSystem.IOS;
const isSSHSupported = !isWindows && !isMobile;
const isSSHSupported = os !== OperatingSystem.IOS;
return (
isSSHSupported && (

View File

@@ -0,0 +1,194 @@
"use client";
import Button from "@components/Button";
import { Input } from "@components/Input";
import {
Modal,
ModalClose,
ModalContent,
ModalFooter,
ModalTrigger,
} from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import { notify } from "@components/Notification";
import Separator from "@components/Separator";
import { Label } from "@components/Label";
import HelpText from "@components/HelpText";
import { useApiCall } from "@utils/api";
import { KeyRound, LockIcon } from "lucide-react";
import React, { useMemo, useState } from "react";
type Props = {
children: React.ReactNode;
userId?: string;
};
export default function ChangePasswordModal({
children,
userId,
}: Readonly<Props>) {
const [modal, setModal] = useState(false);
return (
<Modal open={modal} onOpenChange={setModal} key={modal ? 1 : 0}>
<ModalTrigger asChild>{children}</ModalTrigger>
<ChangePasswordModalContent
userId={userId}
onSuccess={() => setModal(false)}
/>
</Modal>
);
}
type ModalProps = {
userId?: string;
onSuccess?: () => void;
};
export function ChangePasswordModalContent({
userId,
onSuccess,
}: Readonly<ModalProps>) {
const passwordRequest = useApiCall<void>(`/users/${userId}/password`, true);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const currentPasswordError = useMemo(() => {
if (currentPassword.length === 0) return undefined;
return undefined;
}, [currentPassword]);
const newPasswordError = useMemo(() => {
if (newPassword.length === 0) return undefined;
if (newPassword.length < 8) return "Password must be at least 8 characters";
return undefined;
}, [newPassword]);
const confirmPasswordError = useMemo(() => {
if (confirmPassword.length === 0) return undefined;
if (newPassword !== confirmPassword) return "Passwords do not match";
return undefined;
}, [newPassword, confirmPassword]);
const isDisabled = useMemo(() => {
if (currentPassword.length === 0) return true;
if (newPassword.length < 8) return true;
if (confirmPassword.length === 0) return true;
if (newPassword !== confirmPassword) return true;
return false;
}, [currentPassword, newPassword, confirmPassword]);
const changePassword = async () => {
if (!userId || isDisabled) return;
setIsLoading(true);
notify({
title: "Change Password",
description: "Your password has been successfully changed.",
promise: passwordRequest
.put({
old_password: currentPassword,
new_password: newPassword,
})
.then(() => {
onSuccess && onSuccess();
})
.finally(() => {
setIsLoading(false);
}),
loadingMessage: "Changing password...",
});
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !isDisabled && !isLoading) {
changePassword();
}
};
return (
<ModalContent maxWidthClass={"max-w-lg"}>
<ModalHeader
icon={<KeyRound size={18} />}
title={"Change Password"}
description={"Update your account password."}
color={"netbird"}
/>
<Separator />
<form className={"px-8 py-6 flex flex-col gap-6"} onSubmit={changePassword}>
<div>
<Label>Current Password</Label>
<HelpText>Enter your current password to verify your identity.</HelpText>
<Input
type="password"
placeholder={"Enter current password"}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
onKeyDown={handleKeyDown}
showPasswordToggle
error={currentPasswordError}
customPrefix={<LockIcon size={16} className={"text-nb-gray-300"} />}
name={"current-password"}
autoComplete={"current-password"}
/>
</div>
<div>
<Label>New Password</Label>
<HelpText>
Enter your new password. Must be at least 8 characters.
</HelpText>
<Input
type="password"
placeholder={"Enter new password"}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
onKeyDown={handleKeyDown}
showPasswordToggle
error={newPasswordError}
customPrefix={<LockIcon size={16} className={"text-nb-gray-300"} />}
name={"new-password"}
autoComplete={"new-password"}
/>
</div>
<div>
<Label>Confirm New Password</Label>
<HelpText>Re-enter your new password to confirm.</HelpText>
<Input
type="password"
placeholder={"Confirm new password"}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
onKeyDown={handleKeyDown}
showPasswordToggle
error={confirmPasswordError}
customPrefix={<LockIcon size={16} className={"text-nb-gray-300"} />}
name={"confirm-password"}
autoComplete={"confirm-password"}
/>
</div>
</form>
<ModalFooter className={"items-center"}>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button
variant={"primary"}
disabled={isDisabled || isLoading}
onClick={changePassword}
>
Change Password
</Button>
</div>
</ModalFooter>
</ModalContent>
);
}

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

@@ -68,7 +68,7 @@ const loadConfig = (): Config => {
googleAnalyticsID: configJson?.googleAnalyticsID || undefined,
googleTagManagerID: configJson?.googleTagManagerID || undefined,
wasmPath:
configJson?.wasmPath || "https://pkgs.netbird.io/wasm/client/v0.60.2",
configJson?.wasmPath || "https://pkgs.netbird.io/wasm/client/v0.63.0",
} as Config;
};

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 },
);
}