Compare commits

..

4 Commits

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

* update job ui

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

* update job event description

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-01-20 17:12:33 +01:00
Misha Bragin
34dc21c89d Add password change (embedded Idp) (#535) 2026-01-20 15:00:14 +01:00
Eduard Gert
2e37703622 Update CONTRIBUTOR_LICENSE_AGREEMENT.md (#534) 2026-01-19 14:55:04 +01:00
18 changed files with 988 additions and 13 deletions

View File

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

View File

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

View File

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

View File

@@ -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>
</>
);
}

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

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

View File

@@ -277,6 +277,14 @@ 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>
);
/**
* Service User
*/
@@ -693,6 +701,16 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
/**
* Jobs
*/
if (event.activity_code == "peer.job.create")
return (<div className={"inline"}>
Remote job <Value>{m.job_type}</Value> created for peer <Value>{m.for_peer_name}</Value>
</div>
)
if (event.activity_code == "account.settings.extra.flow.group.remove")
return (
<div className={"inline"}>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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