diff --git a/includes/content-gate/class-block-visibility.php b/includes/content-gate/class-block-visibility.php
new file mode 100644
index 0000000000..2fa36d78c1
--- /dev/null
+++ b/includes/content-gate/class-block-visibility.php
@@ -0,0 +1,366 @@
+ [
+ 'type' => 'string',
+ 'default' => 'visible',
+ ],
+ 'newspackAccessControlMode' => [
+ 'type' => 'string',
+ 'default' => 'gate',
+ ],
+ 'newspackAccessControlGateIds' => [
+ 'type' => 'array',
+ 'default' => [],
+ 'items' => [
+ 'type' => 'integer',
+ ],
+ ],
+ 'newspackAccessControlRules' => [
+ 'type' => 'object',
+ 'default' => (object) [],
+ ],
+ ]
+ );
+ return $args;
+ }
+
+ /**
+ * Enqueue block editor assets.
+ */
+ public static function enqueue_block_editor_assets() {
+ if ( ! current_user_can( 'edit_others_posts' ) ) {
+ return;
+ }
+
+ $available_post_types = array_column(
+ Content_Restriction_Control::get_available_post_types(),
+ 'value'
+ );
+ // get_post_type() returns false in the Site Editor / widget screens where
+ // no post is in context — in_array( false, [...], true ) is false, so the
+ // asset is correctly suppressed. This mirrors the guard in Content_Gate.
+ if ( ! in_array( get_post_type(), $available_post_types, true ) ) {
+ return;
+ }
+
+ $asset_file = dirname( NEWSPACK_PLUGIN_FILE ) . '/dist/content-gate-block-visibility.asset.php';
+ if ( ! file_exists( $asset_file ) ) {
+ return;
+ }
+ $asset = require $asset_file;
+
+ wp_enqueue_script(
+ 'newspack-content-gate-block-visibility',
+ Newspack::plugin_url() . '/dist/content-gate-block-visibility.js',
+ $asset['dependencies'],
+ $asset['version'],
+ true
+ );
+
+ wp_localize_script(
+ 'newspack-content-gate-block-visibility',
+ 'newspackBlockVisibility',
+ [
+ 'target_blocks' => self::get_target_blocks(),
+ 'available_access_rules' => array_map(
+ function( $rule ) {
+ unset( $rule['callback'] );
+ return $rule;
+ },
+ Access_Rules::get_access_rules()
+ ),
+ 'available_gates' => array_values(
+ array_map(
+ function( $gate ) {
+ return [
+ 'id' => $gate['id'],
+ 'title' => $gate['title'],
+ ];
+ },
+ Content_Gate::get_gates( Content_Gate::GATE_CPT, 'publish' )
+ )
+ ),
+ ]
+ );
+ }
+
+ /**
+ * Per-request cache: keyed by "{user_id}:{md5(rules)}" or "gate:{user_id}:{md5(gate_ids)}".
+ *
+ * @var bool[]
+ */
+ private static $rules_match_cache = [];
+
+ /**
+ * Reset the per-request cache. Used in unit tests only.
+ */
+ public static function reset_cache_for_tests() {
+ self::$rules_match_cache = [];
+ }
+
+ /**
+ * Public wrapper for tests. Calls evaluate_rules_for_user().
+ *
+ * @param array $rules Rules array.
+ * @param int $user_id User ID.
+ * @return bool
+ */
+ public static function evaluate_rules_for_user_public( $rules, $user_id ) {
+ return self::evaluate_rules_for_user( $rules, $user_id );
+ }
+
+ /**
+ * Evaluate whether a user matches the block's custom access rules (with caching).
+ *
+ * @param array $rules Parsed newspackAccessControlRules attribute.
+ * @param int $user_id User ID (0 for logged-out).
+ * @return bool True if user matches (should be treated as "matching reader").
+ */
+ private static function evaluate_rules_for_user( $rules, $user_id ) {
+ $cache_key = $user_id . ':' . md5( wp_json_encode( $rules ) );
+ if ( isset( self::$rules_match_cache[ $cache_key ] ) ) {
+ return self::$rules_match_cache[ $cache_key ];
+ }
+
+ $result = self::compute_rules_match( $rules, $user_id );
+ self::$rules_match_cache[ $cache_key ] = $result;
+ return $result;
+ }
+
+ /**
+ * Return true if at least one gate in the list is published and accessible.
+ *
+ * Used as an early-exit guard in filter_render_block() so that a block whose
+ * only gates have all been deleted or unpublished is treated as unrestricted,
+ * regardless of the block's visibility setting.
+ *
+ * @param int[] $gate_ids Array of np_content_gate post IDs.
+ * @return bool
+ */
+ private static function has_active_gates( $gate_ids ) {
+ foreach ( $gate_ids as $gate_id ) {
+ $gate = Content_Gate::get_gate( $gate_id );
+ if ( ! \is_wp_error( $gate ) && 'publish' === $gate['status'] ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Evaluate whether a user matches any of the given gate's access rules (with caching).
+ *
+ * Assumes at least one gate in $gate_ids is active; call has_active_gates() first
+ * when a pass-through fallback is needed for fully-inactive gate lists.
+ *
+ * @param int[] $gate_ids Array of np_content_gate post IDs.
+ * @param int $user_id User ID (0 for logged-out).
+ * @return bool
+ */
+ private static function evaluate_gate_rules_for_user( $gate_ids, $user_id ) {
+ $cache_key = 'gate:' . $user_id . ':' . md5( wp_json_encode( $gate_ids ) );
+ if ( isset( self::$rules_match_cache[ $cache_key ] ) ) {
+ return self::$rules_match_cache[ $cache_key ];
+ }
+
+ $result = self::compute_gate_rules_match( $gate_ids, $user_id );
+ self::$rules_match_cache[ $cache_key ] = $result;
+ return $result;
+ }
+
+ /**
+ * Compute whether a user matches the access rules of any of the given gates (uncached).
+ *
+ * @param int[] $gate_ids Array of np_content_gate post IDs.
+ * @param int $user_id User ID (0 for logged-out).
+ * @return bool
+ */
+ private static function compute_gate_rules_match( $gate_ids, $user_id ) {
+ $has_active_gate = false;
+
+ foreach ( $gate_ids as $gate_id ) {
+ $gate = Content_Gate::get_gate( $gate_id );
+
+ // Deleted gate: Content_Gate::get_gate() returns WP_Error when the post
+ // doesn't exist. Unpublished gates have status !== 'publish'. Both are
+ // skipped so only currently-active gates impose restrictions.
+ if ( \is_wp_error( $gate ) || 'publish' !== $gate['status'] ) {
+ continue;
+ }
+
+ $has_active_gate = true;
+
+ $rules = [
+ 'registration' => $gate['registration'],
+ 'custom_access' => $gate['custom_access'],
+ ];
+
+ // OR logic: the user passes if they satisfy any single active gate's rules.
+ if ( self::compute_rules_match( $rules, $user_id ) ) {
+ return true;
+ }
+ }
+
+ // All gates were deleted or unpublished → no active restriction → pass-through.
+ return ! $has_active_gate;
+ }
+
+ /**
+ * Compute whether a user matches the block's access rules (uncached).
+ *
+ * @param array $rules Parsed newspackAccessControlRules attribute.
+ * @param int $user_id User ID (0 for logged-out).
+ * @return bool
+ */
+ private static function compute_rules_match( $rules, $user_id ) {
+ $registration = $rules['registration'] ?? [];
+ $custom_access = $rules['custom_access'] ?? [];
+
+ $registration_passes = true;
+ if ( ! empty( $registration['active'] ) ) {
+ if ( ! $user_id ) {
+ $registration_passes = false;
+ } elseif ( ! empty( $registration['require_verification'] ) ) {
+ $registration_passes = (bool) get_user_meta( $user_id, Reader_Activation::EMAIL_VERIFIED, true );
+ }
+ }
+
+ $access_passes = true;
+ if ( ! empty( $custom_access['active'] ) && ! empty( $custom_access['access_rules'] ) ) {
+ $access_passes = Access_Rules::evaluate_rules( $custom_access['access_rules'], $user_id );
+ }
+
+ // AND logic: both must pass when both are configured.
+ return $registration_passes && $access_passes;
+ }
+}
+Block_Visibility::init();
diff --git a/includes/content-gate/class-content-gate.php b/includes/content-gate/class-content-gate.php
index fa2a4f756c..0d88ee2d72 100644
--- a/includes/content-gate/class-content-gate.php
+++ b/includes/content-gate/class-content-gate.php
@@ -110,6 +110,7 @@ public static function init() {
include __DIR__ . '/class-institution.php';
include __DIR__ . '/class-user-gate-access.php';
include __DIR__ . '/class-premium-newsletters.php';
+ include __DIR__ . '/class-block-visibility.php';
}
/**
diff --git a/src/content-gate/editor/block-visibility.test.ts b/src/content-gate/editor/block-visibility.test.ts
new file mode 100644
index 0000000000..da1b14a3b0
--- /dev/null
+++ b/src/content-gate/editor/block-visibility.test.ts
@@ -0,0 +1,73 @@
+/**
+ * Tests for block-visibility attribute registration filter.
+ */
+
+/**
+ * Capture callbacks registered via addFilter, keyed by namespace.
+ */
+const registeredFilters: Record< string, ( settings: any, name: string ) => any > = {};
+
+jest.mock( '@wordpress/hooks', () => ( {
+ addFilter: jest.fn( ( _hook: string, namespace: string, callback: ( settings: any, name: string ) => any ) => {
+ registeredFilters[ namespace ] = callback;
+ } ),
+} ) );
+
+jest.mock( '@wordpress/compose', () => ( {
+ createHigherOrderComponent: jest.fn( ( fn: any ) => fn ),
+} ) );
+jest.mock( '@wordpress/block-editor', () => ( { InspectorControls: () => null } ) );
+jest.mock( '@wordpress/components', () => ( {} ) );
+jest.mock( '@wordpress/i18n', () => ( { __: ( s: string ) => s } ) );
+jest.mock( '@wordpress/element', () => ( {
+ useState: jest.fn( ( v: any ) => [ v, jest.fn() ] ),
+ useEffect: jest.fn(),
+} ) );
+jest.mock( '@wordpress/api-fetch', () => jest.fn() );
+
+// Importing the module triggers the addFilter side effects.
+require( './block-visibility' );
+
+const attributeFilter = registeredFilters[ 'newspack-plugin/block-visibility/attributes' ];
+
+describe( 'block-visibility attribute registration', () => {
+ it( 'adds attributes to core/group', () => {
+ const result = attributeFilter( { attributes: {} }, 'core/group' );
+ expect( result.attributes ).toHaveProperty( 'newspackAccessControlVisibility' );
+ expect( result.attributes ).toHaveProperty( 'newspackAccessControlRules' );
+ } );
+
+ it( 'adds attributes to core/stack', () => {
+ const result = attributeFilter( { attributes: {} }, 'core/stack' );
+ expect( result.attributes ).toHaveProperty( 'newspackAccessControlVisibility' );
+ expect( result.attributes ).toHaveProperty( 'newspackAccessControlRules' );
+ } );
+
+ it( 'adds attributes to core/row', () => {
+ const result = attributeFilter( { attributes: {} }, 'core/row' );
+ expect( result.attributes ).toHaveProperty( 'newspackAccessControlVisibility' );
+ expect( result.attributes ).toHaveProperty( 'newspackAccessControlRules' );
+ } );
+
+ it( 'does not modify non-target blocks', () => {
+ const settings = { attributes: { align: { type: 'string' } } };
+ const result = attributeFilter( settings, 'core/paragraph' );
+ expect( result ).toBe( settings );
+ } );
+
+ it( 'newspackAccessControlVisibility defaults to visible', () => {
+ const result = attributeFilter( { attributes: {} }, 'core/group' );
+ expect( result.attributes.newspackAccessControlVisibility.default ).toBe( 'visible' );
+ } );
+
+ it( 'newspackAccessControlRules defaults to empty object', () => {
+ const result = attributeFilter( { attributes: {} }, 'core/group' );
+ expect( result.attributes.newspackAccessControlRules.default ).toEqual( {} );
+ } );
+
+ it( 'preserves existing attributes on target blocks', () => {
+ const result = attributeFilter( { attributes: { align: { type: 'string' } } }, 'core/group' );
+ expect( result.attributes ).toHaveProperty( 'align' );
+ expect( result.attributes ).toHaveProperty( 'newspackAccessControlVisibility' );
+ } );
+} );
diff --git a/src/content-gate/editor/block-visibility.tsx b/src/content-gate/editor/block-visibility.tsx
new file mode 100644
index 0000000000..d23fe1cbf1
--- /dev/null
+++ b/src/content-gate/editor/block-visibility.tsx
@@ -0,0 +1,426 @@
+/**
+ * WordPress dependencies
+ */
+import { addFilter } from '@wordpress/hooks';
+import { createHigherOrderComponent } from '@wordpress/compose';
+import { InspectorControls } from '@wordpress/block-editor';
+import {
+ FormTokenField,
+ PanelBody,
+ PanelRow,
+ TextControl,
+ ToggleControl,
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+ __experimentalToggleGroupControl as ToggleGroupControl,
+ // eslint-disable-next-line @wordpress/no-unsafe-wp-apis
+ __experimentalToggleGroupControlOption as ToggleGroupControlOption,
+} from '@wordpress/components';
+import { useState, useEffect } from '@wordpress/element';
+import apiFetch from '@wordpress/api-fetch';
+import { __, sprintf } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import './editor.scss';
+
+/**
+ * Target block types that receive access control attributes.
+ * Sourced from PHP (respects the newspack_content_gate_block_visibility_blocks filter)
+ * with the default list as a fallback for environments where the script is loaded
+ * before localisation runs.
+ */
+const TARGET_BLOCKS: string[] = window.newspackBlockVisibility?.target_blocks ?? [ 'core/group', 'core/stack', 'core/row' ];
+
+/**
+ * Register custom attributes on target block types.
+ */
+addFilter( 'blocks.registerBlockType', 'newspack-plugin/block-visibility/attributes', ( settings: BlockSettings, name: string ) => {
+ if ( ! TARGET_BLOCKS.includes( name ) ) {
+ return settings;
+ }
+ return {
+ ...settings,
+ attributes: {
+ ...settings.attributes,
+ newspackAccessControlVisibility: {
+ type: 'string',
+ default: 'visible',
+ },
+ newspackAccessControlMode: {
+ type: 'string',
+ default: 'gate',
+ },
+ newspackAccessControlGateIds: {
+ type: 'array',
+ default: [],
+ items: { type: 'integer' },
+ },
+ newspackAccessControlRules: {
+ type: 'object',
+ default: {},
+ },
+ },
+ };
+} );
+
+/**
+ * Available access rules from localized data.
+ */
+const availableAccessRules: Record< string, AccessRuleConfig > = window.newspackBlockVisibility?.available_access_rules ?? {};
+
+/**
+ * Available gates from localized data.
+ */
+const availableGates: GateOption[] = window.newspackBlockVisibility?.available_gates ?? [];
+
+/**
+ * Whether any rules are currently active on the block.
+ */
+function hasActiveRules( rules: BlockVisibilityRules, mode: string, gateIds: number[] ): boolean {
+ if ( 'gate' === mode ) {
+ return gateIds.length > 0;
+ }
+ return !! rules?.registration?.active || !! rules?.custom_access?.active;
+}
+
+/** ToggleGroupControl for the two standard visibility options. */
+const VisibilityControl = ( {
+ label,
+ help,
+ value,
+ onChange,
+ disabled,
+}: {
+ label: string;
+ help: string;
+ value: string;
+ onChange: ( value: string ) => void;
+ disabled: boolean;
+} ) => (
+
+ onChange( String( v ?? 'visible' ) ) }
+ isBlock
+ __next40pxDefaultSize
+ __nextHasNoMarginBottom
+ >
+
+
+
+
+);
+
+/**
+ * Gate selector: a FormTokenField that lets the editor link one or more gates.
+ * A reader needs to satisfy any one of the selected gates' rules to match.
+ */
+const GateControls = ( { gateIds, onChange }: { gateIds: number[]; onChange: ( ids: number[] ) => void } ) => {
+ const selectedLabels = availableGates.filter( g => gateIds.includes( g.id ) ).map( g => g.title );
+
+ return (
+
+ g.title ) }
+ onChange={ ( tokens: ( string | { value: string } )[] ) => {
+ const labels = tokens.map( t => ( typeof t === 'string' ? t : t.value ) );
+ onChange( availableGates.filter( g => labels.includes( g.title ) ).map( g => g.id ) );
+ } }
+ __experimentalExpandOnFocus
+ __next40pxDefaultSize
+ __nextHasNoMarginBottom
+ />
+
+ );
+};
+
+/**
+ * Rules whose options must be fetched dynamically.
+ */
+const DYNAMIC_OPTION_RULES: Record< string, { path: string; mapItem: ( item: DynamicOptionItem ) => AccessRuleOption } > = {
+ institution: {
+ path: '/wp/v2/np_institution?per_page=100&context=edit',
+ mapItem: ( item: DynamicOptionItem ) => ( { value: item.id, label: item.title.raw } ),
+ },
+};
+
+/**
+ * Value control for a single access rule.
+ * Renders FormTokenField for rules with options, TextControl for free-text rules.
+ */
+const AccessRuleValueControl = ( {
+ slug,
+ config,
+ value,
+ onChange,
+}: {
+ slug: string;
+ config: AccessRuleConfig;
+ value: ActiveRule[ 'value' ];
+ onChange: ( value: ActiveRule[ 'value' ] ) => void;
+} ) => {
+ const dynamicConfig = DYNAMIC_OPTION_RULES[ slug ];
+ const staticOptions: AccessRuleOption[] = config.options ?? [];
+
+ const [ options, setOptions ] = useState< AccessRuleOption[] >( staticOptions );
+
+ useEffect( () => {
+ if ( ! dynamicConfig ) {
+ return;
+ }
+ let cancelled = false;
+ apiFetch< DynamicOptionItem[] >( { path: dynamicConfig.path } )
+ .then( items => {
+ if ( ! cancelled ) {
+ setOptions( items.map( dynamicConfig.mapItem ) );
+ }
+ } )
+ .catch( () => {} );
+ return () => {
+ cancelled = true;
+ };
+ }, [ slug ] ); // eslint-disable-line react-hooks/exhaustive-deps
+
+ if ( options.length > 0 ) {
+ // Map stored IDs to labels for display; silently drop IDs with no matching option.
+ const valueArr = Array.isArray( value ) ? value : [];
+ const selectedLabels = options.filter( o => valueArr.some( v => String( v ) === String( o.value ) ) ).map( o => o.label );
+
+ return (
+ o.label ) }
+ onChange={ ( tokens: ( string | { value: string } )[] ) => {
+ const labels = tokens.map( t => ( typeof t === 'string' ? t : t.value ) );
+ onChange( options.filter( o => labels.includes( o.label ) ).map( o => o.value ) );
+ } }
+ __experimentalExpandOnFocus
+ __next40pxDefaultSize
+ __nextHasNoMarginBottom
+ />
+ );
+ }
+
+ return (
+ void }
+ __next40pxDefaultSize
+ __nextHasNoMarginBottom
+ />
+ );
+};
+
+/** One toggle + value control per available access rule. */
+const AccessRulesControls = ( { activeRules, onChange }: { activeRules: ActiveRule[]; onChange: ( rules: ActiveRule[] ) => void } ) => {
+ const handleToggle = ( slug: string, defaultValue: ActiveRule[ 'value' ] ) => {
+ const has = activeRules.some( r => r.slug === slug );
+ if ( has ) {
+ onChange( activeRules.filter( r => r.slug !== slug ) );
+ } else {
+ onChange( [ ...activeRules, { slug, value: defaultValue } ] );
+ }
+ };
+
+ const handleValueChange = ( slug: string, value: ActiveRule[ 'value' ] ) => {
+ onChange( activeRules.map( r => ( r.slug === slug ? { ...r, value } : r ) ) );
+ };
+
+ return (
+ <>
+ { Object.entries( availableAccessRules ).map( ( [ slug, config ] ) => {
+ const activeRule = activeRules.find( r => r.slug === slug );
+ return (
+
+
+
handleToggle( slug, config.default ) }
+ />
+ { activeRule && ! config.is_boolean && (
+ handleValueChange( slug, v ) }
+ />
+ ) }
+
+
+ );
+ } ) }
+ >
+ );
+};
+
+/** Registration section: logged-in toggle + verification sub-toggle. */
+const RegistrationControls = ( {
+ registration,
+ onChange,
+}: {
+ registration: RegistrationRule;
+ onChange: ( registration: RegistrationRule ) => void;
+} ) => (
+ <>
+
+ onChange( { ...registration, active } ) }
+ />
+
+
+ onChange( { ...registration, require_verification } ) }
+ />
+
+ >
+);
+
+/**
+ * Inspector panel for block access control.
+ */
+const BlockVisibilityPanel = ( { attributes, setAttributes }: BlockEditProps ) => {
+ const rules: BlockVisibilityRules = attributes.newspackAccessControlRules ?? {};
+ const visibility: string = attributes.newspackAccessControlVisibility ?? 'visible';
+ const mode: string = attributes.newspackAccessControlMode ?? 'gate';
+ const gateIds: number[] = attributes.newspackAccessControlGateIds ?? [];
+
+ const registration: RegistrationRule = rules.registration ?? { active: false };
+ const customAccess: CustomAccessRule = rules.custom_access ?? { active: false, access_rules: [] };
+ // Flatten grouped OR rules for display: [[rule]] → [rule]
+ const activeRules: ActiveRule[] = customAccess.access_rules.map( group => group[ 0 ] ).filter( Boolean );
+
+ const rulesActive = hasActiveRules( rules, mode, gateIds );
+
+ const updateRules = ( updates: Partial< BlockVisibilityRules > ) => {
+ const newRules: BlockVisibilityRules = { ...rules, ...updates };
+ const stillActive = hasActiveRules( newRules, mode, gateIds );
+ setAttributes( {
+ newspackAccessControlRules: newRules,
+ // Reset visibility to 'visible' when all custom rules are cleared.
+ ...( ! stillActive ? { newspackAccessControlVisibility: 'visible' } : {} ),
+ } );
+ };
+
+ const setRegistration = ( newRegistration: RegistrationRule ) => {
+ updateRules( {
+ registration: {
+ ...newRegistration,
+ // Ensure require_verification is cleared when registration is turned off.
+ require_verification: newRegistration.active ? newRegistration.require_verification : false,
+ },
+ } );
+ };
+
+ const setAccessRules = ( flatRules: ActiveRule[] ) => {
+ const grouped: ActiveRule[][] = flatRules.map( rule => [ rule ] );
+ updateRules( {
+ custom_access: {
+ ...customAccess,
+ active: grouped.length > 0,
+ access_rules: grouped,
+ },
+ } );
+ };
+
+ return (
+
+
+ { /* Mode toggle: Gate (default) or Custom */ }
+
+ setAttributes( { newspackAccessControlMode: String( v ?? 'gate' ) } ) }
+ isBlock
+ __next40pxDefaultSize
+ __nextHasNoMarginBottom
+ >
+
+
+
+
+
+ { 'gate' === mode && (
+ {
+ setAttributes( {
+ newspackAccessControlGateIds: ids,
+ // Reset visibility when the last gate is removed.
+ ...( ids.length === 0 ? { newspackAccessControlVisibility: 'visible' } : {} ),
+ } );
+ } }
+ />
+ ) }
+
+ { 'custom' === mode && (
+ <>
+ { /* Registration toggle */ }
+
+
+ { /* Access rule toggles */ }
+
+ >
+ ) }
+
+ setAttributes( { newspackAccessControlVisibility: v } ) }
+ disabled={ ! rulesActive }
+ />
+
+
+ );
+};
+
+/**
+ * Inject the Inspector panel into target block editors.
+ */
+addFilter(
+ 'editor.BlockEdit',
+ 'newspack-plugin/block-visibility/inspector',
+ createHigherOrderComponent( BlockEdit => {
+ const WithBlockVisibilityPanel = ( props: BlockEditProps ) => {
+ if ( ! TARGET_BLOCKS.includes( props.name ) ) {
+ return ;
+ }
+ return (
+ <>
+
+
+ >
+ );
+ };
+ return WithBlockVisibilityPanel;
+ }, 'withBlockVisibilityPanel' )
+);
diff --git a/src/content-gate/editor/editor.scss b/src/content-gate/editor/editor.scss
index 62ef38c11d..cea496a61a 100644
--- a/src/content-gate/editor/editor.scss
+++ b/src/content-gate/editor/editor.scss
@@ -1,4 +1,5 @@
@use "~@wordpress/base-styles/colors" as wp-colors;
+@use "~@wordpress/base-styles/variables" as wp-vars;
@use "../vars" as gate;
.edit-post-post-visibility {
@@ -56,3 +57,29 @@
.post-type-np_gate_layout .editor-post-title {
display: none;
}
+
+/**
+ * Access control block visibility panel.
+ */
+.newspack-access-control-block-visibility-panel {
+ .components-base-control,
+ .components-form-token-field {
+ width: 100%;
+ }
+ // Gutenberg's `.block-editor-block-inspector p:not(.components-base-control__help)` rule
+ // strips the emotion margin-top on FormTokenField's help text. Nested here to bump
+ // specificity above Gutenberg's and restore the gap.
+ .components-form-token-field .components-form-token-field__help {
+ margin-top: wp-vars.$grid-unit-10;
+ }
+ .components-toggle-control {
+ margin-top: wp-vars.$grid-unit-10;
+ width: 100%;
+ .components-base-control__field {
+ margin-bottom: wp-vars.$grid-unit-05;
+ }
+ .components-base-control__help {
+ margin-top: 0;
+ }
+ }
+}
diff --git a/src/content-gate/editor/index.d.ts b/src/content-gate/editor/index.d.ts
new file mode 100644
index 0000000000..ef01c886f8
--- /dev/null
+++ b/src/content-gate/editor/index.d.ts
@@ -0,0 +1,68 @@
+declare module '@wordpress/block-editor';
+
+/**
+ * Types.
+ */
+type BlockSettings = {
+ attributes: Record< string, unknown >;
+ name: string;
+};
+type DynamicOptionItem = {
+ id: string | number;
+ title: {
+ raw: string;
+ };
+};
+type AccessRuleOption = {
+ value: string | number;
+ label: string;
+};
+type AccessRuleConfig = {
+ name: string;
+ description: string;
+ default: string | Array< string | number >;
+ is_boolean?: boolean;
+ placeholder?: string;
+ options?: AccessRuleOption[];
+};
+type ActiveRule = {
+ slug: string;
+ value: string | Array< string | number > | null;
+};
+type RegistrationRule = {
+ active: boolean;
+ require_verification?: boolean;
+};
+type CustomAccessRule = {
+ active: boolean;
+ access_rules: ActiveRule[][];
+};
+type BlockVisibilityRules = {
+ registration?: RegistrationRule;
+ custom_access?: CustomAccessRule;
+};
+type GateOption = {
+ id: number;
+ title: string;
+};
+type BlockVisibilityAttributes = {
+ newspackAccessControlRules: BlockVisibilityRules;
+ newspackAccessControlVisibility: string;
+ newspackAccessControlMode: string;
+ newspackAccessControlGateIds: number[];
+ [ key: string ]: unknown;
+};
+type BlockEditProps = {
+ name: string;
+ attributes: BlockVisibilityAttributes;
+ setAttributes: ( attrs: Partial< BlockVisibilityAttributes > ) => void;
+ [ key: string ]: unknown;
+};
+
+interface Window {
+ newspackBlockVisibility: {
+ target_blocks: string[];
+ available_access_rules: Record< string, AccessRuleConfig >;
+ available_gates: GateOption[];
+ };
+}
diff --git a/tests/unit-tests/content-gate/class-block-visibility.php b/tests/unit-tests/content-gate/class-block-visibility.php
new file mode 100644
index 0000000000..22f799d23c
--- /dev/null
+++ b/tests/unit-tests/content-gate/class-block-visibility.php
@@ -0,0 +1,679 @@
+test_user_id = $this->factory->user->create( [ 'role' => 'subscriber' ] );
+
+ // Register a simple test rule: passes only for our test user.
+ // Guard against duplicate registration: Access_Rules::$rules is static and
+ // persists across test methods within the same PHP process.
+ $registered = \Newspack\Access_Rules::get_registered_rules();
+ if ( ! isset( $registered['test_rule'] ) ) {
+ \Newspack\Access_Rules::register_rule(
+ [
+ 'id' => 'test_rule',
+ 'name' => 'Test Rule',
+ 'callback' => function( $user_id, $value ) {
+ return intval( $user_id ) === intval( $value );
+ },
+ ]
+ );
+ }
+ }
+
+ /**
+ * Tear down test environment.
+ */
+ public function tear_down() {
+ Block_Visibility::reset_cache_for_tests();
+ wp_set_current_user( 0 );
+ parent::tear_down();
+ }
+
+ /**
+ * Test that the Block_Visibility class exists.
+ */
+ public function test_class_exists() {
+ $this->assertTrue( class_exists( 'Newspack\Block_Visibility' ) );
+ }
+
+ /**
+ * Test that the render_block filter is registered.
+ */
+ public function test_render_block_filter_registered() {
+ $this->assertNotFalse(
+ has_filter( 'render_block', [ 'Newspack\Block_Visibility', 'filter_render_block' ] )
+ );
+ }
+
+ /**
+ * Test that the enqueue_block_editor_assets action is registered.
+ */
+ public function test_enqueue_block_editor_assets_action_registered() {
+ $this->assertNotFalse(
+ has_action( 'enqueue_block_editor_assets', [ 'Newspack\Block_Visibility', 'enqueue_block_editor_assets' ] )
+ );
+ }
+
+ /**
+ * Test that the register_block_type_args filter is registered.
+ */
+ public function test_register_block_type_args_filter_registered() {
+ $this->assertNotFalse(
+ has_filter( 'register_block_type_args', [ 'Newspack\Block_Visibility', 'register_block_type_args' ] )
+ );
+ }
+
+ /**
+ * Helper to build a mock block array.
+ *
+ * @param string $name Block name.
+ * @param array $attrs Block attributes.
+ * @return array
+ */
+ private function make_block( $name, $attrs = [] ) {
+ return [
+ 'blockName' => $name,
+ 'attrs' => $attrs,
+ 'innerHTML' => 'content
',
+ ];
+ }
+
+ /**
+ * Test that non-target blocks pass through unchanged.
+ */
+ public function test_non_target_block_passes_through() {
+ $result = Block_Visibility::filter_render_block( 'hello
', $this->make_block( 'core/paragraph' ) );
+ $this->assertSame( 'hello
', $result );
+ }
+
+ /**
+ * Test that a target block with no attrs passes through unchanged.
+ */
+ public function test_target_block_with_no_rules_passes_through() {
+ $result = Block_Visibility::filter_render_block( 'hi
', $this->make_block( 'core/group', [] ) );
+ $this->assertSame( 'hi
', $result );
+ }
+
+ /**
+ * Test that a target block with an empty rules object passes through unchanged.
+ */
+ public function test_target_block_with_empty_rules_object_passes_through() {
+ $result = Block_Visibility::filter_render_block(
+ 'hi
',
+ $this->make_block( 'core/group', [ 'newspackAccessControlRules' => [] ] )
+ );
+ $this->assertSame( 'hi
', $result );
+ }
+
+ /**
+ * Test that a target block with only inactive rules passes through unchanged.
+ */
+ public function test_target_block_with_inactive_rules_passes_through() {
+ $result = Block_Visibility::filter_render_block(
+ 'hi
',
+ $this->make_block(
+ 'core/group',
+ [
+ 'newspackAccessControlRules' => [
+ 'registration' => [ 'active' => false ],
+ 'custom_access' => [
+ 'active' => false,
+ 'access_rules' => [],
+ ],
+ ],
+ ]
+ )
+ );
+ $this->assertSame( 'hi
', $result );
+ }
+
+ /**
+ * Test that a target block with active rules passes through unchanged when is_admin() is true.
+ */
+ public function test_target_block_with_rules_passes_through_in_admin() {
+ set_current_screen( 'dashboard' );
+ $block = $this->make_block(
+ 'core/group',
+ [
+ 'newspackAccessControlRules' => [
+ 'registration' => [ 'active' => true ],
+ ],
+ ]
+ );
+ $result = Block_Visibility::filter_render_block( 'admin view
', $block );
+ $this->assertSame( 'admin view
', $result );
+ unset( $GLOBALS['current_screen'] );
+ }
+
+ /**
+ * Registration: logged-out user does not match.
+ */
+ public function test_registration_logged_out_does_not_match() {
+ wp_set_current_user( 0 );
+ $rules = [ 'registration' => [ 'active' => true ] ];
+ $this->assertFalse( Block_Visibility::evaluate_rules_for_user_public( $rules, 0 ) );
+ }
+
+ /**
+ * Registration: logged-in user matches.
+ */
+ public function test_registration_logged_in_matches() {
+ $rules = [ 'registration' => [ 'active' => true ] ];
+ $this->assertTrue( Block_Visibility::evaluate_rules_for_user_public( $rules, $this->test_user_id ) );
+ }
+
+ /**
+ * Registration + require_verification: unverified user does not match.
+ */
+ public function test_registration_unverified_does_not_match() {
+ $rules = [
+ 'registration' => [
+ 'active' => true,
+ 'require_verification' => true,
+ ],
+ ];
+ $this->assertFalse( Block_Visibility::evaluate_rules_for_user_public( $rules, $this->test_user_id ) );
+ }
+
+ /**
+ * Registration + require_verification: verified user matches.
+ */
+ public function test_registration_verified_matches() {
+ update_user_meta( $this->test_user_id, \Newspack\Reader_Activation::EMAIL_VERIFIED, true );
+ $rules = [
+ 'registration' => [
+ 'active' => true,
+ 'require_verification' => true,
+ ],
+ ];
+ $this->assertTrue( Block_Visibility::evaluate_rules_for_user_public( $rules, $this->test_user_id ) );
+ }
+
+ /**
+ * Custom access rule: matching user passes.
+ */
+ public function test_access_rule_matching_user_passes() {
+ $rules = [
+ 'custom_access' => [
+ 'active' => true,
+ 'access_rules' => [
+ [
+ [
+ 'slug' => 'test_rule',
+ 'value' => $this->test_user_id,
+ ],
+ ],
+ ],
+ ],
+ ];
+ $this->assertTrue( Block_Visibility::evaluate_rules_for_user_public( $rules, $this->test_user_id ) );
+ }
+
+ /**
+ * Custom access rule: non-matching user fails.
+ */
+ public function test_access_rule_non_matching_user_fails() {
+ $other_user = $this->factory->user->create( [ 'role' => 'subscriber' ] );
+ $rules = [
+ 'custom_access' => [
+ 'active' => true,
+ 'access_rules' => [
+ [
+ [
+ 'slug' => 'test_rule',
+ 'value' => $this->test_user_id,
+ ],
+ ],
+ ],
+ ],
+ ];
+ $this->assertFalse( Block_Visibility::evaluate_rules_for_user_public( $rules, $other_user ) );
+ }
+
+ /**
+ * AND logic: registration + access rules — both must pass.
+ */
+ public function test_and_logic_both_must_pass() {
+ $rules = [
+ 'registration' => [ 'active' => true ],
+ 'custom_access' => [
+ 'active' => true,
+ 'access_rules' => [
+ [
+ [
+ 'slug' => 'test_rule',
+ 'value' => $this->test_user_id,
+ ],
+ ],
+ ],
+ ],
+ ];
+ // Logged-in user who matches the access rule: passes.
+ $this->assertTrue( Block_Visibility::evaluate_rules_for_user_public( $rules, $this->test_user_id ) );
+
+ // Logged-out user: fails (registration not met).
+ Block_Visibility::reset_cache_for_tests();
+ $this->assertFalse( Block_Visibility::evaluate_rules_for_user_public( $rules, 0 ) );
+ }
+
+ /**
+ * Helper: build a block with both control attributes.
+ *
+ * @param string $block_name Block type name.
+ * @param array $rules newspackAccessControlRules value.
+ * @param string $visibility 'visible' or 'hidden'.
+ * @return array
+ */
+ private function make_block_with_rules( $block_name, $rules, $visibility = 'visible' ) {
+ return $this->make_block(
+ $block_name,
+ [
+ 'newspackAccessControlMode' => 'custom',
+ 'newspackAccessControlRules' => $rules,
+ 'newspackAccessControlVisibility' => $visibility,
+ ]
+ );
+ }
+
+ /**
+ * "visible" mode: matching user sees the block.
+ */
+ public function test_visible_mode_matching_user_sees_block() {
+ wp_set_current_user( $this->test_user_id );
+ Block_Visibility::reset_cache_for_tests();
+ $rules = [ 'registration' => [ 'active' => true ] ];
+ $block = $this->make_block_with_rules( 'core/group', $rules, 'visible' );
+ $result = Block_Visibility::filter_render_block( 'secret
', $block );
+ $this->assertSame( 'secret
', $result );
+ }
+
+ /**
+ * "visible" mode: non-matching user does not see the block.
+ */
+ public function test_visible_mode_non_matching_user_hidden() {
+ wp_set_current_user( 0 );
+ Block_Visibility::reset_cache_for_tests();
+ $rules = [ 'registration' => [ 'active' => true ] ];
+ $block = $this->make_block_with_rules( 'core/group', $rules, 'visible' );
+ $result = Block_Visibility::filter_render_block( 'secret
', $block );
+ $this->assertSame( '', $result );
+ }
+
+ /**
+ * "hidden" mode: matching user does not see the block.
+ */
+ public function test_hidden_mode_matching_user_hidden() {
+ wp_set_current_user( $this->test_user_id );
+ Block_Visibility::reset_cache_for_tests();
+ $rules = [ 'registration' => [ 'active' => true ] ];
+ $block = $this->make_block_with_rules( 'core/group', $rules, 'hidden' );
+ $result = Block_Visibility::filter_render_block( 'members only
', $block );
+ $this->assertSame( '', $result );
+ }
+
+ /**
+ * "hidden" mode: non-matching user sees the block.
+ */
+ public function test_hidden_mode_non_matching_user_sees_block() {
+ wp_set_current_user( 0 );
+ Block_Visibility::reset_cache_for_tests();
+ $rules = [ 'registration' => [ 'active' => true ] ];
+ $block = $this->make_block_with_rules( 'core/group', $rules, 'hidden' );
+ $result = Block_Visibility::filter_render_block( 'non-member content
', $block );
+ $this->assertSame( 'non-member content
', $result );
+ }
+
+ /**
+ * All three target block types are evaluated.
+ */
+ public function test_all_target_block_types_evaluated() {
+ wp_set_current_user( 0 );
+ Block_Visibility::reset_cache_for_tests();
+ $rules = [ 'registration' => [ 'active' => true ] ];
+ foreach ( [ 'core/group', 'core/stack', 'core/row' ] as $block_name ) {
+ Block_Visibility::reset_cache_for_tests();
+ $block = $this->make_block_with_rules( $block_name, $rules, 'visible' );
+ $result = Block_Visibility::filter_render_block( 'x
', $block );
+ $this->assertSame( '', $result, "Expected empty for $block_name" );
+ }
+ }
+
+ /**
+ * Missing visibility attribute defaults to "visible".
+ */
+ public function test_missing_visibility_attribute_defaults_to_visible() {
+ wp_set_current_user( 0 );
+ Block_Visibility::reset_cache_for_tests();
+ $block = $this->make_block(
+ 'core/group',
+ [
+ 'newspackAccessControlMode' => 'custom',
+ 'newspackAccessControlRules' => [ 'registration' => [ 'active' => true ] ],
+ // newspackAccessControlVisibility intentionally omitted.
+ ]
+ );
+ $result = Block_Visibility::filter_render_block( 'x
', $block );
+ // Logged-out user: rules don't match, so hidden under default "visible" mode.
+ $this->assertSame( '', $result );
+ }
+
+ /**
+ * A user who can edit the post sees restricted blocks on the front end.
+ */
+ public function test_editor_bypasses_access_rules_on_front_end() {
+ $editor_id = $this->factory->user->create( [ 'role' => 'editor' ] );
+ $post_id = $this->factory->post->create();
+ $GLOBALS['post'] = get_post( $post_id );
+
+ wp_set_current_user( $editor_id );
+ Block_Visibility::reset_cache_for_tests();
+
+ $rules = [ 'registration' => [ 'active' => true ] ];
+ $block = $this->make_block_with_rules( 'core/group', $rules, 'visible' );
+ $result = Block_Visibility::filter_render_block( 'restricted
', $block );
+
+ $this->assertSame( 'restricted
', $result );
+
+ unset( $GLOBALS['post'] );
+ }
+
+ /**
+ * A user who cannot edit the post is still subject to access rules.
+ */
+ public function test_non_editor_still_restricted_on_front_end() {
+ $post_id = $this->factory->post->create();
+ $GLOBALS['post'] = get_post( $post_id );
+
+ wp_set_current_user( 0 );
+ Block_Visibility::reset_cache_for_tests();
+
+ $rules = [ 'registration' => [ 'active' => true ] ];
+ $block = $this->make_block_with_rules( 'core/group', $rules, 'visible' );
+ $result = Block_Visibility::filter_render_block( 'restricted
', $block );
+
+ $this->assertSame( '', $result );
+
+ unset( $GLOBALS['post'] );
+ }
+
+ /**
+ * Core/group block has both visibility attributes registered server-side.
+ */
+ public function test_group_block_has_visibility_attribute_registered() {
+ $block_type = \WP_Block_Type_Registry::get_instance()->get_registered( 'core/group' );
+ $this->assertArrayHasKey( 'newspackAccessControlVisibility', $block_type->attributes );
+ $this->assertArrayHasKey( 'newspackAccessControlRules', $block_type->attributes );
+ }
+
+ /**
+ * Caching: second call returns cached result without re-evaluation.
+ */
+ public function test_result_is_cached() {
+ $call_count = 0;
+ $counting_rule_id = 'counting_rule_' . uniqid();
+ \Newspack\Access_Rules::register_rule(
+ [
+ 'id' => $counting_rule_id,
+ 'name' => 'Counting Rule',
+ 'callback' => function( $user_id, $value ) use ( &$call_count ) {
+ $call_count++;
+ return true;
+ },
+ ]
+ );
+ $rules = [
+ 'custom_access' => [
+ 'active' => true,
+ 'access_rules' => [
+ [
+ [
+ 'slug' => $counting_rule_id,
+ 'value' => null,
+ ],
+ ],
+ ],
+ ],
+ ];
+ Block_Visibility::evaluate_rules_for_user_public( $rules, $this->test_user_id );
+ Block_Visibility::evaluate_rules_for_user_public( $rules, $this->test_user_id );
+ // Callback fired only once despite two calls with identical rules + user.
+ $this->assertSame( 1, $call_count );
+ }
+
+ // -----------------------------------------------------------------------
+ // Gate mode tests
+ // -----------------------------------------------------------------------
+
+ /**
+ * Helper: create a published gate post and optionally set its registration meta.
+ *
+ * @param bool $registration_active Whether to activate the registration rule.
+ * @param string $status Post status. Default 'publish'.
+ * @return int Gate post ID.
+ */
+ private function make_gate( $registration_active = true, $status = 'publish' ) {
+ $gate_id = $this->factory->post->create(
+ [
+ 'post_type' => \Newspack\Content_Gate::GATE_CPT,
+ 'post_status' => $status,
+ ]
+ );
+ if ( $registration_active ) {
+ update_post_meta( $gate_id, 'registration', [ 'active' => true ] );
+ }
+ return $gate_id;
+ }
+
+ /**
+ * Gate mode with no gates selected passes through regardless of user.
+ */
+ public function test_gate_mode_no_gates_passes_through() {
+ wp_set_current_user( 0 );
+ Block_Visibility::reset_cache_for_tests();
+ $block = $this->make_block(
+ 'core/group',
+ [
+ 'newspackAccessControlMode' => 'gate',
+ 'newspackAccessControlGateIds' => [],
+ ]
+ );
+ $result = Block_Visibility::filter_render_block( 'x
', $block );
+ $this->assertSame( 'x
', $result );
+ }
+
+ /**
+ * Gate mode: user matching an active gate's rules sees the block.
+ */
+ public function test_gate_mode_matching_user_sees_block() {
+ $gate_id = $this->make_gate();
+
+ wp_set_current_user( $this->test_user_id );
+ Block_Visibility::reset_cache_for_tests();
+
+ $block = $this->make_block(
+ 'core/group',
+ [
+ 'newspackAccessControlMode' => 'gate',
+ 'newspackAccessControlGateIds' => [ $gate_id ],
+ ]
+ );
+ $result = Block_Visibility::filter_render_block( 'members
', $block );
+ $this->assertSame( 'members
', $result );
+ }
+
+ /**
+ * Gate mode: user not matching an active gate's rules does not see the block.
+ */
+ public function test_gate_mode_non_matching_user_hidden() {
+ $gate_id = $this->make_gate();
+
+ wp_set_current_user( 0 );
+ Block_Visibility::reset_cache_for_tests();
+
+ $block = $this->make_block(
+ 'core/group',
+ [
+ 'newspackAccessControlMode' => 'gate',
+ 'newspackAccessControlGateIds' => [ $gate_id ],
+ ]
+ );
+ $result = Block_Visibility::filter_render_block( 'members
', $block );
+ $this->assertSame( '', $result );
+ }
+
+ /**
+ * Gate mode: an unpublished (draft) gate is skipped — results in pass-through.
+ */
+ public function test_gate_mode_unpublished_gate_passes_through() {
+ $gate_id = $this->make_gate( true, 'draft' );
+
+ wp_set_current_user( 0 );
+ Block_Visibility::reset_cache_for_tests();
+
+ $block = $this->make_block(
+ 'core/group',
+ [
+ 'newspackAccessControlMode' => 'gate',
+ 'newspackAccessControlGateIds' => [ $gate_id ],
+ ]
+ );
+ $result = Block_Visibility::filter_render_block( 'x
', $block );
+ $this->assertSame( 'x
', $result );
+ }
+
+ /**
+ * Gate mode: a permanently deleted gate is skipped — results in pass-through.
+ */
+ public function test_gate_mode_deleted_gate_passes_through_in_visible_mode() {
+ $gate_id = $this->make_gate();
+ wp_delete_post( $gate_id, true ); // Force-delete.
+
+ wp_set_current_user( 0 );
+ Block_Visibility::reset_cache_for_tests();
+
+ $block = $this->make_block(
+ 'core/group',
+ [
+ 'newspackAccessControlMode' => 'gate',
+ 'newspackAccessControlGateIds' => [ $gate_id ],
+ ]
+ );
+ $result = Block_Visibility::filter_render_block( 'x
', $block );
+ $this->assertSame( 'x
', $result );
+ }
+
+ /**
+ * Gate mode: deleted gate in 'hidden' mode still passes through — no gate = no restriction.
+ *
+ * Regression: previously $user_matches = true (pass-through sentinel) combined with
+ * visibility = 'hidden' would hide the block from everyone instead of showing it.
+ */
+ public function test_gate_mode_deleted_gate_passes_through_in_hidden_mode() {
+ $gate_id = $this->make_gate();
+ wp_delete_post( $gate_id, true ); // Force-delete.
+
+ wp_set_current_user( 0 );
+ Block_Visibility::reset_cache_for_tests();
+
+ $block = $this->make_block(
+ 'core/group',
+ [
+ 'newspackAccessControlMode' => 'gate',
+ 'newspackAccessControlGateIds' => [ $gate_id ],
+ 'newspackAccessControlVisibility' => 'hidden',
+ ]
+ );
+ $result = Block_Visibility::filter_render_block( 'x
', $block );
+ $this->assertSame( 'x
', $result );
+ }
+
+ /**
+ * Gate mode: a deleted gate alongside an active gate; only the active gate is evaluated.
+ */
+ public function test_gate_mode_deleted_gate_does_not_affect_active_gate() {
+ $active_gate_id = $this->make_gate();
+ $deleted_gate_id = $this->make_gate();
+ wp_delete_post( $deleted_gate_id, true );
+
+ // Logged-out user does not satisfy the active gate's registration rule.
+ wp_set_current_user( 0 );
+ Block_Visibility::reset_cache_for_tests();
+
+ $block = $this->make_block(
+ 'core/group',
+ [
+ 'newspackAccessControlMode' => 'gate',
+ 'newspackAccessControlGateIds' => [ $active_gate_id, $deleted_gate_id ],
+ ]
+ );
+ $result = Block_Visibility::filter_render_block( 'x
', $block );
+ $this->assertSame( '', $result );
+ }
+
+ /**
+ * Gate mode: OR logic — user matching any one of multiple active gates sees the block.
+ */
+ public function test_gate_mode_or_logic_any_matching_gate_passes() {
+ // Gate A: requires custom access rule that only matches test_user_id.
+ $gate_a = $this->make_gate( false ); // No registration rule.
+ update_post_meta(
+ $gate_a,
+ 'custom_access',
+ [
+ 'active' => true,
+ 'access_rules' => [
+ [
+ [
+ 'slug' => 'test_rule',
+ 'value' => $this->test_user_id,
+ ],
+ ],
+ ],
+ ]
+ );
+
+ // Gate B: requires registration (logged-in only).
+ $gate_b = $this->make_gate( true );
+
+ // A logged-out user matches neither gate.
+ wp_set_current_user( 0 );
+ Block_Visibility::reset_cache_for_tests();
+ $block = $this->make_block(
+ 'core/group',
+ [
+ 'newspackAccessControlMode' => 'gate',
+ 'newspackAccessControlGateIds' => [ $gate_a, $gate_b ],
+ ]
+ );
+ $this->assertSame( '', Block_Visibility::filter_render_block( 'x
', $block ) );
+
+ // The test user matches Gate A (custom rule), so they see the block.
+ wp_set_current_user( $this->test_user_id );
+ Block_Visibility::reset_cache_for_tests();
+ $this->assertSame( 'x
', Block_Visibility::filter_render_block( 'x
', $block ) );
+ }
+}
diff --git a/webpack.config.js b/webpack.config.js
index 7ec9d016da..4161fe7baa 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -67,6 +67,7 @@ const entry = {
'content-gate-editor-memberships': path.join( __dirname, 'src', 'content-gate', 'editor', 'memberships.js' ),
'content-gate-editor-metering': path.join( __dirname, 'src', 'content-gate', 'editor', 'metering-settings.js' ),
'content-gate-block-patterns': path.join( __dirname, 'src', 'content-gate', 'editor', 'block-patterns.js' ),
+ 'content-gate-block-visibility': path.join( __dirname, 'src', 'content-gate', 'editor', 'block-visibility.tsx' ),
'content-gate-post-settings': path.join( __dirname, 'src', 'content-gate', 'editor', 'post-settings.js' ),
'content-banner': path.join( __dirname, 'src', 'content-gate', 'content-banner.js' ),
wizards: path.join( __dirname, 'src', 'wizards', 'index.tsx' ),