Update UI to support additions on the resource

This commit is contained in:
Owen
2026-05-06 10:09:05 -07:00
parent 54c1dd3bae
commit c4b3656fad
9 changed files with 621 additions and 198 deletions

View File

@@ -336,7 +336,10 @@ export default function ResourceAuthenticationPage() {
</Button> </Button>
} }
/> />
<EditPolicyForm readonly /> <EditPolicyForm
readonly
resourceId={resource.resourceId}
/>
</ResourcePolicyProvider> </ResourcePolicyProvider>
) )
)} )}

View File

@@ -23,6 +23,7 @@ export type MultiSelectTagsProps<T extends TagValue> = {
onSearch: (query: string) => void; onSearch: (query: string) => void;
ref?: Ref<HTMLButtonElement>; ref?: Ref<HTMLButtonElement>;
disabled?: boolean; disabled?: boolean;
lockedIds?: Set<string>;
}; };
export function MultiSelectContent<T extends TagValue>({ export function MultiSelectContent<T extends TagValue>({
@@ -32,7 +33,8 @@ export function MultiSelectContent<T extends TagValue>({
value, value,
options, options,
onSearch, onSearch,
onChange onChange,
lockedIds
}: MultiSelectTagsProps<T>) { }: MultiSelectTagsProps<T>) {
const t = useTranslations(); const t = useTranslations();
const selectedValues = new Set(value.map((v) => v.id)); const selectedValues = new Set(value.map((v) => v.id));
@@ -48,33 +50,38 @@ export function MultiSelectContent<T extends TagValue>({
{emptyPlaceholder ?? t("noResults")} {emptyPlaceholder ?? t("noResults")}
</CommandEmpty> </CommandEmpty>
<CommandGroup> <CommandGroup>
{options.map((option) => ( {options.map((option) => {
<CommandItem const isLocked = lockedIds?.has(option.id);
value={option.id} return (
key={option.id} <CommandItem
onSelect={() => { value={option.id}
let newValues = []; key={option.id}
if (selectedValues.has(option.id)) { disabled={isLocked}
newValues = value.filter( onSelect={() => {
(v) => v.id !== option.id if (isLocked) return;
); let newValues = [];
} else { if (selectedValues.has(option.id)) {
newValues = [...value, option]; newValues = value.filter(
} (v) => v.id !== option.id
onChange(newValues); );
}} } else {
> newValues = [...value, option];
<CheckIcon }
className={cn( onChange(newValues);
"mr-2 h-4 w-4", }}
selectedValues.has(option.id) >
? "opacity-100" <CheckIcon
: "opacity-0" className={cn(
)} "mr-2 h-4 w-4",
/> selectedValues.has(option.id)
{`${option.text}`} ? "opacity-100"
</CommandItem> : "opacity-0"
))} )}
/>
{`${option.text}`}
</CommandItem>
);
})}
</CommandGroup> </CommandGroup>
</CommandList> </CommandList>
</Command> </Command>

View File

@@ -5,7 +5,7 @@ import {
PopoverTrigger PopoverTrigger
} from "@app/components/ui/popover"; } from "@app/components/ui/popover";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { ChevronDownIcon, XIcon } from "lucide-react"; import { ChevronDownIcon, LockIcon, XIcon } from "lucide-react";
import { import {
type MultiSelectTagsProps, type MultiSelectTagsProps,
type TagValue, type TagValue,
@@ -16,10 +16,12 @@ export interface MultiSelectInputProps<
T extends TagValue T extends TagValue
> extends MultiSelectTagsProps<T> { > extends MultiSelectTagsProps<T> {
buttonText?: string; buttonText?: string;
lockedIds?: Set<string>;
} }
export function MultiSelectTagInput<T extends TagValue>({ export function MultiSelectTagInput<T extends TagValue>({
buttonText, buttonText,
lockedIds,
...props ...props
}: MultiSelectInputProps<T>) { }: MultiSelectInputProps<T>) {
const selectedValues = new Set(props.value.map((v) => v.id)); const selectedValues = new Set(props.value.map((v) => v.id));
@@ -52,46 +54,63 @@ export function MultiSelectTagInput<T extends TagValue>({
"overflow-x-auto" "overflow-x-auto"
)} )}
> >
{props.value.map((option) => ( {props.value.map((option) => {
<span const isLocked = lockedIds?.has(option.id);
key={option.id} return (
className={cn( <span
"bg-muted-foreground/10 font-normal text-foreground rounded-sm", key={option.id}
"py-1 pl-1.5 pr-0.5 text-xs inline-flex items-center gap-0.5" className={cn(
)} "bg-muted-foreground/10 font-normal text-foreground rounded-sm",
onClick={(e) => e.stopPropagation()} "py-1 pl-1.5 pr-0.5 text-xs inline-flex items-center gap-0.5",
> isLocked && "opacity-60"
{option.text} )}
<button onClick={(e) => e.stopPropagation()}
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" /> {option.text}
</button> {isLocked ? (
</span> <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 className="pl-1 font-normal">{buttonText}</span>
</span> </span>
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" /> <ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" />
</div> </div>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0"> <PopoverContent className="p-0">
<MultiSelectContent {...props} /> <MultiSelectContent {...props} lockedIds={lockedIds} />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );

View File

@@ -27,11 +27,13 @@ import { EditPolicyRulesSectionForm } from "./EditPolicyRulesSectionForm";
export type EditPolicyFormProps = { export type EditPolicyFormProps = {
hidePolicyNameForm?: boolean; hidePolicyNameForm?: boolean;
readonly?: boolean; readonly?: boolean;
resourceId?: number;
}; };
export function EditPolicyForm({ export function EditPolicyForm({
hidePolicyNameForm, hidePolicyNameForm,
readonly readonly,
resourceId
}: EditPolicyFormProps) { }: EditPolicyFormProps) {
const { org } = useOrgContext(); const { org } = useOrgContext();
const t = useTranslations(); const t = useTranslations();
@@ -84,6 +86,7 @@ export function EditPolicyForm({
orgId={org.org.orgId} orgId={org.org.orgId}
allIdps={allIdps} allIdps={allIdps}
readonly={readonly} readonly={readonly}
resourceId={resourceId}
/> />
<EditPolicyAuthMethodsSectionForm readonly={readonly} /> <EditPolicyAuthMethodsSectionForm readonly={readonly} />
@@ -97,6 +100,7 @@ export function EditPolicyForm({
isMaxmindAvailable={isMaxmindAvailable} isMaxmindAvailable={isMaxmindAvailable}
isMaxmindAsnAvailable={isMaxmindASNAvailable} isMaxmindAsnAvailable={isMaxmindASNAvailable}
readonly={readonly} readonly={readonly}
resourceId={resourceId}
/> />
</SettingsContainer> </SettingsContainer>
); );

View File

@@ -75,13 +75,28 @@ import {
getSortedRowModel, getSortedRowModel,
useReactTable useReactTable
} from "@tanstack/react-table"; } 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 { UseFormReturn, useForm, useWatch } from "react-hook-form";
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { resourceQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -103,18 +118,21 @@ type LocalRule = {
enabled: boolean; enabled: boolean;
new?: boolean; new?: boolean;
updated?: boolean; updated?: boolean;
fromPolicy?: boolean;
}; };
type PolicyRulesSectionProps = { type PolicyRulesSectionProps = {
isMaxmindAvailable: boolean; isMaxmindAvailable: boolean;
isMaxmindAsnAvailable: boolean; isMaxmindAsnAvailable: boolean;
readonly?: boolean; readonly?: boolean;
resourceId?: number;
}; };
export function EditPolicyRulesSectionForm({ export function EditPolicyRulesSectionForm({
isMaxmindAvailable, isMaxmindAvailable,
isMaxmindAsnAvailable, isMaxmindAsnAvailable,
readonly readonly,
resourceId
}: PolicyRulesSectionProps) { }: PolicyRulesSectionProps) {
const t = useTranslations(); const t = useTranslations();
@@ -122,6 +140,18 @@ export function EditPolicyRulesSectionForm({
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const router = useRouter(); 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({ const form = useForm({
resolver: zodResolver( resolver: zodResolver(
createPolicySchema.pick({ createPolicySchema.pick({
@@ -140,8 +170,42 @@ export function EditPolicyRulesSectionForm({
name: "applyRules" name: "applyRules"
}); });
const [rules, setRules] = useState<LocalRule[]>(policy.rules); const [rules, setRules] = useState<LocalRule[]>(
const [isExpanded, setIsExpanded] = useState(rulesEnabled); 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] = const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] =
useState(false); useState(false);
@@ -275,11 +339,17 @@ export function EditPolicyRulesSectionForm({
const removeRule = useCallback( const removeRule = useCallback(
function removeRule(ruleId: number) { 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); const updatedRules = rules.filter((rule) => rule.ruleId !== ruleId);
setRules(updatedRules); setRules(updatedRules);
syncFormRules(updatedRules); syncFormRules(updatedRules);
}, },
[rules, syncFormRules] [rules, syncFormRules, isResourceOverlay]
); );
const updateRule = useCallback( const updateRule = useCallback(
@@ -328,35 +398,45 @@ export function EditPolicyRulesSectionForm({
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
), ),
cell: ({ row }) => ( cell: ({ row }) => {
<Input const isLocked = row.original.fromPolicy;
defaultValue={row.original.priority} if (isLocked) {
className="w-[75px]" return (
type="number" <span className="px-3 text-muted-foreground">
disabled={readonly} &mdash;
onClick={(e) => e.currentTarget.focus()} </span>
onBlur={(e) => { );
const parsed = z.coerce }
.number() return (
.int() <Input
.optional() defaultValue={row.original.priority}
.safeParse(e.target.value); className="w-[75px]"
if (!parsed.success) { type="number"
toast({ disabled={readonly}
variant: "destructive", onClick={(e) => e.currentTarget.focus()}
title: t("rulesErrorInvalidPriority"), onBlur={(e) => {
description: t( const parsed = z.coerce
"rulesErrorInvalidPriorityDescription" .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", accessorKey: "action",
@@ -364,7 +444,7 @@ export function EditPolicyRulesSectionForm({
cell: ({ row }) => ( cell: ({ row }) => (
<Select <Select
defaultValue={row.original.action} defaultValue={row.original.action}
disabled={readonly} disabled={readonly || row.original.fromPolicy}
onValueChange={(value: "ACCEPT" | "DROP" | "PASS") => onValueChange={(value: "ACCEPT" | "DROP" | "PASS") =>
updateRule(row.original.ruleId, { action: value }) updateRule(row.original.ruleId, { action: value })
} }
@@ -394,7 +474,7 @@ export function EditPolicyRulesSectionForm({
cell: ({ row }) => ( cell: ({ row }) => (
<Select <Select
defaultValue={row.original.match} defaultValue={row.original.match}
disabled={readonly} disabled={readonly || row.original.fromPolicy}
onValueChange={( onValueChange={(
value: "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN" value: "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN"
) => ) =>
@@ -444,7 +524,9 @@ export function EditPolicyRulesSectionForm({
<Button <Button
variant="outline" variant="outline"
role="combobox" role="combobox"
disabled={readonly} disabled={
readonly || row.original.fromPolicy
}
className="min-w-50 justify-between" className="min-w-50 justify-between"
> >
{row.original.value {row.original.value
@@ -500,7 +582,9 @@ export function EditPolicyRulesSectionForm({
<Button <Button
variant="outline" variant="outline"
role="combobox" role="combobox"
disabled={readonly} disabled={
readonly || row.original.fromPolicy
}
className="min-w-50 justify-between" className="min-w-50 justify-between"
> >
{row.original.value {row.original.value
@@ -586,7 +670,7 @@ export function EditPolicyRulesSectionForm({
<Input <Input
defaultValue={row.original.value} defaultValue={row.original.value}
className="min-w-50" className="min-w-50"
disabled={readonly} disabled={readonly || row.original.fromPolicy}
onBlur={(e) => onBlur={(e) =>
updateRule(row.original.ruleId, { updateRule(row.original.ruleId, {
value: e.target.value value: e.target.value
@@ -601,7 +685,7 @@ export function EditPolicyRulesSectionForm({
cell: ({ row }) => ( cell: ({ row }) => (
<Switch <Switch
defaultChecked={row.original.enabled} defaultChecked={row.original.enabled}
disabled={readonly} disabled={readonly || row.original.fromPolicy}
onCheckedChange={(val) => onCheckedChange={(val) =>
updateRule(row.original.ruleId, { enabled: val }) updateRule(row.original.ruleId, { enabled: val })
} }
@@ -613,13 +697,23 @@ export function EditPolicyRulesSectionForm({
header: () => <span className="p-3">{t("actions")}</span>, header: () => <span className="p-3">{t("actions")}</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Button {row.original.fromPolicy ? (
variant="outline" <Button
disabled={readonly} variant="outline"
onClick={() => removeRule(row.original.ruleId)} disabled
> className="cursor-not-allowed"
{t("delete")} >
</Button> <LockIcon className="h-4 w-4" />
</Button>
) : (
<Button
variant="outline"
disabled={readonly}
onClick={() => removeRule(row.original.ruleId)}
>
{t("delete")}
</Button>
)}
</div> </div>
) )
} }
@@ -651,11 +745,15 @@ export function EditPolicyRulesSectionForm({
async function saveRules() { async function saveRules() {
if (readonly) return; if (readonly) return;
if (isResourceOverlay) {
await saveResourceOverlayRules();
return;
}
const isValid = form.trigger(); const isValid = form.trigger();
if (!isValid) return; if (!isValid) return;
const payload = form.getValues(); const payload = form.getValues();
console.log({ payload });
try { try {
const res = await api 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) { if (!isExpanded) {
return ( return (
<SettingsSection> <SettingsSection>
@@ -740,7 +889,7 @@ export function EditPolicyRulesSectionForm({
onCheckedChange={(val) => { onCheckedChange={(val) => {
form.setValue("applyRules", val); form.setValue("applyRules", val);
}} }}
disabled={readonly} disabled={readonly || isResourceOverlay}
/> />
</div> </div>
@@ -763,7 +912,8 @@ export function EditPolicyRulesSectionForm({
value={field.value} value={field.value}
disabled={ disabled={
readonly || readonly ||
!rulesEnabled (!isResourceOverlay &&
!rulesEnabled)
} }
onValueChange={ onValueChange={
field.onChange field.onChange
@@ -802,7 +952,8 @@ export function EditPolicyRulesSectionForm({
value={field.value} value={field.value}
disabled={ disabled={
readonly || readonly ||
!rulesEnabled (!isResourceOverlay &&
!rulesEnabled)
} }
onValueChange={ onValueChange={
field.onChange field.onChange
@@ -872,7 +1023,8 @@ export function EditPolicyRulesSectionForm({
role="combobox" role="combobox"
disabled={ disabled={
readonly || readonly ||
!rulesEnabled (!isResourceOverlay &&
!rulesEnabled)
} }
aria-expanded={ aria-expanded={
openAddRuleCountrySelect openAddRuleCountrySelect
@@ -965,7 +1117,8 @@ export function EditPolicyRulesSectionForm({
role="combobox" role="combobox"
disabled={ disabled={
readonly || readonly ||
!rulesEnabled (!isResourceOverlay &&
!rulesEnabled)
} }
aria-expanded={ aria-expanded={
openAddRuleAsnSelect openAddRuleAsnSelect
@@ -1083,7 +1236,8 @@ export function EditPolicyRulesSectionForm({
{...field} {...field}
disabled={ disabled={
readonly || readonly ||
!rulesEnabled (!isResourceOverlay &&
!rulesEnabled)
} }
/> />
)} )}
@@ -1095,7 +1249,10 @@ export function EditPolicyRulesSectionForm({
<Button <Button
type="submit" type="submit"
variant="outline" variant="outline"
disabled={readonly || !rulesEnabled} disabled={
readonly ||
(!isResourceOverlay && !rulesEnabled)
}
> >
{t("ruleSubmit")} {t("ruleSubmit")}
</Button> </Button>

View File

@@ -45,7 +45,9 @@ import {
} from "@app/components/ui/select"; } from "@app/components/ui/select";
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; 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"; import { useForm, useWatch } from "react-hook-form";
// ─── PolicyUsersRolesSection ────────────────────────────────────────────────── // ─── PolicyUsersRolesSection ──────────────────────────────────────────────────
@@ -54,12 +56,14 @@ type PolicyUsersRolesSectionProps = {
orgId: string; orgId: string;
allIdps: { id: number; text: string }[]; allIdps: { id: number; text: string }[];
readonly?: boolean; readonly?: boolean;
resourceId?: number;
}; };
export function EditPolicyUsersRolesSectionForm({ export function EditPolicyUsersRolesSectionForm({
orgId, orgId,
allIdps, allIdps,
readonly readonly,
resourceId
}: PolicyUsersRolesSectionProps) { }: PolicyUsersRolesSectionProps) {
const t = useTranslations(); const t = useTranslations();
@@ -69,6 +73,105 @@ export function EditPolicyUsersRolesSectionForm({
const api = createApiClient(useEnvContext()); 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({ const form = useForm({
resolver: zodResolver( resolver: zodResolver(
createPolicySchema.pick({ createPolicySchema.pick({
@@ -81,14 +184,8 @@ export function EditPolicyUsersRolesSectionForm({
defaultValues: { defaultValues: {
sso: policy.sso, sso: policy.sso,
skipToIdpId: policy.idpId, skipToIdpId: policy.idpId,
roles: policy.roles.map((role) => ({ roles: policyRoleItems,
id: role.roleId.toString(), users: policyUserItems
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})` : ""}`
}))
} }
}); });
@@ -99,12 +196,17 @@ export function EditPolicyUsersRolesSectionForm({
}); });
const [, formAction, isSubmitting] = useActionState(onSubmit, null); const [, formAction, isSubmitting] = useActionState(onSubmit, null);
const [isSavingOverlay, setIsSavingOverlay] = useState(false);
async function onSubmit() { async function onSubmit() {
if (readonly) return; if (readonly) return;
const isValid = await form.trigger(); if (isResourceOverlay) {
await saveResourceOverlay();
return;
}
const isValid = await form.trigger();
if (!isValid) return; if (!isValid) return;
const payload = form.getValues(); 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 ( return (
<Form {...form}> <Form {...form}>
<form action={formAction}> <form action={formAction}>
@@ -166,78 +349,105 @@ export function EditPolicyUsersRolesSectionForm({
label={t("ssoUse")} label={t("ssoUse")}
defaultChecked={ssoEnabled} defaultChecked={ssoEnabled}
onCheckedChange={(val) => { onCheckedChange={(val) => {
console.log(`form.setValue("sso", ${val})`);
form.setValue("sso", val); form.setValue("sso", val);
}} }}
disabled={readonly} disabled={readonly || isResourceOverlay}
/> />
{ssoEnabled && ( {ssoEnabled && (
<> <>
<FormField <FormItem className="flex flex-col items-start">
control={form.control} <FormLabel>{t("roles")}</FormLabel>
name="roles" <FormControl>
render={({ field }) => ( {isResourceOverlay ? (
<FormItem className="flex flex-col items-start"> <RolesSelector
<FormLabel> orgId={orgId}
{t("roles")} selectedRoles={
</FormLabel> combinedRoles
<FormControl> }
<RolesSelector onSelectRoles={
orgId={orgId} setCombinedRoles
selectedRoles={ }
field.value disabled={isLoading}
} restrictAdminRole
onSelectRoles={( lockedIds={
roles policyRoleLockedIds
) => }
form.setValue( />
"roles", ) : (
<FormField
control={form.control}
name="roles"
render={({ field }) => (
<RolesSelector
orgId={orgId}
selectedRoles={
field.value
}
onSelectRoles={(
roles roles
) ) =>
} form.setValue(
disabled={readonly} "roles",
restrictAdminRole roles
/> )
</FormControl> }
<FormMessage /> disabled={readonly}
<FormDescription> restrictAdminRole
{t( />
"resourceRoleDescription"
)} )}
</FormDescription> />
</FormItem> )}
)} </FormControl>
/> <FormMessage />
<FormField <FormDescription>
control={form.control} {t("resourceRoleDescription")}
name="users" </FormDescription>
render={({ field }) => ( </FormItem>
<FormItem className="flex flex-col items-start">
<FormLabel> <FormItem className="flex flex-col items-start">
{t("users")} <FormLabel>{t("users")}</FormLabel>
</FormLabel> <FormControl>
<FormControl> {isResourceOverlay ? (
<UsersSelector <UsersSelector
orgId={orgId} orgId={orgId}
selectedUsers={ selectedUsers={
field.value combinedUsers
} }
onSelectUsers={( onSelectUsers={
users setCombinedUsers
) => }
form.setValue( disabled={isLoading}
"users", lockedIds={
policyUserLockedIds
}
/>
) : (
<FormField
control={form.control}
name="users"
render={({ field }) => (
<UsersSelector
orgId={orgId}
selectedUsers={
field.value
}
onSelectUsers={(
users users
) ) =>
} form.setValue(
disabled={readonly} "users",
/> users
</FormControl> )
<FormMessage /> }
</FormItem> disabled={readonly}
)} />
/> )}
/>
)}
</FormControl>
<FormMessage />
</FormItem>
</> </>
)} )}
@@ -247,7 +457,7 @@ export function EditPolicyUsersRolesSectionForm({
{t("defaultIdentityProvider")} {t("defaultIdentityProvider")}
</label> </label>
<Select <Select
disabled={readonly} disabled={readonly || isResourceOverlay}
onValueChange={(value) => { onValueChange={(value) => {
if (value === "none") { if (value === "none") {
form.setValue( form.setValue(
@@ -302,8 +512,13 @@ export function EditPolicyUsersRolesSectionForm({
<SettingsSectionFooter> <SettingsSectionFooter>
<Button <Button
type="submit" type="submit"
loading={isSubmitting} loading={isSubmitting || isSavingOverlay}
disabled={readonly || isSubmitting} disabled={
readonly ||
isSubmitting ||
isSavingOverlay ||
isLoading
}
> >
{t("resourceUsersRolesSubmit")} {t("resourceUsersRolesSubmit")}
</Button> </Button>

View File

@@ -16,6 +16,7 @@ export type RolesSelectorProps = {
restrictAdminRole?: boolean; restrictAdminRole?: boolean;
mapRolesByName?: boolean; mapRolesByName?: boolean;
buttonText?: string; buttonText?: string;
lockedIds?: Set<string>;
}; };
export function RolesSelector({ export function RolesSelector({
@@ -25,7 +26,8 @@ export function RolesSelector({
disabled, disabled,
restrictAdminRole, restrictAdminRole,
mapRolesByName, mapRolesByName,
buttonText buttonText,
lockedIds
}: RolesSelectorProps) { }: RolesSelectorProps) {
const t = useTranslations(); const t = useTranslations();
const [roleSearchQuery, setRoleSearchQuery] = useState(""); const [roleSearchQuery, setRoleSearchQuery] = useState("");
@@ -76,6 +78,7 @@ export function RolesSelector({
value={selectedRoles} value={selectedRoles}
onChange={onSelectRoles} onChange={onSelectRoles}
disabled={disabled} disabled={disabled}
lockedIds={lockedIds}
/> />
); );
} }

View File

@@ -19,13 +19,15 @@ export type UsersSelectorProps = {
selectedUsers?: SelectedUser[]; selectedUsers?: SelectedUser[];
onSelectUsers: (users: SelectedUser[]) => void; onSelectUsers: (users: SelectedUser[]) => void;
disabled?: boolean; disabled?: boolean;
lockedIds?: Set<string>;
}; };
export function UsersSelector({ export function UsersSelector({
orgId, orgId,
selectedUsers = [], selectedUsers = [],
onSelectUsers, onSelectUsers,
disabled disabled,
lockedIds
}: UsersSelectorProps) { }: UsersSelectorProps) {
const t = useTranslations(); const t = useTranslations();
const [userSearchQuery, setUserSearchQuery] = useState(""); const [userSearchQuery, setUserSearchQuery] = useState("");
@@ -61,6 +63,7 @@ export function UsersSelector({
value={selectedUsers} value={selectedUsers}
onChange={onSelectUsers} onChange={onSelectUsers}
disabled={disabled} disabled={disabled}
lockedIds={lockedIds}
/> />
); );
} }

View File

@@ -12,6 +12,7 @@ import type {
ListResourceNamesResponse, ListResourceNamesResponse,
ListResourcesResponse, ListResourcesResponse,
ListResourceRolesResponse, ListResourceRolesResponse,
ListResourceRulesResponse,
ListResourceUsersResponse ListResourceUsersResponse
} from "@server/routers/resource"; } from "@server/routers/resource";
import type { ListAlertRulesResponse } from "@server/routers/alertRule/types"; import type { ListAlertRulesResponse } from "@server/routers/alertRule/types";
@@ -641,6 +642,17 @@ export const resourceQueries = {
return res.data.data.roles; 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 }) => siteResourceUsers: ({ siteResourceId }: { siteResourceId: number }) =>
queryOptions({ queryOptions({
queryKey: ["SITE_RESOURCES", siteResourceId, "USERS"] as const, queryKey: ["SITE_RESOURCES", siteResourceId, "USERS"] as const,