mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-07 00:39:53 +00:00
Update UI to support additions on the resource
This commit is contained in:
@@ -336,7 +336,10 @@ export default function ResourceAuthenticationPage() {
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<EditPolicyForm readonly />
|
||||
<EditPolicyForm
|
||||
readonly
|
||||
resourceId={resource.resourceId}
|
||||
/>
|
||||
</ResourcePolicyProvider>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -23,6 +23,7 @@ export type MultiSelectTagsProps<T extends TagValue> = {
|
||||
onSearch: (query: string) => void;
|
||||
ref?: Ref<HTMLButtonElement>;
|
||||
disabled?: boolean;
|
||||
lockedIds?: Set<string>;
|
||||
};
|
||||
|
||||
export function MultiSelectContent<T extends TagValue>({
|
||||
@@ -32,7 +33,8 @@ export function MultiSelectContent<T extends TagValue>({
|
||||
value,
|
||||
options,
|
||||
onSearch,
|
||||
onChange
|
||||
onChange,
|
||||
lockedIds
|
||||
}: MultiSelectTagsProps<T>) {
|
||||
const t = useTranslations();
|
||||
const selectedValues = new Set(value.map((v) => v.id));
|
||||
@@ -48,33 +50,38 @@ export function MultiSelectContent<T extends TagValue>({
|
||||
{emptyPlaceholder ?? t("noResults")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
value={option.id}
|
||||
key={option.id}
|
||||
onSelect={() => {
|
||||
let newValues = [];
|
||||
if (selectedValues.has(option.id)) {
|
||||
newValues = value.filter(
|
||||
(v) => v.id !== option.id
|
||||
);
|
||||
} else {
|
||||
newValues = [...value, option];
|
||||
}
|
||||
onChange(newValues);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedValues.has(option.id)
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{`${option.text}`}
|
||||
</CommandItem>
|
||||
))}
|
||||
{options.map((option) => {
|
||||
const isLocked = lockedIds?.has(option.id);
|
||||
return (
|
||||
<CommandItem
|
||||
value={option.id}
|
||||
key={option.id}
|
||||
disabled={isLocked}
|
||||
onSelect={() => {
|
||||
if (isLocked) return;
|
||||
let newValues = [];
|
||||
if (selectedValues.has(option.id)) {
|
||||
newValues = value.filter(
|
||||
(v) => v.id !== option.id
|
||||
);
|
||||
} else {
|
||||
newValues = [...value, option];
|
||||
}
|
||||
onChange(newValues);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedValues.has(option.id)
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{`${option.text}`}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ChevronDownIcon, XIcon } from "lucide-react";
|
||||
import { ChevronDownIcon, LockIcon, XIcon } from "lucide-react";
|
||||
import {
|
||||
type MultiSelectTagsProps,
|
||||
type TagValue,
|
||||
@@ -16,10 +16,12 @@ export interface MultiSelectInputProps<
|
||||
T extends TagValue
|
||||
> extends MultiSelectTagsProps<T> {
|
||||
buttonText?: string;
|
||||
lockedIds?: Set<string>;
|
||||
}
|
||||
|
||||
export function MultiSelectTagInput<T extends TagValue>({
|
||||
buttonText,
|
||||
lockedIds,
|
||||
...props
|
||||
}: MultiSelectInputProps<T>) {
|
||||
const selectedValues = new Set(props.value.map((v) => v.id));
|
||||
@@ -52,46 +54,63 @@ export function MultiSelectTagInput<T extends TagValue>({
|
||||
"overflow-x-auto"
|
||||
)}
|
||||
>
|
||||
{props.value.map((option) => (
|
||||
<span
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"bg-muted-foreground/10 font-normal text-foreground rounded-sm",
|
||||
"py-1 pl-1.5 pr-0.5 text-xs inline-flex items-center gap-0.5"
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{option.text}
|
||||
<button
|
||||
className="p-0.5 flex-none cursor-pointer"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
let newValues = [];
|
||||
if (selectedValues.has(option.id)) {
|
||||
newValues = props.value.filter(
|
||||
(v) => v.id !== option.id
|
||||
);
|
||||
} else {
|
||||
newValues = [
|
||||
...props.value,
|
||||
option
|
||||
];
|
||||
}
|
||||
props.onChange(newValues);
|
||||
}}
|
||||
{props.value.map((option) => {
|
||||
const isLocked = lockedIds?.has(option.id);
|
||||
return (
|
||||
<span
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"bg-muted-foreground/10 font-normal text-foreground rounded-sm",
|
||||
"py-1 pl-1.5 pr-0.5 text-xs inline-flex items-center gap-0.5",
|
||||
isLocked && "opacity-60"
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{option.text}
|
||||
{isLocked ? (
|
||||
<span className="p-0.5 flex-none">
|
||||
<LockIcon className="size-3" />
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className="p-0.5 flex-none cursor-pointer"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
let newValues = [];
|
||||
if (
|
||||
selectedValues.has(
|
||||
option.id
|
||||
)
|
||||
) {
|
||||
newValues =
|
||||
props.value.filter(
|
||||
(v) =>
|
||||
v.id !==
|
||||
option.id
|
||||
);
|
||||
} else {
|
||||
newValues = [
|
||||
...props.value,
|
||||
option
|
||||
];
|
||||
}
|
||||
props.onChange(newValues);
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<span className="pl-1 font-normal">{buttonText}</span>
|
||||
</span>
|
||||
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<MultiSelectContent {...props} />
|
||||
<MultiSelectContent {...props} lockedIds={lockedIds} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -27,11 +27,13 @@ import { EditPolicyRulesSectionForm } from "./EditPolicyRulesSectionForm";
|
||||
export type EditPolicyFormProps = {
|
||||
hidePolicyNameForm?: boolean;
|
||||
readonly?: boolean;
|
||||
resourceId?: number;
|
||||
};
|
||||
|
||||
export function EditPolicyForm({
|
||||
hidePolicyNameForm,
|
||||
readonly
|
||||
readonly,
|
||||
resourceId
|
||||
}: EditPolicyFormProps) {
|
||||
const { org } = useOrgContext();
|
||||
const t = useTranslations();
|
||||
@@ -84,6 +86,7 @@ export function EditPolicyForm({
|
||||
orgId={org.org.orgId}
|
||||
allIdps={allIdps}
|
||||
readonly={readonly}
|
||||
resourceId={resourceId}
|
||||
/>
|
||||
|
||||
<EditPolicyAuthMethodsSectionForm readonly={readonly} />
|
||||
@@ -97,6 +100,7 @@ export function EditPolicyForm({
|
||||
isMaxmindAvailable={isMaxmindAvailable}
|
||||
isMaxmindAsnAvailable={isMaxmindASNAvailable}
|
||||
readonly={readonly}
|
||||
resourceId={resourceId}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
);
|
||||
|
||||
@@ -75,13 +75,28 @@ import {
|
||||
getSortedRowModel,
|
||||
useReactTable
|
||||
} from "@tanstack/react-table";
|
||||
import { ArrowUpDown, Check, ChevronsUpDown, Plus } from "lucide-react";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
LockIcon,
|
||||
Plus
|
||||
} from "lucide-react";
|
||||
|
||||
import { useCallback, useMemo, useState, useTransition } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition
|
||||
} from "react";
|
||||
import { UseFormReturn, useForm, useWatch } from "react-hook-form";
|
||||
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { resourceQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
@@ -103,18 +118,21 @@ type LocalRule = {
|
||||
enabled: boolean;
|
||||
new?: boolean;
|
||||
updated?: boolean;
|
||||
fromPolicy?: boolean;
|
||||
};
|
||||
|
||||
type PolicyRulesSectionProps = {
|
||||
isMaxmindAvailable: boolean;
|
||||
isMaxmindAsnAvailable: boolean;
|
||||
readonly?: boolean;
|
||||
resourceId?: number;
|
||||
};
|
||||
|
||||
export function EditPolicyRulesSectionForm({
|
||||
isMaxmindAvailable,
|
||||
isMaxmindAsnAvailable,
|
||||
readonly
|
||||
readonly,
|
||||
resourceId
|
||||
}: PolicyRulesSectionProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -122,6 +140,18 @@ export function EditPolicyRulesSectionForm({
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
|
||||
const isResourceOverlay = resourceId !== undefined;
|
||||
|
||||
// ── Fetch resource-specific rules when in overlay mode ───────────────────
|
||||
const { data: resourceRulesData } = useQuery({
|
||||
...resourceQueries.resourceRules({ resourceId: resourceId! }),
|
||||
enabled: isResourceOverlay
|
||||
});
|
||||
|
||||
const deletedResourceRuleIdsRef = useRef<Set<number>>(new Set());
|
||||
const [resourceRulesInitialized, setResourceRulesInitialized] =
|
||||
useState(false);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
createPolicySchema.pick({
|
||||
@@ -140,8 +170,42 @@ export function EditPolicyRulesSectionForm({
|
||||
name: "applyRules"
|
||||
});
|
||||
|
||||
const [rules, setRules] = useState<LocalRule[]>(policy.rules);
|
||||
const [isExpanded, setIsExpanded] = useState(rulesEnabled);
|
||||
const [rules, setRules] = useState<LocalRule[]>(
|
||||
policy.rules.map((r) => ({ ...r, fromPolicy: !isResourceOverlay }))
|
||||
);
|
||||
const [isExpanded, setIsExpanded] = useState(
|
||||
rulesEnabled || isResourceOverlay
|
||||
);
|
||||
|
||||
// Initialize resource-specific rules once fetched
|
||||
useEffect(() => {
|
||||
if (!isResourceOverlay || resourceRulesInitialized) return;
|
||||
if (!resourceRulesData) return;
|
||||
|
||||
const policyRuleIds = new Set(policy.rules.map((r) => r.ruleId));
|
||||
const resourceSpecific: LocalRule[] = resourceRulesData
|
||||
.filter((r) => !policyRuleIds.has(r.ruleId))
|
||||
.map((r) => ({
|
||||
ruleId: r.ruleId,
|
||||
action: r.action as "ACCEPT" | "DROP" | "PASS",
|
||||
match: r.match,
|
||||
value: r.value,
|
||||
priority: r.priority,
|
||||
enabled: r.enabled,
|
||||
fromPolicy: false
|
||||
}));
|
||||
|
||||
setRules([
|
||||
...policy.rules.map((r) => ({ ...r, fromPolicy: true })),
|
||||
...resourceSpecific
|
||||
]);
|
||||
setResourceRulesInitialized(true);
|
||||
}, [
|
||||
isResourceOverlay,
|
||||
resourceRulesData,
|
||||
resourceRulesInitialized,
|
||||
policy.rules
|
||||
]);
|
||||
|
||||
const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] =
|
||||
useState(false);
|
||||
@@ -275,11 +339,17 @@ export function EditPolicyRulesSectionForm({
|
||||
|
||||
const removeRule = useCallback(
|
||||
function removeRule(ruleId: number) {
|
||||
const rule = rules.find((r) => r.ruleId === ruleId);
|
||||
if (!rule || rule.fromPolicy) return; // cannot remove policy rules
|
||||
// Track deletion for resource overlay mode (only for existing DB rules)
|
||||
if (isResourceOverlay && !rule.new) {
|
||||
deletedResourceRuleIdsRef.current.add(ruleId);
|
||||
}
|
||||
const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId);
|
||||
setRules(updatedRules);
|
||||
syncFormRules(updatedRules);
|
||||
},
|
||||
[rules, syncFormRules]
|
||||
[rules, syncFormRules, isResourceOverlay]
|
||||
);
|
||||
|
||||
const updateRule = useCallback(
|
||||
@@ -328,35 +398,45 @@ export function EditPolicyRulesSectionForm({
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Input
|
||||
defaultValue={row.original.priority}
|
||||
className="w-[75px]"
|
||||
type="number"
|
||||
disabled={readonly}
|
||||
onClick={(e) => e.currentTarget.focus()}
|
||||
onBlur={(e) => {
|
||||
const parsed = z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.safeParse(e.target.value);
|
||||
if (!parsed.success) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("rulesErrorInvalidPriority"),
|
||||
description: t(
|
||||
"rulesErrorInvalidPriorityDescription"
|
||||
)
|
||||
cell: ({ row }) => {
|
||||
const isLocked = row.original.fromPolicy;
|
||||
if (isLocked) {
|
||||
return (
|
||||
<span className="px-3 text-muted-foreground">
|
||||
—
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
defaultValue={row.original.priority}
|
||||
className="w-[75px]"
|
||||
type="number"
|
||||
disabled={readonly}
|
||||
onClick={(e) => e.currentTarget.focus()}
|
||||
onBlur={(e) => {
|
||||
const parsed = z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.safeParse(e.target.value);
|
||||
if (!parsed.success) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("rulesErrorInvalidPriority"),
|
||||
description: t(
|
||||
"rulesErrorInvalidPriorityDescription"
|
||||
)
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateRule(row.original.ruleId, {
|
||||
priority: parsed.data
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateRule(row.original.ruleId, {
|
||||
priority: parsed.data
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "action",
|
||||
@@ -364,7 +444,7 @@ export function EditPolicyRulesSectionForm({
|
||||
cell: ({ row }) => (
|
||||
<Select
|
||||
defaultValue={row.original.action}
|
||||
disabled={readonly}
|
||||
disabled={readonly || row.original.fromPolicy}
|
||||
onValueChange={(value: "ACCEPT" | "DROP" | "PASS") =>
|
||||
updateRule(row.original.ruleId, { action: value })
|
||||
}
|
||||
@@ -394,7 +474,7 @@ export function EditPolicyRulesSectionForm({
|
||||
cell: ({ row }) => (
|
||||
<Select
|
||||
defaultValue={row.original.match}
|
||||
disabled={readonly}
|
||||
disabled={readonly || row.original.fromPolicy}
|
||||
onValueChange={(
|
||||
value: "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN"
|
||||
) =>
|
||||
@@ -444,7 +524,9 @@ export function EditPolicyRulesSectionForm({
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={readonly}
|
||||
disabled={
|
||||
readonly || row.original.fromPolicy
|
||||
}
|
||||
className="min-w-50 justify-between"
|
||||
>
|
||||
{row.original.value
|
||||
@@ -500,7 +582,9 @@ export function EditPolicyRulesSectionForm({
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={readonly}
|
||||
disabled={
|
||||
readonly || row.original.fromPolicy
|
||||
}
|
||||
className="min-w-50 justify-between"
|
||||
>
|
||||
{row.original.value
|
||||
@@ -586,7 +670,7 @@ export function EditPolicyRulesSectionForm({
|
||||
<Input
|
||||
defaultValue={row.original.value}
|
||||
className="min-w-50"
|
||||
disabled={readonly}
|
||||
disabled={readonly || row.original.fromPolicy}
|
||||
onBlur={(e) =>
|
||||
updateRule(row.original.ruleId, {
|
||||
value: e.target.value
|
||||
@@ -601,7 +685,7 @@ export function EditPolicyRulesSectionForm({
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
defaultChecked={row.original.enabled}
|
||||
disabled={readonly}
|
||||
disabled={readonly || row.original.fromPolicy}
|
||||
onCheckedChange={(val) =>
|
||||
updateRule(row.original.ruleId, { enabled: val })
|
||||
}
|
||||
@@ -613,13 +697,23 @@ export function EditPolicyRulesSectionForm({
|
||||
header: () => <span className="p-3">{t("actions")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={readonly}
|
||||
onClick={() => removeRule(row.original.ruleId)}
|
||||
>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
{row.original.fromPolicy ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled
|
||||
className="cursor-not-allowed"
|
||||
>
|
||||
<LockIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={readonly}
|
||||
onClick={() => removeRule(row.original.ruleId)}
|
||||
>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -651,11 +745,15 @@ export function EditPolicyRulesSectionForm({
|
||||
async function saveRules() {
|
||||
if (readonly) return;
|
||||
|
||||
if (isResourceOverlay) {
|
||||
await saveResourceOverlayRules();
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const payload = form.getValues();
|
||||
console.log({ payload });
|
||||
|
||||
try {
|
||||
const res = await api
|
||||
@@ -689,6 +787,57 @@ export function EditPolicyRulesSectionForm({
|
||||
}
|
||||
}
|
||||
|
||||
async function saveResourceOverlayRules() {
|
||||
try {
|
||||
const newRules = rules.filter((r) => !r.fromPolicy && r.new);
|
||||
const updatedRules = rules.filter(
|
||||
(r) => !r.fromPolicy && !r.new && r.updated
|
||||
);
|
||||
const deletedIds = [...deletedResourceRuleIdsRef.current];
|
||||
|
||||
await Promise.all([
|
||||
...newRules.map((r) =>
|
||||
api.put(`/resource/${resourceId}/rule`, {
|
||||
action: r.action,
|
||||
match: r.match,
|
||||
value: r.value,
|
||||
priority: r.priority,
|
||||
enabled: r.enabled
|
||||
})
|
||||
),
|
||||
...updatedRules.map((r) =>
|
||||
api.post(`/resource/${resourceId}/rule/${r.ruleId}`, {
|
||||
action: r.action,
|
||||
match: r.match,
|
||||
value: r.value,
|
||||
priority: r.priority,
|
||||
enabled: r.enabled
|
||||
})
|
||||
),
|
||||
...deletedIds.map((id) =>
|
||||
api.delete(`/resource/${resourceId}/rule/${id}`)
|
||||
)
|
||||
]);
|
||||
|
||||
deletedResourceRuleIdsRef.current = new Set();
|
||||
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("policyUpdatedSuccess")
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("policyErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
<SettingsSection>
|
||||
@@ -740,7 +889,7 @@ export function EditPolicyRulesSectionForm({
|
||||
onCheckedChange={(val) => {
|
||||
form.setValue("applyRules", val);
|
||||
}}
|
||||
disabled={readonly}
|
||||
disabled={readonly || isResourceOverlay}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -763,7 +912,8 @@ export function EditPolicyRulesSectionForm({
|
||||
value={field.value}
|
||||
disabled={
|
||||
readonly ||
|
||||
!rulesEnabled
|
||||
(!isResourceOverlay &&
|
||||
!rulesEnabled)
|
||||
}
|
||||
onValueChange={
|
||||
field.onChange
|
||||
@@ -802,7 +952,8 @@ export function EditPolicyRulesSectionForm({
|
||||
value={field.value}
|
||||
disabled={
|
||||
readonly ||
|
||||
!rulesEnabled
|
||||
(!isResourceOverlay &&
|
||||
!rulesEnabled)
|
||||
}
|
||||
onValueChange={
|
||||
field.onChange
|
||||
@@ -872,7 +1023,8 @@ export function EditPolicyRulesSectionForm({
|
||||
role="combobox"
|
||||
disabled={
|
||||
readonly ||
|
||||
!rulesEnabled
|
||||
(!isResourceOverlay &&
|
||||
!rulesEnabled)
|
||||
}
|
||||
aria-expanded={
|
||||
openAddRuleCountrySelect
|
||||
@@ -965,7 +1117,8 @@ export function EditPolicyRulesSectionForm({
|
||||
role="combobox"
|
||||
disabled={
|
||||
readonly ||
|
||||
!rulesEnabled
|
||||
(!isResourceOverlay &&
|
||||
!rulesEnabled)
|
||||
}
|
||||
aria-expanded={
|
||||
openAddRuleAsnSelect
|
||||
@@ -1083,7 +1236,8 @@ export function EditPolicyRulesSectionForm({
|
||||
{...field}
|
||||
disabled={
|
||||
readonly ||
|
||||
!rulesEnabled
|
||||
(!isResourceOverlay &&
|
||||
!rulesEnabled)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -1095,7 +1249,10 @@ export function EditPolicyRulesSectionForm({
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
disabled={readonly || !rulesEnabled}
|
||||
disabled={
|
||||
readonly ||
|
||||
(!isResourceOverlay && !rulesEnabled)
|
||||
}
|
||||
>
|
||||
{t("ruleSubmit")}
|
||||
</Button>
|
||||
|
||||
@@ -45,7 +45,9 @@ import {
|
||||
} from "@app/components/ui/select";
|
||||
|
||||
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
|
||||
import { useActionState, useState } from "react";
|
||||
import { resourceQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useActionState, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
|
||||
// ─── PolicyUsersRolesSection ──────────────────────────────────────────────────
|
||||
@@ -54,12 +56,14 @@ type PolicyUsersRolesSectionProps = {
|
||||
orgId: string;
|
||||
allIdps: { id: number; text: string }[];
|
||||
readonly?: boolean;
|
||||
resourceId?: number;
|
||||
};
|
||||
|
||||
export function EditPolicyUsersRolesSectionForm({
|
||||
orgId,
|
||||
allIdps,
|
||||
readonly
|
||||
readonly,
|
||||
resourceId
|
||||
}: PolicyUsersRolesSectionProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -69,6 +73,105 @@ export function EditPolicyUsersRolesSectionForm({
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
// ── Resource overlay: fetch resource-specific roles & users ──────────────
|
||||
const isResourceOverlay = resourceId !== undefined;
|
||||
|
||||
const { data: resourceRolesData } = useQuery({
|
||||
...resourceQueries.resourceRoles({ resourceId: resourceId! }),
|
||||
enabled: isResourceOverlay
|
||||
});
|
||||
|
||||
const { data: resourceUsersData } = useQuery({
|
||||
...resourceQueries.resourceUsers({ resourceId: resourceId! }),
|
||||
enabled: isResourceOverlay
|
||||
});
|
||||
|
||||
// IDs from the policy (locked — cannot be removed)
|
||||
const policyRoleLockedIds = useMemo(
|
||||
() => new Set(policy.roles.map((r) => r.roleId.toString())),
|
||||
[policy.roles]
|
||||
);
|
||||
const policyUserLockedIds = useMemo(
|
||||
() => new Set(policy.users.map((u) => u.userId)),
|
||||
[policy.users]
|
||||
);
|
||||
|
||||
// Policy entries mapped to selector format
|
||||
const policyRoleItems = useMemo(
|
||||
() =>
|
||||
policy.roles.map((r) => ({
|
||||
id: r.roleId.toString(),
|
||||
text: r.name
|
||||
})),
|
||||
[policy.roles]
|
||||
);
|
||||
const policyUserItems = useMemo(
|
||||
() =>
|
||||
policy.users.map((u) => ({
|
||||
id: u.userId,
|
||||
text: `${getUserDisplayName({ email: u.email, username: u.username })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}`
|
||||
})),
|
||||
[policy.users]
|
||||
);
|
||||
|
||||
// Track the initial resource-specific roles/users for diffing on save
|
||||
const initialResourceRoleIdsRef = useRef<Set<string>>(new Set());
|
||||
const initialResourceUserIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Combined selected roles/users (policy + resource-specific)
|
||||
const [combinedRoles, setCombinedRoles] = useState(policyRoleItems);
|
||||
const [combinedUsers, setCombinedUsers] = useState(policyUserItems);
|
||||
const [resourceRolesInitialized, setResourceRolesInitialized] =
|
||||
useState(false);
|
||||
const [resourceUsersInitialized, setResourceUsersInitialized] =
|
||||
useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResourceOverlay || resourceRolesInitialized) return;
|
||||
if (!resourceRolesData) return;
|
||||
|
||||
const resourceSpecific = resourceRolesData
|
||||
.filter((r) => !policyRoleLockedIds.has(r.roleId.toString()))
|
||||
.map((r) => ({ id: r.roleId.toString(), text: r.name }));
|
||||
|
||||
initialResourceRoleIdsRef.current = new Set(
|
||||
resourceSpecific.map((r) => r.id)
|
||||
);
|
||||
setCombinedRoles([...policyRoleItems, ...resourceSpecific]);
|
||||
setResourceRolesInitialized(true);
|
||||
}, [
|
||||
isResourceOverlay,
|
||||
resourceRolesData,
|
||||
resourceRolesInitialized,
|
||||
policyRoleItems,
|
||||
policyRoleLockedIds
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResourceOverlay || resourceUsersInitialized) return;
|
||||
if (!resourceUsersData) return;
|
||||
|
||||
const resourceSpecific = resourceUsersData
|
||||
.filter((u) => !policyUserLockedIds.has(u.userId))
|
||||
.map((u) => ({
|
||||
id: u.userId,
|
||||
text: `${getUserDisplayName({ email: u.email ?? undefined, username: u.username ?? undefined })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}`
|
||||
}));
|
||||
|
||||
initialResourceUserIdsRef.current = new Set(
|
||||
resourceSpecific.map((u) => u.id)
|
||||
);
|
||||
setCombinedUsers([...policyUserItems, ...resourceSpecific]);
|
||||
setResourceUsersInitialized(true);
|
||||
}, [
|
||||
isResourceOverlay,
|
||||
resourceUsersData,
|
||||
resourceUsersInitialized,
|
||||
policyUserItems,
|
||||
policyUserLockedIds
|
||||
]);
|
||||
|
||||
// ── Standard policy form (non-overlay) ──────────────────────────────────
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
createPolicySchema.pick({
|
||||
@@ -81,14 +184,8 @@ export function EditPolicyUsersRolesSectionForm({
|
||||
defaultValues: {
|
||||
sso: policy.sso,
|
||||
skipToIdpId: policy.idpId,
|
||||
roles: policy.roles.map((role) => ({
|
||||
id: role.roleId.toString(),
|
||||
text: role.name
|
||||
})),
|
||||
users: policy.users.map((user) => ({
|
||||
id: user.userId,
|
||||
text: `${getUserDisplayName({ email: user.email, username: user.username })}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
|
||||
}))
|
||||
roles: policyRoleItems,
|
||||
users: policyUserItems
|
||||
}
|
||||
});
|
||||
|
||||
@@ -99,12 +196,17 @@ export function EditPolicyUsersRolesSectionForm({
|
||||
});
|
||||
|
||||
const [, formAction, isSubmitting] = useActionState(onSubmit, null);
|
||||
const [isSavingOverlay, setIsSavingOverlay] = useState(false);
|
||||
|
||||
async function onSubmit() {
|
||||
if (readonly) return;
|
||||
|
||||
const isValid = await form.trigger();
|
||||
if (isResourceOverlay) {
|
||||
await saveResourceOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const payload = form.getValues();
|
||||
@@ -147,6 +249,87 @@ export function EditPolicyUsersRolesSectionForm({
|
||||
}
|
||||
}
|
||||
|
||||
async function saveResourceOverlay() {
|
||||
setIsSavingOverlay(true);
|
||||
try {
|
||||
// Compute which roles/users are resource-specific (non-locked)
|
||||
const currentResourceRoleIds = new Set(
|
||||
combinedRoles
|
||||
.filter((r) => !policyRoleLockedIds.has(r.id))
|
||||
.map((r) => r.id)
|
||||
);
|
||||
const currentResourceUserIds = new Set(
|
||||
combinedUsers
|
||||
.filter((u) => !policyUserLockedIds.has(u.id))
|
||||
.map((u) => u.id)
|
||||
);
|
||||
|
||||
const initialRoleIds = initialResourceRoleIdsRef.current;
|
||||
const initialUserIds = initialResourceUserIdsRef.current;
|
||||
|
||||
const addedRoleIds = [...currentResourceRoleIds].filter(
|
||||
(id) => !initialRoleIds.has(id)
|
||||
);
|
||||
const removedRoleIds = [...initialRoleIds].filter(
|
||||
(id) => !currentResourceRoleIds.has(id)
|
||||
);
|
||||
const addedUserIds = [...currentResourceUserIds].filter(
|
||||
(id) => !initialUserIds.has(id)
|
||||
);
|
||||
const removedUserIds = [...initialUserIds].filter(
|
||||
(id) => !currentResourceUserIds.has(id)
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
...addedRoleIds.map((id) =>
|
||||
api.post(`/resource/${resourceId}/roles/add`, {
|
||||
roleId: Number(id)
|
||||
})
|
||||
),
|
||||
...removedRoleIds.map((id) =>
|
||||
api.post(`/resource/${resourceId}/roles/remove`, {
|
||||
roleId: Number(id)
|
||||
})
|
||||
),
|
||||
...addedUserIds.map((id) =>
|
||||
api.post(`/resource/${resourceId}/users/add`, {
|
||||
userId: id
|
||||
})
|
||||
),
|
||||
...removedUserIds.map((id) =>
|
||||
api.post(`/resource/${resourceId}/users/remove`, {
|
||||
userId: id
|
||||
})
|
||||
)
|
||||
]);
|
||||
|
||||
// Update refs to reflect new state
|
||||
initialResourceRoleIdsRef.current = currentResourceRoleIds;
|
||||
initialResourceUserIdsRef.current = currentResourceUserIds;
|
||||
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("policyUpdatedSuccess")
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("policyErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("policyErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
setIsSavingOverlay(false);
|
||||
}
|
||||
}
|
||||
|
||||
const isLoading =
|
||||
isResourceOverlay &&
|
||||
(!resourceRolesInitialized || !resourceUsersInitialized);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form action={formAction}>
|
||||
@@ -166,78 +349,105 @@ export function EditPolicyUsersRolesSectionForm({
|
||||
label={t("ssoUse")}
|
||||
defaultChecked={ssoEnabled}
|
||||
onCheckedChange={(val) => {
|
||||
console.log(`form.setValue("sso", ${val})`);
|
||||
form.setValue("sso", val);
|
||||
}}
|
||||
disabled={readonly}
|
||||
disabled={readonly || isResourceOverlay}
|
||||
/>
|
||||
|
||||
{ssoEnabled && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>
|
||||
{t("roles")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RolesSelector
|
||||
orgId={orgId}
|
||||
selectedRoles={
|
||||
field.value
|
||||
}
|
||||
onSelectRoles={(
|
||||
roles
|
||||
) =>
|
||||
form.setValue(
|
||||
"roles",
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("roles")}</FormLabel>
|
||||
<FormControl>
|
||||
{isResourceOverlay ? (
|
||||
<RolesSelector
|
||||
orgId={orgId}
|
||||
selectedRoles={
|
||||
combinedRoles
|
||||
}
|
||||
onSelectRoles={
|
||||
setCombinedRoles
|
||||
}
|
||||
disabled={isLoading}
|
||||
restrictAdminRole
|
||||
lockedIds={
|
||||
policyRoleLockedIds
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<RolesSelector
|
||||
orgId={orgId}
|
||||
selectedRoles={
|
||||
field.value
|
||||
}
|
||||
onSelectRoles={(
|
||||
roles
|
||||
)
|
||||
}
|
||||
disabled={readonly}
|
||||
restrictAdminRole
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"resourceRoleDescription"
|
||||
) =>
|
||||
form.setValue(
|
||||
"roles",
|
||||
roles
|
||||
)
|
||||
}
|
||||
disabled={readonly}
|
||||
restrictAdminRole
|
||||
/>
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>
|
||||
{t("users")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<UsersSelector
|
||||
orgId={orgId}
|
||||
selectedUsers={
|
||||
field.value
|
||||
}
|
||||
onSelectUsers={(
|
||||
users
|
||||
) =>
|
||||
form.setValue(
|
||||
"users",
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t("resourceRoleDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("users")}</FormLabel>
|
||||
<FormControl>
|
||||
{isResourceOverlay ? (
|
||||
<UsersSelector
|
||||
orgId={orgId}
|
||||
selectedUsers={
|
||||
combinedUsers
|
||||
}
|
||||
onSelectUsers={
|
||||
setCombinedUsers
|
||||
}
|
||||
disabled={isLoading}
|
||||
lockedIds={
|
||||
policyUserLockedIds
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<UsersSelector
|
||||
orgId={orgId}
|
||||
selectedUsers={
|
||||
field.value
|
||||
}
|
||||
onSelectUsers={(
|
||||
users
|
||||
)
|
||||
}
|
||||
disabled={readonly}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) =>
|
||||
form.setValue(
|
||||
"users",
|
||||
users
|
||||
)
|
||||
}
|
||||
disabled={readonly}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -247,7 +457,7 @@ export function EditPolicyUsersRolesSectionForm({
|
||||
{t("defaultIdentityProvider")}
|
||||
</label>
|
||||
<Select
|
||||
disabled={readonly}
|
||||
disabled={readonly || isResourceOverlay}
|
||||
onValueChange={(value) => {
|
||||
if (value === "none") {
|
||||
form.setValue(
|
||||
@@ -302,8 +512,13 @@ export function EditPolicyUsersRolesSectionForm({
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isSubmitting}
|
||||
disabled={readonly || isSubmitting}
|
||||
loading={isSubmitting || isSavingOverlay}
|
||||
disabled={
|
||||
readonly ||
|
||||
isSubmitting ||
|
||||
isSavingOverlay ||
|
||||
isLoading
|
||||
}
|
||||
>
|
||||
{t("resourceUsersRolesSubmit")}
|
||||
</Button>
|
||||
|
||||
@@ -16,6 +16,7 @@ export type RolesSelectorProps = {
|
||||
restrictAdminRole?: boolean;
|
||||
mapRolesByName?: boolean;
|
||||
buttonText?: string;
|
||||
lockedIds?: Set<string>;
|
||||
};
|
||||
|
||||
export function RolesSelector({
|
||||
@@ -25,7 +26,8 @@ export function RolesSelector({
|
||||
disabled,
|
||||
restrictAdminRole,
|
||||
mapRolesByName,
|
||||
buttonText
|
||||
buttonText,
|
||||
lockedIds
|
||||
}: RolesSelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [roleSearchQuery, setRoleSearchQuery] = useState("");
|
||||
@@ -76,6 +78,7 @@ export function RolesSelector({
|
||||
value={selectedRoles}
|
||||
onChange={onSelectRoles}
|
||||
disabled={disabled}
|
||||
lockedIds={lockedIds}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,13 +19,15 @@ export type UsersSelectorProps = {
|
||||
selectedUsers?: SelectedUser[];
|
||||
onSelectUsers: (users: SelectedUser[]) => void;
|
||||
disabled?: boolean;
|
||||
lockedIds?: Set<string>;
|
||||
};
|
||||
|
||||
export function UsersSelector({
|
||||
orgId,
|
||||
selectedUsers = [],
|
||||
onSelectUsers,
|
||||
disabled
|
||||
disabled,
|
||||
lockedIds
|
||||
}: UsersSelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [userSearchQuery, setUserSearchQuery] = useState("");
|
||||
@@ -61,6 +63,7 @@ export function UsersSelector({
|
||||
value={selectedUsers}
|
||||
onChange={onSelectUsers}
|
||||
disabled={disabled}
|
||||
lockedIds={lockedIds}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
ListResourceNamesResponse,
|
||||
ListResourcesResponse,
|
||||
ListResourceRolesResponse,
|
||||
ListResourceRulesResponse,
|
||||
ListResourceUsersResponse
|
||||
} from "@server/routers/resource";
|
||||
import type { ListAlertRulesResponse } from "@server/routers/alertRule/types";
|
||||
@@ -641,6 +642,17 @@ export const resourceQueries = {
|
||||
return res.data.data.roles;
|
||||
}
|
||||
}),
|
||||
resourceRules: ({ resourceId }: { resourceId: number }) =>
|
||||
queryOptions({
|
||||
queryKey: ["RESOURCES", resourceId, "RULES"] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListResourceRulesResponse>
|
||||
>(`/resource/${resourceId}/rules`, { signal });
|
||||
|
||||
return res.data.data.rules;
|
||||
}
|
||||
}),
|
||||
siteResourceUsers: ({ siteResourceId }: { siteResourceId: number }) =>
|
||||
queryOptions({
|
||||
queryKey: ["SITE_RESOURCES", siteResourceId, "USERS"] as const,
|
||||
|
||||
Reference in New Issue
Block a user