Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea148545e8 | ||
|
|
d2febbf27b | ||
|
|
615b4487ad | ||
|
|
a7c7800916 | ||
|
|
3d51e0893e | ||
|
|
d7d44b5817 | ||
|
|
f67f39b68b | ||
|
|
d2bc7a1f57 | ||
|
|
818ba5daa4 | ||
|
|
3a30f76629 | ||
|
|
34dc21c89d | ||
|
|
2e37703622 | ||
|
|
8aec338c43 | ||
|
|
f4f0c240fd | ||
|
|
04e22a3c7e |
11
.github/workflows/build_and_push.yml
vendored
11
.github/workflows/build_and_push.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
12
announcements.json
Normal 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
|
||||
}
|
||||
]
|
||||
@@ -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
9
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) +
|
||||
")"
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
8
src/app/invite/layout.tsx
Normal file
8
src/app/invite/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Accept Invite - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
321
src/app/invite/page.tsx
Normal file
321
src/app/invite/page.tsx
Normal 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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
36
src/components/TooltipListItem.tsx
Normal file
36
src/components/TooltipListItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
148
src/components/VersionInfo.tsx
Normal file
148
src/components/VersionInfo.tsx
Normal 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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
23
src/interfaces/Job.ts
Normal 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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
194
src/modules/jobs/CreateDebugJobModal.tsx
Normal file
194
src/modules/jobs/CreateDebugJobModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
src/modules/jobs/table/JobOutputCell.tsx
Normal file
60
src/modules/jobs/table/JobOutputCell.tsx
Normal 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 />;
|
||||
};
|
||||
56
src/modules/jobs/table/JobParametersCell.tsx
Normal file
56
src/modules/jobs/table/JobParametersCell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
30
src/modules/jobs/table/JobStatusCell.tsx
Normal file
30
src/modules/jobs/table/JobStatusCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/modules/jobs/table/JobTypeCell.tsx
Normal file
22
src/modules/jobs/table/JobTypeCell.tsx
Normal 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 />;
|
||||
};
|
||||
141
src/modules/jobs/table/PeerRemoteJobsTable.tsx
Normal file
141
src/modules/jobs/table/PeerRemoteJobsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
64
src/modules/peer/PeerRemoteJobsSection.tsx
Normal file
64
src/modules/peer/PeerRemoteJobsSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
87
src/modules/peer/RemoteJobDropdownButton.tsx
Normal file
87
src/modules/peer/RemoteJobDropdownButton.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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"} />;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
194
src/modules/users/ChangePasswordModal.tsx
Normal file
194
src/modules/users/ChangePasswordModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
528
src/modules/users/UserInvitesTable.tsx
Normal file
528
src/modules/users/UserInvitesTable.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user