Skip to content

feat(content-gate): add per-block access control for Group, Stack, and Row blocks#4646

Open
dkoo wants to merge 34 commits intotrunkfrom
feat/block-access-control
Open

feat(content-gate): add per-block access control for Group, Stack, and Row blocks#4646
dkoo wants to merge 34 commits intotrunkfrom
feat/block-access-control

Conversation

@dkoo
Copy link
Copy Markdown
Contributor

@dkoo dkoo commented Apr 8, 2026

All Submissions:

Changes proposed in this Pull Request:

Adds per-block visibility controls to core/group, core/stack, and core/row blocks, allowing editors to show or hide individual blocks based on one or more content gates, or custom rules using the same Registered and Paid access rules used by content gates.

"Gate" mode:
Screenshot 2026-04-08 at 3 31 34 PM

"Custom" mode:
Screenshot 2026-04-08 at 3 35 15 PM

"Custom" mode exists because publishers often want to control per-block visibility at a different and/or more granular level than content gates. To use a real-world example, a subscriber might have access to restricted content across the site if they have an active subscription to either a Digital-Only or Print+Digital subscription product, but PDF issue embeds on magazine-focused pages should be visible only to Print+Digital subscribers.

Editor UI (edit_others_posts capability required, on supported post types only):

  • A new Access Control panel appears in the block Inspector for Group, Stack, and Row blocks.
  • A "Visible to / Hidden to" toggle sets the visibility mode. It is disabled until at least one rule is configured.
  • A Registered readers toggle restricts the block to logged-in readers, with an optional Require verification sub-option.
  • Additional toggles appear for each registered custom access rule (e.g. subscription products, institution access). Rules with options render a FormTokenField; free-text rules render a TextControl.
  • When all rules are cleared the visibility mode resets to "Visible to" automatically.

Frontend evaluation (via render_block filter):

  • If a block has no active rules, it renders normally with no overhead.
  • When rules are configured: visible mode renders the block only to matching readers; hidden mode renders it only to non-matching readers.
  • Registration rules check login status and optionally the email-verified user meta.
  • Custom access rules delegate to Access_Rules::evaluate_rules(), using the same AND/OR grouped logic as content gates.
  • Results are cached per request (keyed by user_id + md5(rules)) so repeated blocks with identical rules evaluate only once.

New files:

  • includes/content-gate/class-block-visibility.php — PHP class with render_block filter, server-side attribute registration, and asset enqueue.
  • src/content-gate/editor/block-visibility.tsx — React/TS entry: attribute registration filter, Inspector HOC, and all panel components.
  • src/content-gate/editor/index.d.ts — Shared TypeScript types for the content-gate editor directory.
  • tests/unit-tests/content-gate/class-block-visibility.php — 24 PHPUnit tests.
  • src/content-gate/editor/block-visibility.test.ts — 7 Jest tests for the attribute registration filter.

Closes NPPD-1430.

How to test the changes in this Pull Request:

Prerequisites:

  • Ensure NEWSPACK_CONTENT_GATES is true (n wp config set NEWSPACK_CONTENT_GATES true --raw).
  • Build assets: n build newspack-plugin. Confirm dist/content-gate-block-visibility.js exists.
  • Log in as an Editor or Administrator.

Inspector panel:

  1. Create or edit a post. Add a Group block. Open the block Inspector (right sidebar).
  2. Confirm an "Access Control" panel appears near the bottom, above "Advanced".
  3. Expand the panel. Confirm the "Visible to / Hidden to" toggle is visible but disabled (greyed out).
  4. Toggle "Registered readers" on. Confirm the visibility toggle becomes enabled and a "Require verification" checkbox appears beneath it.
  5. Toggle all rules off. Confirm the visibility toggle resets to "Visible to".
  6. Repeat steps 4–8 for a Stack block and a Row block.
  7. Add a Paragraph block. Confirm the Access Control panel does not appear.
  8. Log in as a Contributor. Confirm the Access Control panel does not appear.

Frontend — "Custom" mode with "visible" visibility:

  1. Configure a Group block in "Custom" mode and enable the "Registered readers" rule and "Visible" visibility. Add content inside. Save the post.
  2. Open the post while logged out. Confirm the Group block content is absent from the page source.
  3. Log in as a subscriber. Open the post. Confirm the Group block content is present.
  4. Edit the Group block to add "Active subscription" and choose at least two products. Save.
  5. Open the post while logged out. Confirm the Group block content is absent from the page source.
  6. Log in as a reader who does NOT have an active subscription with any of the required products. Confirm the Group block is absent.
  7. Log in as a reader who has an active subscription with one of the required products and refresh. Confirm the Group block is present.

Frontend — "Custom" mode with "hidden" visibility:

  1. Switch the same block to "Hidden to". Save.
  2. Open while logged in. Confirm block content is absent.
  3. Open while logged out. Confirm block content is present.

Verification sub-option:

  1. Enable "Require verification". Save.
  2. Log in as an unverified reader. Confirm the block is hidden (for "Visible to" mode).
  3. Verify the reader's email. Confirm the block becomes visible.

Front-end — "Gate" mode

  1. Change the block configuration to "Gate" mode and select several content gates. Save.
  2. Repeat block visibility tests for logged-out readers, logged-in readers who can't bypass any of the selected gates, and logged-in readers who can bypass at least one selected gate.

Post type guard:

  1. Open a post type not in Content_Restriction_Control::get_available_post_types(). Confirm the Access Control panel does not appear and the JS asset is not enqueued.

Editors are exempt:

  1. While logged in as a user who has edit access to the containing post (such as an admin, editor, or author/contributor for that post), preview or view the post on the front-end. Confirm that all blocks are visible regardless of access requirements.

Graceful degradation:

  1. Check out another branch of this repo, or deactivate the Newspack Plugin entirely.
  2. Refresh the post in the editor and confirm that all Group/Row/Stack blocks continue to render without block validation errors.
  3. View the post on the front-end and confirm that all Group/Row/Stack blocks render and are visible to all readers.

Other information:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your changes, as applicable?
  • Have you successfully ran tests with your changes locally?

dkoo and others added 19 commits April 7, 2026 16:32
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds per-block visibility controls for core/group, core/stack, and core/row blocks using Newspack Content Gate access rules, enabling editors to show/hide individual blocks to specific reader segments without creating a full gate.

Changes:

  • Adds a new editor Inspector “Access Control” panel and registers block attributes for visibility mode + rule configuration.
  • Adds a PHP render_block filter to enforce per-block visibility on the frontend, with per-request caching.
  • Adds PHPUnit + Jest coverage and wires the new editor bundle into Webpack.

Reviewed changes

Copilot reviewed 7 out of 8 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
webpack.config.js Adds a new build entry for the block-visibility editor script.
includes/content-gate/class-content-gate.php Includes the new Block_Visibility feature file.
includes/content-gate/class-block-visibility.php Implements server-side attribute registration, editor asset enqueue, and frontend render filtering/caching.
src/content-gate/editor/block-visibility.tsx Registers attributes and injects the Inspector panel UI for target blocks.
src/content-gate/editor/editor.scss Styles the Access Control panel UI elements.
src/content-gate/editor/index.d.ts Adds shared TS types used by the content-gate editor code.
tests/unit-tests/content-gate/class-block-visibility.php Adds PHPUnit tests for rule evaluation, rendering behavior, and caching.
src/content-gate/editor/block-visibility.test.ts Adds Jest tests for the attribute registration filter.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread includes/content-gate/class-block-visibility.php Outdated
Comment thread includes/content-gate/class-block-visibility.php Outdated
Comment thread src/content-gate/editor/block-visibility.tsx Outdated
Comment thread src/content-gate/editor/block-visibility.tsx
Comment thread tests/unit-tests/content-gate/class-block-visibility.php Outdated
Comment thread tests/unit-tests/content-gate/class-block-visibility.php Outdated
dkoo and others added 3 commits April 8, 2026 13:54
- Use [] not stdClass as default for newspackAccessControlRules block attribute
- Add comment on get_post_type() === false in enqueue_block_editor_assets
- Fix setRegistration to not mutate its parameter; use immutable spread
- Fix counting_rule test to use unique ID per run to avoid closure issues
- Fix "Visiblity" typo in VisibilityControl help text
- Guard test_rule registration against duplicate registration across tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Bypass access control during REST requests so blocks are never hidden
  in the editor's block renderer or preview contexts
- Defensively cast newspackAccessControlRules to array in case the block
  parser yields a stdClass for the object-typed attribute
- Fix FormTokenField empty label; use config.name + hideLabelFromVision

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@dkoo dkoo requested a review from thomasguillot April 8, 2026 20:18
@dkoo dkoo self-assigned this Apr 8, 2026
@dkoo dkoo added [Status] Needs Review The issue or pull request needs to be reviewed [Status] Needs Design Review labels Apr 8, 2026
Cover the case where a user with edit_post capability on the current
post sees all blocks regardless of access rules, and verify that a
non-editor is still subject to normal rule evaluation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@dkoo dkoo marked this pull request as ready for review April 8, 2026 20:24
@dkoo dkoo requested a review from a team as a code owner April 8, 2026 20:24
dkoo and others added 6 commits April 8, 2026 14:39
Blocks can now be linked to one or more existing content gates instead
of defining access rules inline. Gate rules are resolved server-side at
render time so any change to a gate is immediately reflected in every
block that references it.

- Add newspackAccessControlMode attribute ('gate' default, 'custom')
- Add newspackAccessControlGateIds array attribute for gate links
- Gate mode uses OR logic: reader must satisfy any one selected gate
- Deleted/unpublished gates are silently skipped; a block with only
  inactive gates passes through with no restriction
- Localize available published gates to newspackBlockVisibility JS data
- Add GateControls FormTokenField component to the inspector panel
- Add mode toggle (Gate / Custom) with Gate as the default
- Add 7 new PHPUnit tests covering gate mode evaluation paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@thomasguillot
Copy link
Copy Markdown
Contributor

thomasguillot commented Apr 14, 2026

Pushed a small design tweak in 5292632: replaced the two hardcoded px values in editor.scss with the WordPress base-styles grid-unit tokens ($grid-unit-10 = 8px, $grid-unit-05 = 4px) so the Access Control panel spacing tracks WP core.

@thomasguillot
Copy link
Copy Markdown
Contributor

Reverted the ToggleControl reorder in e844d3a. In the editor context I'd rather follow WordPress's default component design — the Access Control panel should feel native to the block inspector. The Newspack Access Control settings screen is a different context (Newspack settings, not the WP editor), so custom styling is fine there, but inside the inspector we should stick with core defaults.

- Always render Require verification toggle (disabled when parent off) to
  avoid layout jump
- Split registration section into sibling PanelRows
- Swap verification CheckboxControl for ToggleControl
- Move intro copy onto the Access mode ToggleGroupControl help prop
- Opt all inputs in the panel into __nextHasNoMarginBottom
- Restore FormTokenField help margin-top stripped by Gutenberg's
  inspector p-reset
@thomasguillot
Copy link
Copy Markdown
Contributor

Pushed a few more design refinements in 04640ad:

  • Require verification toggle now always renders, disabled when Registered readers is off — avoids the layout jump when toggling the parent.
  • Registration section uses paired PanelRows to match the rest of the panel (no more wrapping div).
  • Swapped the Require verification CheckboxControl for a ToggleControl for visual consistency with sibling controls.
  • The intro copy now lives on the Access mode ToggleGroupControl's help prop instead of a standalone <p>.
  • Opted every control in the panel (both ToggleGroupControls, both FormTokenFields, the free-text TextControl) into __nextHasNoMarginBottom so vertical rhythm is driven by PanelRow / component spacing instead of hidden trailing margins.
  • One small workaround: Gutenberg's inspector CSS has .block-editor-block-inspector p:not(.components-base-control__help) { margin: 0 }, which also zeroes out the margin-top that emotion bakes into FormTokenField's help text (rendered as a <p> with a different class). Restored the 8px gap via a scoped rule in editor.scss — specificity had to beat the core (0,2,1) rule, so the selector is nested one level deeper.

Marking as Design Approved — the panel feels much closer to WP core's native inspector patterns now.

Copy link
Copy Markdown
Member

@adekbadek adekbadek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested the editor UI and frontend rendering in an isolated env with NEWSPACK_CONTENT_GATES enabled. Both Gate and Custom modes work as described -- the Access Control panel renders correctly for Group blocks, visibility toggles behave as expected (disabled until rules are configured, reset when rules are cleared), and the render_block filter correctly hides restricted content from logged-out users while showing it to editors who can edit the post.

Two issues to be addressed before approving (see inline comments for details):

  1. TARGET_BLOCKS sync risk: The PHP filter newspack_content_gate_block_visibility_blocks lets developers add blocks server-side, but the JS side hardcodes TARGET_BLOCKS. If the filter is used, the UI and server get out of sync.
  2. Deleted gates + hidden visibility: In gate mode with "hidden" visibility, if all selected gates are deleted/unpublished, the block silently disappears for everyone instead of passing through.

Also noted a few minor items inline (FormTokenField duplicate-title fragility, default value type mismatch).

/**
* Target block types that receive access control attributes.
*/
const TARGET_BLOCKS = [ 'core/group', 'core/stack', 'core/row' ];
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This list is hardcoded, but the PHP side exposes a newspack_content_gate_block_visibility_blocks filter (class-block-visibility.php:40) that allows developers to add or remove blocks. If someone adds e.g. core/columns via the filter, the server would evaluate access rules for it but the editor UI would never show the Access Control panel for that block.

Consider passing the target block list from PHP to JS via the existing wp_localize_script call in enqueue_block_editor_assets() so they stay in sync:

const TARGET_BLOCKS = window.newspackBlockVisibility?.target_blocks ?? ['core/group', 'core/stack', 'core/row'];

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3d3d276 localizes the result of get_target_blocks() and uses that in the JS, so the list should always match.

return $user_matches ? $block_content : '';
}
// 'hidden'
return $user_matches ? '' : $block_content;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edge case: when all selected gates are deleted or unpublished, compute_gate_rules_match() returns true (line 306 -- "no active restriction, pass-through"). But here, $user_matches = true combined with visibility = 'hidden' returns '' -- the block is hidden from everyone.

The intent of "all gates gone = no restriction" should mean the block always renders, regardless of visibility mode. Consider returning $block_content early (before the visibility check) when the gate-mode evaluation determines there are no active gates, similar to how no-gates-selected is handled on line 69:

if ( 'gate' === $mode && ! self::has_active_gates( $gate_ids ) ) {
    return $block_content;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3d3d276 addresses this by bailing early if the block attribute value contains no active gates.

],
'newspackAccessControlRules' => [
'type' => 'object',
'default' => [],
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: 'default' => [] for a 'type' => 'object' attribute is technically an array, not an object. While WP's block attribute handling is lenient today, the JS side defaults to {}. For consistency and to future-proof against stricter JSON schema validation, consider 'default' => (object) [].

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch—addressed in 3d3d276.

Comment thread src/content-gate/editor/block-visibility.tsx
@github-actions github-actions bot added the [Status] Needs Changes or Feedback The issue or pull request needs action from the original creator label Apr 16, 2026
dkoo and others added 3 commits April 16, 2026 11:33
- Sync TARGET_BLOCKS with PHP: pass get_target_blocks() result via
  wp_localize_script so the JS honours the
  newspack_content_gate_block_visibility_blocks filter
- Fix hidden-mode bug when all gates are inactive: add has_active_gates()
  guard in filter_render_block() so a block whose gates are all deleted or
  unpublished passes through regardless of the visibility setting
- Use (object) [] as default for newspackAccessControlRules to match the
  object type declaration and the JS default of {}
- Add regression test: deleted gate + hidden mode must show the block

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…D-based selection

Switch both FormTokenField instances (GateControls and AccessRuleValueControl)
from plain title/label strings to { value, title } token objects. The value
field carries the item ID so token removal is keyed by ID rather than display
string, which means two items with identical labels can coexist as tokens and
are removed independently.

New tokens added from suggestions still arrive as plain strings and are
resolved to IDs by label lookup — an inherent FormTokenField limitation, but
removal reliability is the more common concern in practice.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ld for ID-based selection"

This reverts commit fdb124a.
@dkoo dkoo requested a review from adekbadek April 16, 2026 17:47
@dkoo
Copy link
Copy Markdown
Contributor Author

dkoo commented Apr 16, 2026

Thanks for the feedback, @adekbadek—this is ready for another look.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Status] Design Approved [Status] Needs Changes or Feedback The issue or pull request needs action from the original creator [Status] Needs Review The issue or pull request needs to be reviewed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants