diff --git a/backend/prompt_studio/prompt_studio_core_v2/serializers.py b/backend/prompt_studio/prompt_studio_core_v2/serializers.py index acb3a243d7..d59e9d3210 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/serializers.py +++ b/backend/prompt_studio/prompt_studio_core_v2/serializers.py @@ -99,6 +99,9 @@ class CustomToolSerializer(IntegrityErrorMixin, AuditSerializer): # groups axis is read-only here (UN-2977 plan §B). Direct viewers live in # the membership table (UN-2202) and surface via the share-modal serializer. shared_groups = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + # The editor needs to know whether to offer edit controls at all; the list + # serializer already carries this. + is_owner = serializers.SerializerMethodField() class Meta: model = CustomTool @@ -107,6 +110,10 @@ class Meta: "shared_to_org": {"read_only": True}, } + def get_is_owner(self, instance: CustomTool) -> bool: + request = self.context.get("request") + return instance.is_owner(request.user) if request else False + unique_error_message_map: dict[str, dict[str, str]] = { "unique_tool_name": { "field": "tool_name", 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} /> + {!canEdit && ( + + )}
@@ -154,7 +163,11 @@ function SettingsModal({ open, setOpen, handleUpdateTool }) {
-
+
{components[selectedId]}
diff --git a/frontend/src/components/navigations/tool-nav-bar/ToolNavBar.jsx b/frontend/src/components/navigations/tool-nav-bar/ToolNavBar.jsx index cb138897ff..f13ba1de4e 100644 --- a/frontend/src/components/navigations/tool-nav-bar/ToolNavBar.jsx +++ b/frontend/src/components/navigations/tool-nav-bar/ToolNavBar.jsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useRef } from "react"; import { useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/shims/antd-button"; import { Input } from "@/components/ui/shims/antd-inputs"; +import { Tooltip } from "@/components/ui/shims/antd-overlays"; import { Segmented } from "@/components/ui/shims/antd-structure"; import { Typography } from "@/components/ui/shims/antd-typography"; @@ -15,6 +16,7 @@ function ToolNavBar({ titleAdornment, subtitle, onEditTitle, + editTitleDisabled = false, enableSearch, customButtons, setSearchList, @@ -80,14 +82,23 @@ function ToolNavBar({ {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 646d2fbced..e678109b9f 100644 --- a/frontend/src/components/widgets/card-grid-view/CardFieldComponents.jsx +++ b/frontend/src/components/widgets/card-grid-view/CardFieldComponents.jsx @@ -27,6 +27,8 @@ import { formattedDateTime, shortenApiEndpoint, } from "../../../helpers/GetStaticData"; +import { canEditResource } from "../../../helpers/resourceAccess"; +import { useSessionStore } from "../../../store/session-store"; /** * Reusable action box with Edit, Share, Delete icons and kebab menu @@ -47,6 +49,11 @@ function CardActionBox({ */ testIdPrefix, }) { + const { sessionDetails } = useSessionStore(); + // 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) => { @@ -63,13 +70,18 @@ function CardActionBox({ return ( - @@ -328,6 +337,7 @@ function ResourceTable({ * because every row has one. */} diff --git a/frontend/src/helpers/resourceAccess.js b/frontend/src/helpers/resourceAccess.js new file mode 100644 index 0000000000..a048c705b8 --- /dev/null +++ b/frontend/src/helpers/resourceAccess.js @@ -0,0 +1,21 @@ +/** + * Whether the current user may change a shared resource. + * + * Sharing — direct, via group, or org-wide — grants READ only. Owners, + * co-owners and org admins may edit and delete. The backend is the authority + * (`is_workflow_mutator` and the `IsOwner` family); this only decides what the + * UI offers, so nobody fills in a form that can only fail. + * + * `is_owner` is set by every shareable resource's serializer. + */ +function canEditResource(resource, sessionDetails) { + // Payload not in yet. The backend still refuses the write, so assume + // editable rather than flash a read-only view at the resource's own owner + // while the request is in flight. + if (!resource || resource.is_owner === undefined) { + return true; + } + return Boolean(resource.is_owner || sessionDetails?.isAdmin); +} + +export { canEditResource }; diff --git a/frontend/src/hooks/usePromptStudioCanEdit.js b/frontend/src/hooks/usePromptStudioCanEdit.js new file mode 100644 index 0000000000..a41bac5940 --- /dev/null +++ b/frontend/src/hooks/usePromptStudioCanEdit.js @@ -0,0 +1,18 @@ +import { canEditResource } from "../helpers/resourceAccess"; +import { useCustomToolStore } from "../store/custom-tool-store"; +import { useSessionStore } from "../store/session-store"; + +/** + * Whether the current user may change the Prompt Studio project being viewed. + * + * Pairs with the existing `isPublicSource` flag rather than replacing it: + * that one means "opened through a public read-only link" and also selects + * API paths, while this one means "shared with me, so read only". + */ +function usePromptStudioCanEdit() { + const { details } = useCustomToolStore(); + const { sessionDetails } = useSessionStore(); + return canEditResource(details, sessionDetails); +} + +export { usePromptStudioCanEdit }; diff --git a/frontend/src/hooks/useWorkflowCanEdit.js b/frontend/src/hooks/useWorkflowCanEdit.js index a86129757f..d183b8b1d0 100644 --- a/frontend/src/hooks/useWorkflowCanEdit.js +++ b/frontend/src/hooks/useWorkflowCanEdit.js @@ -1,17 +1,17 @@ +import { canEditResource } from "../helpers/resourceAccess"; import { useSessionStore } from "../store/session-store"; import { useWorkflowStore } from "../store/workflow-store"; /** * Whether the current user may change the workflow being viewed. * - * Sharing — direct, via group, or org-wide — grants read only. Owners, - * co-owners and org admins may edit. Mirrors the backend's - * `is_workflow_mutator`, which is the authority. + * Thin wrapper over `canEditResource` for the workflow builder, which reads + * its resource from the workflow store rather than a list row. */ function useWorkflowCanEdit() { const { details } = useWorkflowStore(); const { sessionDetails } = useSessionStore(); - return Boolean(details?.is_owner || sessionDetails?.isAdmin); + return canEditResource(details, sessionDetails); } export { useWorkflowCanEdit };