Compare commits

...

2 Commits

Author SHA1 Message Date
Eduard Gert
15fb6e0b05 Refactor resource modal (#582)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-03-12 16:30:51 +01:00
Eduard Gert
55c5525626 Fix resource group policy when adding single resource as destination (#581)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-03-11 19:23:59 +01:00
9 changed files with 203 additions and 180 deletions

View File

@@ -2,6 +2,7 @@
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { cn } from "@utils/helpers";
import { motion } from "framer-motion";
import { ChevronDown } from "lucide-react";
import * as React from "react";
@@ -23,7 +24,7 @@ const AccordionTrigger = React.forwardRef<
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center gap-4 font-medium transition-all [&[data-state=open]>svg.chevron]:rotate-180 hover:opacity-80 my-2",
"flex flex-1 items-center gap-4 font-medium [&[data-state=open]>svg.chevron]:rotate-180 hover:opacity-80 my-2",
className,
)}
{...props}
@@ -36,20 +37,41 @@ const AccordionTrigger = React.forwardRef<
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
HTMLDivElement,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className={cn(
"overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
className,
)}
{...props}
>
<div className=" pt-0">{children}</div>
</AccordionPrimitive.Content>
));
>(({ className, children }, ref) => {
const wrapperRef = React.useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = React.useState(false);
React.useEffect(() => {
const el = wrapperRef.current?.closest("[data-state]");
if (!el) return;
const update = () => setIsOpen(el.getAttribute("data-state") === "open");
update();
const observer = new MutationObserver(update);
observer.observe(el, { attributes: true, attributeFilter: ["data-state"] });
return () => observer.disconnect();
}, []);
return (
<div ref={wrapperRef}>
<motion.div
ref={ref}
initial={false}
animate={{
height: isOpen ? "auto" : 0,
opacity: isOpen ? 1 : 0,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
className={cn("overflow-hidden text-sm", className)}
>
<div className="pt-0">{children}</div>
</motion.div>
</div>
);
});
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };

View File

@@ -85,6 +85,7 @@ interface MultiSelectProps {
users?: User[];
placeholderForSearch?: string;
resourceIds?: string[];
additionalResources?: NetworkResource[];
policies?: Policy[];
}
export function PeerGroupSelector({
@@ -117,12 +118,21 @@ export function PeerGroupSelector({
users,
placeholderForSearch = 'Search groups or add new group by pressing "Enter"...',
resourceIds,
additionalResources,
policies,
}: Readonly<MultiSelectProps>) {
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
const { data: fetchedResources, isLoading: isResourcesLoading } = useFetchApi<
NetworkResource[]
>("/networks/resources");
const resources = useMemo(() => {
if (!additionalResources?.length) return fetchedResources;
const additional = additionalResources.filter(
(ar) => !fetchedResources?.some((r) => r.id === ar.id),
);
return [...(fetchedResources || []), ...additional];
}, [fetchedResources, additionalResources]);
const { data: peers, isLoading: isPeersLoading } =
useFetchApi<Peer[]>("/peers");

View File

@@ -38,6 +38,7 @@ const ModalOverlay = React.forwardRef<
"bg-black/30 dark:bg-black/40 backdrop-blur-sm",
className,
)}
style={{ scrollbarGutter: "stable both-edges" }}
{...props}
/>
));

View File

@@ -26,6 +26,7 @@ const PoliciesContext = React.createContext(
createPoliciesForResource: (
policies: Policy[],
resource: NetworkResource,
knownGroups?: Group[],
) => Promise<void>;
openEditPolicyModal: (policy: Policy, tab?: string) => void;
deletePolicy: (policy: Policy, onSuccess?: () => void) => Promise<void>;
@@ -39,7 +40,7 @@ const PoliciesContext = React.createContext(
export default function PoliciesProvider({ children }: Props) {
const { mutate } = useSWRConfig();
const request = useApiCall<Policy>("/policies");
const { createOrUpdate: createOrUpdateGroup } = useGroups();
const { createOrUpdate: createOrUpdateGroup, groups } = useGroups();
const [policyModal, setPolicyModal] = useState(false);
const [currentPolicy, setCurrentPolicy] = useState<Policy>();
const [initialPolicyTab, setInitialPolicyTab] = useState("");
@@ -49,28 +50,33 @@ export default function PoliciesProvider({ children }: Props) {
const createPolicyForResource = async (
policy: Policy,
resource: NetworkResource,
knownGroups?: Group[],
) => {
const rule = policy.rules[0];
const allGroups = [...(knownGroups || []), ...(groups || [])];
const resolveGroup = async (g: Group | string): Promise<string> => {
if (typeof g === "string") return g;
if (g.id) return g.id;
const existing = allGroups.find((eg) => eg.name === g.name);
if (existing?.id) return existing.id;
const created = await createOrUpdateGroup(g);
return created.id!;
};
const sources = await Promise.all(
(rule.sources ?? []).map((g) => {
if (typeof g === "string") return g;
if (g.id) return g.id;
return createOrUpdateGroup(g).then((r) => r.id);
}),
(rule.sources ?? []).map(resolveGroup),
).then((ids) => ids.filter(Boolean) as string[]);
const hasGroups = resource.groups && resource.groups.length > 0;
const destinations = rule.destinationResource
? undefined
: await Promise.all((rule.destinations ?? []).map(resolveGroup)).then(
(ids) => ids.filter(Boolean) as string[],
);
const destinations = hasGroups
? await Promise.all(
(resource.groups as (Group | string)[]).map((g) => {
if (typeof g === "string") return g;
if (g.id) return g.id;
return createOrUpdateGroup(g).then((r) => r.id);
}),
).then((ids) => ids.filter(Boolean) as string[])
: null;
const destinationResource = rule.destinationResource
? { id: resource.id, type: resource.type }
: undefined;
return createPolicy({
...policy,
@@ -82,9 +88,7 @@ export default function PoliciesProvider({ children }: Props) {
...rule,
sources,
destinations,
destinationResource: hasGroups
? undefined
: { id: resource.id, type: resource.type },
destinationResource,
},
],
} as Policy);
@@ -93,22 +97,17 @@ export default function PoliciesProvider({ children }: Props) {
const createPoliciesForResource = async (
newPolicies: Policy[],
resource: NetworkResource,
knownGroups?: Group[],
) => {
const policiesToCreate = newPolicies.filter((p) => !p.id);
if (policiesToCreate.length === 0) return;
const promise = Promise.all(
policiesToCreate.map((p) => createPolicyForResource(p, resource)),
).then(() => mutate("/policies"));
notify({
title: "Create Policies",
description: "Successfully created policies for resource.",
promise,
showOnlyError: true,
});
return promise;
await Promise.all(
policiesToCreate.map((p) =>
createPolicyForResource(p, resource, knownGroups),
),
);
await mutate("/policies");
};
const serializeRules = (rules: Policy["rules"], enabled?: boolean) => {

View File

@@ -46,6 +46,7 @@ import React, { useMemo, useState } from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import { NetworkResource } from "@/interfaces/Network";
import { Policy, PolicyRuleResource, Protocol } from "@/interfaces/Policy";
import { PostureCheck } from "@/interfaces/PostureCheck";
import { useAccessControl } from "@/modules/access-control/useAccessControl";
@@ -126,6 +127,7 @@ type ModalProps = {
initialDestinationResource?: PolicyRuleResource;
initialTab?: string;
disableDestinationSelector?: boolean;
additionalResources?: NetworkResource[];
};
export function AccessControlModalContent({
@@ -143,6 +145,7 @@ export function AccessControlModalContent({
initialDestinationResource,
initialTab,
disableDestinationSelector = false,
additionalResources,
}: Readonly<ModalProps>) {
const { permission } = usePermissions();
const { users } = useUsers();
@@ -392,6 +395,7 @@ export function AccessControlModalContent({
resource={destinationResource}
onResourceChange={setDestinationResource}
saveGroupAssignments={useSave}
additionalResources={additionalResources}
disabled={
disableDestinationSelector ||
!permission.policies.update ||

View File

@@ -192,6 +192,7 @@ export const NetworkProvider = ({
? [...fetchedResources, additionalResource]
: fetchedResources;
const isMulti = affectedResources.length > 1;
if (!isMulti && action === "edit") return true;
return confirm({
title: isMulti ? (
<>This policy is used by multiple resources</>
@@ -206,8 +207,8 @@ export const NetworkProvider = ({
action === "edit" ? "Updating" : "Deleting"
} this policy will also affect following resources:`
: action === "delete"
? "Are you sure you want to delete this policy? This action cannot be undone."
: undefined,
? "Are you sure you want to delete this policy? This action cannot be undone."
: undefined,
children: isMulti ? (
<AffectedResourceList resources={affectedResources} />
) : undefined,

View File

@@ -68,7 +68,7 @@ export default function NetworkResourceAccessControl({
const currentResource = useMemo<NetworkResource>(() => {
return {
id: resourceId || "",
id: resourceId || resourceName || address,
name: resourceName || address,
address,
type: getResourceType(address),
@@ -289,7 +289,8 @@ export default function NetworkResourceAccessControl({
editingPolicyIndex === null ? destinationResource : undefined
}
disableDestinationSelector={!hasResourceGroups}
initialName={`${resourceName || address} Policy`}
additionalResources={[currentResource]}
initialName={`${resourceName || address} Access`}
initialDescription={
network?.description
? `${network.name}, ${network.description}`

View File

@@ -2,7 +2,6 @@
import Button from "@components/Button";
import { Callout } from "@components/Callout";
import FancyToggleSwitch from "@components/FancyToggleSwitch";
import HelpText from "@components/HelpText";
import InlineLink, { InlineButtonLink } from "@components/InlineLink";
import { Input } from "@components/Input";
@@ -26,11 +25,15 @@ import { useNetworksContext } from "@/modules/networks/NetworkProvider";
import {
ExternalLinkIcon,
PlusCircle,
Power,
ShieldCheck,
Text,
WorkflowIcon,
} from "lucide-react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@components/Accordion";
import React, { useMemo, useRef, useState } from "react";
import { Group } from "@/interfaces/Group";
import { Network, NetworkResource } from "@/interfaces/Network";
@@ -133,9 +136,7 @@ export function ResourceModalContent({
return allPolicies.filter((policy) => {
const rule = policy.rules?.[0];
if (!rule || rule.destinationResource) return false;
const destinations = rule.destinations as
| (Group | string)[]
| undefined;
const destinations = rule.destinations as (Group | string)[] | undefined;
return destinations?.some((d) => {
const id = typeof d === "string" ? d : d.id;
return !!id && groupIds.has(id);
@@ -175,7 +176,7 @@ export function ResourceModalContent({
groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
enabled,
}).then(async (r) => {
await createPoliciesForResource(policies, r);
await createPoliciesForResource(policies, r, savedGroups);
onCreated?.(r);
});
@@ -199,7 +200,7 @@ export function ResourceModalContent({
groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
enabled,
}).then(async (r) => {
await createPoliciesForResource(policies, r);
await createPoliciesForResource(policies, r, savedGroups);
onUpdated?.(r);
});
notify({
@@ -217,7 +218,7 @@ export function ResourceModalContent({
return (
<ModalContent
maxWidthClass={
tab === "access-control" ? "max-w-[790px]" : "max-w-[720px]"
tab === "access-control" ? "max-w-[790px]" : "max-w-[680px]"
}
>
<ModalHeader
@@ -239,32 +240,34 @@ export function ResourceModalContent({
</TabsTrigger>
<TabsTrigger
value={"access-control"}
disabled={!resource && !isAddressValid}
disabled={!resource && !canCreate}
>
<ShieldCheck size={16} />
Access Control
</TabsTrigger>
<TabsTrigger
value={"general"}
disabled={!resource && !isAddressValid}
>
<Text
size={16}
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
/>
Name & Description
</TabsTrigger>
</TabsList>
<TabsContent value={"resource"} className={"pb-8"}>
<div className={"px-8 flex-col flex gap-8"}>
<TabsContent value={"resource"} className={"pb-4"}>
<div className={"px-8 flex-col flex gap-6"}>
<div>
<Label>Name</Label>
<HelpText>
Set an easily identifiable name for your resource
</HelpText>
<Input
ref={nameRef}
autoFocus={true}
tabIndex={0}
placeholder={"e.g., Postgres Database"}
value={name}
error={nameError}
onChange={(e) => setName(e.target.value)}
/>
</div>
<ResourceSingleAddressInput
value={address}
onChange={setAddress}
onError={setAddressError}
autoFocus={true}
description={
<>
Enter a single{" "}
@@ -295,59 +298,83 @@ export function ResourceModalContent({
}
/>
<div>
<Label>Resource Groups (optional)</Label>
<HelpText>
Organize this resource into a group (e.g., Databases, Web
Servers) and reference the group in access policies to keep
rules reusable and easy to maintain.
</HelpText>
<PeerGroupSelector
side={"top"}
onChange={setGroups}
values={groups}
showPeerCounter={false}
placeholder={"Add or select resource group(s)..."}
policies={allPolicies}
/>
{groupPolicyCount > 0 && (
<Callout variant={"info"} className={"mt-3"}>
Your selected resource groups are used in{" "}
<span className="text-white font-medium">
{groupPolicyCount} Access Control{" "}
{groupPolicyCount === 1 ? "Policy" : "Policies"}
<Accordion
type={"multiple"}
className={"flex flex-col gap-2 -mt-2"}
>
<AccordionItem value={"resource-groups"}>
<AccordionTrigger
className={
"text-[0.8rem] tracking-wider text-nb-gray-200 py-4 my-0 leading-none gap-2 flex items-center"
}
>
<span className={"relative top-[1px]"}>
Additional Options (optional)
</span>
. This resource will inherit access from{" "}
{groupPolicyCount === 1 ? "this policy" : "these policies"}.
{isAddressValid || resource ? (
<>
{" "}
Please review them in the{" "}
<InlineButtonLink
onClick={() => setTab("access-control")}
variant={"dashed"}
>
Access Control
</InlineButtonLink>{" "}
tab.
</>
) : (
" Please review them in the Access Control tab."
)}
</Callout>
)}
</div>
<FancyToggleSwitch
value={enabled}
onChange={setEnabled}
label={
<>
<Power size={15} />
Enable Resource
</>
}
helpText={"Use this switch to enable or disable the resource."}
/>
</AccordionTrigger>
<AccordionContent className={""}>
<div className={"flex flex-col gap-6 pb-4 pt-2"}>
<div>
<Label>Description</Label>
<HelpText>
Write a short description to add more context to this
resource.
</HelpText>
<Input
placeholder={"e.g., Production, Development"}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div>
<Label>Resource Groups</Label>
<HelpText className={"mt-1"}>
Add this resource to a group (e.g., Databases, Web
Servers) and reference the group <br /> in access
policies to simplify management.
</HelpText>
<PeerGroupSelector
side={"top"}
onChange={setGroups}
values={groups}
showPeerCounter={false}
placeholder={"Add or select resource group(s)..."}
policies={allPolicies}
/>
{groupPolicyCount > 0 && (
<Callout variant={"info"} className={"mt-3"}>
Your selected resource groups are used in{" "}
<span className="text-white font-medium">
{groupPolicyCount} Access Control{" "}
{groupPolicyCount === 1 ? "Policy" : "Policies"}
</span>
. This resource will inherit access from{" "}
{groupPolicyCount === 1
? "this policy"
: "these policies"}
.
{isAddressValid || resource ? (
<>
{" "}
Please review them in the{" "}
<InlineButtonLink
onClick={() => setTab("access-control")}
variant={"dashed"}
>
Access Control
</InlineButtonLink>{" "}
tab.
</>
) : (
" Please review them in the Access Control tab."
)}
</Callout>
)}
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</TabsContent>
@@ -362,36 +389,6 @@ export function ResourceModalContent({
hasResourceGroups={groups.length > 0}
/>
</TabsContent>
<TabsContent value={"general"} className={"px-8 pb-8"}>
<div className={"flex flex-col gap-6"}>
<div>
<Label>Name</Label>
<HelpText>
Set an easily identifiable name for your resource
</HelpText>
<Input
ref={nameRef}
tabIndex={0}
placeholder={"e.g., Postgres Database"}
value={name}
error={nameError}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div>
<Label>Description (optional)</Label>
<HelpText>
Write a short description to add more context to this resource.
</HelpText>
<Input
placeholder={"e.g., Production, Development"}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
</TabsContent>
</Tabs>
<ModalFooter className={"items-center"}>
@@ -418,7 +415,7 @@ export function ResourceModalContent({
<Button
variant={"primary"}
onClick={() => setTab("access-control")}
disabled={!isAddressValid}
disabled={!canCreate}
>
Continue
</Button>
@@ -433,26 +430,6 @@ export function ResourceModalContent({
>
Back
</Button>
<Button
variant={"primary"}
onClick={() => {
setTab("general");
setTimeout(() => nameRef.current?.focus(), 0);
}}
>
Continue
</Button>
</>
)}
{tab === "general" && (
<>
<Button
variant={"secondary"}
onClick={() => setTab("access-control")}
>
Back
</Button>
<Button
variant={"primary"}
data-cy={"submit-route"}

View File

@@ -12,6 +12,9 @@ import { useApiCall } from "@utils/api";
import * as React from "react";
import { Network, NetworkResource } from "@/interfaces/Network";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
import { FolderGit2 } from "lucide-react";
import Separator from "@components/Separator";
type ResourceGroupModalProps = {
resource?: NetworkResource;
@@ -56,6 +59,7 @@ const ResourceGroupModalContent = ({
`/networks/${network?.id}/resources/${resource?.id}`,
).put;
const { policies } = useNetworksContext();
const [groups, setGroups, { save: saveGroups }] = useGroupHelper({
initial: resource?.groups || [],
});
@@ -80,17 +84,21 @@ const ResourceGroupModalContent = ({
<ModalHeader
title={"Resource Groups"}
description={
"Organize this resource into a group (e.g., Databases, Web Servers) and reference the group in access policies to keep rules reusable and easy to maintain."
"Add this resource to a group (e.g., Databases, Web Servers) and reference the group in access policies to simplify management."
}
icon={<FolderGit2 size={18} />}
/>
<div className={"px-8 py-6 pt-0 flex flex-col gap-8"}>
<Separator />
<div className={"px-8 py-6 pt-6 flex flex-col gap-8"}>
<div>
<PeerGroupSelector
onChange={setGroups}
values={groups}
showPeerCounter={false}
placeholder={"Add or select resource group(s)..."}
policies={policies}
/>
</div>
</div>