diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx index 3194e7343..4a5aac070 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx @@ -336,7 +336,10 @@ export default function ResourceAuthenticationPage() { } /> - + ) )} diff --git a/src/components/multi-select/multi-select-content.tsx b/src/components/multi-select/multi-select-content.tsx index 9f49b41ca..e1b263240 100644 --- a/src/components/multi-select/multi-select-content.tsx +++ b/src/components/multi-select/multi-select-content.tsx @@ -23,6 +23,7 @@ export type MultiSelectTagsProps = { onSearch: (query: string) => void; ref?: Ref; disabled?: boolean; + lockedIds?: Set; }; export function MultiSelectContent({ @@ -32,7 +33,8 @@ export function MultiSelectContent({ value, options, onSearch, - onChange + onChange, + lockedIds }: MultiSelectTagsProps) { const t = useTranslations(); const selectedValues = new Set(value.map((v) => v.id)); @@ -48,33 +50,38 @@ export function MultiSelectContent({ {emptyPlaceholder ?? t("noResults")} - {options.map((option) => ( - { - let newValues = []; - if (selectedValues.has(option.id)) { - newValues = value.filter( - (v) => v.id !== option.id - ); - } else { - newValues = [...value, option]; - } - onChange(newValues); - }} - > - - {`${option.text}`} - - ))} + {options.map((option) => { + const isLocked = lockedIds?.has(option.id); + return ( + { + if (isLocked) return; + let newValues = []; + if (selectedValues.has(option.id)) { + newValues = value.filter( + (v) => v.id !== option.id + ); + } else { + newValues = [...value, option]; + } + onChange(newValues); + }} + > + + {`${option.text}`} + + ); + })} diff --git a/src/components/multi-select/multi-select-tag-input.tsx b/src/components/multi-select/multi-select-tag-input.tsx index 5634f7481..30bd4a913 100644 --- a/src/components/multi-select/multi-select-tag-input.tsx +++ b/src/components/multi-select/multi-select-tag-input.tsx @@ -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 { buttonText?: string; + lockedIds?: Set; } export function MultiSelectTagInput({ buttonText, + lockedIds, ...props }: MultiSelectInputProps) { const selectedValues = new Set(props.value.map((v) => v.id)); @@ -52,46 +54,63 @@ export function MultiSelectTagInput({ "overflow-x-auto" )} > - {props.value.map((option) => ( - e.stopPropagation()} - > - {option.text} - - - ))} + {option.text} + {isLocked ? ( + + + + ) : ( + + )} + + ); + })} {buttonText} - + ); diff --git a/src/components/resource-policy/EditPolicyForm.tsx b/src/components/resource-policy/EditPolicyForm.tsx index b26453c6e..043ab1852 100644 --- a/src/components/resource-policy/EditPolicyForm.tsx +++ b/src/components/resource-policy/EditPolicyForm.tsx @@ -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} /> @@ -97,6 +100,7 @@ export function EditPolicyForm({ isMaxmindAvailable={isMaxmindAvailable} isMaxmindAsnAvailable={isMaxmindASNAvailable} readonly={readonly} + resourceId={resourceId} /> ); diff --git a/src/components/resource-policy/EditPolicyRulesSectionForm.tsx b/src/components/resource-policy/EditPolicyRulesSectionForm.tsx index f00a7c6a5..dc7bec7b3 100644 --- a/src/components/resource-policy/EditPolicyRulesSectionForm.tsx +++ b/src/components/resource-policy/EditPolicyRulesSectionForm.tsx @@ -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>(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(policy.rules); - const [isExpanded, setIsExpanded] = useState(rulesEnabled); + const [rules, setRules] = useState( + 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({ ), - cell: ({ row }) => ( - 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 ( + + — + + ); + } + return ( + 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 }) => ( @@ -444,7 +524,9 @@ export function EditPolicyRulesSectionForm({ + {row.original.fromPolicy ? ( + + ) : ( + + )} ) } @@ -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 ( @@ -740,7 +889,7 @@ export function EditPolicyRulesSectionForm({ onCheckedChange={(val) => { form.setValue("applyRules", val); }} - disabled={readonly} + disabled={readonly || isResourceOverlay} /> @@ -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({ diff --git a/src/components/resource-policy/EditPolicyUserRolesSectionForm.tsx b/src/components/resource-policy/EditPolicyUserRolesSectionForm.tsx index f535021e2..d55a40a94 100644 --- a/src/components/resource-policy/EditPolicyUserRolesSectionForm.tsx +++ b/src/components/resource-policy/EditPolicyUserRolesSectionForm.tsx @@ -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>(new Set()); + const initialResourceUserIdsRef = useRef>(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 (
@@ -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 && ( <> - ( - - - {t("roles")} - - - - form.setValue( - "roles", + + {t("roles")} + + {isResourceOverlay ? ( + + ) : ( + ( + - - - - {t( - "resourceRoleDescription" + ) => + form.setValue( + "roles", + roles + ) + } + disabled={readonly} + restrictAdminRole + /> )} - - - )} - /> - ( - - - {t("users")} - - - - form.setValue( - "users", + /> + )} + + + + {t("resourceRoleDescription")} + + + + + {t("users")} + + {isResourceOverlay ? ( + + ) : ( + ( + - - - - )} - /> + ) => + form.setValue( + "users", + users + ) + } + disabled={readonly} + /> + )} + /> + )} + + + )} @@ -247,7 +457,7 @@ export function EditPolicyUsersRolesSectionForm({ {t("defaultIdentityProvider")}