From a78e28823317e73fb15764cda72881dca1d6d21a Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Wed, 3 Jun 2026 21:53:13 -0400 Subject: [PATCH] fix: make public projects editable; gate only backend writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shared-surface mirror of openplc-web PR #484 (byte-identical). Removes the read-only / fork-redirect feature (read-only-project modal, workspace.isReadOnly UI-lockdown flag, forkProject/listMyFolders port methods + ProjectFolder/ForkProject* types, and all editor/menu/tree gating). Replaces it with a narrowly-scoped workspace.canEdit flag that gates only backend writes (save/commit/branch/stash/discard/restore/ README) with a graceful warning toast. In-memory editing, simulation, and compilation are always allowed. No behavioral change for the editor (desktop has no remote permission concept, so canEdit stays undefined ⇒ treated as editable). This PR exists to keep the byte-identical shared surface in sync. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../branches/create-branch-modal.tsx | 21 +- .../branches/create-branch-popover.tsx | 12 +- .../branches/delete-branch-modal.tsx | 18 +- .../create-element/element-card/index.tsx | 19 +- .../[workspace]/editor/graphical/index.tsx | 4 +- .../[workspace]/editor/monaco/index.tsx | 12 +- .../source-control/changes-section.tsx | 37 +-- .../source-control/commit-details.tsx | 8 + .../source-control/stash-section.tsx | 14 +- .../_molecules/menu-bar/menus/file.tsx | 14 +- .../_molecules/project-tree/index.tsx | 66 ++--- .../modals/project-readme-modal.tsx | 4 +- .../modals/read-only-project-modal.tsx | 239 ------------------ .../workspace-activity-bar/default.tsx | 16 +- .../components/_templates/app-layout.tsx | 2 - src/frontend/screens/workspace-screen.tsx | 2 - src/frontend/services/save-actions.ts | 21 +- .../store/__tests__/shared-slice.test.ts | 18 +- .../store/__tests__/workspace-slice.test.ts | 16 +- src/frontend/store/slices/modal/slice.ts | 1 - src/frontend/store/slices/modal/types.ts | 5 - src/frontend/store/slices/shared/slice.ts | 11 +- src/frontend/store/slices/workspace/slice.ts | 10 +- src/frontend/store/slices/workspace/types.ts | 22 +- .../notify-no-write-permission.test.ts | 25 ++ .../utils/notify-no-write-permission.ts | 22 ++ src/middleware/shared/ports/project-port.ts | 63 +---- 27 files changed, 209 insertions(+), 493 deletions(-) delete mode 100644 src/frontend/components/_organisms/modals/read-only-project-modal.tsx create mode 100644 src/frontend/utils/__tests__/notify-no-write-permission.test.ts create mode 100644 src/frontend/utils/notify-no-write-permission.ts diff --git a/src/frontend/components/_features/[workspace]/branches/create-branch-modal.tsx b/src/frontend/components/_features/[workspace]/branches/create-branch-modal.tsx index 45fc13cf2..69998e595 100644 --- a/src/frontend/components/_features/[workspace]/branches/create-branch-modal.tsx +++ b/src/frontend/components/_features/[workspace]/branches/create-branch-modal.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { useVersionControl } from '../../../../../middleware/shared/providers' import { useOpenPLCStore } from '../../../../store' +import { notifyNoWritePermission } from '../../../../utils/notify-no-write-permission' type CreateBranchModalProps = { isOpen: boolean @@ -12,8 +13,7 @@ type CreateBranchModalProps = { export function CreateBranchModal({ isOpen, projectId, onClose, onCreated }: CreateBranchModalProps) { const versionControl = useVersionControl() - const isReadOnly = useOpenPLCStore((s) => s.workspace.isReadOnly) - const openReadOnlyModal = useOpenPLCStore((s) => s.modalActions.openModal) + const canEdit = useOpenPLCStore((s) => s.workspace.canEdit) const [name, setName] = useState('') const [error, setError] = useState('') const [isPending, setIsPending] = useState(false) @@ -34,20 +34,17 @@ export function CreateBranchModal({ isOpen, projectId, onClose, onCreated }: Cre } }, [isOpen]) - // Read-only ⇒ close this modal and surface the read-only/fork modal - // instead. A useEffect (not an early-return rewrite) avoids breaking - // hook ordering for the rest of the component when isOpen flips. - useEffect(() => { - if (isOpen && isReadOnly) { - onClose() - openReadOnlyModal('read-only-project') - } - }, [isOpen, isReadOnly, onClose, openReadOnlyModal]) - if (!isOpen) return null const handleSubmit = (e: React.FormEvent) => { e.preventDefault() + + // No write permission ⇒ skip the doomed backend write and warn. + if (!canEdit) { + notifyNoWritePermission('create branches in') + return + } + const trimmed = name.trim() if (!trimmed) { diff --git a/src/frontend/components/_features/[workspace]/branches/create-branch-popover.tsx b/src/frontend/components/_features/[workspace]/branches/create-branch-popover.tsx index 3aa2c01a2..975fc7c4e 100644 --- a/src/frontend/components/_features/[workspace]/branches/create-branch-popover.tsx +++ b/src/frontend/components/_features/[workspace]/branches/create-branch-popover.tsx @@ -4,6 +4,7 @@ import { useMemo, useState } from 'react' import { useVersionControl } from '../../../../../middleware/shared/providers' import { useOpenPLCStore } from '../../../../store' import { cn } from '../../../../utils/cn' +import { notifyNoWritePermission } from '../../../../utils/notify-no-write-permission' import { getBranchNameFeedback } from '../../../../utils/sanitize-branch-name' type CreateBranchPopoverProps = { @@ -14,8 +15,7 @@ type CreateBranchPopoverProps = { export function CreateBranchPopover({ projectId, onCreated, onCloseParent }: CreateBranchPopoverProps) { const versionControl = useVersionControl() - const isReadOnly = useOpenPLCStore((s) => s.workspace.isReadOnly) - const openModal = useOpenPLCStore((s) => s.modalActions.openModal) + const canEdit = useOpenPLCStore((s) => s.workspace.canEdit) const [isOpen, setIsOpen] = useState(false) const [name, setName] = useState('') const [error, setError] = useState('') @@ -64,10 +64,10 @@ export function CreateBranchPopover({ projectId, onCreated, onCloseParent }: Cre { - // Read-only ⇒ redirect to the fork-or-cancel modal instead of - // opening a branch form the backend would 403 anyway. - if (open && isReadOnly) { - openModal('read-only-project') + // No write permission ⇒ warn instead of opening a branch form the + // backend would reject anyway. + if (open && !canEdit) { + notifyNoWritePermission('create branches in') return } setIsOpen(open) diff --git a/src/frontend/components/_features/[workspace]/branches/delete-branch-modal.tsx b/src/frontend/components/_features/[workspace]/branches/delete-branch-modal.tsx index 3a8f51b6e..db53fafd8 100644 --- a/src/frontend/components/_features/[workspace]/branches/delete-branch-modal.tsx +++ b/src/frontend/components/_features/[workspace]/branches/delete-branch-modal.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react' import type { Branch } from '../../../../../middleware/shared/ports/version-control-port' import { useVersionControl } from '../../../../../middleware/shared/providers' import { useOpenPLCStore } from '../../../../store' +import { notifyNoWritePermission } from '../../../../utils/notify-no-write-permission' import { toast } from '../../../../utils/toast' type DeleteBranchModalProps = { @@ -15,8 +16,7 @@ type DeleteBranchModalProps = { export function DeleteBranchModal({ isOpen, projectId, branch, onClose, onDeleted }: DeleteBranchModalProps) { const versionControl = useVersionControl() - const isReadOnly = useOpenPLCStore((s) => s.workspace.isReadOnly) - const openReadOnlyModal = useOpenPLCStore((s) => s.modalActions.openModal) + const canEdit = useOpenPLCStore((s) => s.workspace.canEdit) const [isPending, setIsPending] = useState(false) useEffect(() => { @@ -28,19 +28,15 @@ export function DeleteBranchModal({ isOpen, projectId, branch, onClose, onDelete return () => document.removeEventListener('keydown', handleKeyDown) }, [isOpen, isPending, onClose]) - // Same pattern as CreateBranchModal — if a read-only project is open, - // close this dialog and route the user to the fork affordance. - useEffect(() => { - if (isOpen && isReadOnly) { - onClose() - openReadOnlyModal('read-only-project') - } - }, [isOpen, isReadOnly, onClose, openReadOnlyModal]) - if (!isOpen || !branch) return null const handleDelete = () => { if (!versionControl) return + // No write permission ⇒ skip the doomed backend write and warn. + if (!canEdit) { + notifyNoWritePermission('delete branches in') + return + } setIsPending(true) versionControl diff --git a/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx b/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx index 6ec0ae8e7..b09eff4d9 100644 --- a/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx +++ b/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx @@ -128,23 +128,10 @@ const ElementCard = (props: ElementCardProps): ReactNode => { serverActions: { create: createServer }, remoteDeviceActions: { create: createRemoteDevice }, deviceAvailableOptions: { availableBoards }, - modalActions: { openModal }, } = useOpenPLCStore() - const isReadOnly = useOpenPLCStore((state) => state.workspace.isReadOnly) const deviceBoard = useOpenPLCStore((state) => state.deviceDefinitions.configuration.deviceBoard) const [isOpen, setIsOpen] = useState(false) - // Read-only ⇒ the create-element popover/menu just routes to the - // fork-or-cancel modal so the user knows why the affordance exists - // but can't make changes that wouldn't persist. - const handleOpen = (next: boolean) => { - if (next && isReadOnly) { - openModal('read-only-project') - return - } - setIsOpen(next) - } - const currentBoardInfo = availableBoards.get(deviceBoard) const isArduinoTarget = checkIsArduinoTarget(currentBoardInfo) const isSimulator = isSimulatorTarget(currentBoardInfo) @@ -231,15 +218,11 @@ const ElementCard = (props: ElementCardProps): ReactNode => { } const handleMouseEnter = () => { - if (isReadOnly) { - openModal('read-only-project') - return - } setIsOpen(true) } return ( - +
- {readOnly &&
} + {readOnly && ( +
+ )}
diff --git a/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx b/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx index 15cfda836..e034e7f18 100644 --- a/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx @@ -143,7 +143,6 @@ const MonacoEditor = (props: monacoEditorProps): ReturnType disposable.dispose() }, [editorMounted]) - // Update readOnly when debugger visibility or project read-only flag changes. - // Debugger visibility forces read-only for safety; the project's own - // read-only flag (no edit permission) does the same so users browsing - // someone else's project can't make local modifications they couldn't save. + // Update readOnly when debugger visibility changes (editor-only) useEffect(() => { - editorRef.current?.updateOptions({ readOnly: isDebuggerVisible || isReadOnly }) - }, [isDebuggerVisible, isReadOnly]) + editorRef.current?.updateOptions({ readOnly: isDebuggerVisible }) + }, [isDebuggerVisible]) // Apply programmatic cursor jumps (e.g. clicking a compile error in // the console) to an already-mounted editor. The onMount path @@ -1240,7 +1236,7 @@ void loop() const monacoEditorUserOptions: monacoEditorOptionsType = { minimap: { enabled: false }, dropIntoEditor: { enabled: true }, - readOnly: isDebuggerVisible || isReadOnly, + readOnly: isDebuggerVisible, // Lock indentation to 4 spaces across every language Monaco // hosts (ST / IL / Python / C++). Without this Monaco's // `detectIndentation` heuristic kicks in on the existing model diff --git a/src/frontend/components/_features/[workspace]/source-control/changes-section.tsx b/src/frontend/components/_features/[workspace]/source-control/changes-section.tsx index 21ffe4c17..78b908ebd 100644 --- a/src/frontend/components/_features/[workspace]/source-control/changes-section.tsx +++ b/src/frontend/components/_features/[workspace]/source-control/changes-section.tsx @@ -10,6 +10,7 @@ import type { TabsProps } from '../../../../store/slices/tabs' import { CreateEditorObjectFromTab } from '../../../../store/slices/tabs/utils' import type { PendingChangeStatus } from '../../../../store/slices/version-control/types' import { cn } from '../../../../utils/cn' +import { notifyNoWritePermission } from '../../../../utils/notify-no-write-permission' import { isSystemFile } from '../../../../utils/system-files' import { toast } from '../../../../utils/toast' import { DiscardConfirmationModal } from './modals/discard-confirmation-modal' @@ -283,8 +284,7 @@ export function ChangesSection({ projectId }: ChangesSectionProps) { tabsActions: { updateTabs }, editorActions: { setEditor, addModel, getEditorFromEditors }, } = useOpenPLCStore() - const isReadOnly = useOpenPLCStore((s) => s.workspace.isReadOnly) - const openReadOnlyModal = useOpenPLCStore((s) => s.modalActions.openModal) + const canEdit = useOpenPLCStore((s) => s.workspace.canEdit) const pous = project.data.pous @@ -472,10 +472,9 @@ export function ChangesSection({ projectId }: ChangesSectionProps) { const handleCommit = async () => { if (!canCommit || !versionControl) return - // No edit permission ⇒ open the fork-or-cancel affordance instead - // of letting the backend 403 the commit silently. - if (isReadOnly) { - openReadOnlyModal('read-only-project') + // No write permission ⇒ skip the doomed backend commit and warn. + if (!canEdit) { + notifyNoWritePermission('commit to') return } @@ -541,6 +540,11 @@ export function ChangesSection({ projectId }: ChangesSectionProps) { const handleDiscard = async () => { if (!versionControl) return + // No write permission ⇒ skip the doomed backend discard and warn. + if (!canEdit) { + notifyNoWritePermission('discard changes in') + return + } setIsDiscarding(true) setErrorMessage(null) @@ -573,6 +577,11 @@ export function ChangesSection({ projectId }: ChangesSectionProps) { const handleStash = async (stashMessage: string) => { if (!versionControl) return + // No write permission ⇒ skip the doomed backend stash and warn. + if (!canEdit) { + notifyNoWritePermission('stash changes in') + return + } setIsStashing(true) setErrorMessage(null) @@ -723,25 +732,23 @@ export function ChangesSection({ projectId }: ChangesSectionProps) {
- -
- - ) : ( - <> - Fork project -

- Pick a folder in your workspace where the fork should live. -

- -
- {foldersLoading &&
Loading folders…
} - {foldersError &&
{foldersError}
} - {!foldersLoading && !foldersError && flatFolderRows.length === 0 && ( -
No folders available.
- )} - {flatFolderRows.map((row) => { - const selected = selectedFolderId === row.id - return ( - - ) - })} -
- - - - {forkError &&

{forkError}

} - -
- - -
- - )} - - - ) -} - -/** - * Depth-first flatten of the folder hierarchy so the picker can render - * a single vertical list with indentation per nesting level — simpler - * than a true tree component and visually equivalent for small org - * hierarchies (which is what the editor's caller surface produces). - */ -function flattenFolders( - folders: ProjectFolder[], - depth: number, -): Array<{ id: string; name: string; type: string; depth: number }> { - const rows: Array<{ id: string; name: string; type: string; depth: number }> = [] - for (const folder of folders) { - rows.push({ id: folder.id, name: folder.name, type: folder.type, depth }) - if (folder.children && folder.children.length > 0) { - rows.push(...flattenFolders(folder.children, depth + 1)) - } - } - return rows -} - -export { ReadOnlyProjectModal } diff --git a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx index 68a16896a..6a1b14518 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx @@ -88,7 +88,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const jwtToken = useOpenPLCStore((state) => state.runtimeConnection.jwtToken) const editingState = useOpenPLCStore((state) => state.workspace.editingState) const isDebuggerVisible = useOpenPLCStore((state) => state.workspace.isDebuggerVisible) - const isReadOnly = useOpenPLCStore((state) => state.workspace.isReadOnly) + const canEdit = useOpenPLCStore((state) => state.workspace.canEdit) const currentBoardInfo = availableBoards.get(deviceDefinitions.configuration.deviceBoard) const isSimulatorBoard = resolveTargetCapabilities(currentBoardInfo).isInProcessSimulator @@ -139,13 +139,11 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa async (overrides?: { compileOnly?: boolean; cleanBuild?: boolean }) => { if (isCompiling) return - // Read-only projects can't save — but Run/Build must still work since - // we want the viewer to be able to compile and run the project on - // their own device. The Monaco/graphical gates prevent the user from - // actually modifying anything, so `editingState` should stay 'saved' - // here; the explicit isReadOnly check is belt-and-suspenders so a - // stray dirty flag doesn't trip the save and 403 the build. - if (editingState === 'unsaved' && !isReadOnly) { + // Viewers without write permission (e.g. a public project they don't + // own) can edit and compile their local copy but can't push it back. + // Skip the pre-build auto-save for them so the doomed backend write + // never gates the build — we compile the in-memory project as-is. + if (editingState === 'unsaved' && canEdit) { const saved = await executeSave() if (!saved) return } @@ -229,7 +227,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa isCompiling, editingState, executeSave, - isReadOnly, + canEdit, jwtToken, ], ) diff --git a/src/frontend/components/_templates/app-layout.tsx b/src/frontend/components/_templates/app-layout.tsx index 928e4bd82..a728eaf10 100644 --- a/src/frontend/components/_templates/app-layout.tsx +++ b/src/frontend/components/_templates/app-layout.tsx @@ -17,7 +17,6 @@ import { MissingLibrariesModal } from '../_organisms/modals/missing-libraries-mo import { ProjectReadmeModal } from '../_organisms/modals/project-readme-modal' import { PublicCatalogBrowserModal } from '../_organisms/modals/public-catalog-browser-modal' import { QuitApplicationModal } from '../_organisms/modals/quit-application-modal' -import { ReadOnlyProjectModal } from '../_organisms/modals/read-only-project-modal' import { RuntimeConnectionLostModal } from '../_organisms/modals/runtime-connection-lost-modal' import type { SaveChangesFileModalData } from '../_organisms/modals/save-changes-file-modal' import { SaveChangesFileModal } from '../_organisms/modals/save-changes-file-modal' @@ -132,7 +131,6 @@ const AppLayout = ({ children, ...rest }: AppLayoutProps): ReactNode => { {modals?.['missing-libraries']?.open === true && } {modals?.['public-catalog-browser']?.open === true && } {modals?.['confirm-install-libraries']?.open === true && } - {modals?.['read-only-project']?.open === true && } {modals?.['project-readme']?.open === true && } {modals?.['runtime-login']?.open === true && } {modals?.['runtime-create-user']?.open === true && } diff --git a/src/frontend/screens/workspace-screen.tsx b/src/frontend/screens/workspace-screen.tsx index df51a9f78..3190839ee 100644 --- a/src/frontend/screens/workspace-screen.tsx +++ b/src/frontend/screens/workspace-screen.tsx @@ -82,7 +82,6 @@ const WorkspaceScreen = () => { const pous = useOpenPLCStore(useCallback((s) => s.project.data.pous, [])) const projectPath = useOpenPLCStore(useCallback((s) => s.project.meta.path, [])) const projectType = useOpenPLCStore(useCallback((s) => s.project.meta.type, [])) - const isReadOnly = useOpenPLCStore(useCallback((s) => s.workspace.isReadOnly, [])) // Project-type capability matrix. Combines with `capabilities` // (host platform features) below to decide what affordances render // in this workspace shell. @@ -651,7 +650,6 @@ const WorkspaceScreen = () => { name={model.meta.name} language={model.meta.language} isActive={isActive} - readOnly={isReadOnly} /> )}
diff --git a/src/frontend/services/save-actions.ts b/src/frontend/services/save-actions.ts index c5003ca6d..13a93f577 100644 --- a/src/frontend/services/save-actions.ts +++ b/src/frontend/services/save-actions.ts @@ -19,6 +19,7 @@ import type { LadderFlowType } from '../store/slices/ladder' import { parseIecStringToVariables } from '../utils/generate-iec-string-to-variables' import { generateIecVariablesToString } from '../utils/generate-iec-variables-to-string' import { syncNodesWithVariables, syncNodesWithVariablesFBD } from '../utils/graphical/sync-nodes-with-variables' +import { notifyNoWritePermission } from '../utils/notify-no-write-permission' import { getExtensionFromLanguage, getFolderFromPouType } from '../utils/PLC/pou-file-extensions' import { parseGraphicalPouFromString, parseTextualPouFromString } from '../utils/PLC/pou-text-parser' import { serializePouToText } from '../utils/PLC/pou-text-serializer' @@ -313,12 +314,14 @@ export async function executeSaveProject( capabilities: PlatformCapabilities, ): Promise<{ success: boolean }> { const state = openPLCStoreBase.getState() - // Read-only gate. The Monaco / graphical editors are already in - // read-only mode, but explicit Save shortcuts (Ctrl+S, menu File → - // Save) still funnel through here. Surface the modal so the user - // gets the "fork me" affordance instead of a silent no-op. - if (state.workspace.isReadOnly) { - state.modalActions.openModal('read-only-project') + // Persist gate. Every save path — Ctrl+S, File → Save, auto-save after + // a rename/delete, the AI panel — funnels through here. When the viewer + // lacks write permission (e.g. a public project they don't own) we skip + // the doomed backend write and warn instead of surfacing a raw 401. + // (Compile's auto-save guards on `canEdit` before calling us, so a build + // proceeds with the in-memory project rather than tripping this.) + if (!state.workspace.canEdit) { + notifyNoWritePermission('save changes to') return { success: false } } const { project, pendingDeletions } = state @@ -481,9 +484,9 @@ export async function executeSaveFile( capabilities: PlatformCapabilities, ): Promise<{ success: boolean }> { const state = openPLCStoreBase.getState() - // See executeSaveProject for rationale — same read-only gate. - if (state.workspace.isReadOnly) { - state.modalActions.openModal('read-only-project') + // See executeSaveProject for rationale — same persist gate. + if (!state.workspace.canEdit) { + notifyNoWritePermission('save changes to') return { success: false } } const { project, files } = state diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index 846528837..7facad445 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -1923,27 +1923,27 @@ describe('createSharedSlice', () => { }) // ----------------------------------------------------------------------- - // canEdit → workspace.isReadOnly + // canEdit → workspace.canEdit (persist-permission gate) // ----------------------------------------------------------------------- - it('sets workspace.isReadOnly=true when canEdit is false', () => { + it('sets workspace.canEdit=false when backend canEdit is false', () => { const data = { ...makeMinimalProjectResponse(), canEdit: false } store.getState().sharedWorkspaceActions.handleOpenProjectResponse(data) - expect(store.getState().workspace.isReadOnly).toBe(true) + expect(store.getState().workspace.canEdit).toBe(false) }) - it('keeps workspace.isReadOnly=false when canEdit is true', () => { - // Pre-seed read-only so we observe the reset path, not just the default. - store.getState().workspaceActions.setReadOnly(true) + it('keeps workspace.canEdit=true when backend canEdit is true', () => { + // Pre-seed denied so we observe the reset path, not just the default. + store.getState().workspaceActions.setCanEdit(false) const data = { ...makeMinimalProjectResponse(), canEdit: true } store.getState().sharedWorkspaceActions.handleOpenProjectResponse(data) - expect(store.getState().workspace.isReadOnly).toBe(false) + expect(store.getState().workspace.canEdit).toBe(true) }) it('treats absent canEdit as editable (desktop / dev-local default)', () => { - store.getState().workspaceActions.setReadOnly(true) + store.getState().workspaceActions.setCanEdit(false) const data = makeMinimalProjectResponse() store.getState().sharedWorkspaceActions.handleOpenProjectResponse(data) - expect(store.getState().workspace.isReadOnly).toBe(false) + expect(store.getState().workspace.canEdit).toBe(true) }) }) }) diff --git a/src/frontend/store/__tests__/workspace-slice.test.ts b/src/frontend/store/__tests__/workspace-slice.test.ts index c25d5d7b9..976a6461b 100644 --- a/src/frontend/store/__tests__/workspace-slice.test.ts +++ b/src/frontend/store/__tests__/workspace-slice.test.ts @@ -570,22 +570,22 @@ describe('createWorkspaceSlice', () => { store.getState().workspaceActions.setPlcLogsLastId(5) store.getState().workspaceActions.setProjectLoading(true, 'Loading project...') - store.getState().workspaceActions.setReadOnly(true) + store.getState().workspaceActions.setCanEdit(false) expect(store.getState().workspace.isProjectLoading).toBe(true) expect(store.getState().workspace.projectLoadingMessage).toBe('Loading project...') - expect(store.getState().workspace.isReadOnly).toBe(true) + expect(store.getState().workspace.canEdit).toBe(false) store.getState().workspaceActions.setProjectLoading(false) - store.getState().workspaceActions.setReadOnly(false) + store.getState().workspaceActions.setCanEdit(true) expect(store.getState().workspace.isProjectLoading).toBe(false) expect(store.getState().workspace.projectLoadingMessage).toBe('') - expect(store.getState().workspace.isReadOnly).toBe(false) + expect(store.getState().workspace.canEdit).toBe(true) - // setReadOnly(true) again so clearWorkspace's reset path is exercised - // — keeps the assertion below honest about the reset. - store.getState().workspaceActions.setReadOnly(true) + // setCanEdit(false) again so clearWorkspace's reset path is exercised + // — keeps the assertion below honest about the reset back to `true`. + store.getState().workspaceActions.setCanEdit(false) store.getState().workspaceActions.clearWorkspace() @@ -617,6 +617,6 @@ describe('createWorkspaceSlice', () => { searchTerm: '', timestampFormat: 'full', }) - expect(workspace.isReadOnly).toBe(false) + expect(workspace.canEdit).toBe(true) }) }) diff --git a/src/frontend/store/slices/modal/slice.ts b/src/frontend/store/slices/modal/slice.ts index 202ecf099..499a02028 100644 --- a/src/frontend/store/slices/modal/slice.ts +++ b/src/frontend/store/slices/modal/slice.ts @@ -25,7 +25,6 @@ const ALL_MODAL_TYPES: ModalTypes[] = [ 'missing-libraries', 'public-catalog-browser', 'confirm-install-libraries', - 'read-only-project', 'project-readme', ] diff --git a/src/frontend/store/slices/modal/types.ts b/src/frontend/store/slices/modal/types.ts index 102973015..86a7a2b59 100644 --- a/src/frontend/store/slices/modal/types.ts +++ b/src/frontend/store/slices/modal/types.ts @@ -29,11 +29,6 @@ export type ModalTypes = /** Confirmation step chained off `public-catalog-browser` — lists * the user's selection and runs the install on confirm. */ | 'confirm-install-libraries' - /** Surfaced whenever the user tries a write action (save, commit, - * create/delete branch, create/rename/delete POU) on a project they - * don't have edit permission on. Shows the "this project belongs to - * someone else" message and the inline Fork flow. */ - | 'read-only-project' /** Project README viewer/editor — GitHub-style edit/preview tabs + * commit-message override. Available only when the project port * exposes the README slot (web adapter against the Edge API). */ diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index aa535f2c8..95f5e0301 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -511,11 +511,12 @@ const createSharedSlice: StateCreator = (s handleOpenProjectResponse: (data) => { getState().sharedWorkspaceActions.clearStatesOnCloseProject() getState().workspaceActions.setEditingState('saved') - // Apply the edit-permission flag from the backend. `canEdit === - // false` ⇒ read-only mode; `true` or `undefined` ⇒ editable. - // clearStatesOnCloseProject above already reset to `false`, so an - // editable project just stays in that default. - getState().workspaceActions.setReadOnly(data.canEdit === false) + // Apply the persist-permission flag from the backend. `canEdit === + // false` ⇒ the viewer can't push changes back (e.g. a public project + // they don't own), so backend writes (save/commit/branch) are gated; + // `true` or `undefined` ⇒ full write access. Only persistence is + // affected — in-memory editing, simulation, and compilation stay on. + getState().workspaceActions.setCanEdit(data.canEdit !== false) // Log any parsing warnings to the app console (after clear so they aren't wiped) if (data.warnings) { diff --git a/src/frontend/store/slices/workspace/slice.ts b/src/frontend/store/slices/workspace/slice.ts index 1f4f2c09b..2aeb3d2d1 100644 --- a/src/frontend/store/slices/workspace/slice.ts +++ b/src/frontend/store/slices/workspace/slice.ts @@ -58,8 +58,8 @@ const createWorkspaceSlice: StateCreator // Project loading state isProjectLoading: false, projectLoadingMessage: '', - // Read-only mode (no edit permission on the open project) - isReadOnly: false, + // Persist-permission flag (backend write access on the open project) + canEdit: true, }, workspaceActions: { @@ -185,7 +185,7 @@ const createWorkspaceSlice: StateCreator } workspace.isProjectLoading = false workspace.projectLoadingMessage = '' - workspace.isReadOnly = false + workspace.canEdit = true }), ) }, @@ -445,10 +445,10 @@ const createWorkspaceSlice: StateCreator }), ) }, - setReadOnly: (value: boolean) => { + setCanEdit: (value: boolean) => { setState( produce(({ workspace }: WorkspaceSlice) => { - workspace.isReadOnly = value + workspace.canEdit = value }), ) }, diff --git a/src/frontend/store/slices/workspace/types.ts b/src/frontend/store/slices/workspace/types.ts index 9adc069fe..f325db5bd 100644 --- a/src/frontend/store/slices/workspace/types.ts +++ b/src/frontend/store/slices/workspace/types.ts @@ -115,15 +115,17 @@ export type WorkspaceState = { isProjectLoading: boolean projectLoadingMessage: string /** - * True when the currently open project is read-only for the viewer - * (no edit permission). Drives every write-action gate in the UI - * — Monaco/graphical readOnly, Save/Commit/Branch dialog, project- - * tree menu items, etc. Set by `handleOpenProjectResponse` from - * the `canEdit` flag returned by the backend; reset to `false` on - * project close so a subsequent open of an editable project comes - * up unrestricted. + * Whether the viewer has permission to persist changes to the open + * project (e.g. they own it or have write access). Only gates + * operations that write to the backend — save, commit, branch + * create/delete, stash, discard. In-memory editing, simulation, + * and compilation are always allowed: a viewer of a public project + * works on a local copy and just can't push it back. Set by + * `handleOpenProjectResponse` from the backend `canEdit` flag; + * absent (desktop editor / dev-local) ⇒ `true`. Reset to `true` + * on project close. */ - isReadOnly: boolean + canEdit: boolean } } @@ -189,8 +191,8 @@ export type WorkspaceActions = { removeDebugVariable: (compositeKey: string) => void // Project loading setProjectLoading: (isLoading: boolean, message?: string) => void - // Read-only mode (no edit permission on the open project) - setReadOnly: (value: boolean) => void + // Persist-permission flag (backend write access on the open project) + setCanEdit: (value: boolean) => void } export type WorkspaceSlice = WorkspaceState & { diff --git a/src/frontend/utils/__tests__/notify-no-write-permission.test.ts b/src/frontend/utils/__tests__/notify-no-write-permission.test.ts new file mode 100644 index 000000000..7143bdbad --- /dev/null +++ b/src/frontend/utils/__tests__/notify-no-write-permission.test.ts @@ -0,0 +1,25 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { notifyNoWritePermission } from '../notify-no-write-permission' +import { toast } from '../toast' + +vi.mock('../toast', () => ({ + toast: vi.fn(), +})) + +describe('notifyNoWritePermission', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('shows a warn toast that slots the action verb into the message', () => { + notifyNoWritePermission('save changes to') + + expect(toast).toHaveBeenCalledTimes(1) + expect(toast).toHaveBeenCalledWith({ + title: 'No write permission', + description: "You don't have permission to save changes to this project.", + variant: 'warn', + }) + }) +}) diff --git a/src/frontend/utils/notify-no-write-permission.ts b/src/frontend/utils/notify-no-write-permission.ts new file mode 100644 index 000000000..792b3d34a --- /dev/null +++ b/src/frontend/utils/notify-no-write-permission.ts @@ -0,0 +1,22 @@ +import { toast } from './toast' + +/** + * Non-blocking warning shown when the user triggers an operation that would + * write to the backend on a project they lack write permission on (e.g. a + * public project they don't own). The action is skipped gracefully instead + * of letting the backend reject it with an opaque 401/403 error toast. + * + * Only persistence is denied — the user keeps editing, simulating, and + * compiling their local working copy; the changes just can't be pushed back. + * + * @param action Verb phrase naming the blocked operation, slotted into + * "You don't have permission to {action} this project." + * e.g. `'save changes to'`, `'commit to'`, `'create branches in'`. + */ +export function notifyNoWritePermission(action: string): void { + toast({ + title: 'No write permission', + description: `You don't have permission to ${action} this project.`, + variant: 'warn', + }) +} diff --git a/src/middleware/shared/ports/project-port.ts b/src/middleware/shared/ports/project-port.ts index ce7cd488e..59fa73fba 100644 --- a/src/middleware/shared/ports/project-port.ts +++ b/src/middleware/shared/ports/project-port.ts @@ -56,10 +56,11 @@ export interface ProjectResponse { */ rawLoadedFiles?: Record /** - * Whether the current user has edit permission on this project. Drives - * the editor's read-only gating (Monaco/graphical/save/commit/branch). - * Absent ⇒ treated as `true` (desktop editor and dev:local mode have no - * remote permission concept and must remain fully editable). + * Whether the current user has permission to persist changes to this + * project. Gates only backend writes (save/commit/branch/stash/discard); + * in-memory editing, simulation, and compilation stay enabled so a viewer + * works on a local copy. Absent ⇒ treated as `true` (desktop editor and + * dev:local mode have no remote permission concept). */ canEdit?: boolean /** @@ -167,41 +168,6 @@ export interface RawProjectFiles { error?: { title: string; description: string } } -/** - * Folder in the user's namespace, used by the read-only project modal's - * Fork flow to let the user pick where the fork should land. Mirrors the - * shape returned by autonomy-edge `GET /folders?includeHierarchy=true`. - */ -export interface ProjectFolder { - id: string - name: string - /** Backend folder kind. The well-known value `'root'` identifies the - * implicit user-root folder (shown as "Root (/)" in the picker); any - * other string is a normal user-created folder type from - * autonomy-edge's `/folders` endpoint. */ - type: string - parentId: string | null - children?: ProjectFolder[] -} - -/** Params for {@link ProjectPort.forkProject}. */ -export interface ForkProjectParams { - projectId: string - destinationFolderId: string - /** Optional rename. When forking a project that already lives in the - * caller's namespace the backend requires a name different from the - * source; otherwise it falls back to " (N)". */ - name?: string -} - -/** Result of {@link ProjectPort.forkProject}. On success the new project - * id is surfaced so the editor can navigate the URL to `?project_id=`. */ -export interface ForkProjectResponse { - success: boolean - data?: { projectId: string } - error?: { title: string; description: string } -} - export interface ProjectPort { /** Create a new project. */ createProject(params: CreateProjectParams): Promise @@ -308,23 +274,4 @@ export interface ProjectPort { migrated?: boolean error?: string }> - - /** - * Fork a project into the caller's namespace. Optional — only the - * web adapter implements this (desktop editor has no remote project - * concept). Returns `{ success: true, data: { projectId } }` on success - * so the caller can navigate to the new project. - */ - forkProject?(params: ForkProjectParams): Promise - - /** - * List the caller's folders (root + nested hierarchy) so the fork - * destination picker can render a tree. Optional — desktop editor - * returns `{ success: false, error }` since it has no remote folders. - */ - listMyFolders?(): Promise<{ - success: boolean - data?: ProjectFolder[] - error?: { title: string; description: string } - }> }