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>
}
/>
<EditPolicyForm readonly />
<EditPolicyForm
readonly
resourceId={resource.resourceId}
/>
</ResourcePolicyProvider>
)
)}

View File

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

View File

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

View File

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

View File

@@ -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">
&mdash;
</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>

View File

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

View File

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

View File

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

View File

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