fix(uve): gate contentlet edit affordances on edit permission - #37412
fix(uve): gate contentlet edit affordances on edit permission#37412rjvelazco wants to merge 20 commits into
Conversation
Carries the /speckit-plan Phase 1 outputs that belong with the implementation: the contentlet-permission data model (payload shape, value states, the four guard points) and the interface contracts (DOM attribute, SDK dataset reader, editor components, inline-editing guards). Kept out of the spec PR deliberately — those review the behavior, these review the design. plan.md, research.md and quickstart.md stay gitignored per .gitignore:229-233. Refs: #37376 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…37376) Red commit. Adds readContentletDataset coverage for the canEdit field the editor's permission gates will read from `data-dot-can-edit`. The assertions pin fail-open as a requirement rather than an implementation detail: only the literal "false" denies, while "true", absent, empty, malformed and a missing dataset all resolve to allowed. Headless and SDK-rendered pages never emit the attribute, so a fail-closed default would disable every edit affordance on all of them. Also guards the existing fields — this reader feeds every contentlet interaction in the editor, so the addition must not disturb them. Fails with TS2339 until the field exists, which is the only shape Red can take for a new field in a type-checked test. Refs: #37376 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ad (#37376) Green commit for the foundational phase of #37376. `ContainerLoader` has stamped `data-dot-can-edit` on every contentlet wrapper in EDIT mode since 2018, computed from a WRITE-level permission check against the contentlet instance. Nothing read it — a repo-wide search finds only producers. This wires it through to the editor so the gates that follow have something to consult. - readContentletDataset() now returns `canEdit`, failing open: only the literal "false" denies. - ContentletPayload gains an optional `canEdit`, so existing fixtures and headless payloads stay valid. - Adds `uve.contentlet.no.edit.permission`, the single message key shared by the pencil tooltip, the Quick Edit tooltip, the read-only panel notice and the inline-edit toast. - Regenerates the shipped `dot-uve.js` bundle, whose entry graph includes the reader. No behavior changes yet; the gates land in the following commits. The SDK version is untouched — ADR-0019 leaves it to the release pipeline. Refs: #37376 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Red commit for US1. Fifteen tests across the contentlet toolbar and the editor component. The toolbar set asserts the pencil is disabled only for an explicit `canEdit: false`, that its tooltip switches to the permission message, and that the collapsed overflow menu entry stays in lockstep with the icon row. Three of them are deliberate negative guards: delete, drag and add-content must remain usable on a restricted contentlet. Those actions change the page's composition rather than the contentlet's content, so they are governed by page edit permission and must not be caught by this gate. The editor set covers the behavioral guard — a denied contentlet reaches neither the content editor nor the multi-page copy-decision modal — plus the fail-open control for payloads with no permission field. Refs: #37376 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…37376) Green commit for US1, and the fix for the symptom reported in #37376. `canEditContentlet` derives the permission from the hovered contentlet's payload and drives both the icon-row pencil and its collapsed overflow-menu twin, mirroring how `allowContentDelete` already gates the delete button. The tooltip switches to the shared permission message so the user is told why rather than left guessing. `handleEditWithCopyDecision` also refuses outright, so a stale toolbar or a programmatic emit cannot reach the editor — the guarantee does not rest on a disabled attribute. Structural page actions are deliberately untouched: a user permitted to open the page in edit mode may still delete, move and add content. Contentlet-level permission governs modifying the contentlet, nothing else. Quick Edit and inline editing are the remaining write paths; they follow in subsequent commits. Refs: #37376 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Red commit for US2. Seventeen tests across the toolbar, the quick-edit side panel and the editor component. The quick-edit form writes contentlet fields through saveQuickEditFields, so gating the pencil alone would leave an equivalent write path open. Gating only the toolbar's button is not enough either: the panel follows the current selection once open, so a user could open it on an editable contentlet and then click a restricted one. The editor set pins the enabling behavior — `canEdit` must survive $contentletEditData's page-asset swap, which replaces the DOM payload contentlet to pick up a fresh inode and would otherwise drop the permission. That test injects a container into the store so the swap is guaranteed to fire and asserts the swapped inode; without that it would pass vacuously. Refs: #37376 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Green commit for US2. The toolbar's quick-edit button and its overflow-menu twin now share the pencil's `canEditContentlet` gate and permission tooltip, and `handleOpenQuickEdit` refuses outright so the panel is unreachable through a stale toolbar. `$contentletEditData` carries `canEdit` across the page-asset swap. That swap exists to pick up a fresh inode after a save; the permission lives only on the DOM payload, so without this the panel had nothing to gate on. The panel gains a dedicated `no-permission` mode rather than a disabled form, matching its existing one-branch-per-state design. It is ranked above `decide` deliberately: a user who cannot edit the original must not be offered the copy-and-edit prompt either. `empty` still wins, so an empty container is unaffected. Refs: #37376 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…7376) Red commit for US3. Eight tests over the two inline-editing entry points, which are separate events with separate handlers. The WYSIWYG path is a direct click on a `[data-mode]` element handled in the editor component. The Block Editor path is bound by the SDK on `[data-block-editor-content]` and arrives as an INIT_INLINE_EDITING message in the actions handler. Gating only the first would leave block-editor fields fully editable. Both sets assert the refusal is announced with a toast rather than silent. An inline field has no control to grey out and no hover target for a tooltip, so a click that does nothing is indistinguishable from a broken editor. Also fixes two harness gaps this exposed: the shared inline-edit service mock never defined handleInlineEdit, and jest.spyOn over a useValue mock recorded no calls, which made the control tests fail and would have masked a broken gate. Refs: #37376 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Green commit for US3, completing the gate over every affordance that modifies a contentlet. Both inline-editing paths now refuse a contentlet the user cannot edit and say why. The WYSIWYG path walks up from the clicked field to its contentlet wrapper; the Block Editor path arrives as a postMessage with no element, so it resolves the wrapper from the payload's inode inside the iframe document — matching on the dataset rather than building a selector, so an unexpected value cannot break the query. The shared helper keeps one permission rule, one message key and one toast across both call sites rather than duplicating them in two classes. Refs: #37376 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The spec's verification commands used `--testPathPattern`, which this repo's Jest rejects — it was renamed to `--testPathPatterns` and is command-line only. Every command in the spec failed as written; caught while running the first baseline. Refs: #37376 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @rjvelazco's task in 3m 48s —— View job Code Review — PR #37412Reviewed the full diff against New Issues
Notes (non-blocking, no action required)
Everything else in the diff is clean. · |
CI's format-test step failed on three files, which aborted the frontend job before the unit tests ran and cancelled 22 other jobs in the matrix. The changes are formatting only: Prettier re-indents the describe blocks added for the permission gates and collapses one import. Lint was already clean — 0 errors — and both suites still pass (portlet 1446, sdk-uve 111). The pre-commit hook reported success on every commit here, so `nx format:write --files` appears not to rewrite in this setup while the unscoped format:check CI runs does compare correctly. Refs: #37376 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…37376) The bounds payload built by getDotCMSContentletsBound carried only identifier, title, inode and contentType. Any iframe layout change — opening the right-hand panel, switching its tabs, a resize — makes the SDK emit fresh bounds, and withSelectionAnchor re-anchors the current selection from that payload. The permission was not in it, so `canEdit` came back undefined, fail-open applied, and every gate silently reopened. Reproduced as: open Quick Edit on an editable contentlet, select a restricted one (panel correctly shows the permission notice), switch to the style tab and back — the form is editable again. The fail-open rule now lives in one helper shared by both payload builders, so the hover/click path and the bounds path cannot drift apart again. Three existing assertions on the bounds payload shape were updated: it legitimately gained a field. This function runs on traditional and headless pages alike, so the fix applies to both — though headless has no permission to carry yet. Refs: #37376 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Headless was scoped out on the assumption that covering it meant a substantial Page API change. Two findings overturned that. The permission is already computed per contentlet at PageRenderUtil:338 for the Velocity $EDIT_CONTENT_PERMISSION variable and simply discarded for JSON, so surfacing it costs no extra permission lookups. And GraphQL exposes contentlets through the `_map` JSON scalar, which the client SDK spreads verbatim — so one server-side line reaches both /api/v1/page/json and GraphQL with no schema change and no SDK query change. Adds AC-011..013 and moves headless from non-goal to in scope. A typed canEdit field on the contentlet GraphQL type stays out: only the SDK's named-field query variant would need it, and nothing requires that today. This changes an approved spec and needs re-approval on the PR. Refs: #37376 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…7376) Red commit for the amended scope, one test per link in the chain. The integration test asserts every contentlet in the Page API JSON carries a canEdit flag, and compares it against an independent permission check rather than a literal so the test fails if the value is ever hardcoded. It also asserts the fixture placed at least one contentlet, so it cannot pass by iterating an empty page. The SDK tests pin the headless attribute emitter, and the Angular wrapper test pins the host binding — React spreads the attribute object, so it needs no change. All three fail open on a missing permission: a customer running a dotCMS release that predates the field must keep today's behavior rather than lock up. Refs: #37376 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Green commit for the amended scope, closing the permission chain for headless and SDK-rendered pages. PageRenderUtil already computed the per-contentlet WRITE permission for the Velocity $EDIT_CONTENT_PERMISSION variable and threw it away for JSON. It now also lands on the contentlet map, which reaches /api/v1/page/json directly and GraphQL through the `_map` scalar the client SDK spreads — no schema change, no SDK query change, and no additional permission lookup. From there the SDK stamps `data-dot-can-edit` on the wrappers it builds, so the editor reads one attribute whether the page came from Velocity or from a headless app. React already spreads the attribute object; Angular enumerates its host bindings, so it gets an explicit one. Every hop fails open. A customer whose dotCMS predates the field, or whose SDK predates this release, keeps exactly today's behavior rather than a locked editor — which matters because ADR-0019 makes mismatched pairs a normal state. Refs: #37376 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… spec The side panel must not offer a way to change a restricted contentlet through either tab. Style properties describe how that contentlet presents itself, so they follow contentlet permission rather than page permission, and style editing moves out of the structural-actions carve-out. Delete, move/drag and add content stay ungated: those change which contentlets the page uses, not the contentlet itself. Adds AC-014; narrows AC-010. Refs: #37376 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Red commit. Six tests over the toolbar's palette button, its overflow-menu twin, the selected-contentlet permission the side panel binds to, and the handleSelectContent guard that opens the style panel. Closes the last hole in the reported reproduction: with a restricted contentlet selected the quick-edit tab correctly refused, but the style tab still rendered an editable form. Refs: #37376 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Green commit. The toolbar's palette button and its overflow-menu entry now share the pencil's gate and permission tooltip, handleSelectContent refuses to open the style panel for a restricted contentlet, and the side panel's style tab renders the shared permission notice instead of the style form. Both panel tabs now refuse consistently, so switching between them cannot surface an editable surface for a contentlet the user has no permission on. Refs: #37376 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review question from @KevinDavilaDotCMS: the early return read as "no data, so allow", which is the one place in this file where allowing is the surprising answer. It is intentional. Failing to look the contentlet up is not the same as the user being denied, and the iframe document is unreachable on cross-origin headless pages — denying there would block inline editing for everyone on those, admins included. The comment now says so. Refs: #37376 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
63bfa07
…37376) The test failed in MainSuite 1b on its own guard: "The fixture must place at least one contentlet on the page, otherwise this test asserts nothing". PageRenderTestUtil.createPage() only wires up the containers — content has to be placed explicitly — so the assertion loop had nothing to iterate. The guard was added for exactly this case and did its job; without it the test would have passed while verifying nothing. Now uses the fixture's own addContent(getFirstContainer()). Verified locally this time, against real Postgres and Elasticsearch rather than compilation alone: Tests run: 1, Failures: 0, Errors: 0, Skipped: 0. Refs: #37376 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Proposed Changes
Gates every UVE affordance that modifies a contentlet on the user's EDIT permission for that contentlet instance, and deliberately leaves structural page actions alone.
⋯menu)⋯menu)no-permissionmode with a notice — no form, no saveThat last row is the point, not an omission: a user allowed into the page editor may change the page's composition — which contentlets it uses. Contentlet permission governs the contentlet itself, which is why style editing is gated: style properties describe how that contentlet presents itself, not how the page is assembled.
Both side-panel tabs refuse consistently. An earlier build gated the quick-edit tab but not the style tab, so switching tabs on a restricted contentlet surfaced an editable style form. Fixed in
13e2cd7d88.Root cause
ContainerLoader.java:358has stampeddata-dot-can-editon every contentlet wrapper in EDIT mode since 2018, from a WRITE-level check against the contentlet instance. A repo-wide search finds only two producers and no consumer anywhere — Java, Velocity or TypeScript. Every gate defaulted to "allowed".This PR wires that existing value through
readContentletDataset()into the editor payload and applies it at four guard points.Three things worth a reviewer's attention
dataset['dotCanEdit'] !== 'false', never=== 'true'. Headless/SDK-rendered pages never emit the attribute; failing closed would disable all five affordances on every headless page for every user, including admins. ADR-0019 (date-lockstep SDK versioning) requires the same graceful degradation. Pinned by tests indom.spec.tsand controls in every suite.$contentletEditDataneeded a fix to make the panel gateable. It swaps the DOM payload contentlet for the page-asset one to pick up a fresh inode after a save — and the permission exists only on the DOM payload. Without carrying it across, the panel had nothing to gate on.[data-block-editor-content]), different transport (postMessage, not a DOM handler), different file. Gating onlyhandleInlineEditingwould have left block-editor fields fully editable.This is not a security fix
ESContentletAPIImpl.checkin→checkPermissionalready requiresPERMISSION_WRITEon the instance, and #36510 hardened the adjacent copy paths for #34215. This corrects a misleading affordance — the UI advertising actions the server refuses — not a write bypass. Severity stays Medium.Checklist
test(...)(Red) →fix(...)(Green).uve.contentlet.no.edit.permission, shared by all four surfaces so they read identically.Not done, on purpose
quickstart.md§3) — needs a live environment with the permission split; not run here, handed to QA. The full scenario is written out.FEATURE_FLAG_UVE_LEGACY_SCRIPT_INJECTION, off by default.Amended scope: headless / SDK pages are now covered
Originally scoped out on the assumption it meant a substantial Page API change. Two findings overturned that:
PageRenderUtil:338computes the per-contentlet WRITE permission for the Velocity$EDIT_CONTENT_PERMISSIONvariable, then drops it for JSON. Surfacing it adds zero permission lookups._mapJSON scalar (ContentFields:89→ContentMapDataFetcher), which the client SDK spreads verbatim (transforms.ts:129). One server-side line reaches both/api/v1/page/jsonand GraphQL — and the SDK's query is unchanged.The chain, and where each link landed:
PageRenderUtil— permission onto the contentlet map_mapDotCMSBasicContentlet.canEdit,DotContentletAttributes['data-dot-can-edit']getDotContentletAttributesstamps the attributeStill out of scope: a typed
canEditfield on the contentlet GraphQL type. Only the SDK's named-field query variant (DEFAULT_PAGE_CONTENTLETS_CONTENT) would need it, and nothing requires that today.Kept in this PR rather than split out at the author's request — review capacity is the binding constraint, and shipping the traditional gate while headless stays open repeats the "close one door, leave three open" problem.
Verification after the amendment
dotcms-corebuilddotcms-integrationtest-compileopenapi.yamlThe integration test has not been executed locally — it needs Postgres + Elasticsearch. It will run for the first time in CI.
Additional Info
Area : Backendlabel is now accurate rather than an artifact of the bundle path.dotCMS/src/main/webapp/ext/uve/dot-uve.jsmatches the backend glob in.github/filters.yaml:29(webapp/!(html|WEB-INF)/**), not the frontend one. So this substantively frontend-only PR will run the full integration and Postman suites and be labeledArea : Backend— expected, per ADR-0013, not a misconfiguration.@dotcms/uveversioning to the release pipeline.getContentTypemock, one where the page-asset swap never fired). Two harness gaps were fixed:mockInlineEditServicelackedhandleInlineEdit, andjest.spyOnover auseValuemock recorded zero calls.Videos
video-1.mov
video.mov
Fixes: #37376
🤖 Generated with Claude Code