Skip to content
Merged
7 changes: 7 additions & 0 deletions backend/prompt_studio/prompt_studio_core_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/custom-tools/header/Header.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -444,6 +447,7 @@ function Header({
onEditTitle={
isPublicSource || !details?.tool_id ? undefined : handleOpenEditModal
}
editTitleDisabled={!canEdit}
customButtons={actionButtons}
/>
<Modal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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([]);
Expand Down Expand Up @@ -140,6 +146,9 @@ function SettingsModal({ open, setOpen, handleUpdateTool }) {
Settings
</Typography.Text>
</div>
{!canEdit && (
<ReadOnlyNotice message="Shared with you — settings are view only. Only the owner can change them." />
)}
<Row className="conn-modal-row" style={{ height: "800px" }}>
<Col span={4} className="conn-modal-col conn-modal-col-left">
<div className="conn-modal-menu conn-modal-form-pad-right">
Expand All @@ -154,7 +163,11 @@ function SettingsModal({ open, setOpen, handleUpdateTool }) {
</div>
</Col>
<Col span={20} className="conn-modal-col">
<div className="conn-modal-form-pad-left">
<div
className={`conn-modal-form-pad-left${
canEdit ? "" : " uneditable"
}`}
>
{components[selectedId]}
</div>
</Col>
Expand Down
28 changes: 20 additions & 8 deletions frontend/src/components/navigations/tool-nav-bar/ToolNavBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -15,6 +16,7 @@ function ToolNavBar({
titleAdornment,
subtitle,
onEditTitle,
editTitleDisabled = false,
enableSearch,
customButtons,
setSearchList,
Expand Down Expand Up @@ -80,14 +82,23 @@ function ToolNavBar({
</Typography.Text>
{titleAdornment}
{onEditTitle && (
<Button
type="text"
size="small"
icon={<Pencil />}
className="tool-nav-bar__edit-icon"
onClick={onEditTitle}
aria-label="Edit title"
/>
<Tooltip
title={
editTitleDisabled
? "Only the owner can change this"
: undefined
}
>
<Button
type="text"
size="small"
icon={<Pencil />}
className="tool-nav-bar__edit-icon"
onClick={onEditTitle}
disabled={editTitleDisabled}
aria-label="Edit title"
/>
</Tooltip>
)}
</div>
{subtitle && (
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) => {
Expand All @@ -63,13 +70,18 @@ function CardActionBox({

return (
<Space className="card-list-action-box">
<Button
type="text"
className="action-icon-btn edit-icon"
data-testid={testId("edit")}
icon={<Pencil />}
onClick={handleEditAction}
/>
<Tooltip title={lockedTitle}>
<Button
type="text"
className="action-icon-btn edit-icon"
data-testid={testId("edit")}
icon={<Pencil />}
disabled={!canEdit}
onClick={handleEditAction}
/>
</Tooltip>
{/* Sharing stays open to shared users: they may pass access on to a
group they belong to, or to a user in the same organisation. */}
<Button
type="text"
className="action-icon-btn share-icon"
Expand All @@ -78,6 +90,7 @@ function CardActionBox({
onClick={handleShareAction}
/>
<Popconfirm
disabled={!canEdit}
title={deleteTitle}
description="This action cannot be undone."
onConfirm={() => {
Expand All @@ -97,13 +110,16 @@ function CardActionBox({
testIdPrefix ? `${testIdPrefix}-delete-confirm` : undefined
}
>
<Button
type="text"
className="action-icon-btn delete-icon"
data-testid={testId("delete")}
icon={<Trash2 />}
onClick={(e) => e.stopPropagation()}
/>
<Tooltip title={lockedTitle}>
<Button
type="text"
className="action-icon-btn delete-icon"
data-testid={testId("delete")}
icon={<Trash2 />}
disabled={!canEdit}
onClick={(e) => e.stopPropagation()}
/>
</Tooltip>
</Popconfirm>
<Dropdown
menu={kebabMenuItems}
Expand Down
18 changes: 15 additions & 3 deletions frontend/src/components/widgets/resource-table/ResourceTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { Table } from "@/components/ui/shims/antd-structure";
import { Typography } from "@/components/ui/shims/antd-typography";

import { formattedDateTime, timeAgo } from "../../../helpers/GetStaticData";
import { canEditResource } from "../../../helpers/resourceAccess";
import "./ResourceTable.css";

// Stable, distinct avatar swatch per owner (seeded on email/name) like the
Expand Down Expand Up @@ -288,21 +289,29 @@ function ResourceTable({
const renderActions = (item) => {
const deprecated = item?.is_deprecated;
const disabledTitle = deprecated ? "This adapter is deprecated" : "";
// Sharing grants read only: no edit, no delete. Both controls stay on
// screen but disabled, so it is obvious they exist and why they are not
// available. Sharing onward stays open to shared users.
const canEdit = canEditResource(item, sessionDetails);
const locked = !canEdit;
const lockedTitle = "Only the owner can change this";
return (
<Space
size={18}
className="resource-table-actions"
onClick={(event) => event.stopPropagation()}
role="none"
>
<Tooltip title={disabledTitle}>
<Tooltip title={locked ? lockedTitle : disabledTitle}>
<button
type="button"
className="action-icon-btn"
aria-label={`Edit ${type}`}
data-testid={rowTestId(item, "edit")}
aria-disabled={deprecated}
onClick={(event) => !deprecated && handleEdit?.(event, item)}
aria-disabled={deprecated || locked}
onClick={(event) =>
!deprecated && canEdit && handleEdit?.(event, item)
}
>
<Pencil className="action-icon-buttons edit-icon" />
</button>
Expand All @@ -328,6 +337,7 @@ function ResourceTable({
* because every row has one.
*/}
<Popconfirm
disabled={locked}
title={`Delete the ${type}`}
description={`Are you sure to delete ${item?.[titleProp]}`}
okText="Yes"
Expand All @@ -341,6 +351,8 @@ function ResourceTable({
className="action-icon-btn"
aria-label={`Delete ${type}`}
data-testid={rowTestId(item, "delete")}
aria-disabled={locked}
title={locked ? lockedTitle : undefined}
>
<Trash2 className="action-icon-buttons delete-icon" />
</button>
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/helpers/resourceAccess.js
Original file line number Diff line number Diff line change
@@ -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) {

Check warning on line 15 in frontend/src/helpers/resourceAccess.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=Zipstack_unstract&issues=AaBlqcRS5ZQ-j9iC7kaI&open=AaBlqcRS5ZQ-j9iC7kaI&pullRequest=2273
return true;
}
return Boolean(resource.is_owner || sessionDetails?.isAdmin);
}

export { canEditResource };
18 changes: 18 additions & 0 deletions frontend/src/hooks/usePromptStudioCanEdit.js
Original file line number Diff line number Diff line change
@@ -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 };
8 changes: 4 additions & 4 deletions frontend/src/hooks/useWorkflowCanEdit.js
Original file line number Diff line number Diff line change
@@ -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 };
Loading