Skip to content

Admin Pages and Settings

The BuddyNext admin surface and the contracts that shape it: the registered wp-admin pages (a single Hub menu with one sub-menu per populated section in free, 18 legacy pages in Pro), the AdminHub section + tab-placement system that arranges every screen into a capped information architecture, the bn_admin_hub_sections and bn_admin_hub_tab_placement filters for adding or relocating tabs from a mu-plugin, and the options-wiring model (per-tab settings groups derived from field descriptors). This page is for developers adding an admin screen, moving an existing tab, or wiring a new setting.

The Platform Features admin tab, a live BuddyNext screen arranged by the AdminHub section and tab-placement system

The admin dashboard - one of the registered wp-admin pages whose option wiring this page documents

All BuddyNext admin pages gate on the native manage_options capability. There is no BuddyNext-specific admin role; site administrators reach the screens, and every other user is denied. AdminHub::render_section() re-checks current_user_can( 'manage_options' ) on render and wp_die()s otherwise, and each tab can additionally declare its own cap (default manage_options).

The admin is built on three layers:

  1. Sections - the top-level wp-admin sub-menu entries (?page= slugs). Declared in AdminHub::default_sections(), filterable via bn_admin_hub_sections.
  2. Tabs - the individual screens, contributed by feature classes through AdminHub::register_tab(). Each tab declares an origin section:slug.
  3. Placement - a canonical map (AdminHub::TAB_PLACEMENT) that moves each tab to its final section and sidebar position, filterable via bn_admin_hub_tab_placement. This lets a feature keep registering against its own domain while the hub arranges the final layout in one place.

A section appears in the sidebar only when at least one visible tab is registered into it. Empty sections are hidden. No section holds more than five tabs by design, so no screen overwhelms the owner.

AdminHub::build_menu() (hooked on admin_menu priority 9) registers a single top-level menu and then one sub-menu per populated section - all on manage_options. Individual feature classes do not register their own pages; they contribute tabs (see the section / tab API below) and the Hub builds the menu. The section slugs come from AdminHub::default_sections():

Page slug Type Title Section key
buddynext menu + first submenu BuddyNext / Settings settings (top)
buddynext-platform submenu Platform platform
buddynext-members submenu Members members
buddynext-spaces submenu Spaces spaces
buddynext-engagement submenu Engagement engagement
buddynext-notifications submenu Notifications notifications
buddynext-moderation submenu Moderation moderation
buddynext-upgrade submenu Upgrade upgrade (free-only “Free vs Pro” tab)

default_sections() declares 12 sections (Settings, Platform, Members, Spaces, Engagement, Notifications, Realtime & Push, Campaigns, Moderation, Moderation Tools, Monetization, Upgrade), and only sections with at least one registered tab render. The four that register no tabs in free (Realtime & Push, Campaigns, Moderation Tools, Monetization) stay hidden until Pro is active. Integrations is not its own section - it is a tab (origin settings:integrations) placed into the Platform section by the placement map.

Pro registers 18 admin pages, each a submenu under the buddynext parent, all on manage_options. Their sidebar entries come from the AdminHub placement map; the registered page slugs are kept so legacy/bookmarked URLs still resolve, and they render inside the Hub chrome:

Page slug Title
buddynextpro-analytics Analytics
buddynextpro-broadcasts Broadcast Campaigns
buddynextpro-drip-sequences Drip Sequences
buddynextpro-member-labels Member Labels
bnpro-membership-tiers Membership Plans
bnpro-subscriptions Subscriptions
bnpro-paywall-settings Paywall Settings
buddynextpro-mod-rules Moderation Rules
buddynextpro-bulk-mod Bulk Moderation
buddynextpro-push Push
buddynextpro-push-prefs Push Preferences
buddynextpro-realtime Realtime
buddynextpro-scheduled-posts Scheduled Posts
buddynextpro-ai-feed AI Feed
buddynextpro-ai-moderation AI Moderation
buddynextpro-payments Payments
buddynextpro-whitelabel White-label
buddynextpro-custom-reactions Custom Reactions

Pro tabs register against their domain origin section (for example monetization:tiers, growth:broadcasts, moderation:rules) and are routed into the matching hidden-until-active sections by the placement map. The White-label tab is placed as a visible Settings tab (origin settings:white-label).

AdminHub::default_sections() is keyed by a short section key, each entry carrying its ?page= slug, label, and Lucide icon. One section is marked top (Settings) - its slug is shared with the top-level menu, so clicking “BuddyNext” lands on it.

// AdminHub::sections() = default_sections() merged with the bn_admin_hub_sections filter.
'settings' => array( 'slug' => 'buddynext', 'label' => 'Settings', 'top' => true ),
'members' => array( 'slug' => 'buddynext-members', 'label' => 'Members' ),
// ...

Feature classes contribute a tab from their register() method (or any code that runs before admin_menu priority 9):

AdminHub::register_tab(
string $section, // origin section key, e.g. 'settings'
string $slug, // tab slug -> ?tab= value, e.g. 'general'
string $label, // visible, already-translated label
callable $render, // body render callback
array $args = [] // cap, position, badge, icon, group, layout, subtitle, action
);

Recognised $args keys include cap (capability, default manage_options), position (lower sorts earlier), badge (a fn(): int that renders a counter pill when > 0), icon (a Lucide slug, auto-mapped from the tab slug when omitted), layout (sidebar default, or wide for list-detail editors), and subtitle / action for the standardized sub-header bar.

AdminHub::TAB_PLACEMENT is the single source of truth for where each tab lands. It is keyed by the tab’s origin section:slug and sets the final section, the sidebar position, and optional hidden. When register_tab() runs it applies the matching rule: a hidden rule drops the tab entirely, a section rule relocates it, a position rule reorders it.

This is why a feature can register growth:broadcasts while the tab actually renders under the Campaigns section - the placement map performs the move, and AdminHub::tab_url( 'growth', 'broadcasts' ) resolves to the Campaigns page slug.

BuddyNext wires a large set of option keys, the great majority prefixed buddynext_ (a few legacy bn_* avatar keys and the core admin_email also appear). Each is read at its consumption point and written from its admin tab. Examples: buddynext_site_name, buddynext_default_post_privacy, buddynext_space_creation_role, buddynext_banned_words, buddynext_enabled_reactions.

Settings are registered in Admin/Settings.php::register_settings(), which calls SettingsDriver::register_page( $this, 'buddynext' ). SettingsDriver walks the page’s field descriptors (Settings::settings_fields()) and issues one register_setting() per field under a per-tab group named buddynext_{tab}, so a save only touches the active tab’s options - and SettingsDriver::save_group_of( $key, 'buddynext' ) answers which group saves a given key. This descriptor-driven approach replaces the old hand-maintained SETTINGS_MAP + TAB_OPTIONS lists. The tab groups derived from the descriptors are:

buddynext_general buddynext_registration buddynext_social
buddynext_spaces buddynext_moderation buddynext_notifications
buddynext_email buddynext_privacy buddynext_webhooks
buddynext_features

Three options carry custom array sanitizers and are registered with explicit standalone register_setting() calls (in addition to flowing through their tab group):

Option key Type Group Sanitizer
buddynext_features array buddynext_features sanitize_features_option
buddynext_social_login array buddynext_registration sanitize_social_login_option
buddynext_enabled_reactions array buddynext_social sanitize_enabled_reactions

An option whose key matches no descriptor field falls back to the buddynext group. Add new settings as Field descriptors in the relevant Settings::fields_*() method so they are registered under the right group and sanitize on save.

Two Settings tabs do not use SettingsDriver at all

Section titled “Two Settings tabs do not use SettingsDriver at all”

includes/Admin/NavManager.php registers two tabs in the Settings section (both in the Advanced group, both layout => 'wide') that save through admin_post handlers rather than the Settings API. Their options therefore appear in no tab group above, and the per-tab save-scope rule does not apply to them.

Tab Save action What it writes
Navigation admin_post_bn_save_nav -> NavManager::handle_save_nav() The five nav-override options below.
Pages & URLs admin_post_bn_save_hub_pages -> NavManager::handle_save_hub_pages() The buddynext_page_* slug/page assignments.

The Navigation tab edits five scopes, one option each (NavManager::SCOPE_OPTION_MAP, mirrored by Nav\NavOverrides::SCOPE_OPTION):

Scope Option The surface it controls
main buddynext_nav_overrides The left rail.
profile buddynext_nav_overrides_profile Member-profile tabs.
space buddynext_nav_overrides_space Space tabs.
mobile buddynext_nav_overrides_mobile The mobile bottom bar.
account buddynext_nav_overrides_account The header avatar dropdown.

Each scope stores the owner’s hide / relabel / reorder / capability-gate choices plus any custom links, and Nav\NavOverrides applies them on that surface’s own filter at priority 20. See the Navigation API page for the full model and for how a developer-registered item interacts with these overrides.

The page assignments are kept out of the nav-override options on purpose: handle_save_nav() only writes display overrides, so PageRouter and other services can read a hub’s slug without knowing anything about the nav system.

Every owner setting declares ONE default, in the Field descriptor, and that single source drives three things: the register_setting() default, the value every get_option() reads back, and what a tab’s “Restore defaults” resets to. A gate (bin/check-option-defaults.php, run from bin/check.sh in both repos) fails the build if a driver-registered setting has no declared default and no resettable => false reason - so a new setting cannot drift in.

new Field( array(
'key' => 'myplugin_widget_limit',
'type' => 'number',
'label' => __( 'Widgets per page', 'my-plugin' ),
'default' => 20,
) );

For a default that is genuinely dynamic (it follows another value), declare a default_callback instead of default - it is resolved once when the option is registered, so get_option() still inherits one value:

'default_callback' => static fn() => get_option( 'posts_per_page', 10 ),

Read the option with NO inline fallback where the Settings API is active (admin), or pass the SAME declared default (a const or helper) everywhere else - never a re-typed literal. The gate flags a get_option() whose fallback drifts from the canonical one.

“Restore defaults” is for CONFIGURATION. Owner DATA - a site name, banned-word list, sender identity, API keys, page mappings, uploaded images - is never reset and needs no default. Mark it:

new Field( array(
'key' => 'myplugin_api_key',
'type' => 'secret',
'label' => __( 'API key', 'my-plugin' ),
'resettable' => false,
) );

A resettable => false field satisfies the gate without a default, never appears in the Restore dialog, and is left exactly as the owner set it.

Implement BuddyNext\Contracts\ProvidesSettings and call SettingsRegistry::register( $this ) in your page’s register(). Your fields then flow through SettingsDriver exactly like the first-party ones: register_setting() with your declared defaults, ⌘K search, and - for any tab that has resettable fields - the shared Restore defaults button, its confirm dialog listing exactly what would change, and the reset itself. React to a reset with:

add_action( 'buddynext_settings_tab_reset', function ( $tab, $keys, $user_id ) {
// Clear a derived cache after the owner restores your tab, etc.
}, 10, 3 );

Add your tab with AdminHub::register_tab( '<section>', '<slug>', … ); register a whole dedicated section for your add-on with the bn_admin_hub_sections filter (see “Add a new top-level section” below) if you do not want to sit inside an existing one.

bn_admin_hub_tab_placement overrides the canonical placement map. Keys are the tab’s origin section:slug.

add_filter( 'bn_admin_hub_tab_placement', function ( array $map ) {
// Hide a tab entirely.
$map['settings:webhooks']['hidden'] = true;
// Move the Social tab from Engagement to Notifications.
$map['settings:social']['section'] = 'notifications';
// Reorder a tab within its section (lower sorts earlier).
$map['settings:reactions']['position'] = 5;
return $map;
} );

bn_admin_hub_sections adds or renames a section. It appears in the sidebar only once a tab registers into it.

add_filter( 'bn_admin_hub_sections', function ( array $sections ) {
$sections['marketplace'] = array(
'slug' => 'buddynext-marketplace',
'label' => __( 'Marketplace', 'my-ext' ),
);
return $sections;
} );
add_action( 'init', function () {
\BuddyNext\Admin\AdminHub::register_tab(
'marketplace', // origin section key
'listings', // tab slug
__( 'Listings', 'my-ext' ),
'my_ext_render_listings_tab',
array(
'cap' => 'manage_options',
'position' => 10,
'icon' => 'grid',
'subtitle' => __( 'Manage marketplace listings.', 'my-ext' ),
)
);
} );

Build the link to it with AdminHub::tab_url( 'marketplace', 'listings' ) rather than hand-assembling ?page=...&tab=..., so a future placement move never breaks the URL.

Admin styles live in one stylesheet, assets/css/bn-admin.css, enqueued by AssetService::enqueue_admin_assets() on BuddyNext admin screens. Addon and Pro screens consume these primitives rather than shipping their own - a second copy is how two screens drift apart.

Primitive Class Purpose
Button .bn-btn The admin button, on <button> and <a> alike. Sets box-sizing: border-box itself: WP’s admin reset gives <button> border-box but leaves <a> at content-box, so without it an <a class="bn-btn"> came out 2px taller than the <button class="bn-btn"> beside it. Any Actions cell mixing a link and a form button showed it.
Badge .bn-badge Status pill.
Row actions .bn-row-actions The action cluster in a list table’s Actions column.
Row action form .bn-row-actions__form Wrap a nonce-carrying <form> inside .bn-row-actions.

.bn-row-actions exists because an Actions cell is a mix of <form>-wrapped buttons (they need the nonce plus hidden inputs) and bare <a>s. Dropped loose into the <td> they are separate inline boxes, so as soon as the column narrows they stack on their own lines flush against one another with no vertical gap. .bn-row-actions is a wrapping flex row that gives them one consistent gap in both axes; .bn-row-actions__form makes the carrier <form> an inline-flex with no margin so the gap applies to the button rather than to the wrapper.

<td>
<div class="bn-row-actions">
<a class="bn-btn" data-variant="secondary" data-size="sm" href="<?php echo esc_url( $edit_url ); ?>">
<?php esc_html_e( 'Edit', 'my-addon' ); ?>
</a>
<form class="bn-row-actions__form" method="post">
<?php wp_nonce_field( 'my_addon_delete' ); ?>
<input type="hidden" name="id" value="<?php echo (int) $row_id; ?>" />
<button class="bn-btn" data-variant="danger" data-size="sm" type="submit">
<?php esc_html_e( 'Delete', 'my-addon' ); ?>
</button>
</form>
</div>
</td>

Core consumers to copy from: includes/Admin/Spaces.php, includes/Admin/Members.php, includes/Admin/Members/MemberTypesManager.php.

  • manage_options everywhere. Every page registrar and the Hub renderer gate on manage_options. A tab’s own cap can tighten this further but the section page itself always requires manage_options.
  • Register tabs before admin_menu priority 9. AdminHub::build_menu() runs at that priority; tabs registered later will not appear in their section’s sub-menu.
  • Empty sections are hidden, not removed. A section with no registered tab is skipped during menu build. Pro-only sections (Campaigns, Realtime & Push, Auto-Moderation, Monetization) stay hidden in free for this reason.
  • Origin section vs final section. Always register against your tab’s domain origin and let the placement map decide the final location. Resolve URLs and active-state through AdminHub::tab_url() / is_tab_active(), which apply the same placement, so a relocated tab keeps its assets and links.
  • Per-tab save scope. Because options are grouped per tab, saving one tab never overwrites another tab’s options. Add new settings as Field descriptors in the relevant Settings::fields_*() method; SettingsDriver registers them under buddynext_{tab} and runs their sanitizer on save.

Provider secrets are encrypted at rest (Pro)

Section titled “Provider secrets are encrypted at rest (Pro)”

Provider credentials — the FCM service-account JSON, the Soketi secret, the Stripe and PayPal secret keys, the AI embedding key — are encrypted before they are written to the options table. A plain database export (or a plugin that dumps options) no longer reveals them.

How it works, and its honest limit:

  • Encryption is transparent at the option layer: an option_{key} filter decrypts on read and a pre_update_option_{key} filter encrypts on write, so every get_option() / update_option() caller keeps working unchanged. The stored value is prefixed bnpsec:v1: and uses AES-256-GCM.
  • The key is derived from your site’s wp-config salts — so it lives outside the database. That defeats the realistic threat, a leaked DB backup, but not a full server compromise where wp-config.php is readable too. This is defense-in-depth, not a vault.
  • Salt rotation. Because the default key comes from the salts, regenerating them makes the stored secrets undecryptable. The plugin fails closed — a decrypt miss returns empty, so the feature reads as “needs setup” rather than breaking with a corrupt credential — and you re-enter the key. To make secrets survive salt rotation, define a stable BUDDYNEXT_SECRETS_KEY in wp-config.php; it is used as the encryption key instead of the salts.
  • Admin fields for secrets render blank with a “Saved” badge, never re-emitting the stored value into the page HTML. Leaving a field blank keeps the saved secret; entering a new value replaces it. To clear a secret entirely, use the feature’s own remove/disconnect control.

Existing plaintext secrets are migrated to the encrypted form once, automatically, on upgrade.

See also Roles and Capabilities for the manage_options-vs-community-role distinction these screens rely on.