From 6d502604d7c7177a2170579f0ac0e0be4675c41b Mon Sep 17 00:00:00 2001 From: nixpanel Date: Fri, 3 Jul 2026 19:59:00 -0500 Subject: [PATCH] Add free/paid feature gating (Role Editor, Activity Log, DB Explorer, Analytics, White Label, remote site switcher are paid) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New License class is the single gate every paid feature checks — today it's a placeholder (any non-empty license_key counts as active) since there's no license server yet, but every gated feature already funnels through it so swapping in real remote validation later won't touch anything else. - Role Editor, Activity Log, and Database Explorer stay registered/ capability-checked as before, but render an UpgradeNotice screen instead of the real feature when unlicensed (and skip loading the real JS bundle/REST data). Menu Creator, Admin Notices, Comments, and Plugins stay free. - Analytics' tracking hooks and REST endpoint, and White Label's favicon/rename/text-replace hooks, simply don't register without a license. Settings fields for both stay visible/editable either way. - Remote site switcher gated alongside its existing capability check. - Sidebar shows a small lock badge on locked Aurora nav items; the Dashboard's Analytics tab shows the same upgrade prompt when locked. - Fixed an unrelated bug surfaced while testing Role Editor: RolesData's REST callback called get_editable_roles() without loading wp-admin/includes/user.php first, which REST requests don't get for free (only real wp-admin page loads do) — 500'd every time. Co-Authored-By: Claude Sonnet 5 --- admin/src/Analytics/Analytics.php | 10 ++- admin/src/Licensing/License.php | 56 +++++++++++++ admin/src/Pages/SubApps.php | 37 +++++++-- admin/src/Rest/AnalyticsData.php | 3 +- admin/src/Roles/RolesData.php | 7 ++ admin/src/Shell/Shell.php | 19 +++-- admin/src/WhiteLabel/WhiteLabel.php | 9 +++ app/src/entries/locked-feature.js | 10 +++ app/src/pages/_shared/UpgradeNotice.vue | 81 +++++++++++++++++++ app/src/pages/dashboard/Dashboard.vue | 9 ++- .../settings/components/SettingsField.vue | 2 +- app/src/pages/settings/settingsConfig.js | 6 +- app/src/shell/components/Sidebar.vue | 15 ++++ app/vite.config.js | 1 + aurora-admin.php | 1 + 15 files changed, 244 insertions(+), 22 deletions(-) create mode 100644 admin/src/Licensing/License.php create mode 100644 app/src/entries/locked-feature.js create mode 100644 app/src/pages/_shared/UpgradeNotice.vue diff --git a/admin/src/Analytics/Analytics.php b/admin/src/Analytics/Analytics.php index b0fe807..0ab9a9d 100644 --- a/admin/src/Analytics/Analytics.php +++ b/admin/src/Analytics/Analytics.php @@ -1,6 +1,8 @@ "Role Editor", + "activity-log" => "Activity Log", + "database-explorer" => "Database Explorer", + "analytics" => "Analytics", + "white-label" => "White Label", + "remote-site-switcher" => "Remote Site Switcher", + ]; + + public static function is_active() + { + $key = trim((string) Settings::get("license_key", "")); + return $key !== ""; + } + + /** + * True for any feature not in PAID_FEATURES (nothing to gate) or when a + * license is active; false only when the feature is paid and unlicensed. + */ + public static function is_feature_available($feature) + { + if (!isset(self::PAID_FEATURES[$feature])) { + return true; + } + return self::is_active(); + } + + public static function feature_label($feature) + { + return isset(self::PAID_FEATURES[$feature]) ? self::PAID_FEATURES[$feature] : ""; + } +} diff --git a/admin/src/Pages/SubApps.php b/admin/src/Pages/SubApps.php index 016652e..d950288 100644 --- a/admin/src/Pages/SubApps.php +++ b/admin/src/Pages/SubApps.php @@ -2,6 +2,7 @@ namespace AuroraAdmin\Pages; use AuroraAdmin\Utility\Assets; +use AuroraAdmin\Licensing\License; defined("ABSPATH") || exit(); @@ -31,14 +32,19 @@ defined("ABSPATH") || exit(); */ class SubApps { + // "feature" ties a page to a License::PAID_FEATURES key — null means the + // page is free. Locked pages still register normally (real capability, + // real URL) but render() swaps in an upgrade prompt instead of the real + // feature when unlicensed, and maybe_enqueue() loads that prompt's tiny + // JS bundle instead of the real (much heavier) feature bundle. const PAGES = [ - "aurora-admin-menu-creator" => ["title" => "Menu Creator", "entry" => "menu-creator"], - "aurora-admin-notices" => ["title" => "Admin Notices", "entry" => "admin-notices"], - "aurora-admin-roles" => ["title" => "Role Editor", "entry" => "role-editor"], - "aurora-admin-activity-log" => ["title" => "Activity Log", "entry" => "activity-log"], - "aurora-admin-database" => ["title" => "Database Explorer", "entry" => "database-explorer"], - "aurora-admin-plugins" => ["title" => "Installed Plugins", "entry" => "plugins"], - "aurora-admin-comments" => ["title" => "Comments", "entry" => "comments"], + "aurora-admin-menu-creator" => ["title" => "Menu Creator", "entry" => "menu-creator", "feature" => null], + "aurora-admin-notices" => ["title" => "Admin Notices", "entry" => "admin-notices", "feature" => null], + "aurora-admin-roles" => ["title" => "Role Editor", "entry" => "role-editor", "feature" => "role-editor"], + "aurora-admin-activity-log" => ["title" => "Activity Log", "entry" => "activity-log", "feature" => "activity-log"], + "aurora-admin-database" => ["title" => "Database Explorer", "entry" => "database-explorer", "feature" => "database-explorer"], + "aurora-admin-plugins" => ["title" => "Installed Plugins", "entry" => "plugins", "feature" => null], + "aurora-admin-comments" => ["title" => "Comments", "entry" => "comments", "feature" => null], ]; private static $hook_suffixes = []; @@ -92,7 +98,13 @@ class SubApps return; } - Assets::enqueue("aurora-admin-" . self::PAGES[$slug]["entry"], "entries/" . self::PAGES[$slug]["entry"] . ".js"); + $page = self::PAGES[$slug]; + if ($page["feature"] && !License::is_feature_available($page["feature"])) { + Assets::enqueue("aurora-admin-locked-feature", "entries/locked-feature.js"); + return; + } + + Assets::enqueue("aurora-admin-" . $page["entry"], "entries/" . $page["entry"] . ".js"); } public static function redirect_native_pages() @@ -118,6 +130,15 @@ class SubApps return; } + $feature = self::PAGES[$page]["feature"]; + if ($feature && !License::is_feature_available($feature)) { + $data = ["feature" => License::feature_label($feature)]; + ?> +
+ roles as $slug => $info) { $roles[] = ["value" => $slug, "label" => translate_user_role($info["name"])]; diff --git a/admin/src/Rest/AnalyticsData.php b/admin/src/Rest/AnalyticsData.php index 93dbe75..6619633 100644 --- a/admin/src/Rest/AnalyticsData.php +++ b/admin/src/Rest/AnalyticsData.php @@ -2,6 +2,7 @@ namespace AuroraAdmin\Rest; use AuroraAdmin\Analytics\Analytics; +use AuroraAdmin\Licensing\License; defined("ABSPATH") || exit(); @@ -22,7 +23,7 @@ class AnalyticsData "methods" => "GET", "callback" => [self::class, "handle"], "permission_callback" => function () { - return current_user_can("read"); + return current_user_can("read") && License::is_feature_available("analytics"); }, ]); } diff --git a/admin/src/Roles/RolesData.php b/admin/src/Roles/RolesData.php index e10c426..b359ce0 100644 --- a/admin/src/Roles/RolesData.php +++ b/admin/src/Roles/RolesData.php @@ -57,6 +57,13 @@ class RolesData public static function list_roles() { + // get_editable_roles() lives in wp-admin/includes/user.php, which WP + // only auto-loads for actual wp-admin page requests — REST API requests + // (this endpoint) don't get it for free, so it's undefined there + // without this. + if (!function_exists("get_editable_roles")) { + require_once ABSPATH . "wp-admin/includes/user.php"; + } $editable = get_editable_roles(); $all_caps = self::all_known_capabilities(); diff --git a/admin/src/Shell/Shell.php b/admin/src/Shell/Shell.php index 65cc9e8..88a58ea 100644 --- a/admin/src/Shell/Shell.php +++ b/admin/src/Shell/Shell.php @@ -3,6 +3,7 @@ namespace AuroraAdmin\Shell; use AuroraAdmin\Utility\Assets; use AuroraAdmin\Options\Settings; +use AuroraAdmin\Licensing\License; defined("ABSPATH") || exit(); @@ -207,7 +208,7 @@ class Shell "currentUrl" => esc_url_raw(self::current_admin_url()), // Secrets stripped — this is serialized for every admin user. "settings" => Settings::get_safe(), - "canSwitchSites" => current_user_can($switcher_cap), + "canSwitchSites" => current_user_can($switcher_cap) && License::is_feature_available("remote-site-switcher"), "menu" => MenuSerializer::serialize(), "auroraNav" => self::aurora_nav(), ]; @@ -234,12 +235,12 @@ class Shell private static function aurora_nav() { $items = [ - ["id" => "aurora-admin", "title" => __("Settings", "aurora-admin"), "icon" => "dashicons-admin-generic"], - ["id" => "aurora-admin-menu-creator", "title" => __("Menu Creator", "aurora-admin"), "icon" => "dashicons-menu-alt"], - ["id" => "aurora-admin-notices", "title" => __("Admin Notices", "aurora-admin"), "icon" => "dashicons-megaphone"], - ["id" => "aurora-admin-roles", "title" => __("Role Editor", "aurora-admin"), "icon" => "dashicons-groups"], - ["id" => "aurora-admin-activity-log", "title" => __("Activity Log", "aurora-admin"), "icon" => "dashicons-clock"], - ["id" => "aurora-admin-database", "title" => __("Database Explorer", "aurora-admin"), "icon" => "dashicons-database"], + ["id" => "aurora-admin", "title" => __("Settings", "aurora-admin"), "icon" => "dashicons-admin-generic", "feature" => null], + ["id" => "aurora-admin-menu-creator", "title" => __("Menu Creator", "aurora-admin"), "icon" => "dashicons-menu-alt", "feature" => null], + ["id" => "aurora-admin-notices", "title" => __("Admin Notices", "aurora-admin"), "icon" => "dashicons-megaphone", "feature" => null], + ["id" => "aurora-admin-roles", "title" => __("Role Editor", "aurora-admin"), "icon" => "dashicons-groups", "feature" => "role-editor"], + ["id" => "aurora-admin-activity-log", "title" => __("Activity Log", "aurora-admin"), "icon" => "dashicons-clock", "feature" => "activity-log"], + ["id" => "aurora-admin-database", "title" => __("Database Explorer", "aurora-admin"), "icon" => "dashicons-database", "feature" => "database-explorer"], ]; $nav = []; @@ -252,6 +253,10 @@ class Shell "title" => $item["title"], "url" => esc_url_raw(admin_url("admin.php?page=" . $item["id"])), "icon" => ["type" => "dashicon", "value" => $item["icon"]], + // Still a real, navigable link when locked — SubApps::render() shows + // an upgrade prompt there instead of the real feature. This flag + // just drives the small lock badge in the sidebar (Sidebar.vue). + "locked" => $item["feature"] && !License::is_feature_available($item["feature"]), ]; } diff --git a/admin/src/WhiteLabel/WhiteLabel.php b/admin/src/WhiteLabel/WhiteLabel.php index e9464e9..2286a5c 100644 --- a/admin/src/WhiteLabel/WhiteLabel.php +++ b/admin/src/WhiteLabel/WhiteLabel.php @@ -2,6 +2,7 @@ namespace AuroraAdmin\WhiteLabel; use AuroraAdmin\Options\Settings; +use AuroraAdmin\Licensing\License; defined("ABSPATH") || exit(); @@ -10,6 +11,10 @@ defined("ABSPATH") || exit(); * - admin_favicon: replaces the favicon across wp-admin * - plugin_name: renames the plugin in the plugins list and the admin menu * - text_replacements: find/replace pairs applied to translated admin text + * + * Paid feature — the settings fields stay visible either way (so an admin + * can pre-configure before upgrading), but none of the hooks below register + * without a license, so nothing actually applies yet. */ class WhiteLabel { @@ -18,6 +23,10 @@ class WhiteLabel public function __construct() { + if (!License::is_feature_available("white-label")) { + return; + } + add_action("admin_head", [self::class, "favicon"]); add_filter("all_plugins", [self::class, "rename_plugin"]); add_action("admin_menu", [self::class, "rename_menu"], 999); diff --git a/app/src/entries/locked-feature.js b/app/src/entries/locked-feature.js new file mode 100644 index 0000000..7b0977a --- /dev/null +++ b/app/src/entries/locked-feature.js @@ -0,0 +1,10 @@ +import { createApp } from 'vue'; +import '@/shell/shell-frame.css'; +import UpgradeNotice from '@/pages/_shared/UpgradeNotice.vue'; + +const mountEl = document.querySelector('[data-aurora-locked-feature]'); + +if (mountEl) { + const data = JSON.parse(mountEl.dataset.auroraLockedFeature || '{}'); + createApp(UpgradeNotice, { feature: data.feature, description: data.description }).mount(mountEl); +} diff --git a/app/src/pages/_shared/UpgradeNotice.vue b/app/src/pages/_shared/UpgradeNotice.vue new file mode 100644 index 0000000..ad812a5 --- /dev/null +++ b/app/src/pages/_shared/UpgradeNotice.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/app/src/pages/dashboard/Dashboard.vue b/app/src/pages/dashboard/Dashboard.vue index 58b534f..f687620 100644 --- a/app/src/pages/dashboard/Dashboard.vue +++ b/app/src/pages/dashboard/Dashboard.vue @@ -13,13 +13,14 @@ import ActiveUsers from './cards/ActiveUsers.vue'; import DeviceUsage from './cards/DeviceUsage.vue'; import Engagement from './cards/Engagement.vue'; import TopList from './cards/TopList.vue'; +import UpgradeNotice from '../_shared/UpgradeNotice.vue'; const props = defineProps({ serverData: { type: Object, required: true }, }); const { data, loading, load } = useDashboardData(props.serverData); -const { data: analytics, load: loadAnalytics } = useAnalyticsData(props.serverData); +const { data: analytics, error: analyticsError, load: loadAnalytics } = useAnalyticsData(props.serverData); // Date range — default last 7 days, with simple presets. const today = new Date(); @@ -107,6 +108,12 @@ onMounted(() => reload(range.value[0], range.value[1])); + +
diff --git a/app/src/pages/settings/components/SettingsField.vue b/app/src/pages/settings/components/SettingsField.vue index 089be4d..7504aee 100644 --- a/app/src/pages/settings/components/SettingsField.vue +++ b/app/src/pages/settings/components/SettingsField.vue @@ -91,7 +91,7 @@ const openFontList = () => {