Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b949f60afe | ||
|
|
d498e4cc25 | ||
|
|
130dc0c32c | ||
|
|
f5824d6ddb | ||
|
|
829395f908 | ||
|
|
8eebec78b4 | ||
|
|
3e01a6dafd | ||
|
|
1555b94043 | ||
|
|
6c62127d42 |
@@ -1,9 +1,9 @@
|
||||
[
|
||||
{
|
||||
"tag": "New",
|
||||
"text": "Custom DNS Zones for Private Network Resolution",
|
||||
"link": "https://netbird.io/knowledge-hub/custom-dns-zones",
|
||||
"linkText": "Read Release Article",
|
||||
"text": "NetBird Reverse Proxy - Expose internal services to the public with automatic TLS and optional authentication.",
|
||||
"link": "https://docs.netbird.io/manage/reverse-proxy",
|
||||
"linkText": "Learn more",
|
||||
"variant": "important",
|
||||
"isExternal": true,
|
||||
"closeable": true,
|
||||
|
||||
@@ -6,5 +6,5 @@ import React from "react";
|
||||
|
||||
export default function Redirect() {
|
||||
useRedirect("/events/audit");
|
||||
return <FullScreenLoading height={"auto"} />;
|
||||
return <FullScreenLoading fullScreen={false} />;
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@ export default function DNS() {
|
||||
router.push("/dns/nameservers");
|
||||
}, [router]);
|
||||
|
||||
return <FullScreenLoading height={"auto"} />;
|
||||
return <FullScreenLoading fullScreen={false} />;
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@ export default function ReverseProxyRedirectPage() {
|
||||
router.replace("/reverse-proxy/services");
|
||||
}, [router]);
|
||||
|
||||
return <FullScreenLoading height={"auto"} />;
|
||||
return <FullScreenLoading fullScreen={false} />;
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@ export default function Team() {
|
||||
router.push("/team/users");
|
||||
}, [router]);
|
||||
|
||||
return <FullScreenLoading height={"auto"} />;
|
||||
return <FullScreenLoading fullScreen={false} />;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const QUERY_PARAMS_KEY = "netbird-query-params";
|
||||
const PRESERVE_QUERY_PARAMS_PATHS = ["/peer/ssh", "/peer/rdp"];
|
||||
const VALID_PARAMS = [
|
||||
"tab",
|
||||
"search",
|
||||
@@ -28,9 +29,9 @@ export const SecureProvider = ({ children }: Props) => {
|
||||
const currentPath = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
if (isAuthenticated && !PRESERVE_QUERY_PARAMS_PATHS.includes(currentPath)) {
|
||||
localStorage.removeItem(QUERY_PARAMS_KEY);
|
||||
} else {
|
||||
} else if (!isAuthenticated) {
|
||||
try {
|
||||
const params = window.location.search.substring(1);
|
||||
if (params) {
|
||||
@@ -41,7 +42,7 @@ export const SecureProvider = ({ children }: Props) => {
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
}, [isAuthenticated, currentPath]);
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: NodeJS.Timeout | undefined = undefined;
|
||||
|
||||
@@ -6,24 +6,26 @@ export const ListItem = ({
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
value: string | React.ReactNode;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-between gap-12 border-b border-nb-gray-920 py-2 px-4 last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
className={cn(" border-b border-nb-gray-920 last:border-b-0", className)}
|
||||
>
|
||||
<div className={"flex items-center gap-2 text-nb-gray-100 font-medium"}>
|
||||
{icon}
|
||||
{label}
|
||||
<div className={cn("flex justify-between gap-12 py-2 px-4")}>
|
||||
<div className={"flex items-center gap-2 text-nb-gray-100 font-medium"}>
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className={"text-nb-gray-300"}>{value}</div>
|
||||
</div>
|
||||
<div className={"text-nb-gray-300"}>{value}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -76,7 +76,7 @@ interface MultiSelectProps {
|
||||
closeOnSelect?: boolean;
|
||||
resource?: PolicyRuleResource;
|
||||
onResourceChange?: (resource?: PolicyRuleResource) => void;
|
||||
placeholder?: string;
|
||||
placeholder?: React.ReactNode | string;
|
||||
customTrigger?: React.ReactNode;
|
||||
align?: "start" | "end";
|
||||
side?: "top" | "bottom";
|
||||
@@ -397,7 +397,9 @@ export function PeerGroupSelector({
|
||||
})}
|
||||
|
||||
{values.length == 0 && !resource && (
|
||||
<span className={"pl-1"}>{placeholder}</span>
|
||||
<span className={cn(typeof placeholder === "string" && "pl-1")}>
|
||||
{placeholder}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
20
src/components/skeletons/SkeletonSettings.tsx
Normal file
20
src/components/skeletons/SkeletonSettings.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
|
||||
export const SkeletonSettings = () => {
|
||||
return (
|
||||
<div className={"p-default py-6 max-w-2xl"}>
|
||||
<Skeleton height={24} width={200} className={"mb-6"} />
|
||||
<Skeleton height={32} width={110} className={"mb-10"} />
|
||||
<div className={"mb-8"}>
|
||||
<Skeleton height={17} width={200} className={"mb-2"} />
|
||||
<Skeleton height={80} width={"100%"} />
|
||||
</div>
|
||||
<div className={"mb-8"}>
|
||||
<Skeleton height={17} width={200} className={"mb-2"} />
|
||||
<Skeleton height={80} width={"100%"} />
|
||||
</div>
|
||||
<Skeleton height={80} width={"100%"} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,18 +2,17 @@ import { cn } from "@utils/helpers";
|
||||
import LoadingIcon from "@/assets/icons/LoadingIcon";
|
||||
|
||||
type Props = {
|
||||
height?: "screen" | "auto";
|
||||
fullScreen?: boolean
|
||||
};
|
||||
export default function FullScreenLoading({ height = "screen" }: Props) {
|
||||
export default function FullScreenLoading({ fullScreen = true }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center w-screen",
|
||||
height == "screen" && "h-screen",
|
||||
height == "auto" && "h-auto",
|
||||
fullScreen && "h-screen",
|
||||
)}
|
||||
>
|
||||
<LoadingIcon className={"fill-netbird"} size={44} />
|
||||
<LoadingIcon className="fill-netbird" size={44} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { isNetBirdHosted } from "@utils/netbird";
|
||||
import { isLocalDev, isNetBirdHosted } from "@utils/netbird";
|
||||
import announcementFile from "../../announcements.json";
|
||||
|
||||
const ANNOUNCEMENTS_URL =
|
||||
"https://raw.githubusercontent.com/netbirdio/dashboard/main/announcements.json";
|
||||
@@ -64,7 +65,9 @@ const getAnnouncements = async (): Promise<AnnouncementInfo[]> => {
|
||||
|
||||
let raw: Announcement[];
|
||||
|
||||
if (stored && now - stored.timestamp < CACHE_DURATION_MS) {
|
||||
if (isLocalDev()) {
|
||||
raw = announcementFile as Announcement[];
|
||||
} else if (stored && now - stored.timestamp < CACHE_DURATION_MS) {
|
||||
raw = stored.announcements;
|
||||
} else {
|
||||
const response = await fetch(ANNOUNCEMENTS_URL);
|
||||
|
||||
@@ -28,14 +28,30 @@ const UserProfileContext = React.createContext(
|
||||
);
|
||||
|
||||
export default function UsersProvider({ children }: Readonly<Props>) {
|
||||
const { data: users, mutate, isLoading } = useFetchApi<User[]>("/users");
|
||||
const { data: users, mutate, isLoading } = useFetchApi<User[]>(
|
||||
"/users?service_user=false",
|
||||
);
|
||||
const { data: serviceUsers, mutate: mutateServiceUsers, isLoading: isLoadingServiceUsers } = useFetchApi<
|
||||
User[]
|
||||
>("/users?service_user=true");
|
||||
|
||||
const refresh = () => {
|
||||
mutate().then();
|
||||
mutateServiceUsers().then();
|
||||
};
|
||||
|
||||
const allUsers = useMemo(() => {
|
||||
return [...(users ?? []), ...(serviceUsers ?? [])];
|
||||
}, [users, serviceUsers]);
|
||||
|
||||
return (
|
||||
<UsersContext.Provider value={{ users, refresh, isLoading }}>
|
||||
<UsersContext.Provider
|
||||
value={{
|
||||
users: allUsers,
|
||||
refresh,
|
||||
isLoading: isLoading || isLoadingServiceUsers,
|
||||
}}
|
||||
>
|
||||
<UserProfileProvider>{children}</UserProfileProvider>
|
||||
</UsersContext.Provider>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface Account {
|
||||
user_approval_required: boolean;
|
||||
};
|
||||
peer_login_expiration_enabled: boolean;
|
||||
peer_expose_enabled?: boolean;
|
||||
peer_expose_groups?: string[];
|
||||
peer_login_expiration: number;
|
||||
peer_inactivity_expiration_enabled: boolean;
|
||||
peer_inactivity_expiration: number;
|
||||
|
||||
@@ -664,6 +664,35 @@ export default function ActivityDescription({ event }: Props) {
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Reverse Proxy
|
||||
*/
|
||||
|
||||
if (event.activity_code == "service.peer.expose")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Peer <Value>{m.peer_name}</Value> exposed service{" "}
|
||||
<Value>{m.domain}</Value> with auth{" "}
|
||||
<Value>{m.auth ? "Enabled" : "Disabled"}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "service.peer.unexpose")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Peer <Value>{m.peer_name}</Value> unexposed service{" "}
|
||||
<Value>{m.domain}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "service.peer.expose.expire")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Service <Value>{m.domain}</Value> exposed by peer{" "}
|
||||
<Value>{m.peer_name}</Value> was removed due to renewal expiration
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Networks
|
||||
*/
|
||||
|
||||
@@ -66,6 +66,7 @@ export default function DNSRecordsTable({ zone }: Props) {
|
||||
className={"bg-nb-gray-960 py-2"}
|
||||
inset={true}
|
||||
text={"DNS Records"}
|
||||
initialPageSize={zone?.records?.length}
|
||||
manualPagination={true}
|
||||
sorting={sorting}
|
||||
columnVisibility={{}}
|
||||
|
||||
@@ -240,7 +240,7 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
||||
row.setup_keys_count > 0 ||
|
||||
row.users_count > 0 ||
|
||||
row.resources_count > 0 ||
|
||||
row.zones_count
|
||||
row.zones_count > 0
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -405,7 +405,7 @@ export default function ReverseProxyModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reverseProxy?.proxy_cluster && (
|
||||
{reverseProxy?.proxy_cluster && !isClusterConnected && (
|
||||
<Callout variant={"error"}>
|
||||
Cluster {reverseProxy.proxy_cluster} is offline. Make sure the
|
||||
proxy server is running and connected to the right management
|
||||
@@ -791,6 +791,7 @@ export default function ReverseProxyModal({
|
||||
onOpenChange={setSsoModalOpen}
|
||||
key={ssoModalOpen ? "sso1" : "sso0"}
|
||||
currentGroups={bearerGroups}
|
||||
isEnabled={bearerEnabled}
|
||||
onSave={(groups) => {
|
||||
setTimeout(() => {
|
||||
setBearerGroups(groups);
|
||||
|
||||
@@ -5,11 +5,15 @@ import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
|
||||
import React, { useState } from "react";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { useUsers } from "@/contexts/UsersProvider";
|
||||
import Badge from "@components/Badge";
|
||||
import { CircleUser } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentGroups: Group[];
|
||||
isEnabled: boolean;
|
||||
onSave: (groups: Group[]) => void;
|
||||
onRemove: () => void;
|
||||
};
|
||||
@@ -18,17 +22,17 @@ export default function AuthSSOModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentGroups,
|
||||
isEnabled,
|
||||
onSave,
|
||||
onRemove,
|
||||
}: Readonly<Props>) {
|
||||
const { users } = useUsers();
|
||||
const [groups, setGroups] = useState<Group[]>(currentGroups);
|
||||
const isEditing = currentGroups.length > 0;
|
||||
const isEditing = isEnabled;
|
||||
|
||||
const handleSave = () => {
|
||||
if (groups.length > 0) {
|
||||
onOpenChange(false);
|
||||
onSave(groups);
|
||||
}
|
||||
onOpenChange(false);
|
||||
onSave(groups);
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
@@ -51,7 +55,17 @@ export default function AuthSSOModal({
|
||||
<PeerGroupSelector
|
||||
values={groups}
|
||||
onChange={setGroups}
|
||||
placeholder="Select distribution groups..."
|
||||
placeholder={
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<Badge className={"py-[3px]"} variant={"gray-ghost"}>
|
||||
<CircleUser size={12} />
|
||||
All Users
|
||||
</Badge>
|
||||
Select user groups...
|
||||
</div>
|
||||
}
|
||||
users={users}
|
||||
hideAllGroup={true}
|
||||
/>
|
||||
<div className="flex gap-3 w-full justify-between mt-6">
|
||||
{isEditing ? (
|
||||
@@ -63,11 +77,7 @@ export default function AuthSSOModal({
|
||||
<ModalClose asChild>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={groups.length === 0}
|
||||
>
|
||||
<Button variant="primary" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
@@ -79,12 +89,8 @@ export default function AuthSSOModal({
|
||||
<ModalClose asChild>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={groups.length === 0}
|
||||
>
|
||||
Add Groups
|
||||
<Button variant="primary" onClick={handleSave}>
|
||||
Add SSO
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,11 +1,51 @@
|
||||
import Badge from "@components/Badge";
|
||||
import Button from "@components/Button";
|
||||
import { Settings, ShieldCheck, ShieldOff } from "lucide-react";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@components/HoverCard";
|
||||
import { ListItem } from "@components/ListItem";
|
||||
import GroupBadge from "@components/ui/GroupBadge";
|
||||
import { UserCountStack } from "@components/ui/MultipleGroups";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
Binary,
|
||||
LucideIcon,
|
||||
RectangleEllipsis,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { ReverseProxy } from "@/interfaces/ReverseProxy";
|
||||
|
||||
const AUTH_METHODS: {
|
||||
key: "password_auth" | "pin_auth" | "bearer_auth";
|
||||
label: string;
|
||||
hoverLabel: string;
|
||||
Icon: LucideIcon;
|
||||
}[] = [
|
||||
{
|
||||
key: "password_auth",
|
||||
label: "Password",
|
||||
hoverLabel: "Password",
|
||||
Icon: RectangleEllipsis,
|
||||
},
|
||||
{ key: "pin_auth", label: "PIN Code", hoverLabel: "PIN Code", Icon: Binary },
|
||||
{
|
||||
key: "bearer_auth",
|
||||
label: "SSO",
|
||||
hoverLabel: "SSO (Single Sign On)",
|
||||
Icon: Users,
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
reverseProxy: ReverseProxy;
|
||||
};
|
||||
@@ -15,14 +55,36 @@ export default function ReverseProxyAuthCell({
|
||||
}: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
const { openModal } = useReverseProxies();
|
||||
const { groups } = useGroups();
|
||||
const auth = reverseProxy.auth;
|
||||
|
||||
const enabledCount = [
|
||||
auth?.bearer_auth?.enabled,
|
||||
auth?.link_auth?.enabled,
|
||||
auth?.password_auth?.enabled,
|
||||
auth?.pin_auth?.enabled,
|
||||
].filter(Boolean).length;
|
||||
const enabled = AUTH_METHODS.filter((m) => auth?.[m.key]?.enabled);
|
||||
|
||||
const ssoGroups = auth?.bearer_auth?.enabled
|
||||
? (auth.bearer_auth.distribution_groups ?? [])
|
||||
.map((groupId) => groups?.find((g) => g.id === groupId))
|
||||
.filter((g): g is Group => g != undefined)
|
||||
: [];
|
||||
|
||||
const showHoverContent =
|
||||
enabled.length > 1 || (enabled.length === 1 && auth?.bearer_auth?.enabled);
|
||||
|
||||
const SingleIcon = enabled.length === 1 ? enabled[0].Icon : null;
|
||||
|
||||
const badgeContent =
|
||||
SingleIcon ? (
|
||||
<>
|
||||
<SingleIcon size={12} className="text-green-500" />
|
||||
<span className={"font-medium text-xs"}>{enabled[0].label}</span>
|
||||
</>
|
||||
) : enabled.length > 1 ? (
|
||||
<>
|
||||
<ShieldCheck size={12} className="text-green-400" />
|
||||
<span className={"font-medium text-xs"}>
|
||||
{enabled.length} Enabled
|
||||
</span>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -32,19 +94,66 @@ export default function ReverseProxyAuthCell({
|
||||
openModal({ proxy: reverseProxy, initialTab: "auth" });
|
||||
}}
|
||||
>
|
||||
{enabledCount > 0 ? (
|
||||
<Badge variant={"gray"} useHover={false} className={"cursor-pointer"}>
|
||||
<ShieldCheck size={12} className="text-green-400" />
|
||||
<div>
|
||||
<span className={"font-medium text-xs"}>Enabled</span>
|
||||
</div>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant={"gray"}>
|
||||
<ShieldOff size={12} className="text-red-500" />
|
||||
<span className={"font-medium text-xs"}>None</span>
|
||||
</Badge>
|
||||
)}
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild={true}>
|
||||
{badgeContent ? (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
useHover={false}
|
||||
className={"cursor-pointer"}
|
||||
>
|
||||
{badgeContent}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant={"gray"}>
|
||||
<ShieldOff size={12} className="text-red-500" />
|
||||
<span className={"font-medium text-xs"}>None</span>
|
||||
</Badge>
|
||||
)}
|
||||
</HoverCardTrigger>
|
||||
{showHoverContent && (
|
||||
<HoverCardContent
|
||||
className={"p-0"}
|
||||
sideOffset={14}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={"text-xs"}>
|
||||
{enabled.map(({ key, hoverLabel, Icon }) => (
|
||||
<ListItem
|
||||
key={key}
|
||||
className={"py-0.5"}
|
||||
icon={<Icon size={14} />}
|
||||
label={hoverLabel}
|
||||
value={
|
||||
<div className={"text-green-500"}>
|
||||
{key === "bearer_auth" && ssoGroups.length === 0
|
||||
? "All Users"
|
||||
: "Enabled"}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{key === "bearer_auth" && ssoGroups.length > 0 && (
|
||||
<div className={"flex flex-col gap-2 px-4 pt-2 pb-3"}>
|
||||
{ssoGroups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className={
|
||||
"flex gap-2 items-center justify-between"
|
||||
}
|
||||
>
|
||||
<GroupBadge group={group} />
|
||||
<ArrowRightIcon size={14} />
|
||||
<UserCountStack group={group} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
)}
|
||||
</HoverCard>
|
||||
|
||||
<Button
|
||||
size={"xs"}
|
||||
|
||||
@@ -75,7 +75,7 @@ const ReverseProxyColumns: ColumnDef<ReverseProxy>[] = [
|
||||
{
|
||||
accessorKey: "auth",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Authentication</DataTableHeader>;
|
||||
return <DataTableHeader column={column}>Auth Methods</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <ReverseProxyAuthCell reverseProxy={row.original} />,
|
||||
},
|
||||
|
||||
@@ -104,7 +104,7 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
|
||||
{
|
||||
accessorKey: "auth",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Authentication</DataTableHeader>
|
||||
<DataTableHeader column={column}>Auth Methods</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<ReverseProxyAuthCell reverseProxy={row.original.proxy} />
|
||||
|
||||
@@ -6,6 +6,7 @@ import InlineLink from "@components/InlineLink";
|
||||
import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
import { notify } from "@components/Notification";
|
||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import {
|
||||
SelectDropdown,
|
||||
SelectOption,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
import { useHasChanges } from "@hooks/useHasChanges";
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { validator } from "@utils/helpers";
|
||||
import { cn, validator } from "@utils/helpers";
|
||||
import {
|
||||
ClockFadingIcon,
|
||||
ExternalLinkIcon,
|
||||
@@ -27,6 +28,10 @@ import SettingsIcon from "@/assets/icons/SettingsIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Account } from "@/interfaces/Account";
|
||||
import { SmallBadge } from "@components/ui/SmallBadge";
|
||||
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { SkeletonSettings } from "@components/skeletons/SkeletonSettings";
|
||||
|
||||
type Props = {
|
||||
account: Account;
|
||||
@@ -48,6 +53,16 @@ const latestOrCustomVersion = [
|
||||
] as SelectOption[];
|
||||
|
||||
export default function ClientSettingsTab({ account }: Readonly<Props>) {
|
||||
const { isLoading: isGroupsLoading } = useGroups();
|
||||
|
||||
return isGroupsLoading ? (
|
||||
<SkeletonSettings />
|
||||
) : (
|
||||
<ClientSettingsTabContent account={account} />
|
||||
);
|
||||
}
|
||||
|
||||
function ClientSettingsTabContent({ account }: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
@@ -69,9 +84,23 @@ export default function ClientSettingsTab({ account }: Readonly<Props>) {
|
||||
isCustomVersion ? autoUpdateSetting : "",
|
||||
);
|
||||
|
||||
const [peerExposeEnabled, setPeerExposeEnabled] = useState<boolean>(
|
||||
account?.settings?.peer_expose_enabled ?? false,
|
||||
);
|
||||
const [peerExposeGroups, setPeerExposeGroups, { save: saveGroups }] =
|
||||
useGroupHelper({
|
||||
initial: account.settings?.peer_expose_groups,
|
||||
});
|
||||
const peerExposeGroupNames = useMemo(
|
||||
() => peerExposeGroups.map((g) => g.name).sort(),
|
||||
[peerExposeGroups],
|
||||
);
|
||||
|
||||
const { hasChanges, updateRef } = useHasChanges([
|
||||
autoUpdateMethod,
|
||||
autoUpdateCustomVersion,
|
||||
peerExposeEnabled,
|
||||
peerExposeGroupNames,
|
||||
]);
|
||||
|
||||
const handleUpdateMethodChange = (value: string) => {
|
||||
@@ -99,16 +128,24 @@ export default function ClientSettingsTab({ account }: Readonly<Props>) {
|
||||
return (
|
||||
!hasChanges ||
|
||||
!permission.settings.update ||
|
||||
(autoUpdateMethod === "custom" && !canSaveCustomVersion)
|
||||
(autoUpdateMethod === "custom" && !canSaveCustomVersion) ||
|
||||
(peerExposeEnabled && peerExposeGroups.length === 0)
|
||||
);
|
||||
}, [
|
||||
hasChanges,
|
||||
permission.settings.update,
|
||||
autoUpdateMethod,
|
||||
canSaveCustomVersion,
|
||||
peerExposeEnabled,
|
||||
peerExposeGroups,
|
||||
]);
|
||||
|
||||
const saveChanges = async () => {
|
||||
const groups = await saveGroups();
|
||||
const peerExposeGroupIds = groups
|
||||
.map((group) => group.id)
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
notify({
|
||||
title: "Client Settings",
|
||||
description: `Client settings successfully updated.`,
|
||||
@@ -118,11 +155,18 @@ export default function ClientSettingsTab({ account }: Readonly<Props>) {
|
||||
settings: {
|
||||
...account.settings,
|
||||
auto_update_version: autoUpdateCustomVersion || autoUpdateMethod,
|
||||
peer_expose_enabled: peerExposeEnabled,
|
||||
peer_expose_groups: peerExposeGroupIds,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
mutate("/accounts");
|
||||
updateRef([autoUpdateMethod, autoUpdateCustomVersion]);
|
||||
updateRef([
|
||||
autoUpdateMethod,
|
||||
autoUpdateCustomVersion,
|
||||
peerExposeEnabled,
|
||||
peerExposeGroupNames,
|
||||
]);
|
||||
}),
|
||||
loadingMessage: "Updating client settings...",
|
||||
});
|
||||
@@ -152,7 +196,7 @@ export default function ClientSettingsTab({ account }: Readonly<Props>) {
|
||||
|
||||
return (
|
||||
<Tabs.Content value={"clients"}>
|
||||
<div className={"p-default py-6 max-w-xl"}>
|
||||
<div className={"p-default py-6 max-w-2xl"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/settings"}
|
||||
@@ -178,7 +222,7 @@ export default function ClientSettingsTab({ account }: Readonly<Props>) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={"flex flex-col gap-6 w-full mt-8"}>
|
||||
<div className={"flex flex-col gap-10 w-full mt-8"}>
|
||||
<div className={"flex flex-col relative"}>
|
||||
<Label>
|
||||
<RefreshCcw size={15} />
|
||||
@@ -223,7 +267,63 @@ export default function ClientSettingsTab({ account }: Readonly<Props>) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"mt-3"}>
|
||||
<div>
|
||||
<div>
|
||||
<Label>
|
||||
<ReverseProxyIcon size={15} className={"fill-nb-gray-300"} />
|
||||
Expose Services from CLI
|
||||
</Label>
|
||||
<HelpText>
|
||||
Allow peers to expose local services through the NetBird reverse
|
||||
proxy using the CLI. <br /> This requires at least NetBird{" "}
|
||||
<span className={"text-white font-medium"}>v0.66.0</span>.{" "}
|
||||
<InlineLink
|
||||
href={
|
||||
"https://docs.netbird.io/manage/reverse-proxy/expose-from-cli"
|
||||
}
|
||||
target={"_blank"}
|
||||
>
|
||||
Learn more
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<FancyToggleSwitch
|
||||
className={"mt-2"}
|
||||
value={peerExposeEnabled}
|
||||
onChange={setPeerExposeEnabled}
|
||||
label={"Enable Peer Expose"}
|
||||
helpText={
|
||||
"When enabled, peers can expose local HTTP services accessible via a public URL."
|
||||
}
|
||||
disabled={!permission.settings.update}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"border border-nb-gray-900 border-t-0 rounded-b-md bg-nb-gray-940 px-[1.28rem] pt-3 pb-5 flex flex-col gap-4 mx-[0.25rem]",
|
||||
!peerExposeEnabled
|
||||
? "opacity-50 pointer-events-none"
|
||||
: "bg-nb-gray-930/80",
|
||||
)}
|
||||
>
|
||||
<div className={"mt-2"}>
|
||||
<Label>Allowed peer groups</Label>
|
||||
<HelpText>
|
||||
Select which peer groups are allowed to expose services. At
|
||||
least one group is required.
|
||||
</HelpText>
|
||||
<PeerGroupSelector
|
||||
values={peerExposeGroups}
|
||||
onChange={setPeerExposeGroups}
|
||||
placeholder="Select peer groups..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
<FlaskConicalIcon size={15} />
|
||||
Experimental
|
||||
@@ -241,25 +341,26 @@ export default function ClientSettingsTab({ account }: Readonly<Props>) {
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</HelpText>
|
||||
<FancyToggleSwitch
|
||||
className={"mt-2"}
|
||||
value={lazyConnection}
|
||||
onChange={toggleLazyConnection}
|
||||
label={
|
||||
<>
|
||||
<ClockFadingIcon size={15} />
|
||||
Enable Lazy Connections
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
<>
|
||||
Allow to establish connections between peers only when
|
||||
required. This requires NetBird client v0.45 or higher.
|
||||
Changes will only take effect after restarting the clients.
|
||||
</>
|
||||
}
|
||||
disabled={!permission.settings.update}
|
||||
/>
|
||||
</div>
|
||||
<FancyToggleSwitch
|
||||
value={lazyConnection}
|
||||
onChange={toggleLazyConnection}
|
||||
label={
|
||||
<>
|
||||
<ClockFadingIcon size={15} />
|
||||
Enable Lazy Connections
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
<>
|
||||
Allow to establish connections between peers only when required.
|
||||
This requires NetBird client v0.45 or higher. Changes will only
|
||||
take effect after restarting the clients.
|
||||
</>
|
||||
}
|
||||
disabled={!permission.settings.update}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
Reference in New Issue
Block a user