From a70584b74e5eaf90df38e179ff8bde58b535c63b Mon Sep 17 00:00:00 2001 From: kirtimanmishrazipstack Date: Wed, 2 Sep 2026 13:41:38 +0530 Subject: [PATCH 01/11] UN-2868 [FIX] Restrict workflow connector and tool changes to owners and org admins Workflow sub-resources were never gated when the sharing model landed. tool_instance_v2 was fixed; endpoint_v2 was missed, so any user a workflow was shared with could change its destination folder, database table or connector. Adds a reusable WorkflowOwnerMutationMixin next to is_workflow_mutator and applies it to WorkflowEndpointViewSet. Sharing -- direct, group or org-wide -- now grants read only; owners, co-owners, org admins and service accounts may still write. The UI now says so up front instead of failing on save: a shared user sees a read-only notice and greyed controls in the connector modal, tool settings, and the Prompt Studio project selector, with no Save button to press. The connector modal's Save now also flushes the HITL plugin's rules. It previously lit up for rule changes it could not save, then closed as if it had saved them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016eB6bLmieWVwUnmYH6H5WZ --- backend/permissions/permission.py | 29 ++++++++ backend/workflow_manager/endpoint_v2/views.py | 14 +++- .../src/components/agency/agency/Agency.jsx | 4 ++ .../ConfigureConnectorModal.jsx | 66 ++++++++++++------- .../agency/tool-settings/ToolSettings.jsx | 48 ++++++++------ .../read-only-notice/ReadOnlyNotice.css | 25 +++++++ .../read-only-notice/ReadOnlyNotice.jsx | 24 +++++++ frontend/src/hooks/useWorkflowCanEdit.js | 17 +++++ 8 files changed, 184 insertions(+), 43 deletions(-) create mode 100644 frontend/src/components/widgets/read-only-notice/ReadOnlyNotice.css create mode 100644 frontend/src/components/widgets/read-only-notice/ReadOnlyNotice.jsx create mode 100644 frontend/src/hooks/useWorkflowCanEdit.js diff --git a/backend/permissions/permission.py b/backend/permissions/permission.py index 33d4dd5079..c61a4a2879 100644 --- a/backend/permissions/permission.py +++ b/backend/permissions/permission.py @@ -3,6 +3,7 @@ from adapter_processor_v2.models import AdapterInstance from rest_framework import permissions +from rest_framework.exceptions import PermissionDenied from rest_framework.request import Request from rest_framework.views import APIView from tenant_account_v2.organization_member_service import OrganizationMemberService @@ -148,6 +149,34 @@ def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bo return is_workflow_mutator(request, obj.workflow) +class WorkflowOwnerMutationMixin: + """Viewset mixin gating mutation of a workflow sub-resource. + + Shared access to the parent workflow -- direct, via group, or org-wide -- + grants read only. Admits owners, co-owners, org admins and service + accounts, via :func:`is_workflow_mutator`. Requires the resource to carry + a ``workflow`` FK. + + ``create`` is handled separately from the rest: it is collection-level, so + DRF never calls ``get_object()`` and ``IsParentWorkflowOwner`` cannot run. + """ + + mutation_denied_message = ( + "Only the workflow owner or an organization admin can change this." + ) + + def get_permissions(self) -> list[Any]: + if self.action in ("update", "partial_update", "destroy"): + return [IsParentWorkflowOwner()] + return list(super().get_permissions()) + + def perform_create(self, serializer: Any) -> None: + workflow = serializer.validated_data.get("workflow") + if workflow and not is_workflow_mutator(self.request, workflow): + raise PermissionDenied(self.mutation_denied_message) + serializer.save() + + class IsParentToolOwner(permissions.BasePermission): """Mutation gate for Prompt Studio sub-resources owned via the parent tool. diff --git a/backend/workflow_manager/endpoint_v2/views.py b/backend/workflow_manager/endpoint_v2/views.py index 67c5f012ad..ce9985c235 100644 --- a/backend/workflow_manager/endpoint_v2/views.py +++ b/backend/workflow_manager/endpoint_v2/views.py @@ -1,4 +1,5 @@ from django.db.models import QuerySet +from permissions.permission import WorkflowOwnerMutationMixin from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.request import Request @@ -11,8 +12,19 @@ from workflow_manager.workflow_v2.models.workflow import Workflow -class WorkflowEndpointViewSet(viewsets.ModelViewSet): +class WorkflowEndpointViewSet(WorkflowOwnerMutationMixin, viewsets.ModelViewSet): + """Workflow source / destination endpoints. + + Config here selects the connector and its settings -- the destination + folder for filesystem, the table for database. Shared users may read + it; only owners and org admins may change it. + """ + serializer_class = WorkflowEndpointSerializer + mutation_denied_message = ( + "Only the workflow owner or an organization admin can change its " + "connector configuration." + ) def get_queryset(self) -> QuerySet: # Get workflows accessible to the user (owned or shared) diff --git a/frontend/src/components/agency/agency/Agency.jsx b/frontend/src/components/agency/agency/Agency.jsx index 10735cb67d..6126b5cf07 100644 --- a/frontend/src/components/agency/agency/Agency.jsx +++ b/frontend/src/components/agency/agency/Agency.jsx @@ -15,6 +15,7 @@ import useClearFileHistory from "../../../hooks/useClearFileHistory"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; import usePostHogEvents from "../../../hooks/usePostHogEvents.js"; import useRequestUrl from "../../../hooks/useRequestUrl"; +import { useWorkflowCanEdit } from "../../../hooks/useWorkflowCanEdit"; import { IslandLayout } from "../../../layouts/island-layout/IslandLayout.jsx"; import { useAlertStore } from "../../../store/alert-store"; import { useSessionStore } from "../../../store/session-store"; @@ -56,6 +57,7 @@ function Agency() { } = workflowStore; const { sessionDetails } = useSessionStore(); const { orgName } = sessionDetails; + const canEdit = useWorkflowCanEdit(); const { getUrl } = useRequestUrl(); const axiosPrivate = useAxiosPrivate(); const { setAlertDetails } = useAlertStore(); @@ -1154,6 +1156,7 @@ function Agency() { type="link" onClick={() => setShowToolSelectionSidebar(true)} size="small" + disabled={!canEdit} > Change Prompt Studio project @@ -1163,6 +1166,7 @@ function Agency() { type="default" onClick={() => setShowToolSelectionSidebar(true)} className="select-tool-btn" + disabled={!canEdit} > Select Prompt Studio project diff --git a/frontend/src/components/agency/configure-connector-modal/ConfigureConnectorModal.jsx b/frontend/src/components/agency/configure-connector-modal/ConfigureConnectorModal.jsx index 1c90a2d813..087ca22779 100644 --- a/frontend/src/components/agency/configure-connector-modal/ConfigureConnectorModal.jsx +++ b/frontend/src/components/agency/configure-connector-modal/ConfigureConnectorModal.jsx @@ -15,10 +15,12 @@ import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; import usePostHogEvents from "../../../hooks/usePostHogEvents"; import useRequestUrl from "../../../hooks/useRequestUrl"; +import { useWorkflowCanEdit } from "../../../hooks/useWorkflowCanEdit"; import { useAlertStore } from "../../../store/alert-store"; import { AddSourceModal } from "../../input-output/add-source-modal/AddSourceModal"; import { ManageFiles } from "../../input-output/manage-files/ManageFiles"; import { CustomButton } from "../../widgets/custom-button/CustomButton"; +import { ReadOnlyNotice } from "../../widgets/read-only-notice/ReadOnlyNotice"; import { ConfigureFormsLayout } from "../configure-forms-layout/ConfigureFormsLayout"; import "./ConfigureConnectorModal.css"; @@ -72,6 +74,11 @@ function ConfigureConnectorModal({ const [hasInitializedFormData, setHasInitializedFormData] = useState(false); const [schemaLoadedForSession, setSchemaLoadedForSession] = useState(false); const [ruleEngineHasChanges, setRuleEngineHasChanges] = useState(false); + const canEdit = useWorkflowCanEdit(); + // Grey out a region without touching each third-party widget inside it. + const roClass = canEdit ? undefined : "uneditable"; + // Lets the single footer Save flush the HITL plugin's rules too. + const ruleEngineRef = useRef(null); const fileExplorerRef = useRef(null); const formRef = useRef(null); @@ -333,19 +340,26 @@ function ConfigureConnectorModal({ const handleSave = async () => { const hasConfigChanges = !isEqual(formDataConfig, initialFormDataConfig); - if (hasConfigChanges && formRef?.current) { - if (formRef?.current?.validateForm()) { - await handleValidateAndSubmit(formDataConfig); - return true; - } else { - // RJSF shows validation errors - return false; + if ( + hasConfigChanges && + formRef?.current && + !formRef.current.validateForm() + ) { + // RJSF shows validation errors + return false; + } + await handleValidateAndSubmit(formDataConfig); + // HITL rules live in the plugin and used to need their own button. One + // Save now writes everything the modal shows. + if (ruleEngineRef.current?.save) { + setIsSavingEndpoint(true); + try { + await ruleEngineRef.current.save(); + } finally { + setIsSavingEndpoint(false); } - } else { - // No config changes, just save connector changes if any - await handleValidateAndSubmit(formDataConfig); - return true; } + return true; }; const handleModalClose = () => { @@ -532,8 +546,10 @@ function ConfigureConnectorModal({ footer={ connDetails?.id || connMode === "API" ? (
- - {connMode !== "API" && ( + + {canEdit && ( - + {canEdit && ( + + + + )} {handleShare && ( - + + + )} ); }; From 8d405fcd5ed504c0d9f66ba7f516571bbb8ff15b Mon Sep 17 00:00:00 2001 From: kirtimanmishrazipstack Date: Wed, 2 Sep 2026 16:42:13 +0530 Subject: [PATCH 08/11] UN-2868 [FIX] Make Prompt Studio settings read-only for shared users Settings hold the project's LLM profiles and adapter selections -- the credential-bearing part. A shared user can read them but not change them: the panel gets the read-only notice and its controls are inert. Prompts are deliberately untouched. Editing, running and deleting prompts is what a project is shared for; only the settings panel is restricted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016eB6bLmieWVwUnmYH6H5WZ --- .../custom-tools/settings-modal/SettingsModal.jsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/custom-tools/settings-modal/SettingsModal.jsx b/frontend/src/components/custom-tools/settings-modal/SettingsModal.jsx index 99a68d5fb9..453583dcad 100644 --- a/frontend/src/components/custom-tools/settings-modal/SettingsModal.jsx +++ b/frontend/src/components/custom-tools/settings-modal/SettingsModal.jsx @@ -12,6 +12,8 @@ import { Modal } from "@/components/ui/shims/antd-overlays"; import { Menu } from "@/components/ui/shims/antd-structure"; import { Typography } from "@/components/ui/shims/antd-typography"; import { getMenuItem } from "../../../helpers/GetStaticData"; +import { usePromptStudioCanEdit } from "../../../hooks/usePromptStudioCanEdit"; +import { ReadOnlyNotice } from "../../widgets/read-only-notice/ReadOnlyNotice"; import SpaceWrapper from "../../widgets/space-wrapper/SpaceWrapper"; import { CustomDataSettings } from "../custom-data-settings/CustomDataSettings"; import { CustomSynonyms } from "../custom-synonyms/CustomSynonyms"; @@ -41,6 +43,10 @@ try { // Component will remain null if it is not present. } function SettingsModal({ open, setOpen, handleUpdateTool }) { + // Settings hold the project's adapter credentials, so a shared user reads + // them but cannot change them. Prompts stay editable -- that is what the + // project was shared for. + const canEdit = usePromptStudioCanEdit(); const [selectedId, setSelectedId] = useState(1); const [menuItems, setMenuItems] = useState([]); const [components, setComponents] = useState([]); @@ -140,6 +146,9 @@ function SettingsModal({ open, setOpen, handleUpdateTool }) { Settings
+ {!canEdit && ( + + )}
@@ -154,7 +163,11 @@ function SettingsModal({ open, setOpen, handleUpdateTool }) {
-
+
{components[selectedId]}
From 91c840495c12aeec1e571c15b5e225901a012e8d Mon Sep 17 00:00:00 2001 From: kirtimanmishrazipstack Date: Wed, 2 Sep 2026 16:59:01 +0530 Subject: [PATCH 09/11] UN-2868 [FIX] Restrict the gate to edit and delete only The scope is settings and deletion, nothing else. The pipeline and API deployment cards had also lost their enable/disable toggle, Manage Keys, Notifications and Clear File History for shared users, which goes further than intended. Both card configs are reverted. Only the Edit and Delete controls in the two shared list widgets stay gated; Share, the toggle and every kebab action are available again. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016eB6bLmieWVwUnmYH6H5WZ --- .../ApiDeploymentCardConfig.jsx | 70 +++++++--------- .../pipelines/PipelineCardConfig.jsx | 84 ++++++++----------- 2 files changed, 63 insertions(+), 91 deletions(-) diff --git a/frontend/src/components/deployments/api-deployment/ApiDeploymentCardConfig.jsx b/frontend/src/components/deployments/api-deployment/ApiDeploymentCardConfig.jsx index 50f65a4612..fde548844c 100644 --- a/frontend/src/components/deployments/api-deployment/ApiDeploymentCardConfig.jsx +++ b/frontend/src/components/deployments/api-deployment/ApiDeploymentCardConfig.jsx @@ -12,7 +12,6 @@ import { Flex, Space } from "@/components/ui/shims/antd-layout"; import { Tooltip } from "@/components/ui/shims/antd-overlays"; import { Typography } from "@/components/ui/shims/antd-typography"; -import { canEditResource } from "../../../helpers/resourceAccess"; import { StatusPills } from "../../pipelines-or-deployments/pipelines/PipelineCardConfig"; import { ApiEndpointSection, @@ -52,9 +51,6 @@ function createApiDeploymentCardConfig({ }, expandable: false, listContent: (deployment) => { - // Sharing grants read only: keep the read actions, drop the ones that - // change the deployment. - const canEdit = canEditResource(deployment, sessionDetails); const kebabMenuItems = { items: [ { @@ -63,23 +59,19 @@ function createApiDeploymentCardConfig({ label: "View Logs", onClick: () => onViewLogs?.(deployment), }, - ...(canEdit - ? [ - { type: "divider" }, - { - key: "manage-keys", - icon: , - label: "Manage Keys", - onClick: () => onManageKeys?.(deployment), - }, - { - key: "notifications", - icon: , - label: "Notifications", - onClick: () => onSetupNotifications?.(deployment), - }, - ] - : []), + { type: "divider" }, + { + key: "manage-keys", + icon: , + label: "Manage Keys", + onClick: () => onManageKeys?.(deployment), + }, + { + key: "notifications", + icon: , + label: "Notifications", + onClick: () => onSetupNotifications?.(deployment), + }, { type: "divider" }, { key: "code-snippets", @@ -103,25 +95,23 @@ function createApiDeploymentCardConfig({ description={deployment.description} > - {canEdit && ( - - { - e.stopPropagation(); - updateStatus(deployment); - }} - /> - - )} + + { + e.stopPropagation(); + updateStatus(deployment); + }} + /> + onViewFileHistory?.(pipeline), }, - ...(canEdit - ? [ - { - key: "clear-history", - icon: , - label: isClearingFileHistory - ? "Clearing..." - : "Clear File History", - disabled: isClearingFileHistory, - onClick: () => onClearFileHistory?.(pipeline), - }, - ] - : []), + { + key: "clear-history", + icon: , + label: isClearingFileHistory ? "Clearing..." : "Clear File History", + disabled: isClearingFileHistory, + onClick: () => onClearFileHistory?.(pipeline), + }, { type: "divider" }, { key: "sync-now", @@ -299,23 +289,19 @@ function createPipelineCardConfig({ label: "Sync Now", onClick: () => onSyncNow?.(pipeline), }, - ...(canEdit - ? [ - { type: "divider" }, - { - key: "manage-keys", - icon: , - label: "Manage Keys", - onClick: () => onManageKeys?.(pipeline), - }, - { - key: "notifications", - icon: , - label: "Notifications", - onClick: () => onSetupNotifications?.(pipeline), - }, - ] - : []), + { type: "divider" }, + { + key: "manage-keys", + icon: , + label: "Manage Keys", + onClick: () => onManageKeys?.(pipeline), + }, + { + key: "notifications", + icon: , + label: "Notifications", + onClick: () => onSetupNotifications?.(pipeline), + }, { type: "divider" }, { key: "download-postman", @@ -337,23 +323,19 @@ function createPipelineCardConfig({ - {canEdit && ( - - { - e.stopPropagation(); - handleEnablePipeline(checked, pipeline.id); - }} - /> - - )} + + { + e.stopPropagation(); + handleEnablePipeline(checked, pipeline.id); + }} + /> + Date: Wed, 2 Sep 2026 17:37:26 +0530 Subject: [PATCH 10/11] UN-2868 [FIX] Show Edit and Delete disabled rather than hiding them Hiding the two controls left a shared user with no idea they existed or why they were missing. They now stay on screen, greyed out, with a tooltip reading "Only the owner can change this". Same treatment in both list widgets so every resource looks the same. The rename pencil beside a project title follows the same rule: ToolNavBar takes an editTitleDisabled prop, and Prompt Studio passes it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016eB6bLmieWVwUnmYH6H5WZ --- .../components/custom-tools/header/Header.jsx | 4 + .../navigations/tool-nav-bar/ToolNavBar.jsx | 28 +++++-- .../card-grid-view/CardFieldComponents.jsx | 54 +++++++------- .../widgets/resource-table/ResourceTable.jsx | 74 ++++++++++--------- 4 files changed, 92 insertions(+), 68 deletions(-) diff --git a/frontend/src/components/custom-tools/header/Header.jsx b/frontend/src/components/custom-tools/header/Header.jsx index 34bae4dd8e..bae4bbdc4a 100644 --- a/frontend/src/components/custom-tools/header/Header.jsx +++ b/frontend/src/components/custom-tools/header/Header.jsx @@ -9,6 +9,7 @@ import { ExportToolIcon } from "../../../assets"; import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler"; import usePostHogEvents from "../../../hooks/usePostHogEvents"; +import { usePromptStudioCanEdit } from "../../../hooks/usePromptStudioCanEdit"; import { useAlertStore } from "../../../store/alert-store"; import { useCustomToolStore } from "../../../store/custom-tool-store"; import { useSessionStore } from "../../../store/session-store"; @@ -53,6 +54,8 @@ function Header({ const { details, isPublicSource, markChangesAsExported } = useCustomToolStore(); const { sessionDetails } = useSessionStore(); + // Renaming a shared project is an edit, so it follows the same rule. + const canEdit = usePromptStudioCanEdit(); const { setAlertDetails } = useAlertStore(); const axiosPrivate = useAxiosPrivate(); const handleException = useExceptionHandler(); @@ -444,6 +447,7 @@ function Header({ onEditTitle={ isPublicSource || !details?.tool_id ? undefined : handleOpenEditModal } + editTitleDisabled={!canEdit} customButtons={actionButtons} /> {titleAdornment} {onEditTitle && ( -
{subtitle && ( @@ -134,6 +145,7 @@ ToolNavBar.propTypes = { titleAdornment: PropTypes.node, subtitle: PropTypes.string, onEditTitle: PropTypes.func, + editTitleDisabled: PropTypes.bool, enableSearch: PropTypes.bool, customButtons: PropTypes.node, setSearchList: PropTypes.func, diff --git a/frontend/src/components/widgets/card-grid-view/CardFieldComponents.jsx b/frontend/src/components/widgets/card-grid-view/CardFieldComponents.jsx index 8fc990feae..e678109b9f 100644 --- a/frontend/src/components/widgets/card-grid-view/CardFieldComponents.jsx +++ b/frontend/src/components/widgets/card-grid-view/CardFieldComponents.jsx @@ -53,6 +53,7 @@ function CardActionBox({ // Sharing grants read only: no edit, no delete. Sharing onward stays // available -- see the Share button below. const canEdit = canEditResource(item, sessionDetails); + const lockedTitle = canEdit ? undefined : "Only the owner can change this"; const testId = (suffix) => testIdPrefix ? `${testIdPrefix}-${suffix}-${item?.id}` : undefined; const handleEditAction = (e) => { @@ -69,15 +70,16 @@ function CardActionBox({ return ( - {canEdit && ( + - - )} + + + {handleShare && ( - - )} + + + ); }; From 8a84d68e726b037617f78933f4f3666f8113d183 Mon Sep 17 00:00:00 2001 From: kirtimanmishrazipstack Date: Thu, 3 Sep 2026 11:17:33 +0530 Subject: [PATCH 11/11] UN-2868 [FIX] Report one outcome when the connector save also writes rules The connector write showed "Configuration saved successfully" before the rule write ran. A rule failure after it left a green toast on screen next to a red one, reading as though everything had landed. The connector success message is now suppressed when a rule write follows, and the rule write reports the outcome for both. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TaTtTMaDriVZgw6BR8HZ4G --- .../ConfigureConnectorModal.jsx | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/agency/configure-connector-modal/ConfigureConnectorModal.jsx b/frontend/src/components/agency/configure-connector-modal/ConfigureConnectorModal.jsx index 9f61c1e067..ba44a92723 100644 --- a/frontend/src/components/agency/configure-connector-modal/ConfigureConnectorModal.jsx +++ b/frontend/src/components/agency/configure-connector-modal/ConfigureConnectorModal.jsx @@ -304,7 +304,10 @@ function ConfigureConnectorModal({ return hasConfigChanges || hasConnectorChanged || ruleEngineHasChanges; }; - const handleValidateAndSubmit = async (validatedFormData) => { + const handleValidateAndSubmit = async ( + validatedFormData, + notifySuccess = true, + ) => { const hasConfigChanges = !isEqual(validatedFormData, initialFormDataConfig); const hasConnectorChanged = connDetails?.id !== initialConnectorId; const hasChanges = hasConfigChanges || hasConnectorChanged; @@ -325,10 +328,12 @@ function ConfigureConnectorModal({ // Update initial values after successful save setInitialFormDataConfig(cloneDeep(validatedFormData)); setInitialConnectorId(connDetails?.id); - setAlertDetails({ - type: "success", - content: "Configuration saved successfully.", - }); + if (notifySuccess) { + setAlertDetails({ + type: "success", + content: "Configuration saved successfully.", + }); + } return true; } catch (error) { setAlertDetails({ @@ -347,7 +352,7 @@ function ConfigureConnectorModal({ // The read-only styling stops the mouse but not the keyboard, so cut the // form's own submit path too rather than let Enter fire a doomed request. - const submitIfEditable = canEdit ? handleValidateAndSubmit : () => {}; + const submitIfEditable = canEdit ? handleValidateAndSubmit : undefined; const handleSave = async () => { const hasConfigChanges = !isEqual(formDataConfig, initialFormDataConfig); @@ -360,15 +365,18 @@ function ConfigureConnectorModal({ // RJSF shows validation errors return false; } - // Stop here if the endpoint write failed, rather than writing half the - // configuration and closing as though everything saved. - if (!(await handleValidateAndSubmit(formDataConfig))) { - return false; - } // HITL rules live in the plugin and used to need their own button. One // Save now writes everything the modal shows. Only when they actually // changed -- otherwise every connector save would write a rule too. - if (ruleEngineHasChanges && ruleEngineRef.current?.save) { + const writesRules = ruleEngineHasChanges && !!ruleEngineRef.current?.save; + // Stop here if the endpoint write failed, rather than writing half the + // configuration and closing as though everything saved. Stay quiet on + // success when rules follow: the rule write reports the real outcome, and + // a success toast ahead of its failure would read as though both landed. + if (!(await handleValidateAndSubmit(formDataConfig, !writesRules))) { + return false; + } + if (writesRules) { setIsSavingEndpoint(true); try { // Keep the modal open on failure so the edit is not lost.