Compare commits

...

6 Commits

Author SHA1 Message Date
Eduard Gert
f5824d6ddb Allow empty groups for reverse proxy sso auth (#563)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-02-18 16:27:13 +01:00
Eduard Gert
829395f908 Add hover to reverse proxy auth methods (#564) 2026-02-18 13:39:19 +01:00
Eduard Gert
8eebec78b4 Preserve query params for ssh and rdp (#559)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-02-16 17:34:08 +01:00
raghvendra
3e01a6dafd refactor: simplify FullScreenLoading to use boolean prop instead of string union (#555) 2026-02-16 11:10:26 +01:00
Maycon Santos
1555b94043 Fix service cluster status (#556)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-02-16 09:23:22 +01:00
Eduard Gert
6c62127d42 Update announcement (#553) 2026-02-13 20:56:40 +01:00
16 changed files with 208 additions and 69 deletions

View File

@@ -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,

View File

@@ -6,5 +6,5 @@ import React from "react";
export default function Redirect() {
useRedirect("/events/audit");
return <FullScreenLoading height={"auto"} />;
return <FullScreenLoading fullScreen={false} />;
}

View File

@@ -11,5 +11,5 @@ export default function DNS() {
router.push("/dns/nameservers");
}, [router]);
return <FullScreenLoading height={"auto"} />;
return <FullScreenLoading fullScreen={false} />;
}

View File

@@ -11,5 +11,5 @@ export default function ReverseProxyRedirectPage() {
router.replace("/reverse-proxy/services");
}, [router]);
return <FullScreenLoading height={"auto"} />;
return <FullScreenLoading fullScreen={false} />;
}

View File

@@ -11,5 +11,5 @@ export default function Team() {
router.push("/team/users");
}, [router]);
return <FullScreenLoading height={"auto"} />;
return <FullScreenLoading fullScreen={false} />;
}

View File

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

View File

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

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
</>

View File

@@ -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"}

View File

@@ -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} />,
},

View File

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