Compare commits

...
9 Commits
Author SHA1 Message Date
Eduard Gert 750f660bcc Update NextJS to 16.1.6 (#547)
* Update NextJS to 16.1.6

* Update Node in workflow

* Fix rabbit comments

* Fix types

* Add engines field
2026-02-02 15:34:23 +01:00
Misha Bragin ea148545e8 Disable local users when LocalAuthDisabled = true (#546) 2026-02-01 14:31:57 +01:00
Misha Bragin d2febbf27b Fix version comparison (#544) 2026-01-27 14:13:27 +01:00
Misha Bragin 615b4487ad Point to the right upgrade doc (#543) 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
41 changed files with 3957 additions and 2755 deletions

No files matched your search

+12 -1
View File
@@ -19,7 +19,7 @@ jobs:
- name: setup-node
uses: actions/setup-node@v3
with:
node-version: '18'
node-version: '20'
cache: 'npm'
- name: Install dependencies
@@ -54,8 +54,19 @@ jobs:
fileName: "ironrdp_web_bg.wasm"
out-file-path: 'public/ironrdp-pkg'
- name: Get version from tag
id: version
run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
else
echo "version=development" >> $GITHUB_OUTPUT
fi
- name: Build
run: npm run build
env:
NEXT_PUBLIC_DASHBOARD_VERSION: ${{ steps.version.outputs.version }}
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2
+12
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
}
]
+2
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",
},
};
+2103 -2305
View File
File diff suppressed because it is too large. Load diff
+35 -32
View File
@@ -2,6 +2,9 @@
"name": "netbird-dashboard",
"version": "2.0.0",
"private": true,
"engines": {
"node": ">=20.9.0"
},
"scripts": {
"copy": "copyfiles -f ./node_modules/@axa-fr/react-oidc/dist/OidcServiceWorker.js ./public",
"copytrusted": "copyfiles -f ./public/local/OidcTrustedDomains.js ./public",
@@ -13,34 +16,34 @@
"cypress:open": "cypress open"
},
"dependencies": {
"@axa-fr/react-oidc": "^7.22.18",
"@axa-fr/react-oidc": "^7.26.3",
"@dagrejs/dagre": "^1.1.5",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.1.4",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@tabler/icons-react": "^2.39.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@tabler/icons-react": "^3.36.1",
"@tanstack/match-sorter-utils": "^8.8.4",
"@tanstack/react-table": "^8.10.7",
"@types/crypto-js": "^4.2.2",
"@types/d3": "^7.4.3",
"@types/lodash": "^4.14.200",
"@types/node": "20.10.6",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-window": "^1.8.8",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
@@ -49,8 +52,9 @@
"chart.js": "^4.4.8",
"chroma-js": "^3.1.2",
"class-variance-authority": "^0.7.0",
"classnames": "^2.5.1",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"cmdk": "^1.1.1",
"crypto-js": "^4.2.0",
"d3": "^7.9.0",
"date-fns": "^2.30.0",
@@ -58,24 +62,23 @@
"elkjs": "^0.10.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-simple-import-sort": "^10.0.0",
"flowbite": "^1.8.1",
"flowbite-react": "^0.6.4",
"framer-motion": "^10.16.4",
"framer-motion": "^12.29.2",
"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": "^16.1.6",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
"react": "^18.3.1",
"react-day-picker": "^8.9.1",
"react-dom": "^18.3.1",
"react": "^19.2.4",
"react-day-picker": "^9.13.0",
"react-dom": "^19.2.4",
"react-ga4": "^2.1.0",
"react-hot-toast": "^2.4.1",
"react-hotjar": "^6.2.0",
"react-hotjar": "^6.3.1",
"react-hotkeys-hook": "^4.4.1",
"react-icons": "^5.5.0",
"react-jwt": "^1.2.0",
"react-loading-skeleton": "^3.3.1",
"react-responsive": "^9.0.2",
@@ -91,7 +94,7 @@
"@types/chroma-js": "^3.1.1",
"@types/js-cookie": "^3.0.6",
"eslint": "^9.39.1",
"eslint-config-next": "^16.0.5",
"eslint-config-next": "^16.1.6",
"postcss": "^8",
"prettier": "3.0.3",
"tailwindcss": "^3.4.17"
+3 -1
View File
@@ -2,7 +2,9 @@
@tailwind components;
@tailwind utilities;
html{
@apply bg-nb-gray;
}
h1 {
@apply text-2xl font-medium text-gray-700 dark:text-nb-gray-100 my-1;
+8
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
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>
);
}
+3 -1
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
-90
View File
@@ -1,90 +0,0 @@
import { Checkbox } from "@components/Checkbox";
import { Input } from "@components/Input";
import { Popover, PopoverContent } from "@components/Popover";
import { useElementSize } from "@hooks/useElementSize";
import { Anchor } from "@radix-ui/react-popover";
import * as React from "react";
import { useEffect, useRef, useState } from "react";
import { FaWindows } from "react-icons/fa6";
type Props = {};
export const AutoCompleteInput = ({}: Props) => {
const [open, setOpen] = useState<boolean>(false);
const inputRef = useRef<HTMLInputElement>(null);
const [elementWidth, { width }] = useElementSize<HTMLDivElement>();
useEffect(() => {
const input = inputRef.current;
const onFocus = () => {
setOpen(true);
};
if (input) {
inputRef.current.addEventListener("focus", onFocus);
}
return () => {
if (input) {
inputRef.current.removeEventListener("focus", onFocus);
}
};
}, []);
return (
<div className={"z-10 relative"}>
<Popover modal={false} open={open} onOpenChange={setOpen}>
<Anchor ref={elementWidth}>
<Input
placeholder={"11"}
ref={inputRef}
maxWidthClass={"max-w-[200px]"}
customPrefix={
<div className={"flex items-center gap-2"}>
<Checkbox></Checkbox>
<div
className={"flex gap-2 items-center text-sm text-nb-gray-200"}
>
<FaWindows className={"text-sky-600 text-lg"} />
Windows
</div>
</div>
}
/>
</Anchor>
<PopoverContent
hideWhenDetached={false}
className="w-full p-0 shadow-sm shadow-nb-gray-950"
style={{
width: width,
}}
forceMount={true}
align="start"
side={"bottom"}
sideOffset={10}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
onInteractOutside={(event) => {
event.preventDefault();
if (event.target !== inputRef.current) {
setOpen(false);
}
}}
onPointerDownOutside={(event) => {
event.preventDefault();
if (event.target !== inputRef.current) {
setOpen(false);
}
}}
onFocusOutside={(event) => {
event.preventDefault();
if (event.target !== inputRef.current) {
setOpen(false);
}
}}
></PopoverContent>
</Popover>
</div>
);
};
+1 -4
View File
@@ -53,13 +53,10 @@ const TooltipContent = React.forwardRef<
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
asChild={true}
sideOffset={sideOffset}
className={cn(tooltipVariants({ variant }), className)}
{...props}
>
<div>{props.children}</div>
</TooltipPrimitive.Content>
/>
</TooltipPrimitive.Portal>
),
);
+148
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;
+16 -3
View File
@@ -1,12 +1,25 @@
import {
MemoizedScrollArea,
MemoizedScrollAreaViewport,
ScrollAreaViewport,
} from "@components/ScrollArea";
import { cn } from "@utils/helpers";
import * as React from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
forwardRef,
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
const VirtuosoScroller = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>((props, ref) => <ScrollAreaViewport ref={ref} {...props} />);
type Props<T extends { id?: string }> = {
items: T[];
onSelect: (item: T) => void;
@@ -183,7 +196,7 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
}}
style={virtuosoHeight}
components={{
Scroller: MemoizedScrollAreaViewport,
Scroller: VirtuosoScroller,
}}
/>
</MemoizedScrollArea>
+27 -24
View File
@@ -2,6 +2,7 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { DialogTriggerProps } from "@radix-ui/react-dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { cn } from "@utils/helpers";
import { X } from "lucide-react";
import * as React from "react";
@@ -74,18 +75,19 @@ const ModalContent = React.forwardRef<
{...props}
onClick={(e) => e.stopPropagation()}
>
<>
{children}
{showClose && (
<DialogPrimitive.Close
data-cy={"modal-close"}
className="absolute right-4 z-10 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</>
<VisuallyHidden asChild>
<DialogPrimitive.Title>Dialog</DialogPrimitive.Title>
</VisuallyHidden>
{children}
{showClose && (
<DialogPrimitive.Close
data-cy={"modal-close"}
className="absolute right-4 z-10 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</ModalOverlay>
</ModalPortal>
@@ -129,18 +131,19 @@ const SidebarModalContent = React.forwardRef<
}}
onClick={(e) => e.stopPropagation()}
>
<>
{children}
{showClose && (
<DialogPrimitive.Close
data-cy={"modal-close"}
className="absolute right-4 z-10 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</>
<VisuallyHidden asChild>
<DialogPrimitive.Title>Dialog</DialogPrimitive.Title>
</VisuallyHidden>
{children}
{showClose && (
<DialogPrimitive.Close
data-cy={"modal-close"}
className="absolute right-4 z-10 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</div>
</ModalPortal>
+85 -111
View File
@@ -14,11 +14,6 @@ import {
TableWrapper,
} from "@components/table/Table";
import NoResults from "@components/ui/NoResults";
import {
Accordion,
AccordionContent,
AccordionItem,
} from "@radix-ui/react-accordion";
import { RankingInfo } from "@tanstack/match-sorter-utils";
import {
ColumnDef,
@@ -493,117 +488,97 @@ export function DataTable<TData, TValue>({
</TableHeaderComponent>
)}
<Accordion
asChild={true}
type={"multiple"}
value={accordion}
onValueChange={setAccordion}
<TableBodyComponent
className={cn(
"relative",
data == undefined && "blur-sm",
wrapperClassName,
)}
>
<TableBodyComponent
className={cn(
"relative",
data == undefined && "blur-sm",
wrapperClassName,
)}
>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => {
const expandedRow = renderExpandedRow?.(row.original);
const rowContent = (
<AccordionItem
value={row.original.id}
asChild={true}
key={row.id}
>
<>
<TableRowComponent
minimal={minimal}
data-row-id={row.original.id}
className={cn(
(onRowClick || renderExpandedRow) &&
"relative group/accordion",
(onRowClick || expandedRow) && "cursor-pointer",
rowClassName,
)}
data-state={row.getIsSelected() && "selected"}
data-accordion={
accordion?.includes(row.original.id)
? "opened"
: "closed"
}
onClick={(e) => {
if (expandedRow) {
e.preventDefault();
e.stopPropagation();
setAccordion((prev) => {
if (prev?.includes(row.original.id)) {
return prev.filter(
(item) => item !== row.original.id,
);
} else {
return [...(prev ?? []), row.original.id];
}
});
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => {
const expandedRow = renderExpandedRow?.(row.original);
const rowId = row.original.id ?? row.id;
const isExpanded = accordion?.includes(rowId);
const rowContent = (
<React.Fragment key={row.id}>
<TableRowComponent
minimal={minimal}
data-row-id={rowId}
className={cn(
(onRowClick || renderExpandedRow) &&
"relative group/accordion",
(onRowClick || expandedRow) && "cursor-pointer",
rowClassName,
)}
data-state={row.getIsSelected() && "selected"}
data-accordion={isExpanded ? "opened" : "closed"}
onClick={(e) => {
if (expandedRow) {
e.preventDefault();
e.stopPropagation();
setAccordion((prev) => {
if (prev?.includes(rowId)) {
return prev.filter(
(item) => item !== rowId,
);
} else {
return [...(prev ?? []), rowId];
}
});
}
}}
>
{row.getVisibleCells().map((cell) => (
<TableCellComponent
key={cell.id}
className={cn("relative", tableCellClassName)}
minimal={minimal}
inset={inset}
onClick={() => {
onRowClick && onRowClick(row, cell.column.id);
}}
>
<>
{row.getVisibleCells().map((cell) => (
<TableCellComponent
key={cell.id}
className={cn("relative", tableCellClassName)}
minimal={minimal}
inset={inset}
onClick={() => {
onRowClick &&
onRowClick(row, cell.column.id);
}}
>
<div
className={
"absolute left-0 top-0 w-full h-full z-0"
}
></div>
<div className={"relative z-[1]"}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</div>
</TableCellComponent>
))}
</>
</TableRowComponent>
<div
className={
"absolute left-0 top-0 w-full h-full z-0"
}
></div>
<div className={"relative z-[1]"}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</div>
</TableCellComponent>
))}
</TableRowComponent>
{expandedRow && (
<AccordionContent asChild={true}>
<TableRowComponent
data-row-id={row.id + "-expanded-row"}
key={row.id + "-expanded-row"}
minimal={minimal}
className={cn(
onRowClick && "cursor-pointer relative",
rowClassName,
)}
data-state={row.getIsSelected() && "selected"}
>
<TableDataUnstyledComponent
className={"w-full"}
colSpan={row.getVisibleCells().length}
>
{expandedRow}
</TableDataUnstyledComponent>
</TableRowComponent>
</AccordionContent>
{expandedRow && isExpanded && (
<TableRowComponent
data-row-id={row.id + "-expanded-row"}
minimal={minimal}
className={cn(
onRowClick && "cursor-pointer relative",
rowClassName,
)}
</>
</AccordionItem>
);
data-state={row.getIsSelected() && "selected"}
>
<TableDataUnstyledComponent
className={"w-full"}
colSpan={row.getVisibleCells().length}
>
{expandedRow}
</TableDataUnstyledComponent>
</TableRowComponent>
)}
</React.Fragment>
);
return renderRow
? renderRow(row.original, rowContent)
: rowContent;
})
return renderRow
? renderRow(row.original, rowContent)
: rowContent;
})
) : (
<TableRowUnstyledComponent>
<TableCellComponent
@@ -614,8 +589,7 @@ export function DataTable<TData, TValue>({
</TableCellComponent>
</TableRowUnstyledComponent>
)}
</TableBodyComponent>
</Accordion>
</TableBodyComponent>
</TableComponent>
)}
</TableWrapper>
+9 -1
View File
@@ -64,8 +64,16 @@ const Time = ({
}
}, [value]);
const { ref, ...rootProps } = getRootProps();
return (
<div className={"timescape w-full"} {...getRootProps()}>
<div
className={"timescape w-full"}
ref={(element) => {
ref(element);
}}
{...rootProps}
>
<div>
<input {...getInputProps("years")} />
<span className={"separator"}>/</span>
+29 -23
View File
@@ -19,40 +19,46 @@ function Calendar({
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
months: "flex flex-col sm:flex-row space-y-4 sm:space-y-0 relative",
month: "space-y-4 pr-4 last:pr-0",
month_caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
button_previous: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute left-0 top-0 z-10",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
button_next: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute right-0 top-0 z-10",
),
month_grid: "w-full border-collapse space-y-1",
weekdays: "flex",
weekday:
"text-neutral-500 rounded-md w-9 font-normal text-[0.8rem] dark:text-neutral-400",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-neutral-100/50 [&:has([aria-selected])]:bg-neutral-100 first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20 dark:[&:has([aria-selected].day-outside)]:bg-neutral-800/50 dark:[&:has([aria-selected])]:bg-neutral-800",
day: cn("h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
day_range_end: "day-range-end rounded-r-md",
day_range_start: "day-range-start rounded-l-md",
day_selected:
week: "flex w-full mt-2",
day: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-neutral-100/50 [&:has([aria-selected])]:bg-neutral-100 first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20 dark:[&:has([aria-selected].day-outside)]:bg-neutral-800/50 dark:[&:has([aria-selected])]:bg-neutral-800",
day_button: cn("h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
range_end: "day-range-end rounded-r-md",
range_start: "day-range-start rounded-l-md",
selected:
"bg-neutral-900 text-neutral-50 hover:bg-neutral-900 hover:text-neutral-50 focus:bg-neutral-900 focus:text-neutral-50 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50 dark:hover:text-neutral-900 dark:focus:bg-neutral-50 dark:focus:text-neutral-900",
day_today: "text-neutral-900 dark:text-red-500",
day_outside:
today: "text-neutral-900 dark:text-red-500",
outside:
"day-outside text-neutral-500 opacity-50 aria-selected:bg-neutral-100/50 aria-selected:text-neutral-500 aria-selected:opacity-30 dark:text-neutral-400 dark:aria-selected:bg-neutral-800/50 dark:aria-selected:text-neutral-400",
day_disabled: "text-neutral-500 opacity-50 dark:text-neutral-400",
day_range_middle:
disabled: "text-neutral-500 opacity-50 dark:text-neutral-400",
range_middle:
"aria-selected:bg-neutral-100 aria-selected:text-neutral-900 dark:aria-selected:bg-nb-gray-800 dark:aria-selected:text-neutral-50 rounded-none",
day_hidden: "invisible",
hidden: "invisible",
...classNames,
}}
components={{
IconLeft: () => <ChevronLeft className="h-4 w-4" />,
IconRight: () => <ChevronRight className="h-4 w-4" />,
Chevron: ({ orientation }) =>
orientation === "left" ? (
<ChevronLeft className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
),
}}
{...props}
/>
+1 -1
View File
@@ -56,7 +56,7 @@ export default function AnalyticsProvider({ children }: Readonly<Props>) {
});
}
if (hjid && window._DATADOG_SYNTHETICS_BROWSER === undefined) {
hotjar.initialize(hjid, 6);
hotjar.initialize({ id: hjid, sv: 6 });
}
setInitialized(true);
}, []);
+100 -57
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);
+1 -1
View File
@@ -34,7 +34,7 @@ export default function DialogProvider({ children }: Props) {
isOpen: false,
});
const [dialogOptions, setDialogOptions] = useState<DialogOptions>();
const fn = useRef<Function>();
const fn = useRef<Function>(undefined);
const confirm = useCallback((data: DialogOptions): Promise<boolean> => {
return new Promise((resolve) => {
Loaded 20 of 41 files, more files were not shown because too many files have changed in this diff. Show more