Add free/paid feature gating (Role Editor, Activity Log, DB Explorer,
Analytics, White Label, remote site switcher are paid) - 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 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
73591e0ca4
commit
6d502604d7
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
namespace AuroraAdmin\Analytics;
|
||||
|
||||
use AuroraAdmin\Licensing\License;
|
||||
|
||||
defined("ABSPATH") || exit();
|
||||
|
||||
/**
|
||||
@@ -9,7 +11,9 @@ defined("ABSPATH") || exit();
|
||||
* Records one row per front-end pageview into a custom table, server-side
|
||||
* (no client script, so it isn't blocked by ad blockers). All aggregation
|
||||
* happens later in the REST endpoint. Written fresh — does not reuse the
|
||||
* original plugin's analytics code.
|
||||
* original plugin's analytics code. Paid feature — the hooks below simply
|
||||
* don't register without a license, so no tracking happens and the table
|
||||
* never gets written to.
|
||||
*/
|
||||
class Analytics
|
||||
{
|
||||
@@ -20,6 +24,10 @@ class Analytics
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
if (!License::is_feature_available("analytics")) {
|
||||
return;
|
||||
}
|
||||
|
||||
add_action("init", [self::class, "maybe_install"]);
|
||||
// Record on the front end only, after the main query is resolved.
|
||||
add_action("template_redirect", [self::class, "record"], 999);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
namespace AuroraAdmin\Licensing;
|
||||
|
||||
use AuroraAdmin\Options\Settings;
|
||||
|
||||
defined("ABSPATH") || exit();
|
||||
|
||||
/**
|
||||
* Single gate every paid feature funnels through, so the free/paid split
|
||||
* lives in one place instead of being re-decided in each feature's code.
|
||||
*
|
||||
* Today's is_active() is a placeholder: any non-empty license_key counts as
|
||||
* active, with no remote validation. There's no license server yet — when
|
||||
* one exists, swap is_active()'s internals for a real check (cached in a
|
||||
* transient so it isn't a remote call on every admin page load); nothing
|
||||
* else in the plugin needs to change, since every gated feature already
|
||||
* calls into this class rather than reading the setting directly.
|
||||
*/
|
||||
class License
|
||||
{
|
||||
/**
|
||||
* Paid sub-apps/features, keyed by the id used to gate them, mapped to a
|
||||
* human label used in upgrade prompts.
|
||||
*/
|
||||
const PAID_FEATURES = [
|
||||
"role-editor" => "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] : "";
|
||||
}
|
||||
}
|
||||
@@ -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)];
|
||||
?>
|
||||
<div data-aurora-locked-feature="<?php echo esc_attr(wp_json_encode($data)); ?>"></div>
|
||||
<?php
|
||||
return;
|
||||
}
|
||||
|
||||
$roles = [];
|
||||
foreach (wp_roles()->roles as $slug => $info) {
|
||||
$roles[] = ["value" => $slug, "label" => translate_user_role($info["name"])];
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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"]),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
feature: { type: String, required: true },
|
||||
description: { type: String, default: '' },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="upgrade-notice">
|
||||
<div class="upgrade-notice__card">
|
||||
<span class="upgrade-notice__icon">
|
||||
<span class="dashicons dashicons-lock" />
|
||||
</span>
|
||||
<h1 class="upgrade-notice__title">{{ feature }} is a Pro feature</h1>
|
||||
<p class="upgrade-notice__text">
|
||||
{{ description || `Upgrade to Aurora Admin Pro to unlock ${feature}.` }}
|
||||
</p>
|
||||
<a class="upgrade-notice__cta" href="admin.php?page=aurora-admin#license">
|
||||
Enter a license key
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.upgrade-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.upgrade-notice__card {
|
||||
max-width: 380px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.upgrade-notice__icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--aurora-accent-soft);
|
||||
color: var(--aurora-accent);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.upgrade-notice__icon .dashicons {
|
||||
font-size: 26px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
.upgrade-notice__title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--aurora-text);
|
||||
}
|
||||
.upgrade-notice__text {
|
||||
font-size: 0.875rem;
|
||||
color: var(--aurora-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
.upgrade-notice__cta {
|
||||
margin-top: 8px;
|
||||
padding: 8px 18px;
|
||||
border-radius: 8px;
|
||||
background: var(--aurora-accent);
|
||||
color: var(--aurora-accent-text);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.upgrade-notice__cta:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
@@ -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]));
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UpgradeNotice
|
||||
v-else-if="analyticsError"
|
||||
feature="Analytics"
|
||||
description="Upgrade to Aurora Admin Pro to unlock first-party visitor analytics for this dashboard."
|
||||
/>
|
||||
|
||||
<div v-else class="dash__grid">
|
||||
<div class="dash__cell" style="grid-column: span 8">
|
||||
<PageViews :data="analytics ? analytics.pageViews : {}" />
|
||||
|
||||
@@ -91,7 +91,7 @@ const openFontList = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="field">
|
||||
<div class="field" :id="field.key === 'license_key' ? 'license' : null">
|
||||
<div class="field__info">
|
||||
<div class="field__label">{{ field.label }}</div>
|
||||
<p v-if="field.description" class="field__desc">{{ field.description }}</p>
|
||||
|
||||
@@ -42,7 +42,7 @@ export const settingsCategories = [
|
||||
type: 'text',
|
||||
label: 'Remote Site Switcher Capability',
|
||||
description:
|
||||
'WordPress capability required to see the remote site switcher in the toolbar. Defaults to "manage_options" (admin users). Users with this capability or below will see the switcher.',
|
||||
'Pro feature — requires a license. WordPress capability required to see the remote site switcher in the toolbar. Defaults to "manage_options" (admin users). Users with this capability or below will see the switcher.',
|
||||
placeholder: 'manage_options',
|
||||
},
|
||||
{
|
||||
@@ -111,7 +111,7 @@ export const settingsCategories = [
|
||||
{
|
||||
id: 'whitelabel',
|
||||
title: 'White Label',
|
||||
subtitle: 'Rebrand and customize interface text and icons',
|
||||
subtitle: 'Rebrand and customize interface text and icons (Pro feature — requires a license)',
|
||||
icon: 'dashicons-tag',
|
||||
fields: [
|
||||
{
|
||||
@@ -198,7 +198,7 @@ export const settingsCategories = [
|
||||
{
|
||||
id: 'analytics',
|
||||
title: 'Analytics',
|
||||
subtitle: 'Built-in analytics and tracking settings',
|
||||
subtitle: 'Built-in analytics and tracking settings (Pro feature — requires a license)',
|
||||
icon: 'dashicons-chart-bar',
|
||||
fields: [
|
||||
{
|
||||
|
||||
@@ -185,6 +185,9 @@ const isAuroraActive = (item) => normalize(item.url) === current.value;
|
||||
:class="{ 'sidebar__sublink--active': isAuroraActive(item) }"
|
||||
>
|
||||
{{ item.title }}
|
||||
<span v-if="item.locked" class="sidebar__pro-badge" title="Pro feature">
|
||||
<span class="dashicons dashicons-lock" />
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -382,6 +385,18 @@ const isAuroraActive = (item) => normalize(item.url) === current.value;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar__pro-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--aurora-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar__pro-badge .dashicons {
|
||||
font-size: 13px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
/* --- Aurora branded nav (right under the native menu's own divider) ---- */
|
||||
.sidebar__aurora {
|
||||
padding-bottom: 4px;
|
||||
|
||||
@@ -30,6 +30,7 @@ export default defineConfig({
|
||||
'database-explorer': fileURLToPath(new URL('./src/entries/database-explorer.js', import.meta.url)),
|
||||
plugins: fileURLToPath(new URL('./src/entries/plugins.js', import.meta.url)),
|
||||
comments: fileURLToPath(new URL('./src/entries/comments.js', import.meta.url)),
|
||||
'locked-feature': fileURLToPath(new URL('./src/entries/locked-feature.js', import.meta.url)),
|
||||
},
|
||||
output: {
|
||||
entryFileNames: '[name].[hash].js',
|
||||
|
||||
@@ -20,6 +20,7 @@ define("AURORA_ADMIN_URL", plugins_url("aurora-admin/"));
|
||||
|
||||
require AURORA_ADMIN_PATH . "admin/src/App/AuroraAdmin.php";
|
||||
require AURORA_ADMIN_PATH . "admin/src/Options/Settings.php";
|
||||
require AURORA_ADMIN_PATH . "admin/src/Licensing/License.php";
|
||||
require AURORA_ADMIN_PATH . "admin/src/Utility/Assets.php";
|
||||
require AURORA_ADMIN_PATH . "admin/src/Shell/MenuSerializer.php";
|
||||
require AURORA_ADMIN_PATH . "admin/src/Shell/Shell.php";
|
||||
|
||||
Reference in New Issue
Block a user