Skip to content

fix(uve): gate contentlet edit affordances on edit permission - #37412

Open
rjvelazco wants to merge 20 commits into
mainfrom
issue-37376-uve-edit-pencil-permission-implementation
Open

fix(uve): gate contentlet edit affordances on edit permission#37412
rjvelazco wants to merge 20 commits into
mainfrom
issue-37376-uve-edit-pencil-permission-implementation

Conversation

@rjvelazco

@rjvelazco rjvelazco commented Sep 4, 2026

Copy link
Copy Markdown
Member

Spec-Kit PR 2 of 2 — implementation. Spec approved and merged in #37404.

⚠️ The spec was amended after approval and needs re-approval (commit 96a4669c51). Headless/SDK pages were an explicit non-goal; they are now in scope. Reasoning below under Amended scope.

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.

Path Behavior when denied
Edit pencil (icon row + menu) disabled, permission tooltip
Quick Edit ⚡ (icon row + menu) disabled, permission tooltip
Quick Edit side panel (tab 0) dedicated no-permission mode with a notice — no form, no save
Style editor button + side panel (tab 1) disabled; the tab shows the permission notice instead of the style form
Inline edit — WYSIWYG refused, toast explains why
Inline edit — Block Editor refused, toast explains why
Delete / move / add unchanged — still work

That 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:358 has stamped data-dot-can-edit on 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

  1. Fail open is deliberate. 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 in dom.spec.ts and controls in every suite.
  2. $contentletEditData needed 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.
  3. The Block Editor is a genuinely separate event. Different DOM marker ([data-block-editor-content]), different transport (postMessage, not a DOM handler), different file. Gating only handleInlineEditing would have left block-editor fields fully editable.

This is not a security fix

ESContentletAPIImpl.checkincheckPermission already requires PERMISSION_WRITE on 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

  • Tests — TDD throughout, Red confirmed before each implementation. sdk-uve 111 passed; portlets-edit-ema-portlet 1446 passed, 5 skipped. Commits alternate test(...) (Red) → fix(...) (Green).
  • Translations — one new key, uve.contentlet.no.edit.permission, shared by all four surfaces so they read identically.
  • Security Implications Contemplated — see above. Consumes a server-computed permission rather than inventing a client-side one; the refusal message exposes no permission internals.

Not done, on purpose

  • Playwright e2e — explicit recorded decision. No limited-permission user fixture exists; building one (user, role, type/instance permission split, page) exceeds the fix. Worth its own ticket.
  • Manual QA (quickstart.md §3) — needs a live environment with the permission split; not run here, handed to QA. The full scenario is written out.
  • Legacy script-injection client — behind 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:

  1. The permission was already being computed and discarded. PageRenderUtil:338 computes the per-contentlet WRITE permission for the Velocity $EDIT_CONTENT_PERMISSION variable, then drops it for JSON. Surfacing it adds zero permission lookups.
  2. GraphQL needs no schema change. Contentlets are exposed through the _map JSON scalar (ContentFields:89ContentMapDataFetcher), which the client SDK spreads verbatim (transforms.ts:129). One server-side line reaches both /api/v1/page/json and GraphQL — and the SDK's query is unchanged.

The chain, and where each link landed:

Link Change
Backend PageRenderUtil — permission onto the contentlet map
GraphQL none — flows through _map
SDK types DotCMSBasicContentlet.canEdit, DotContentletAttributes['data-dot-can-edit']
SDK emitter getDotContentletAttributes stamps the attribute
React wrapper none — already spreads the attribute object
Angular wrapper one host binding (it enumerates rather than spreads)

Still out of scope: a typed canEdit field 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

Check Result
dotcms-core build BUILD SUCCESS
dotcms-integration test-compile BUILD SUCCESS
openapi.yaml unchanged — no REST annotations touched
sdk-uve / sdk-angular / sdk-react 117 / 246 / 138 passed
portlets-edit-ema-portlet 1446 passed, 5 skipped
format:check + lint clean

The integration test has not been executed locally — it needs Postgres + Elasticsearch. It will run for the first time in CI.

Additional Info

  • This PR is now genuinely full-stack. One Java file plus one integration test, so the Area : Backend label is now accurate rather than an artifact of the bundle path.
  • CI heads-up: the regenerated dotCMS/src/main/webapp/ext/uve/dot-uve.js matches 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 labeled Area : Backend — expected, per ADR-0013, not a misconfiguration.
  • SDK version untouched — ADR-0019 leaves @dotcms/uve versioning to the release pipeline.
  • Two vacuous tests were caught and rewritten during development (one missing a getContentType mock, one where the page-asset swap never fired). Two harness gaps were fixed: mockInlineEditService lacked handleInlineEdit, and jest.spyOn over a useValue mock recorded zero calls.

Videos

video-1.mov
video.mov

Fixes: #37376

🤖 Generated with Claude Code

rjvelazco and others added 10 commits September 4, 2026 11:41
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>
@github-actions github-actions Bot added Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code Area : SDK PR changes SDK libraries labels Sep 4, 2026
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Claude finished @rjvelazco's task in 3m 48s —— View job


Code Review — PR #37412

Reviewed the full diff against origin/main (backend PageRenderUtil, SDK emitter/types, and the four portlet guard points). One finding worth a look before merge; the rest of the implementation is sound and the fail-open reasoning is consistent throughout.

New Issues

  • 🟡 Medium: core-web/libs/portlets/edit-ema/portlet/src/lib/services/dot-uve-actions-handler/dot-uve-actions-handler.service.ts:415 — Reading contentWindow?.document can throw on a cross-origin iframe rather than fail open. The optional chain guards only a null window; once contentWindow is a real Window on a different origin, accessing the .document property triggers a SecurityError (DOM cross-origin access), so the exception is thrown before canEditContentletByInode ever runs its !doc fail-open branch. The rest of the codebase avoids this by using iframe.contentDocument (which returns null cross-origin) — e.g. dot-uve-iframe.component.ts:243 — but this call uses the throwing window.document form.

    This matters precisely for the headless scenario this PR adds: headless pages load via the iframe src URL (dot-uve-iframe.component.ts:165/177src()), which can be a different origin, whereas traditional pages use same-origin srcdoc/contentDocument. When a Block Editor inline-edit postMessage arrives from a cross-origin headless page, handleInlineEditingEvent would throw instead of gracefully allowing the edit — the opposite of the stated fail-open contract, and the sidebar path doesn't even need document access to function.

    Suggested guard so the fail-open path is actually reached:

    let doc: Document | null = null;
    try {
        doc = contentWindow?.document ?? null;
    } catch {
        doc = null; // cross-origin headless page — fall through to fail-open
    }
    if (!canEditContentletByInode(doc, data?.inode)) { ... }

    Assumption: cross-origin property access throws synchronously (standard browser behavior) and the Block Editor inline flow is reachable on a cross-origin headless page. What to verify: load a headless page served from a different origin than the dotCMS admin, trigger a Block Editor inline edit, and confirm it isn't broken by an uncaught SecurityError. If headless pages are always same-origin-proxied in UVE, this is moot. Fix this →

Notes (non-blocking, no action required)

  • PageRenderUtil.java:338-347 — Correct. contentlet here is a per-request transformer copy (transformer.hydrate().get(0), line 321), not a shared/cached instance, so writing the per-user canEdit into getMap() carries no cross-user leak risk, and it reuses the already-computed permission value — zero extra lookups, as the comment claims.
  • Fail-open is applied consistently (!== 'false' / !== false) across dom.utils.ts, contentlet-permission.ts, and all component computeds — verified against the SDK types being optional (canEdit?: boolean).
  • The two defense-in-depth guards in edit-ema-editor.component.ts (handleOpenQuickEdit, and the pencil/style handlers) read canEdit from different sources (editorSelected().payload vs $contentletEditData()) — intentional and consistent.

Everything else in the diff is clean.

· issue-37376-uve-edit-pencil-permission-implementation

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>
@rjvelazco rjvelazco changed the title fix(uve): gate contentlet edit affordances on edit permission (#37376) fix(uve): gate contentlet edit affordances on edit permission Sep 4, 2026
…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>
rjvelazco and others added 6 commits September 4, 2026 14:43
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>
ihoffmann-dot
ihoffmann-dot previously approved these changes Sep 4, 2026
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>
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code Area : SDK PR changes SDK libraries

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Edit pencil not disabled for contentlets without edit permission in UVE

3 participants