Initial commit: Aurora Admin plugin

This commit is contained in:
Kris Parish
2026-07-03 02:53:29 -05:00
commit 03cabe2fe4
73 changed files with 9591 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
<?php
namespace AuroraAdmin\ActivityLog;
defined("ABSPATH") || exit();
/**
* Records a real event log across core WP actions. Storage + the hook
* listeners live here; REST list/filter lives in ActivityLogData.php.
*/
class ActivityLog
{
const TABLE = "aurora_admin_activity_log";
const DB_VERSION = "1";
const DB_OPTION = "aurora_admin_activity_log_db_version";
public function __construct()
{
add_action("init", [self::class, "maybe_install"]);
add_action("save_post", [self::class, "on_save_post"], 10, 3);
add_action("before_delete_post", [self::class, "on_delete_post"]);
add_action("user_register", [self::class, "on_user_register"]);
add_action("profile_update", [self::class, "on_profile_update"]);
add_action("wp_login", [self::class, "on_login"], 10, 2);
add_action("activated_plugin", [self::class, "on_plugin_activated"]);
add_action("deactivated_plugin", [self::class, "on_plugin_deactivated"]);
add_action("switch_theme", [self::class, "on_theme_switch"]);
add_action("update_option_aurora_admin_settings", [self::class, "on_settings_updated"]);
}
public static function table_name()
{
global $wpdb;
return $wpdb->prefix . self::TABLE;
}
public static function maybe_install()
{
if (get_option(self::DB_OPTION) === self::DB_VERSION) {
return;
}
global $wpdb;
$table = self::table_name();
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
action_type VARCHAR(60) NOT NULL DEFAULT '',
object_type VARCHAR(60) NOT NULL DEFAULT '',
object_id BIGINT UNSIGNED NULL,
object_label VARCHAR(191) NOT NULL DEFAULT '',
user_id BIGINT UNSIGNED NULL,
user_name VARCHAR(191) NOT NULL DEFAULT '',
details LONGTEXT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_action (action_type),
KEY idx_object (object_type),
KEY idx_created (created_at)
) {$charset};";
require_once ABSPATH . "wp-admin/includes/upgrade.php";
dbDelta($sql);
update_option(self::DB_OPTION, self::DB_VERSION);
}
private static function record($action_type, $object_type, $object_id, $object_label, $details = [])
{
global $wpdb;
$user = wp_get_current_user();
$wpdb->insert(self::table_name(), [
"action_type" => $action_type,
"object_type" => $object_type,
"object_id" => $object_id,
"object_label" => substr((string) $object_label, 0, 191),
"user_id" => $user && $user->exists() ? $user->ID : null,
"user_name" => $user && $user->exists() ? $user->display_name : "",
"details" => $details ? wp_json_encode($details) : null,
"created_at" => current_time("mysql"),
]);
}
public static function on_save_post($post_id, $post, $update)
{
// save_post fires on autosaves and revisions too — neither represents a
// real user-visible change, so both are skipped here.
if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
return;
}
if (!in_array($post->post_status, ["publish", "draft", "pending", "future", "private"], true)) {
return;
}
self::record($update ? "post_updated" : "post_created", "post", $post_id, $post->post_title ?: "(no title)");
}
public static function on_delete_post($post_id)
{
$post = get_post($post_id);
if (!$post || $post->post_type === "revision") {
return;
}
self::record("post_deleted", "post", $post_id, $post->post_title ?: "(no title)");
}
public static function on_user_register($user_id)
{
$user = get_userdata($user_id);
self::record("user_registered", "user", $user_id, $user ? $user->user_login : "");
}
public static function on_profile_update($user_id)
{
$user = get_userdata($user_id);
self::record("user_updated", "user", $user_id, $user ? $user->user_login : "");
}
public static function on_login($user_login, $user)
{
self::record("login", "user", $user->ID, $user_login);
}
public static function on_plugin_activated($plugin)
{
self::record("plugin_activated", "plugin", null, $plugin);
}
public static function on_plugin_deactivated($plugin)
{
self::record("plugin_deactivated", "plugin", null, $plugin);
}
public static function on_theme_switch($new_name)
{
self::record("theme_switched", "theme", null, $new_name);
}
public static function on_settings_updated()
{
self::record("settings_updated", "settings", null, "Aurora Admin settings");
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
namespace AuroraAdmin\ActivityLog;
defined("ABSPATH") || exit();
/**
* REST list for the activity log. GET /activity-log?action_type=&object_type=&page=&per_page=
*/
class ActivityLogData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/activity-log", [
"methods" => "GET",
"callback" => [self::class, "handle"],
"permission_callback" => function () {
return current_user_can("manage_options");
},
"args" => [
"action_type" => ["type" => "string", "required" => false],
"object_type" => ["type" => "string", "required" => false],
"search" => ["type" => "string", "required" => false],
"page" => ["type" => "integer", "required" => false, "default" => 1],
"per_page" => ["type" => "integer", "required" => false, "default" => 20],
],
]);
}
public static function handle($request)
{
global $wpdb;
$table = ActivityLog::table_name();
$where = ["1=1"];
$params = [];
if ($request->get_param("action_type")) {
$where[] = "action_type = %s";
$params[] = sanitize_text_field($request->get_param("action_type"));
}
if ($request->get_param("object_type")) {
$where[] = "object_type = %s";
$params[] = sanitize_text_field($request->get_param("object_type"));
}
if ($request->get_param("search")) {
$where[] = "(object_label LIKE %s OR user_name LIKE %s)";
$like = "%" . $wpdb->esc_like(sanitize_text_field($request->get_param("search"))) . "%";
$params[] = $like;
$params[] = $like;
}
$where_sql = implode(" AND ", $where);
$per_page = max(1, min(100, (int) $request->get_param("per_page") ?: 20));
$page = max(1, (int) $request->get_param("page") ?: 1);
$offset = ($page - 1) * $per_page;
$count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}";
$total = (int) ($params ? $wpdb->get_var($wpdb->prepare($count_sql, $params)) : $wpdb->get_var($count_sql));
$sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
$rows = $wpdb->get_results($wpdb->prepare($sql, array_merge($params, [$per_page, $offset])));
$items = array_map(function ($row) {
return [
"id" => (int) $row->id,
"actionType" => $row->action_type,
"objectType" => $row->object_type,
"objectId" => $row->object_id ? (int) $row->object_id : null,
"objectLabel" => $row->object_label,
"userId" => $row->user_id ? (int) $row->user_id : null,
"userName" => $row->user_name,
"details" => $row->details ? json_decode($row->details, true) : null,
"createdAt" => $row->created_at,
];
}, $rows);
return new \WP_REST_Response([
"items" => $items,
"total" => $total,
"page" => $page,
"perPage" => $per_page,
"totalPages" => (int) ceil($total / $per_page),
], 200);
}
}
+191
View File
@@ -0,0 +1,191 @@
<?php
namespace AuroraAdmin\Analytics;
defined("ABSPATH") || exit();
/**
* Lightweight first-party visitor analytics.
*
* 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.
*/
class Analytics
{
const TABLE = "aurora_admin_pageviews";
const DB_VERSION = "1";
const DB_OPTION = "aurora_admin_analytics_db_version";
const COOKIE = "aurora_admin_sid";
public function __construct()
{
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);
}
public static function table_name()
{
global $wpdb;
return $wpdb->prefix . self::TABLE;
}
public static function maybe_install()
{
if (get_option(self::DB_OPTION) === self::DB_VERSION) {
return;
}
global $wpdb;
$table = self::table_name();
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
session_id VARCHAR(32) NOT NULL DEFAULT '',
url VARCHAR(255) NOT NULL DEFAULT '',
referrer VARCHAR(255) NOT NULL DEFAULT '',
device VARCHAR(20) NOT NULL DEFAULT 'desktop',
browser VARCHAR(40) NOT NULL DEFAULT '',
country VARCHAR(2) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_created (created_at),
KEY idx_session (session_id)
) {$charset};";
require_once ABSPATH . "wp-admin/includes/upgrade.php";
dbDelta($sql);
update_option(self::DB_OPTION, self::DB_VERSION);
}
/**
* Records the current front-end request as a pageview, skipping admin,
* AJAX/REST/cron, bots, and logged-in administrators.
*/
public static function record()
{
// Tracking is on unless explicitly disabled in Settings > General.
$enabled = \AuroraAdmin\Options\Settings::get("enable_analytics", true);
if ($enabled === false || $enabled === 0 || $enabled === "0") {
return;
}
if (is_admin() || wp_doing_ajax() || wp_doing_cron() || is_robots() || is_feed()) {
return;
}
if (defined("REST_REQUEST") && REST_REQUEST) {
return;
}
// Admins browsing their own site are skipped unless Settings >
// Analytics > "Track logged-in admins" is enabled.
$track_admins = \AuroraAdmin\Options\Settings::get("track_admins", false);
if (!$track_admins && current_user_can("manage_options")) {
return;
}
$ua = isset($_SERVER["HTTP_USER_AGENT"]) ? $_SERVER["HTTP_USER_AGENT"] : "";
if (self::is_bot($ua)) {
return;
}
global $wpdb;
$wpdb->insert(self::table_name(), [
"session_id" => self::session_id(),
"url" => substr(self::current_path(), 0, 255),
"referrer" => substr(self::referrer(), 0, 255),
"device" => self::device($ua),
"browser" => self::browser($ua),
"country" => self::country(),
"created_at" => current_time("mysql"),
]);
}
private static function session_id()
{
if (!empty($_COOKIE[self::COOKIE])) {
return preg_replace("/[^a-f0-9]/", "", substr($_COOKIE[self::COOKIE], 0, 32));
}
$sid = md5(uniqid("", true));
// 30-minute rolling session window
if (!headers_sent()) {
setcookie(self::COOKIE, $sid, time() + 1800, COOKIEPATH ?: "/", COOKIE_DOMAIN);
}
$_COOKIE[self::COOKIE] = $sid;
return $sid;
}
private static function current_path()
{
$uri = isset($_SERVER["REQUEST_URI"]) ? $_SERVER["REQUEST_URI"] : "/";
return wp_parse_url($uri, PHP_URL_PATH) ?: "/";
}
private static function referrer()
{
$ref = isset($_SERVER["HTTP_REFERER"]) ? $_SERVER["HTTP_REFERER"] : "";
if (!$ref) {
return "";
}
$host = wp_parse_url($ref, PHP_URL_HOST);
// Ignore self-referrals
if ($host && $host === wp_parse_url(home_url(), PHP_URL_HOST)) {
return "";
}
return $host ?: "";
}
private static function device($ua)
{
$ua = strtolower($ua);
if (preg_match("/(ipad|tablet|playbook|silk)|(android(?!.*mobile))/", $ua)) {
return "tablet";
}
if (preg_match("/(mobile|iphone|ipod|android|blackberry|opera mini|iemobile)/", $ua)) {
return "mobile";
}
return "desktop";
}
private static function browser($ua)
{
$map = [
"Edg" => "Edge",
"OPR" => "Opera",
"Chrome" => "Chrome",
"Firefox" => "Firefox",
"Safari" => "Safari",
];
foreach ($map as $needle => $name) {
if (stripos($ua, $needle) !== false) {
return $name;
}
}
return "Other";
}
private static function country()
{
// Use a CDN-provided country header if available; otherwise leave blank.
foreach (["HTTP_CF_IPCOUNTRY", "GEOIP_COUNTRY_CODE", "HTTP_X_COUNTRY_CODE"] as $h) {
if (!empty($_SERVER[$h]) && strlen($_SERVER[$h]) === 2) {
return strtoupper(sanitize_text_field($_SERVER[$h]));
}
}
return "";
}
private static function is_bot($ua)
{
if (!$ua) {
return true;
}
return (bool) preg_match(
"/bot|crawl|slurp|spider|mediapartners|facebookexternalhit|embedly|quora|pingdom|monitor|headless|lighthouse|gtmetrix/i",
$ua
);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace AuroraAdmin\App;
use AuroraAdmin\Options\Settings;
use AuroraAdmin\Shell\Shell;
use AuroraAdmin\Pages\DashboardTakeover;
use AuroraAdmin\Pages\SettingsPage;
use AuroraAdmin\Rest\DashboardData;
use AuroraAdmin\Analytics\Analytics;
use AuroraAdmin\Rest\AnalyticsData;
use AuroraAdmin\Posts\ScheduledDayFilter;
use AuroraAdmin\WhiteLabel\WhiteLabel;
use AuroraAdmin\Pages\SubApps;
use AuroraAdmin\MenuCreator\MenuCreator;
use AuroraAdmin\MenuCreator\MenuCreatorData;
use AuroraAdmin\Notices\Notices;
use AuroraAdmin\Notices\NoticesData;
use AuroraAdmin\Roles\RolesData;
use AuroraAdmin\ActivityLog\ActivityLog;
use AuroraAdmin\ActivityLog\ActivityLogData;
use AuroraAdmin\DatabaseExplorer\DatabaseExplorerData;
defined("ABSPATH") || exit();
/**
* Plugin bootstrap. Deliberately minimal: each piece of functionality
* registers its own hooks in its own constructor, instantiated here.
* No global state, no manifest-parsing gymnastics beyond Assets::enqueue,
* no body-hider/temporary-style tricks.
*/
class AuroraAdmin
{
public function __construct()
{
new Settings();
new Shell();
new DashboardTakeover();
new SettingsPage();
new DashboardData();
new Analytics();
new AnalyticsData();
new ScheduledDayFilter();
new WhiteLabel();
new SubApps();
new MenuCreator();
new MenuCreatorData();
new Notices();
new NoticesData();
new RolesData();
new ActivityLog();
new ActivityLogData();
new DatabaseExplorerData();
}
}
@@ -0,0 +1,149 @@
<?php
namespace AuroraAdmin\DatabaseExplorer;
defined("ABSPATH") || exit();
/**
* Read-heavy admin dev tool for browsing this install's real database —
* table list with row counts/sizes, paginated row browsing, and a raw
* SELECT-only query runner. manage_options-gated throughout.
*
* Table/column identifiers can't go through $wpdb->prepare() (it only
* binds values, not identifiers), so the actual safety boundary here is
* validating every identifier against $wpdb's own known table list before
* use, then backtick-escaping it — never interpolating a client-supplied
* identifier unchecked.
*/
class DatabaseExplorerData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
$permission = function () {
return current_user_can("manage_options");
};
register_rest_route("aurora-admin/v1", "/db-tables", [
"methods" => "GET",
"callback" => [self::class, "list_tables"],
"permission_callback" => $permission,
]);
register_rest_route("aurora-admin/v1", "/db-tables/(?P<table>[a-zA-Z0-9_]+)", [
"methods" => "GET",
"callback" => [self::class, "browse_table"],
"permission_callback" => $permission,
"args" => [
"page" => ["type" => "integer", "required" => false, "default" => 1],
"per_page" => ["type" => "integer", "required" => false, "default" => 25],
],
]);
register_rest_route("aurora-admin/v1", "/db-query", [
"methods" => "POST",
"callback" => [self::class, "run_query"],
"permission_callback" => $permission,
"args" => [
"query" => ["type" => "string", "required" => true],
],
]);
}
private static function known_tables()
{
global $wpdb;
return $wpdb->get_col("SHOW TABLES");
}
public static function list_tables()
{
global $wpdb;
$rows = $wpdb->get_results("SHOW TABLE STATUS", ARRAY_A);
$tables = array_map(function ($row) {
return [
"name" => $row["Name"],
"rows" => (int) $row["Rows"],
"sizeBytes" => (int) $row["Data_length"] + (int) $row["Index_length"],
];
}, $rows ?: []);
return new \WP_REST_Response($tables, 200);
}
public static function browse_table($request)
{
global $wpdb;
$table = self::validate_table($request->get_param("table"));
if (!$table) {
return new \WP_Error("invalid_table", "Unknown table", ["status" => 404]);
}
$escaped = self::escape_identifier($table);
$per_page = max(1, min(100, (int) $request->get_param("per_page") ?: 25));
$page = max(1, (int) $request->get_param("page") ?: 1);
$offset = ($page - 1) * $per_page;
$columns = $wpdb->get_results("SHOW COLUMNS FROM {$escaped}", ARRAY_A);
$total = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$escaped}");
$rows = $wpdb->get_results(
$wpdb->prepare("SELECT * FROM {$escaped} LIMIT %d OFFSET %d", $per_page, $offset),
ARRAY_A
);
return new \WP_REST_Response([
"columns" => array_map(function ($c) {
return ["name" => $c["Field"], "type" => $c["Type"]];
}, $columns),
"rows" => $rows,
"total" => $total,
"page" => $page,
"perPage" => $per_page,
"totalPages" => (int) ceil($total / $per_page),
], 200);
}
public static function run_query($request)
{
global $wpdb;
$query = trim((string) $request->get_param("query"));
if (!preg_match('/^(SELECT|SHOW|DESCRIBE|EXPLAIN)\b/i', $query)) {
return new \WP_Error(
"unsafe_query",
"Only SELECT / SHOW / DESCRIBE / EXPLAIN queries can be run here.",
["status" => 400]
);
}
// Guard against stacked statements — a single semicolon at the very
// end is fine, anything after it is not.
if (preg_match('/;.*\S/s', $query)) {
return new \WP_Error("unsafe_query", "Multiple statements aren't allowed.", ["status" => 400]);
}
if (!preg_match('/\bLIMIT\s+\d+/i', $query)) {
$query = rtrim(rtrim($query), ";") . " LIMIT 200";
}
$rows = $wpdb->get_results($query, ARRAY_A);
if ($rows === null) {
return new \WP_Error("query_error", $wpdb->last_error ?: "Query failed", ["status" => 400]);
}
return new \WP_REST_Response(["rows" => $rows, "count" => count($rows)], 200);
}
private static function validate_table($table)
{
$table = preg_replace("/[^a-zA-Z0-9_]/", "", (string) $table);
return in_array($table, self::known_tables(), true) ? $table : null;
}
private static function escape_identifier($identifier)
{
return "`" . str_replace("`", "``", $identifier) . "`";
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
namespace AuroraAdmin\MenuCreator;
defined("ABSPATH") || exit();
/**
* Custom admin-menu configurations: hide/rename/reorder WP's native menu
* items per role. Storage + the runtime hook that actually applies a
* published config live here; REST CRUD lives in MenuCreatorData.php.
*/
class MenuCreator
{
const TABLE = "aurora_admin_menus";
const DB_VERSION = "1";
const DB_OPTION = "aurora_admin_menus_db_version";
public function __construct()
{
add_action("init", [self::class, "maybe_install"]);
// Late priority so it runs after all other plugins have registered
// their own menu items — there's nothing left to reorder/hide otherwise.
add_action("admin_menu", [self::class, "apply"], PHP_INT_MAX);
}
public static function table_name()
{
global $wpdb;
return $wpdb->prefix . self::TABLE;
}
public static function maybe_install()
{
if (get_option(self::DB_OPTION) === self::DB_VERSION) {
return;
}
global $wpdb;
$table = self::table_name();
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(191) NOT NULL DEFAULT '',
status VARCHAR(20) NOT NULL DEFAULT 'draft',
roles TEXT NULL,
items LONGTEXT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_status (status)
) {$charset};";
require_once ABSPATH . "wp-admin/includes/upgrade.php";
dbDelta($sql);
update_option(self::DB_OPTION, self::DB_VERSION);
}
/**
* Applies every published config whose target roles match the current
* user: hides items marked hidden and relabels items with a custom label.
*/
public static function apply()
{
global $menu, $wpdb;
if (!is_admin() || !function_exists("wp_get_current_user")) {
return;
}
$user = wp_get_current_user();
if (!$user || !$user->exists()) {
return;
}
$table = self::table_name();
$configs = $wpdb->get_results("SELECT roles, items FROM {$table} WHERE status = 'published'");
if (!$configs) {
return;
}
foreach ($configs as $config) {
$roles = json_decode($config->roles ?: "[]", true);
if (!is_array($roles) || !$roles) {
continue;
}
if (!array_intersect((array) $user->roles, $roles)) {
continue;
}
$items = json_decode($config->items ?: "[]", true);
if (!is_array($items)) {
continue;
}
foreach ($items as $item) {
$native_id = isset($item["native_id"]) ? $item["native_id"] : "";
if ($native_id === "") {
continue;
}
foreach ($menu as $key => $entry) {
if (!isset($entry[2]) || $entry[2] !== $native_id) {
continue;
}
if (!empty($item["hidden"])) {
unset($menu[$key]);
continue 2;
}
if (!empty($item["label"])) {
$menu[$key][0] = sanitize_text_field($item["label"]);
}
}
}
}
}
}
+225
View File
@@ -0,0 +1,225 @@
<?php
namespace AuroraAdmin\MenuCreator;
defined("ABSPATH") || exit();
/**
* REST CRUD for saved menu configurations. GET/POST /menus,
* GET/PUT/DELETE /menus/:id. All manage_options-gated.
*/
class MenuCreatorData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
$permission = function () {
return current_user_can("manage_options");
};
register_rest_route("aurora-admin/v1", "/menus", [
[
"methods" => "GET",
"callback" => [self::class, "list_menus"],
"permission_callback" => $permission,
"args" => [
"status" => ["type" => "string", "required" => false],
],
],
[
"methods" => "POST",
"callback" => [self::class, "create"],
"permission_callback" => $permission,
"args" => [
"name" => ["type" => "string", "required" => true],
"status" => ["type" => "string", "required" => false],
"roles" => ["type" => "array", "required" => false],
"items" => ["type" => "array", "required" => false],
],
],
]);
register_rest_route("aurora-admin/v1", "/menus/native-items", [
"methods" => "GET",
"callback" => [self::class, "native_items"],
"permission_callback" => $permission,
]);
register_rest_route("aurora-admin/v1", "/menus/(?P<id>\d+)", [
[
"methods" => "GET",
"callback" => [self::class, "get_one"],
"permission_callback" => $permission,
],
[
"methods" => "PUT",
"callback" => [self::class, "update"],
"permission_callback" => $permission,
"args" => [
"name" => ["type" => "string", "required" => false],
"status" => ["type" => "string", "required" => false],
"roles" => ["type" => "array", "required" => false],
"items" => ["type" => "array", "required" => false],
],
],
[
"methods" => "DELETE",
"callback" => [self::class, "delete"],
"permission_callback" => $permission,
],
]);
}
/**
* The real, current WordPress menu (same source MenuSerializer feeds to
* the sidebar) flattened so the menu editor has something concrete to
* toggle hide/rename on — top-level items plus their submenu items.
*/
public static function native_items()
{
$menu = \AuroraAdmin\Shell\MenuSerializer::serialize();
$flat = [];
foreach ($menu as $item) {
if (!empty($item["separator"]) || empty($item["id"])) {
continue;
}
$flat[] = ["native_id" => $item["id"], "title" => $item["title"], "icon" => $item["icon"]];
foreach ($item["submenu"] ?? [] as $sub) {
if (empty($sub["id"])) {
continue;
}
$flat[] = ["native_id" => $sub["id"], "title" => $item["title"] . " › " . $sub["title"], "icon" => null];
}
}
return new \WP_REST_Response($flat, 200);
}
public static function list_menus($request)
{
global $wpdb;
$table = MenuCreator::table_name();
$status = $request->get_param("status");
if ($status) {
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$table} WHERE status = %s ORDER BY updated_at DESC",
$status
));
} else {
$rows = $wpdb->get_results("SELECT * FROM {$table} ORDER BY updated_at DESC");
}
return new \WP_REST_Response(array_map([self::class, "format"], $rows), 200);
}
public static function get_one($request)
{
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM " . MenuCreator::table_name() . " WHERE id = %d",
(int) $request->get_param("id")
));
if (!$row) {
return new \WP_Error("not_found", "Menu not found", ["status" => 404]);
}
return new \WP_REST_Response(self::format($row), 200);
}
public static function create($request)
{
global $wpdb;
$now = current_time("mysql");
$wpdb->insert(MenuCreator::table_name(), [
"name" => sanitize_text_field($request->get_param("name")),
"status" => self::sanitize_status($request->get_param("status")),
"roles" => wp_json_encode(self::sanitize_roles($request->get_param("roles"))),
"items" => wp_json_encode(self::sanitize_items($request->get_param("items"))),
"created_at" => $now,
"updated_at" => $now,
]);
return self::get_one(new \WP_REST_Request("GET", "", ["id" => $wpdb->insert_id]));
}
public static function update($request)
{
global $wpdb;
$id = (int) $request->get_param("id");
$fields = ["updated_at" => current_time("mysql")];
if ($request->get_param("name") !== null) {
$fields["name"] = sanitize_text_field($request->get_param("name"));
}
if ($request->get_param("status") !== null) {
$fields["status"] = self::sanitize_status($request->get_param("status"));
}
if ($request->get_param("roles") !== null) {
$fields["roles"] = wp_json_encode(self::sanitize_roles($request->get_param("roles")));
}
if ($request->get_param("items") !== null) {
$fields["items"] = wp_json_encode(self::sanitize_items($request->get_param("items")));
}
$wpdb->update(MenuCreator::table_name(), $fields, ["id" => $id]);
return self::get_one($request);
}
public static function delete($request)
{
global $wpdb;
$wpdb->delete(MenuCreator::table_name(), ["id" => (int) $request->get_param("id")]);
return new \WP_REST_Response(["deleted" => true], 200);
}
private static function format($row)
{
return [
"id" => (int) $row->id,
"name" => $row->name,
"status" => $row->status,
"roles" => json_decode($row->roles ?: "[]", true) ?: [],
"items" => json_decode($row->items ?: "[]", true) ?: [],
"createdAt" => $row->created_at,
"updatedAt" => $row->updated_at,
];
}
private static function sanitize_status($status)
{
return $status === "published" ? "published" : "draft";
}
private static function sanitize_roles($roles)
{
if (!is_array($roles)) {
return [];
}
$valid = array_keys(wp_roles()->roles);
return array_values(array_intersect(array_map("sanitize_key", $roles), $valid));
}
private static function sanitize_items($items)
{
if (!is_array($items)) {
return [];
}
$out = [];
foreach ($items as $item) {
if (!is_array($item) || empty($item["native_id"])) {
continue;
}
$out[] = [
"native_id" => sanitize_text_field($item["native_id"]),
"label" => isset($item["label"]) ? sanitize_text_field($item["label"]) : "",
"hidden" => !empty($item["hidden"]),
"order" => isset($item["order"]) ? (int) $item["order"] : 0,
];
}
return $out;
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace AuroraAdmin\Notices;
defined("ABSPATH") || exit();
/**
* Custom admin notices: storage + the runtime hook that actually renders
* active notices matching the current user/page. REST CRUD lives in
* NoticesData.php.
*/
class Notices
{
const TABLE = "aurora_admin_notices";
const DB_VERSION = "1";
const DB_OPTION = "aurora_admin_notices_db_version";
public function __construct()
{
add_action("init", [self::class, "maybe_install"]);
add_action("admin_notices", [self::class, "render"]);
}
public static function table_name()
{
global $wpdb;
return $wpdb->prefix . self::TABLE;
}
public static function maybe_install()
{
if (get_option(self::DB_OPTION) === self::DB_VERSION) {
return;
}
global $wpdb;
$table = self::table_name();
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
title VARCHAR(191) NOT NULL DEFAULT '',
message LONGTEXT NULL,
type VARCHAR(20) NOT NULL DEFAULT 'info',
roles TEXT NULL,
pages TEXT NULL,
dismissible TINYINT(1) NOT NULL DEFAULT 1,
active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_active (active)
) {$charset};";
require_once ABSPATH . "wp-admin/includes/upgrade.php";
dbDelta($sql);
update_option(self::DB_OPTION, self::DB_VERSION);
}
public static function render()
{
global $wpdb;
$user = wp_get_current_user();
if (!$user || !$user->exists()) {
return;
}
$current_page = isset($GLOBALS["pagenow"]) ? $GLOBALS["pagenow"] : "";
$rows = $wpdb->get_results(
"SELECT * FROM " . self::table_name() . " WHERE active = 1"
);
foreach ($rows as $row) {
$roles = json_decode($row->roles ?: "[]", true);
if (is_array($roles) && $roles && !array_intersect((array) $user->roles, $roles)) {
continue;
}
$pages = json_decode($row->pages ?: "[]", true);
if (is_array($pages) && $pages && !in_array($current_page, $pages, true)) {
continue;
}
$type = in_array($row->type, ["success", "warning", "error", "info"], true) ? $row->type : "info";
$classes = "notice notice-{$type} aurora-native-notice";
if ($row->dismissible) {
$classes .= " is-dismissible";
}
printf(
'<div class="%1$s"><p><strong>%2$s</strong>%3$s</p></div>',
esc_attr($classes),
esc_html($row->title),
$row->message ? ": " . esc_html($row->message) : ""
);
}
}
}
+150
View File
@@ -0,0 +1,150 @@
<?php
namespace AuroraAdmin\Notices;
defined("ABSPATH") || exit();
/**
* REST CRUD for admin notices. GET/POST /notices, PUT/DELETE /notices/:id.
*/
class NoticesData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
$permission = function () {
return current_user_can("manage_options");
};
register_rest_route("aurora-admin/v1", "/notices", [
[
"methods" => "GET",
"callback" => [self::class, "list_notices"],
"permission_callback" => $permission,
],
[
"methods" => "POST",
"callback" => [self::class, "create"],
"permission_callback" => $permission,
"args" => [
"title" => ["type" => "string", "required" => true],
],
],
]);
register_rest_route("aurora-admin/v1", "/notices/(?P<id>\d+)", [
[
"methods" => "PUT",
"callback" => [self::class, "update"],
"permission_callback" => $permission,
],
[
"methods" => "DELETE",
"callback" => [self::class, "delete"],
"permission_callback" => $permission,
],
]);
}
public static function list_notices()
{
global $wpdb;
$rows = $wpdb->get_results("SELECT * FROM " . Notices::table_name() . " ORDER BY updated_at DESC");
return new \WP_REST_Response(array_map([self::class, "format"], $rows), 200);
}
public static function create($request)
{
global $wpdb;
$now = current_time("mysql");
$wpdb->insert(Notices::table_name(), [
"title" => sanitize_text_field($request->get_param("title")),
"message" => sanitize_textarea_field((string) $request->get_param("message")),
"type" => self::sanitize_type($request->get_param("type")),
"roles" => wp_json_encode(self::sanitize_list($request->get_param("roles"))),
"pages" => wp_json_encode(self::sanitize_list($request->get_param("pages"))),
"dismissible" => $request->get_param("dismissible") === false ? 0 : 1,
"active" => $request->get_param("active") === false ? 0 : 1,
"created_at" => $now,
"updated_at" => $now,
]);
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM " . Notices::table_name() . " WHERE id = %d",
$wpdb->insert_id
));
return new \WP_REST_Response(self::format($row), 200);
}
public static function update($request)
{
global $wpdb;
$id = (int) $request->get_param("id");
$fields = ["updated_at" => current_time("mysql")];
foreach (["title" => "sanitize_text_field", "message" => "sanitize_textarea_field"] as $key => $fn) {
if ($request->get_param($key) !== null) {
$fields[$key] = call_user_func($fn, (string) $request->get_param($key));
}
}
if ($request->get_param("type") !== null) {
$fields["type"] = self::sanitize_type($request->get_param("type"));
}
if ($request->get_param("roles") !== null) {
$fields["roles"] = wp_json_encode(self::sanitize_list($request->get_param("roles")));
}
if ($request->get_param("pages") !== null) {
$fields["pages"] = wp_json_encode(self::sanitize_list($request->get_param("pages")));
}
if ($request->get_param("dismissible") !== null) {
$fields["dismissible"] = $request->get_param("dismissible") ? 1 : 0;
}
if ($request->get_param("active") !== null) {
$fields["active"] = $request->get_param("active") ? 1 : 0;
}
$wpdb->update(Notices::table_name(), $fields, ["id" => $id]);
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM " . Notices::table_name() . " WHERE id = %d", $id));
return new \WP_REST_Response(self::format($row), 200);
}
public static function delete($request)
{
global $wpdb;
$wpdb->delete(Notices::table_name(), ["id" => (int) $request->get_param("id")]);
return new \WP_REST_Response(["deleted" => true], 200);
}
private static function format($row)
{
return [
"id" => (int) $row->id,
"title" => $row->title,
"message" => $row->message,
"type" => $row->type,
"roles" => json_decode($row->roles ?: "[]", true) ?: [],
"pages" => json_decode($row->pages ?: "[]", true) ?: [],
"dismissible" => (bool) $row->dismissible,
"active" => (bool) $row->active,
"createdAt" => $row->created_at,
"updatedAt" => $row->updated_at,
];
}
private static function sanitize_type($type)
{
return in_array($type, ["success", "warning", "error", "info"], true) ? $type : "info";
}
private static function sanitize_list($list)
{
if (!is_array($list)) {
return [];
}
return array_values(array_map("sanitize_text_field", $list));
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace AuroraAdmin\Options;
defined("ABSPATH") || exit();
/**
* Single source of truth for the aurora_admin_settings option.
* Deliberately simple — no request-lifetime caching layer, no schema
* ceremony beyond what register_setting requires. Add fields as pages
* actually need them.
*/
class Settings
{
const OPTION_NAME = "aurora_admin_settings";
public function __construct()
{
add_action("init", [self::class, "register"]);
}
public static function register()
{
register_setting("aurora-admin", self::OPTION_NAME, [
"type" => "object",
"default" => [],
"capability" => "manage_options",
// Merge incoming payloads into the stored value instead of replacing
// it wholesale. This lets pages save just the keys they own (e.g. the
// dashboard sends only dashboard_card_order, the shell only dark_mode)
// without wiping settings they never received — which matters because
// most pages get a sanitized copy with secrets stripped (see
// get_safe()) and must never write that stripped copy back.
"sanitize_callback" => [self::class, "merge_on_save"],
"show_in_rest" => [
"schema" => [
"type" => "object",
"properties" => [
"dark_mode" => ["type" => "string"],
"dashboard_card_order" => ["type" => "object"],
],
"additionalProperties" => true,
],
],
]);
}
public static function merge_on_save($value)
{
$existing = get_option(self::OPTION_NAME, []);
if (!is_array($existing)) {
$existing = [];
}
if (!is_array($value)) {
return $existing;
}
return array_merge($existing, $value);
}
public static function get($key = null, $default = null)
{
$settings = get_option(self::OPTION_NAME, []);
if (!is_array($settings)) {
$settings = [];
}
if ($key === null) {
return $settings;
}
return isset($settings[$key]) ? $settings[$key] : $default;
}
/**
* Settings safe to expose on every admin page (the shell serializes this
* for all logged-in users). Strips secrets: the license key and remote
* site application passwords. The settings page itself (manage_options
* only) uses get() and receives the full value.
*/
public static function get_safe()
{
$settings = self::get();
unset($settings["license_key"]);
if (isset($settings["remote_sites"]) && is_array($settings["remote_sites"])) {
$settings["remote_sites"] = array_values(array_map(function ($site) {
return [
"url" => isset($site["url"]) ? $site["url"] : "",
"username" => isset($site["username"]) ? $site["username"] : "",
];
}, $settings["remote_sites"]));
}
return $settings;
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace AuroraAdmin\Pages;
use AuroraAdmin\Utility\Assets;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
class Dashboard
{
private static $hook_suffix = null;
public function __construct()
{
add_action("admin_menu", [self::class, "register_menu"]);
// Runs late (after submenus are registered) to drop the duplicate
// first submenu item WordPress auto-creates for a top-level menu.
add_action("admin_menu", [self::class, "remove_duplicate_submenu"], 100);
add_action("admin_enqueue_scripts", [self::class, "maybe_enqueue"]);
}
public static function register_menu()
{
self::$hook_suffix = add_menu_page(
__("Aurora Admin", "aurora-admin"),
__("Aurora Admin", "aurora-admin"),
"read",
"aurora-admin",
[self::class, "render"],
"dashicons-admin-generic",
3
);
}
/**
* WordPress auto-adds a submenu item duplicating the top-level menu
* (same "Aurora Admin" label + slug). Remove it so the submenu only
* shows the real child pages (e.g. Settings).
*/
public static function remove_duplicate_submenu()
{
remove_submenu_page("aurora-admin", "aurora-admin");
}
public static function maybe_enqueue($hook_suffix)
{
if ($hook_suffix !== self::$hook_suffix) {
return;
}
Assets::enqueue("aurora-admin-dashboard", "entries/dashboard.js");
}
public static function render()
{
$current_user = wp_get_current_user();
$data = [
"restUrl" => esc_url_raw(get_rest_url()),
"restNonce" => wp_create_nonce("wp_rest"),
"userName" => $current_user->display_name,
"settings" => Settings::get(),
];
?>
<div
id="aurora-admin-dashboard-root"
data-aurora-admin="<?php echo esc_attr(wp_json_encode($data)); ?>"
></div>
<?php
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace AuroraAdmin\Pages;
use AuroraAdmin\Utility\Assets;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Replaces the native WordPress dashboard (index.php) with Aurora's own
* dashboard, the way the original plugin did.
*
* Rather than removing/moving WordPress's dashboard DOM, we render the
* Aurora mount point at the very top of the content area (the
* `in_admin_header` hook fires there, before the native .wrap) and simply
* hide the native dashboard's .wrap with CSS. Clean and reversible — if
* this plugin is disabled, the native dashboard returns untouched.
*/
class DashboardTakeover
{
public function __construct()
{
add_action("admin_enqueue_scripts", [self::class, "maybe_enqueue"]);
add_action("admin_head", [self::class, "hide_native"]);
add_action("in_admin_header", [self::class, "render_mount"]);
}
public static function is_dashboard()
{
return isset($GLOBALS["pagenow"]) && $GLOBALS["pagenow"] === "index.php";
}
public static function maybe_enqueue()
{
if (!self::is_dashboard()) {
return;
}
Assets::enqueue("aurora-admin-dashboard", "entries/dashboard.js");
}
public static function hide_native()
{
if (!self::is_dashboard()) {
return;
}
?>
<style id="aurora-admin-dashboard-takeover">
/* The Aurora dashboard is rendered in #wpcontent (above #wpbody) via
in_admin_header, so the entire native #wpbody — heading, welcome
panel, widgets, and the Screen Options / Help tabs inside it — is
hidden. This leaves just the Aurora dashboard with no empty native
frame below it. Printed only on the dashboard screen. */
html.aurora-active #wpbody {
display: none !important;
}
</style>
<?php
}
public static function render_mount()
{
if (!self::is_dashboard()) {
return;
}
$current_user = wp_get_current_user();
$data = [
"restUrl" => esc_url_raw(get_rest_url()),
"restNonce" => wp_create_nonce("wp_rest"),
"userName" => $current_user->display_name,
"settings" => Settings::get_safe(),
];
?>
<div
id="aurora-admin-dashboard-root"
data-aurora-admin="<?php echo esc_attr(wp_json_encode($data)); ?>"
></div>
<?php
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace AuroraAdmin\Pages;
use AuroraAdmin\Utility\Assets;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
class SettingsPage
{
private static $hook_suffix = null;
public function __construct()
{
add_action("admin_menu", [self::class, "register_menu"]);
add_action("admin_enqueue_scripts", [self::class, "maybe_enqueue"]);
}
public static function register_menu()
{
// Top-level "Aurora Admin" menu opens the Settings page directly. The
// Aurora dashboard is reached through WordPress's own Dashboard menu
// (taken over on index.php), so this menu has no submenu of its own.
self::$hook_suffix = add_menu_page(
__("Aurora Admin", "aurora-admin"),
__("Aurora Admin", "aurora-admin"),
"manage_options",
"aurora-admin",
[self::class, "render"],
"dashicons-admin-generic",
3
);
}
public static function maybe_enqueue($hook_suffix)
{
if ($hook_suffix !== self::$hook_suffix) {
return;
}
Assets::enqueue("aurora-admin-settings", "entries/settings.js");
}
public static function render()
{
// Option lists for multiselect fields (roles for "Disable theme",
// public post types for "Search post types").
$roles = [];
foreach (wp_roles()->roles as $slug => $info) {
$roles[] = ["value" => $slug, "label" => translate_user_role($info["name"])];
}
$post_types = [];
foreach (get_post_types(["public" => true], "objects") as $pt) {
if ($pt->name === "attachment") {
continue;
}
$post_types[] = ["value" => $pt->name, "label" => $pt->labels->name];
}
$data = [
"restUrl" => esc_url_raw(get_rest_url()),
"restNonce" => wp_create_nonce("wp_rest"),
"userName" => wp_get_current_user()->display_name,
// Full settings (including secrets) — this page is manage_options-only
// and needs to display/edit them.
"settings" => Settings::get(),
"roles" => $roles,
"postTypes" => $post_types,
];
?>
<div
id="aurora-admin-settings-root"
data-aurora-admin="<?php echo esc_attr(wp_json_encode($data)); ?>"
></div>
<?php
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
namespace AuroraAdmin\Pages;
use AuroraAdmin\Utility\Assets;
defined("ABSPATH") || exit();
/**
* Registers Aurora's 5 secondary full-page apps (Menu Creator, Admin
* Notices, Role Editor, Activity Log, Database Explorer). Each is a real WP
* admin page (proper capability check, working URL, its own hook_suffix for
* asset gating) registered as a submenu of "aurora-admin" — then
* immediately hidden from WordPress's own submenu via remove_submenu_page(),
* the standard register-but-hide trick. This keeps them out of the
* WP-native menu list (MenuSerializer never sees them, nothing to filter)
* while still being reachable, permission-checked pages; they're presented
* instead in the sidebar's dedicated Aurora nav section (see Shell::aurora_nav()).
*/
class SubApps
{
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"],
];
private static $hook_suffixes = [];
public function __construct()
{
add_action("admin_menu", [self::class, "register_menus"]);
add_action("admin_menu", [self::class, "hide_from_native_submenu"], 999);
add_action("admin_enqueue_scripts", [self::class, "maybe_enqueue"]);
}
public static function register_menus()
{
foreach (self::PAGES as $slug => $page) {
$hook = add_submenu_page(
"aurora-admin",
__($page["title"], "aurora-admin"),
__($page["title"], "aurora-admin"),
"manage_options",
$slug,
[self::class, "render"]
);
self::$hook_suffixes[$slug] = $hook;
}
}
public static function hide_from_native_submenu()
{
foreach (array_keys(self::PAGES) as $slug) {
remove_submenu_page("aurora-admin", $slug);
}
}
public static function maybe_enqueue($hook_suffix)
{
$slug = array_search($hook_suffix, self::$hook_suffixes, true);
if ($slug === false || !isset(self::PAGES[$slug])) {
return;
}
Assets::enqueue("aurora-admin-" . self::PAGES[$slug]["entry"], "entries/" . self::PAGES[$slug]["entry"] . ".js");
}
public static function render()
{
$page = isset($_GET["page"]) ? sanitize_text_field(wp_unslash($_GET["page"])) : "";
if (!isset(self::PAGES[$page])) {
return;
}
$roles = [];
foreach (wp_roles()->roles as $slug => $info) {
$roles[] = ["value" => $slug, "label" => translate_user_role($info["name"])];
}
global $wpdb;
$data = [
"restUrl" => esc_url_raw(get_rest_url()),
"restNonce" => wp_create_nonce("wp_rest"),
"userName" => wp_get_current_user()->display_name,
"roles" => $roles,
"tablePrefix" => $wpdb->prefix,
];
$root_id = "aurora-admin-" . self::PAGES[$page]["entry"] . "-root";
?>
<div
id="<?php echo esc_attr($root_id); ?>"
data-aurora-admin="<?php echo esc_attr(wp_json_encode($data)); ?>"
></div>
<?php
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace AuroraAdmin\Posts;
defined("ABSPATH") || exit();
/**
* When the posts list (edit.php) is opened with ?aurora_day=YYYY-MM-DD
* (from the dashboard's Scheduled Content calendar), constrain the main
* query to scheduled posts on that exact day.
*/
class ScheduledDayFilter
{
public function __construct()
{
add_action("pre_get_posts", [self::class, "filter"]);
add_action("in_admin_header", [self::class, "notice"]);
}
private static function day()
{
$day = isset($_GET["aurora_day"]) ? sanitize_text_field(wp_unslash($_GET["aurora_day"])) : "";
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $day) ? $day : "";
}
public static function filter($query)
{
if (!is_admin() || !$query->is_main_query()) {
return;
}
if (($GLOBALS["pagenow"] ?? "") !== "edit.php") {
return;
}
$day = self::day();
if (!$day) {
return;
}
[$y, $m, $d] = array_map("intval", explode("-", $day));
$query->set("post_status", "future");
$query->set("date_query", [["year" => $y, "month" => $m, "day" => $d]]);
}
/**
* Small heading on the filtered posts list so it's clear you're viewing a
* single day's scheduled content (with a link back to all scheduled).
*/
public static function notice()
{
if (($GLOBALS["pagenow"] ?? "") !== "edit.php") {
return;
}
$day = self::day();
if (!$day) {
return;
}
$label = date_i18n(get_option("date_format"), strtotime($day));
$all = admin_url("edit.php?post_status=future");
?>
<div class="notice notice-info" style="margin-top:12px;">
<p>
<?php printf(
/* translators: %s: formatted date */
esc_html__("Showing scheduled content for %s.", "aurora-admin"),
"<strong>" . esc_html($label) . "</strong>"
); ?>
&nbsp;<a href="<?php echo esc_url($all); ?>"><?php esc_html_e("View all scheduled", "aurora-admin"); ?></a>
</p>
</div>
<?php
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
namespace AuroraAdmin\Rest;
use AuroraAdmin\Analytics\Analytics;
defined("ABSPATH") || exit();
/**
* Aggregates the first-party pageview table into the figures the Analytics
* tab cards need. GET aurora-admin/v1/analytics?from=YYYY-MM-DD&to=YYYY-MM-DD
*/
class AnalyticsData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/analytics", [
"methods" => "GET",
"callback" => [self::class, "handle"],
"permission_callback" => function () {
return current_user_can("read");
},
]);
}
public static function handle($request)
{
global $wpdb;
$table = Analytics::table_name();
$from = $request->get_param("from") ?: gmdate("Y-m-d", strtotime("-7 days"));
$to = $request->get_param("to") ?: gmdate("Y-m-d");
$from_dt = $from . " 00:00:00";
$to_dt = $to . " 23:59:59";
$where = $wpdb->prepare("created_at BETWEEN %s AND %s", $from_dt, $to_dt);
// --- Totals ---
$total = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table} WHERE {$where}");
$unique = (int) $wpdb->get_var("SELECT COUNT(DISTINCT session_id) FROM {$table} WHERE {$where}");
// --- Daily series ---
$rows = $wpdb->get_results(
"SELECT DATE(created_at) AS d, COUNT(*) AS c FROM {$table} WHERE {$where} GROUP BY DATE(created_at)",
OBJECT_K
);
$series = [];
$cursor = strtotime($from);
$end = strtotime($to);
while ($cursor <= $end) {
$key = gmdate("Y-m-d", $cursor);
$series[] = [
"date" => gmdate("M j", $cursor),
"count" => isset($rows[$key]) ? (int) $rows[$key]->c : 0,
];
$cursor = strtotime("+1 day", $cursor);
}
// --- Device breakdown ---
$devices = $wpdb->get_results(
"SELECT device, COUNT(*) AS c FROM {$table} WHERE {$where} GROUP BY device ORDER BY c DESC"
);
$deviceUsage = [];
foreach ($devices as $d) {
$deviceUsage[] = [
"device" => $d->device,
"count" => (int) $d->c,
"pct" => $total ? round(($d->c / $total) * 100) : 0,
];
}
// --- Top pages / referrers / countries ---
$topPages = self::top($wpdb, $table, $where, "url", 6);
$topReferrers = self::top($wpdb, $table, $where, "referrer", 6, true);
$topCountries = self::top($wpdb, $table, $where, "country", 6, true);
// --- Bounce rate & avg session depth ---
$sessionCounts = $wpdb->get_results(
"SELECT session_id, COUNT(*) AS c,
TIMESTAMPDIFF(SECOND, MIN(created_at), MAX(created_at)) AS dur
FROM {$table} WHERE {$where} GROUP BY session_id"
);
$sessions = count($sessionCounts);
$bounces = 0;
$totalDur = 0;
foreach ($sessionCounts as $s) {
if ((int) $s->c <= 1) {
$bounces++;
}
$totalDur += (int) $s->dur;
}
$bounceRate = $sessions ? round(($bounces / $sessions) * 100) : 0;
$avgSeconds = $sessions ? round($totalDur / $sessions) : 0;
// --- Active users (last 5 minutes) ---
$activeCutoff = gmdate("Y-m-d H:i:s", current_time("timestamp") - 300);
$active = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(DISTINCT session_id) FROM {$table} WHERE created_at >= %s",
$activeCutoff
)
);
return new \WP_REST_Response([
"pageViews" => ["total" => $total, "unique" => $unique, "series" => $series],
"deviceUsage" => $deviceUsage,
"topPages" => $topPages,
"topReferrers" => $topReferrers,
"topCountries" => $topCountries,
"bounce" => [
"rate" => $bounceRate,
"avgTime" => self::format_duration($avgSeconds),
"sessions" => $sessions,
],
"activeUsers" => $active,
], 200);
}
private static function top($wpdb, $table, $where, $col, $limit, $skip_empty = false)
{
$extra = $skip_empty ? " AND {$col} <> ''" : "";
$results = $wpdb->get_results(
"SELECT {$col} AS label, COUNT(*) AS c FROM {$table}
WHERE {$where}{$extra} GROUP BY {$col} ORDER BY c DESC LIMIT {$limit}"
);
$out = [];
foreach ($results as $r) {
$out[] = ["label" => $r->label !== "" ? $r->label : "Direct", "count" => (int) $r->c];
}
return $out;
}
private static function format_duration($seconds)
{
if ($seconds <= 0) {
return "0s";
}
$m = floor($seconds / 60);
$s = $seconds % 60;
return $m > 0 ? "{$m}m {$s}s" : "{$s}s";
}
}
+292
View File
@@ -0,0 +1,292 @@
<?php
namespace AuroraAdmin\Rest;
defined("ABSPATH") || exit();
/**
* REST endpoint that supplies all data for the Aurora dashboard cards.
* GET aurora-admin/v1/dashboard?from=YYYY-MM-DD&to=YYYY-MM-DD
*
* Everything is computed fresh here from core WordPress APIs — no reuse of
* the original plugin's data layer.
*/
class DashboardData
{
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
register_rest_route("aurora-admin/v1", "/dashboard", [
"methods" => "GET",
"callback" => [self::class, "handle"],
"permission_callback" => function () {
return current_user_can("read");
},
"args" => [
"from" => ["type" => "string", "required" => false],
"to" => ["type" => "string", "required" => false],
],
]);
}
public static function handle($request)
{
$to = self::parse_date($request->get_param("to"), "now");
$from = self::parse_date($request->get_param("from"), "-7 days");
return new \WP_REST_Response([
"recentContent" => self::recent_content(),
"scheduledContent" => self::scheduled_content(),
"recentComments" => self::recent_comments(),
"userAnalytics" => self::user_analytics($from, $to),
"mediaAnalytics" => self::media_analytics(),
"serverHealth" => self::server_health(),
], 200);
}
private static function parse_date($value, $fallback)
{
$ts = $value ? strtotime($value) : false;
if ($ts === false) {
$ts = strtotime($fallback);
}
return $ts;
}
private static function recent_content()
{
$counts = wp_count_posts("post");
$recent = get_posts([
"post_type" => "post",
"post_status" => ["publish", "draft", "future"],
"numberposts" => 5,
"orderby" => "date",
"order" => "DESC",
]);
$latest = [];
foreach ($recent as $p) {
$latest[] = [
"id" => $p->ID,
"title" => get_the_title($p),
"author" => get_the_author_meta("display_name", $p->post_author),
"date" => get_the_date("M j, Y", $p),
"status" => $p->post_status,
"editUrl" => get_edit_post_link($p->ID, "raw"),
];
}
return [
"published" => (int) $counts->publish,
"draft" => (int) $counts->draft,
"scheduled" => (int) $counts->future,
"total" => (int) $counts->publish + (int) $counts->draft + (int) $counts->future,
"latest" => $latest,
"listUrl" => admin_url("edit.php"),
];
}
private static function scheduled_content()
{
$future = get_posts([
"post_type" => "post",
"post_status" => "future",
"numberposts" => 100,
"orderby" => "date",
"order" => "ASC",
]);
$days = [];
foreach ($future as $p) {
$days[get_the_date("Y-m-d", $p)][] = [
"id" => $p->ID,
"title" => get_the_title($p),
"time" => get_the_time("g:i a", $p),
];
}
return [
"count" => count($future),
"days" => $days,
];
}
private static function recent_comments()
{
$counts = wp_count_comments();
$recent = get_comments([
"number" => 5,
"status" => "approve",
"orderby" => "comment_date_gmt",
"order" => "DESC",
]);
$latest = [];
foreach ($recent as $c) {
$latest[] = [
"id" => $c->comment_ID,
"author" => $c->comment_author,
"excerpt" => wp_trim_words($c->comment_content, 18),
"postTitle" => get_the_title($c->comment_post_ID),
"date" => mysql2date("M j, Y", $c->comment_date),
];
}
return [
"approved" => (int) $counts->approved,
"pending" => (int) $counts->moderated,
"spam" => (int) $counts->spam,
"total" => (int) $counts->approved,
"latest" => $latest,
"listUrl" => admin_url("edit-comments.php"),
];
}
private static function user_analytics($from, $to)
{
$total = count_users();
$total_users = (int) $total["total_users"];
$from_date = gmdate("Y-m-d 00:00:00", $from);
$to_date = gmdate("Y-m-d 23:59:59", $to);
$in_range = get_users([
"date_query" => [["after" => $from_date, "before" => $to_date, "inclusive" => true]],
"fields" => "ID",
]);
$recent7 = get_users([
"date_query" => [["after" => "-7 days", "inclusive" => true]],
"fields" => "ID",
]);
// Daily registration series across the range
$series = [];
$cursor = strtotime(gmdate("Y-m-d", $from));
$end = strtotime(gmdate("Y-m-d", $to));
$by_day = [];
foreach ($in_range as $uid) {
$reg = get_userdata($uid)->user_registered;
$by_day[mysql2date("Y-m-d", $reg)] = (isset($by_day[mysql2date("Y-m-d", $reg)]) ? $by_day[mysql2date("Y-m-d", $reg)] : 0) + 1;
}
while ($cursor <= $end) {
$key = gmdate("Y-m-d", $cursor);
$series[] = [
"date" => gmdate("M j", $cursor),
"count" => isset($by_day[$key]) ? $by_day[$key] : 0,
];
$cursor = strtotime("+1 day", $cursor);
}
return [
"total" => $total_users,
"inRange" => count($in_range),
"recent7d" => count($recent7),
"series" => $series,
];
}
private static function media_analytics()
{
$attachments = get_posts([
"post_type" => "attachment",
"post_status" => "inherit",
"numberposts" => -1,
"fields" => "ids",
]);
$total_size = 0;
$large = 0;
$recent30 = 0;
$thirty_ago = strtotime("-30 days");
foreach ($attachments as $id) {
$file = get_attached_file($id);
$size = $file && file_exists($file) ? filesize($file) : 0;
$total_size += $size;
if ($size > 5 * 1024 * 1024) {
$large++;
}
if (strtotime(get_post_field("post_date", $id)) >= $thirty_ago) {
$recent30++;
}
}
return [
"totalFiles" => count($attachments),
"totalSize" => self::format_bytes($total_size),
"recent30d" => $recent30,
"unused" => 0,
"largeFiles" => $large,
"diskUsage" => self::format_bytes($total_size),
"manageUrl" => admin_url("upload.php"),
];
}
private static function server_health()
{
$mem_limit = self::to_bytes(ini_get("memory_limit"));
$mem_used = memory_get_usage(true);
$disk_total = @disk_total_space(ABSPATH) ?: 0;
$disk_free = @disk_free_space(ABSPATH) ?: 0;
$disk_used = $disk_total - $disk_free;
$updates = function_exists("wp_get_update_data") ? wp_get_update_data() : ["counts" => []];
$counts = isset($updates["counts"]) ? $updates["counts"] : [];
return [
"wp" => get_bloginfo("version"),
"php" => PHP_VERSION,
"memory" => [
"used" => self::format_bytes($mem_used),
"total" => self::format_bytes($mem_limit),
"pct" => $mem_limit > 0 ? round(($mem_used / $mem_limit) * 100, 1) : 0,
],
"disk" => [
"used" => self::format_bytes($disk_used),
"total" => self::format_bytes($disk_total),
"pct" => $disk_total > 0 ? round(($disk_used / $disk_total) * 100, 1) : 0,
],
"plugins" => isset($counts["plugins"]) ? (int) $counts["plugins"] : 0,
"themes" => isset($counts["themes"]) ? (int) $counts["themes"] : 0,
"core" => isset($counts["wordpress"]) ? (int) $counts["wordpress"] : 0,
"updates" => isset($counts["total"]) ? (int) $counts["total"] : 0,
"ssl" => is_ssl(),
"timezone" => wp_timezone_string(),
];
}
private static function format_bytes($bytes)
{
$bytes = (float) $bytes;
$units = ["B", "KB", "MB", "GB", "TB"];
$i = 0;
while ($bytes >= 1024 && $i < count($units) - 1) {
$bytes /= 1024;
$i++;
}
return round($bytes, 2) . " " . $units[$i];
}
private static function to_bytes($val)
{
$val = trim($val);
if ($val === "" || $val === "-1") {
return 0;
}
$last = strtolower($val[strlen($val) - 1]);
$num = (float) $val;
switch ($last) {
case "g":
$num *= 1024;
case "m":
$num *= 1024;
case "k":
$num *= 1024;
}
return $num;
}
}
+166
View File
@@ -0,0 +1,166 @@
<?php
namespace AuroraAdmin\Roles;
defined("ABSPATH") || exit();
/**
* REST CRUD wrapping WordPress's own roles/capabilities API — no custom
* table, this reads and writes the real wp_user_roles option via WP_Roles.
*/
class RolesData
{
// WordPress has no built-in "is this a default role" check — these are
// the five core roles, hard-coded here as delete-protected.
const PROTECTED_ROLES = ["administrator", "editor", "author", "contributor", "subscriber"];
public function __construct()
{
add_action("rest_api_init", [self::class, "register"]);
}
public static function register()
{
$permission = function () {
return current_user_can("manage_options");
};
register_rest_route("aurora-admin/v1", "/roles", [
[
"methods" => "GET",
"callback" => [self::class, "list_roles"],
"permission_callback" => $permission,
],
[
"methods" => "POST",
"callback" => [self::class, "create"],
"permission_callback" => $permission,
"args" => [
"slug" => ["type" => "string", "required" => true],
"displayName" => ["type" => "string", "required" => true],
],
],
]);
register_rest_route("aurora-admin/v1", "/roles/(?P<slug>[a-z0-9_\-]+)", [
[
"methods" => "PUT",
"callback" => [self::class, "update"],
"permission_callback" => $permission,
],
[
"methods" => "DELETE",
"callback" => [self::class, "delete"],
"permission_callback" => $permission,
],
]);
}
public static function list_roles()
{
$editable = get_editable_roles();
$all_caps = self::all_known_capabilities();
$out = [];
foreach ($editable as $slug => $role) {
$user_count = count(get_users(["role" => $slug, "fields" => "ID"]));
$out[] = [
"slug" => $slug,
"name" => translate_user_role($role["name"]),
"capabilities" => array_keys(array_filter($role["capabilities"])),
"userCount" => $user_count,
"isDefault" => in_array($slug, self::PROTECTED_ROLES, true),
];
}
return new \WP_REST_Response(["roles" => $out, "allCapabilities" => $all_caps], 200);
}
public static function create($request)
{
$slug = sanitize_key($request->get_param("slug"));
$display_name = sanitize_text_field($request->get_param("displayName"));
if (!$slug || !$display_name) {
return new \WP_Error("invalid_role", "A role slug and name are required", ["status" => 400]);
}
if (get_role($slug)) {
return new \WP_Error("role_exists", "A role with this slug already exists", ["status" => 409]);
}
$caps = self::sanitize_caps($request->get_param("capabilities"));
add_role($slug, $display_name, array_fill_keys($caps, true));
return new \WP_REST_Response(["slug" => $slug, "name" => $display_name, "capabilities" => $caps], 200);
}
public static function update($request)
{
$slug = $request->get_param("slug");
$role = get_role($slug);
if (!$role) {
return new \WP_Error("not_found", "Role not found", ["status" => 404]);
}
if ($request->get_param("capabilities") !== null) {
$desired = self::sanitize_caps($request->get_param("capabilities"));
foreach (array_keys($role->capabilities) as $existing_cap) {
if (!in_array($existing_cap, $desired, true)) {
$role->remove_cap($existing_cap);
}
}
foreach ($desired as $cap) {
$role->add_cap($cap);
}
}
if ($request->get_param("displayName")) {
global $wp_roles;
$wp_roles->roles[$slug]["name"] = sanitize_text_field($request->get_param("displayName"));
$wp_roles->role_names[$slug] = $wp_roles->roles[$slug]["name"];
update_option($wp_roles->role_key, $wp_roles->roles);
}
return new \WP_REST_Response(["updated" => true], 200);
}
public static function delete($request)
{
$slug = $request->get_param("slug");
if (in_array($slug, self::PROTECTED_ROLES, true)) {
return new \WP_Error("protected_role", "Default WordPress roles can't be deleted", ["status" => 403]);
}
if (!get_role($slug)) {
return new \WP_Error("not_found", "Role not found", ["status" => 404]);
}
remove_role($slug);
return new \WP_REST_Response(["deleted" => true], 200);
}
private static function sanitize_caps($caps)
{
if (!is_array($caps)) {
return [];
}
return array_values(array_unique(array_map("sanitize_key", $caps)));
}
/**
* WordPress has no single "list all capabilities" API — build one from
* every capability already assigned across existing roles, which is what
* every native role-editor plugin does in practice.
*/
private static function all_known_capabilities()
{
$caps = [];
foreach (wp_roles()->roles as $role) {
foreach (array_keys($role["capabilities"]) as $cap) {
$caps[$cap] = true;
}
}
$caps = array_keys($caps);
sort($caps);
return $caps;
}
}
+177
View File
@@ -0,0 +1,177 @@
<?php
namespace AuroraAdmin\Shell;
defined("ABSPATH") || exit();
/**
* Turns WordPress's global $menu / $submenu structures into a clean,
* JSON-friendly array the Vue sidebar can render.
*
* This is the deliberate, readable replacement for the original plugin's
* reverse-engineered menu reconstruction. It reads the same source of
* truth WordPress itself uses to paint the admin menu, so third-party
* plugin menu items appear automatically.
*/
class MenuSerializer
{
/**
* @return array List of top-level menu items, each:
* [ id, title, url, icon, count, separator(bool), submenu[] ]
*/
public static function serialize()
{
global $menu, $submenu;
if (!is_array($menu)) {
return [];
}
$items = [];
foreach ($menu as $item) {
// $item indexes (WordPress core):
// 0 => menu title (may contain HTML), 1 => capability,
// 2 => menu slug, 4 => classes, 6 => icon
$raw_title = isset($item[0]) ? $item[0] : "";
$capability = isset($item[1]) ? $item[1] : "";
$slug = isset($item[2]) ? $item[2] : "";
$classes = isset($item[4]) ? $item[4] : "";
$icon = isset($item[6]) ? $item[6] : "";
// Separators have a wp-menu-separator class and no real title.
if (strpos($classes, "wp-menu-separator") !== false) {
$items[] = ["separator" => true];
continue;
}
if (empty($capability) || !current_user_can($capability)) {
continue;
}
// Aurora's own top-level page is represented in the sidebar's
// dedicated branded nav section (see Shell::print_mount()'s
// "auroraNav"), not in this WP-native-menu list — skip it here so it
// isn't shown twice.
if ($slug === "aurora-admin") {
continue;
}
$title_data = self::clean_title($raw_title);
$entry = [
"id" => $slug,
"title" => $title_data["title"],
"url" => self::resolve_url($slug),
"icon" => self::resolve_icon($icon),
"count" => $title_data["count"],
"separator" => false,
"submenu" => [],
];
if (isset($submenu[$slug]) && is_array($submenu[$slug])) {
foreach ($submenu[$slug] as $sub) {
$sub_cap = isset($sub[1]) ? $sub[1] : "";
if (empty($sub_cap) || !current_user_can($sub_cap)) {
continue;
}
$sub_title = self::clean_title(isset($sub[0]) ? $sub[0] : "");
$sub_slug = isset($sub[2]) ? $sub[2] : "";
$entry["submenu"][] = [
"id" => $sub_slug,
"title" => $sub_title["title"],
"url" => self::resolve_url($sub_slug, $slug),
"count" => $sub_title["count"],
];
}
}
$items[] = $entry;
}
return $items;
}
/**
* Strips the markup WordPress packs into menu titles (update/count
* bubbles) and pulls the numeric count out separately.
*
* @return array{title:string,count:int|null}
*/
private static function clean_title($raw)
{
$count = null;
if (preg_match('/<span[^>]*>(\d+)/', $raw, $matches)) {
$count = (int) $matches[1];
}
// WordPress menu titles are "Label <span ...>count bubble</span>" — the
// human label is always the plain text before the first tag, so take
// that rather than stripping tags (which would leave the count digits
// dangling in the title).
$label = $raw;
$first_tag = strpos($raw, "<");
if ($first_tag !== false) {
$label = substr($raw, 0, $first_tag);
}
$title = trim(wp_strip_all_tags($label));
return ["title" => $title, "count" => $count];
}
/**
* Builds the admin URL for a menu slug, mirroring how WordPress decides
* between a direct file (foo.php) and a registered page (admin.php?page=).
*/
private static function resolve_url($slug, $parent_slug = "")
{
if (empty($slug)) {
return "";
}
// Already an absolute URL.
if (preg_match('#^https?://#', $slug)) {
return $slug;
}
// A slug pointing at a real admin file, possibly with a query string.
if (strpos($slug, ".php") !== false) {
return admin_url($slug);
}
// Submenu of a file-based parent (e.g. edit.php) — the child slug is a
// file too, handled above; otherwise it's a page registered under that
// parent's screen, reached via the parent file with ?page=.
if (!empty($parent_slug) && strpos($parent_slug, ".php") !== false) {
return admin_url(
$parent_slug . (strpos($parent_slug, "?") !== false ? "&" : "?") . "page=" . $slug
);
}
return admin_url("admin.php?page=" . $slug);
}
/**
* Normalizes the menu icon into something the Vue side can render:
* a dashicon class name, an image URL, or null for "use a default".
*/
private static function resolve_icon($icon)
{
if (empty($icon) || $icon === "none" || $icon === "div") {
return null;
}
if (strpos($icon, "dashicons-") === 0) {
return ["type" => "dashicon", "value" => $icon];
}
if (strpos($icon, "data:image") === 0 || preg_match('#^https?://#', $icon)) {
return ["type" => "image", "value" => $icon];
}
return null;
}
}
+251
View File
@@ -0,0 +1,251 @@
<?php
namespace AuroraAdmin\Shell;
use AuroraAdmin\Utility\Assets;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* The site-wide immersive admin shell.
*
* Renders a custom Vue sidebar + toolbar on every admin screen and frames
* WordPress's own content area inside a styled container. Crucially it does
* NOT relocate or re-implement WP's content — native pages render normally
* inside the frame, so they keep working without any reskinning.
*
* Native chrome (admin menu, toolbar, footer) is hidden via a small set of
* explicit ID selectors gated on an html.aurora-loading class. A failsafe
* reveals native admin again if the Vue app never signals ready, so a
* broken build can never lock the user out of wp-admin.
*/
class Shell
{
public function __construct()
{
// Print the chrome-hiding CSS + loading class as early as possible so
// the native menu/toolbar never flash before the shell takes over.
add_action("admin_head", [self::class, "print_early_styles"], 0);
add_action("admin_enqueue_scripts", [self::class, "enqueue"]);
// Mount point + serialized data, output at the end of the admin body.
add_action("admin_footer", [self::class, "print_mount"]);
}
/**
* Whether the shell should run on the current request. Skipped on a few
* full-screen native experiences that own the whole viewport.
*/
private static function is_enabled()
{
if (!is_admin()) {
return false;
}
$screen = function_exists("get_current_screen") ? get_current_screen() : null;
if ($screen && in_array($screen->id, ["site-editor", "customize"], true)) {
return false;
}
// Settings > General > "Disable theme": roles the shell is turned off
// for — those users get the native WordPress admin.
$disabled_roles = Settings::get("disable_theme", []);
if (is_array($disabled_roles) && $disabled_roles) {
$user = wp_get_current_user();
if ($user && $user->exists() && array_intersect((array) $user->roles, $disabled_roles)) {
return false;
}
}
return apply_filters("aurora_admin_shell_enabled", true);
}
/**
* Whether the current screen is one of Aurora's own pages (vs a native
* WordPress admin page). Used to decide whether dark mode themes the
* content area or leaves it in WP's native light theme.
*/
private static function is_own_page()
{
$page = isset($_GET["page"]) ? sanitize_text_field(wp_unslash($_GET["page"])) : "";
if (strpos($page, "aurora-admin") === 0) {
return true;
}
// The dashboard (index.php) is taken over by Aurora's own dashboard, so
// treat it as one of our pages: dark frame, and the native-dark theme
// sheet is skipped there.
return isset($GLOBALS["pagenow"]) && $GLOBALS["pagenow"] === "index.php";
}
public static function print_early_styles()
{
if (!self::is_enabled()) {
return;
}
$native_class = self::is_own_page() ? "" : "aurora-native-page";
?>
<script>
// Synchronous: marks the document active before first paint so the
// hide-native-chrome CSS below applies with no flash. This class
// persists for the whole session (chrome stays hidden); aurora-ready
// is added later by the Vue shell only to reveal the framed content.
document.documentElement.classList.add("aurora-active");
<?php if ($native_class): ?>
document.documentElement.classList.add("<?php echo esc_js($native_class); ?>");
<?php endif; ?>
// Failsafe: if the Vue shell never signals ready, drop aurora-active
// so native admin returns and a failed build can't lock the user out.
window.setTimeout(function () {
if (!document.documentElement.classList.contains("aurora-ready")) {
document.documentElement.classList.remove("aurora-active");
}
}, 8000);
</script>
<style id="aurora-admin-early">
/* Hide native chrome for the whole active session (not just loading),
so it stays hidden once the shell is ready. */
html.aurora-active #adminmenumain,
html.aurora-active #wpadminbar,
html.aurora-active #wpfooter {
display: none !important;
}
html.aurora-active.wp-toolbar {
padding-top: 0 !important;
}
/* Keep the framed content hidden only until the shell mounts, to avoid
a flash of unframed/unpositioned content. No !important and no
margin rules here, so the framing rules in shell-frame.css win
cleanly once aurora-ready is set. */
html.aurora-active:not(.aurora-ready) #wpbody {
opacity: 0;
}
html.aurora-ready #wpbody {
opacity: 1;
transition: opacity 0.15s ease;
}
</style>
<?php
}
public static function enqueue()
{
if (!self::is_enabled()) {
return;
}
// The sidebar renders WordPress's own dashicon menu glyphs, so make
// sure the dashicons font is present (core registers it under this
// handle; it's normally enqueued already, this just guarantees it).
wp_enqueue_style("dashicons");
Assets::enqueue("aurora-admin-shell", "entries/shell.js");
// Full dark theme for native WP pages. Loaded only on native pages
// (Aurora's own pages style themselves) and inert unless dark mode is
// active (every rule is gated on html.aurora-dark in the file itself).
if (!self::is_own_page()) {
// Version static assets by file mtime so edits bust the browser cache
// even when the plugin version hasn't changed.
$native_dark = AURORA_ADMIN_PATH . "admin/assets/native-dark.css";
$css_ver = file_exists($native_dark) ? (string) filemtime($native_dark) : AURORA_ADMIN_VERSION;
wp_enqueue_style(
"aurora-admin-native-dark",
AURORA_ADMIN_URL . "admin/assets/native-dark.css",
["aurora-admin-shell-0"],
$css_ver
);
// Progressive enhancement (boxed sections on the Updates page, etc.)
$native_js = AURORA_ADMIN_PATH . "admin/assets/native-enhance.js";
$js_ver = file_exists($native_js) ? (string) filemtime($native_js) : AURORA_ADMIN_VERSION;
wp_enqueue_script(
"aurora-admin-native-enhance",
AURORA_ADMIN_URL . "admin/assets/native-enhance.js",
[],
$js_ver,
true
);
}
}
public static function print_mount()
{
if (!self::is_enabled()) {
return;
}
$current_user = wp_get_current_user();
// Who may see the remote-site switcher in the toolbar (Settings >
// General > Remote Site Switcher Capability, default manage_options).
$switcher_cap = Settings::get("remote_site_switcher_capability", "");
if (!is_string($switcher_cap) || trim($switcher_cap) === "") {
$switcher_cap = "manage_options";
}
$data = [
"restUrl" => esc_url_raw(get_rest_url()),
"restNonce" => wp_create_nonce("wp_rest"),
"adminUrl" => esc_url_raw(admin_url()),
"siteUrl" => esc_url_raw(home_url("/")),
"logoutUrl" => esc_url_raw(wp_logout_url()),
"userName" => $current_user->display_name,
"userEmail" => $current_user->user_email,
"avatar" => get_avatar_url($current_user->ID, ["size" => 64]),
"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),
"menu" => MenuSerializer::serialize(),
"auroraNav" => self::aurora_nav(),
];
?>
<div
id="aurora-admin-shell-root"
data-aurora-admin="<?php echo esc_attr(wp_json_encode($data)); ?>"
></div>
<?php
}
private static function current_admin_url()
{
$request = isset($_SERVER["REQUEST_URI"]) ? $_SERVER["REQUEST_URI"] : "";
return home_url($request);
}
/**
* Aurora's own branded nav section, rendered in a dedicated block at the
* bottom of the sidebar (separate from the WP-native menu list above it).
* A small static list rather than menu-scraped, since these are our own
* pages, not WordPress's.
*/
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"],
];
$nav = [];
foreach ($items as $item) {
if (!current_user_can("manage_options")) {
continue;
}
$nav[] = [
"id" => $item["id"],
"title" => $item["title"],
"url" => esc_url_raw(admin_url("admin.php?page=" . $item["id"])),
"icon" => ["type" => "dashicon", "value" => $item["icon"]],
];
}
return $nav;
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace AuroraAdmin\Utility;
defined("ABSPATH") || exit();
/**
* Reads the Vite build manifest and enqueues a single entry's JS (as an
* ES module) and CSS. Kept deliberately simple — one entry in, manifest
* lookup, two enqueue calls out. No scoping/theme tricks live here.
*/
class Assets
{
private static $manifest = null;
private static function get_manifest()
{
if (self::$manifest !== null) {
return self::$manifest;
}
$path = AURORA_ADMIN_PATH . "app/dist/.vite/manifest.json";
if (!file_exists($path)) {
self::$manifest = [];
return self::$manifest;
}
$contents = file_get_contents($path);
$decoded = json_decode($contents, true);
self::$manifest = is_array($decoded) ? $decoded : [];
return self::$manifest;
}
/**
* Enqueues a built entry's JS as a module script plus its CSS, if any.
*
* @param string $handle WP script/style handle prefix, e.g. "aurora-admin-dashboard"
* @param string $entry Manifest key, e.g. "src/entries/dashboard.js"
*/
public static function enqueue($handle, $entry)
{
$manifest = self::get_manifest();
if (!isset($manifest[$entry])) {
return;
}
$entry_data = $manifest[$entry];
$base_url = AURORA_ADMIN_URL . "app/dist/";
if (isset($entry_data["file"])) {
wp_enqueue_script($handle, $base_url . $entry_data["file"], [], AURORA_ADMIN_VERSION, true);
add_filter("script_loader_tag", function ($tag, $tag_handle) use ($handle) {
if ($tag_handle !== $handle) {
return $tag;
}
return str_replace(" src", " type=\"module\" src", $tag);
}, 10, 2);
}
// Vite splits shared code (e.g. the AppShell used by every page) into
// its own chunk with its own CSS file. The entry's own "css" key only
// lists CSS pulled in directly by that entry — CSS from anything it
// imports has to be collected by walking "imports" too, otherwise
// shared-component styles silently never get linked on the page.
$css_files = [];
self::collect_css($manifest, $entry, $css_files);
$index = 0;
foreach (array_unique($css_files) as $css_file) {
wp_enqueue_style($handle . "-" . $index, $base_url . $css_file, [], AURORA_ADMIN_VERSION);
$index++;
}
}
private static function collect_css($manifest, $entry, &$css_files)
{
if (!isset($manifest[$entry])) {
return;
}
$entry_data = $manifest[$entry];
if (!empty($entry_data["css"]) && is_array($entry_data["css"])) {
foreach ($entry_data["css"] as $css_file) {
$css_files[] = $css_file;
}
}
if (!empty($entry_data["imports"]) && is_array($entry_data["imports"])) {
foreach ($entry_data["imports"] as $imported_key) {
self::collect_css($manifest, $imported_key, $css_files);
}
}
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
namespace AuroraAdmin\WhiteLabel;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Applies the White Label settings:
* - 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
*/
class WhiteLabel
{
/** @var array<string,string>|null Cached find=>replace map for gettext. */
private static $replacements = null;
public function __construct()
{
add_action("admin_head", [self::class, "favicon"]);
add_filter("all_plugins", [self::class, "rename_plugin"]);
add_action("admin_menu", [self::class, "rename_menu"], 999);
add_filter("gettext", [self::class, "replace_text"], 20, 3);
}
public static function favicon()
{
$url = Settings::get("admin_favicon", "");
if (!$url) {
return;
}
printf('<link rel="icon" href="%s" />' . "\n", esc_url($url));
}
public static function rename_plugin($plugins)
{
$name = trim((string) Settings::get("plugin_name", ""));
if ($name === "") {
return $plugins;
}
$basename = "aurora-admin/aurora-admin.php";
if (isset($plugins[$basename])) {
$plugins[$basename]["Name"] = $name;
$plugins[$basename]["Title"] = $name;
}
return $plugins;
}
public static function rename_menu()
{
$name = trim((string) Settings::get("plugin_name", ""));
if ($name === "") {
return;
}
global $menu;
if (!is_array($menu)) {
return;
}
foreach ($menu as $index => $item) {
if (isset($item[2]) && $item[2] === "aurora-admin") {
$menu[$index][0] = $name;
break;
}
}
}
public static function replace_text($translation, $text, $domain)
{
// Front end and login screens stay untouched.
if (!is_admin()) {
return $translation;
}
if (self::$replacements === null) {
self::$replacements = [];
$pairs = Settings::get("text_replacements", []);
if (is_array($pairs)) {
foreach ($pairs as $pair) {
if (
is_array($pair) &&
isset($pair["find"], $pair["replace"]) &&
$pair["find"] !== ""
) {
self::$replacements[$pair["find"]] = (string) $pair["replace"];
}
}
}
}
if (!self::$replacements) {
return $translation;
}
return strtr($translation, self::$replacements);
}
}