Author SHA1 Message Date
NixReaper ebf5e2652f Set readme Contributors to Aurora Dragon Studio and KrakenRT 2026-07-26 15:59:19 -05:00
NixReaper 3815dfb671 Remove internal PROJECT_STATUS.md from the public repo
Development-notes file, not something users or WP.org reviewers of
this repo need to see.
2026-07-26 15:42:41 -05:00
NixReaper 7d2bc032a5 Consolidate 1.0-1.13 history into a single v1.0 changelog entry
This repo starts fresh at version 1.0 rather than exposing the
iterative internal version numbers from earlier development.
2026-07-26 15:36:05 -05:00
NixReaper 0a85fad7cf Reset versioning to 1.0 for the fresh public launch
Consolidates the 1.0-1.13 iteration history into a single v1.0
changelog entry and drops the old internal 3.x legacy changelog -
this repo is the clean starting point going forward.
2026-07-26 15:35:12 -05:00
NixReaper 8b66155ba5 Fix Theme/Plugin File Editor sidebar legibility in dark mode (v1.13)
Core sets an explicit light background on the shared #templateside > ul
file-tree component, which nothing in native-dark.css covered - combined
with this file's dark-surface text colors, that produced light text on
a light background rather than just unstyled.
2026-07-26 15:26:26 -05:00
NixReaper d39155e4d5 Merge initial GitHub repo setup 2026-07-26 15:25:19 -05:00
WPAuroraAdmin 88e7a724d5 Initial commit 2026-07-26 15:20:42 -05:00
NixReaperandClaude Sonnet 5 24be114f9a Advance full admin takeover from v1.1 to v1.12
Batches everything built since the 1.0 WP.org submission reset: Login
Redirect, the Tools/Export/Import screen takeovers, Modules rebuilt as
an addon marketplace, Menu Creator's reorder fix, Activity Log
redesigned into a filterable table with Admin/User tabs, dark-theme
fixes for Site Health/Personal Data/Updates, and an opt-in Disable
Gutenberg toggle. Continues the "nothing stays native" takeover goal
and closes gaps found via live testing and the WordPress Plugin Check
tool. See CHANGELOG.md for the full per-version breakdown (1.1-1.12).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-26 13:37:35 -05:00
39 changed files with 2103 additions and 3764 deletions
+51 -1291
View File
File diff suppressed because it is too large Load Diff
-2078
View File
File diff suppressed because it is too large Load Diff
+71 -2
View File
@@ -51,6 +51,11 @@ html.aurora-dark.aurora-ready #wpbody-content .postbox,
html.aurora-dark.aurora-ready #wpbody-content .card,
html.aurora-dark.aurora-ready #wpbody-content .welcome-panel,
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table,
/* .widefat: WP's general "wide flat table" utility class, used beyond just
WP_List_Table screens — e.g. update-core.php's plugin/theme update
tables (class="widefat updates-table", no .wp-list-table and no
.striped either), which were completely uncovered without this. */
html.aurora-dark.aurora-ready #wpbody-content .widefat,
html.aurora-dark.aurora-ready #wpbody-content .stuffbox {
background: var(--aurora-card-bg);
border-color: var(--aurora-card-border);
@@ -79,17 +84,53 @@ html.aurora-dark.aurora-ready #wpbody-content .postbox .handle-order-lower {
/* Tables ---------------------------------------------------------------- */
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table th,
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table td {
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table td,
html.aurora-dark.aurora-ready #wpbody-content .widefat th,
html.aurora-dark.aurora-ready #wpbody-content .widefat td {
border-color: var(--aurora-border);
/* Core sets an explicit white background directly on <td> (not just the
row), which otherwise wins over .wp-list-table/.widefat's own dark
background above — transparent lets the row's actual background
(plain dark, or the striped-odd shade below) show through instead of a
white cell sitting on an otherwise-dark row. Seen first on Export/
Erase Personal Data's request-queue table, but this is a generic
WP_List_Table/.widefat gap, not specific to any one screen. */
background: transparent;
}
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table thead th,
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table tfoot th,
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table thead th a,
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table tfoot th a {
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table tfoot th a,
html.aurora-dark.aurora-ready #wpbody-content .widefat thead th,
html.aurora-dark.aurora-ready #wpbody-content .widefat tfoot th,
html.aurora-dark.aurora-ready #wpbody-content .widefat thead th a,
html.aurora-dark.aurora-ready #wpbody-content .widefat tfoot th a {
color: var(--aurora-text);
}
/* core's list-tables.css sets `.plugins tr { background: #fff; }` for the
native Plugins screen's list table — .plugins is a plain class name, not
scoped to that screen, so it also matches update-core.php's plugin-update
table (its <tbody> reuses the same class). Aurora's own Plugins screen is
a full custom rebuild that never hits this native markup, which is why
this collision hadn't surfaced before update-core.php. */
html.aurora-dark.aurora-ready #wpbody-content .plugins tr {
background: transparent;
}
/* Theme File Editor / Plugin File Editor (theme-editor.php, plugin-editor.php)
share the same #templateside file-tree component. Core sets an explicit
light background directly on `#templateside > ul` (not just inherited from
the page), which nothing else here targets — combined with this file's own
light text/link colors (meant for a dark surface), that produced light text
on a light background, i.e. effectively unreadable rather than just
unstyled. Both screens are deliberately left as framed native content (not
rebuilt), same as Site Health/Updates, but still need to be legible. */
html.aurora-dark.aurora-ready #wpbody-content #templateside > ul {
background: transparent;
}
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table.striped > tbody > :nth-child(odd),
html.aurora-dark.aurora-ready #wpbody-content .striped > tbody > :nth-child(odd) {
background: var(--aurora-bg-subtle);
@@ -492,3 +533,31 @@ html.aurora-dark.aurora-ready #wpbody-content .font-library__font-card {
border-color: var(--aurora-card-border);
color: var(--aurora-text);
}
/* Site Health (site-health.php) is left running WordPress core's own test
* logic rather than reimplemented — its Status tab async checks hit core's
* own /wp-site-health/v1/tests/* REST routes, and third-party plugins can
* add their own checks via the site_status_tests filter, so reproducing it
* risks both drifting from core and silently dropping other plugins'
* checks. Only the framing/theming is Aurora's, same "frame native content"
* treatment as the block editor above.
*
* Three of its own elements paint a hardcoded white/off-white background
* that the generic #wpbody-content light-text cascade doesn't reach,
* leaving light text unreadable against it — the same "boot-layout" pattern
* fixed above, just a different screen. */
html.aurora-dark.aurora-ready #wpbody-content .health-check-header,
html.aurora-dark.aurora-ready #wpbody-content .health-check-accordion-panel {
background: var(--aurora-card-bg);
}
html.aurora-dark.aurora-ready #wpbody-content .health-check-accordion-trigger {
background: var(--aurora-bg-subtle);
color: var(--aurora-text);
}
/* The Security/Performance category pill inside each trigger row keeps its
* own dark text color (readable against the trigger's original near-white
* background) — needs to flip to light along with the trigger's background
* above, or it becomes unreadable in the other direction. */
html.aurora-dark.aurora-ready #wpbody-content .health-check-accordion-trigger .badge {
color: var(--aurora-text);
}
+99 -18
View File
@@ -10,7 +10,7 @@ defined("ABSPATH") || exit();
class ActivityLog
{
const TABLE = "aurora_admin_activity_log";
const DB_VERSION = "1";
const DB_VERSION = "2";
const DB_OPTION = "aurora_admin_activity_log_db_version";
public function __construct()
@@ -19,9 +19,12 @@ class ActivityLog
add_action("save_post", [self::class, "on_save_post"], 10, 3);
add_action("before_delete_post", [self::class, "on_delete_post"]);
add_action("add_attachment", [self::class, "on_add_attachment"]);
add_action("delete_attachment", [self::class, "on_delete_attachment"]);
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("wp_logout", [self::class, "on_logout"]);
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"]);
@@ -52,6 +55,7 @@ class ActivityLog
object_label VARCHAR(191) NOT NULL DEFAULT '',
user_id BIGINT UNSIGNED NULL,
user_name VARCHAR(191) NOT NULL DEFAULT '',
ip_address VARCHAR(45) NULL,
details LONGTEXT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
@@ -66,10 +70,23 @@ class ActivityLog
update_option(self::DB_OPTION, self::DB_VERSION);
}
private static function record($action_type, $object_type, $object_id, $object_label, $details = [])
/**
* @param array|null $actor Overrides the "current user" attribution with
* an explicit ["id" => int, "name" => string] — needed for wp_logout,
* where wp_get_current_user() has already been reset to the logged-out
* (id 0) user by the time this hook fires, which would otherwise record
* every logout as done by nobody.
*/
private static function record($action_type, $object_type, $object_id, $object_label, $details = [], $actor = null)
{
global $wpdb;
$user = wp_get_current_user();
if ($actor === null) {
$current = wp_get_current_user();
$actor = [
"id" => $current && $current->exists() ? $current->ID : null,
"name" => $current && $current->exists() ? $current->display_name : "",
];
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- $wpdb->insert() is WordPress's own escaping-safe API for writing to a custom table (not raw SQL); there's no core post/postmeta abstraction for Aurora's own activity-log table, so any access to it is necessarily "direct" by this sniff's definition.
$wpdb->insert(self::table_name(), [
@@ -77,69 +94,133 @@ class ActivityLog
"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 : "",
"user_id" => $actor["id"],
"user_name" => $actor["name"],
"ip_address" => self::client_ip(),
"details" => $details ? wp_json_encode($details) : null,
"created_at" => current_time("mysql"),
]);
}
/**
* REMOTE_ADDR only — the actual TCP peer, not spoofable by a request
* header. Behind a reverse proxy/load balancer this will read as the
* proxy's own address rather than the original visitor; deliberately not
* trusting X-Forwarded-For or similar instead, since blindly trusting a
* client-supplied header for anything (even just display, as here) is the
* standard IP-spoofing footgun, and this plugin has no reliable way to
* know whether a given host's proxy is trusted.
*/
private static function client_ip()
{
$ip = isset($_SERVER["REMOTE_ADDR"]) ? sanitize_text_field(wp_unslash($_SERVER["REMOTE_ADDR"])) : "";
return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : null;
}
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)) {
// save_post fires on autosaves, revisions, and attachments too — none
// represent a real user-visible content change (attachments get their
// own add_attachment/delete_attachment hooks below), so all three are
// skipped here.
if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id) || $post->post_type === "attachment") {
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)");
// object_type is the post's real type (post/page/a custom post type),
// not a hardcoded "post" — the Activity Log's Type column needs this to
// read correctly for anything that isn't the built-in Posts type.
self::record($update ? "updated" : "created", $post->post_type, $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") {
if (!$post || $post->post_type === "revision" || $post->post_type === "attachment") {
return;
}
self::record("post_deleted", "post", $post_id, $post->post_title ?: "(no title)");
self::record("deleted", $post->post_type, $post_id, $post->post_title ?: "(no title)");
}
public static function on_add_attachment($post_id)
{
$post = get_post($post_id);
self::record("created", "attachment", $post_id, $post ? $post->post_title : "(no title)");
}
public static function on_delete_attachment($post_id)
{
$post = get_post($post_id);
self::record("deleted", "attachment", $post_id, $post ? $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 : "");
// Self-registration means the visitor isn't authenticated yet at this
// point, so wp_get_current_user() would show "not logged in" rather
// than the account that was just created. Attributing explicitly to the
// new account instead — including when an admin creates the account —
// is a deliberate choice for this specific event: unlike profile edits
// (where the acting admin, shown via the default wp_get_current_user()
// attribution, is the more useful "who did this"), a registration event
// is overwhelmingly self-service, so "who this account belongs to" is
// the more useful read here, and treating both origins the same way
// keeps this one event type's Author column consistent regardless of
// which flow created the account.
self::record("registered", "user", $user_id, $user ? $user->user_login : "", [], [
"id" => $user_id,
"name" => $user ? $user->display_name : "",
]);
}
public static function on_profile_update($user_id)
{
$user = get_userdata($user_id);
self::record("user_updated", "user", $user_id, $user ? $user->user_login : "");
self::record("profile_updated", "user", $user_id, $user ? $user->user_login : "");
}
public static function on_login($user_login, $user)
{
self::record("login", "user", $user->ID, $user_login);
// Not wp_get_current_user(): wp_login fires from inside wp_signon(),
// before wp-login.php's own form handler calls wp_set_current_user() —
// the same ordering issue on_logout() has, just in the other direction.
// $user is passed in for exactly this reason; use it directly instead.
self::record("login", "user", $user->ID, $user_login, [], [
"id" => $user->ID,
"name" => $user->display_name,
]);
}
public static function on_logout($user_id)
{
$user = $user_id ? get_userdata($user_id) : false;
$login = $user ? $user->user_login : "";
self::record("logout", "user", $user_id ?: null, $login, [], [
"id" => $user_id ?: null,
"name" => $user ? $user->display_name : "",
]);
}
public static function on_plugin_activated($plugin)
{
self::record("plugin_activated", "plugin", null, $plugin);
self::record("activated", "plugin", null, $plugin);
}
public static function on_plugin_deactivated($plugin)
{
self::record("plugin_deactivated", "plugin", null, $plugin);
self::record("deactivated", "plugin", null, $plugin);
}
public static function on_theme_switch($new_name)
{
self::record("theme_switched", "theme", null, $new_name);
self::record("switched", "theme", null, $new_name);
}
public static function on_settings_updated()
{
self::record("settings_updated", "settings", null, "Aurora Admin settings");
self::record("updated", "settings", null, "Aurora Admin settings");
}
}
+49 -2
View File
@@ -23,6 +23,11 @@ class ActivityLogData
return current_user_can("manage_options");
},
"args" => [
// "admin" = site/content activity (everything except user-account
// events), "user" = account activity (login/logout/registration/
// profile updates) — the Activity Log page's two tabs. Omit for
// unfiltered (both).
"scope" => ["type" => "string", "required" => false, "enum" => ["admin", "user"]],
"action_type" => ["type" => "string", "required" => false],
"object_type" => ["type" => "string", "required" => false],
"search" => ["type" => "string", "required" => false],
@@ -40,6 +45,13 @@ class ActivityLogData
$where = ["1=1"];
$params = [];
$scope = $request->get_param("scope");
if ($scope === "user") {
$where[] = "object_type = 'user'";
} elseif ($scope === "admin") {
$where[] = "object_type != 'user'";
}
if ($request->get_param("action_type")) {
$where[] = "action_type = %s";
$params[] = sanitize_text_field($request->get_param("action_type"));
@@ -73,15 +85,50 @@ class ActivityLogData
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- reading Aurora's own custom activity-log table; always passed through $wpdb->prepare() with $params plus the trailing $per_page/$offset bound via %d/%d.
$rows = $wpdb->get_results($wpdb->prepare($sql, array_merge($params, [$per_page, $offset])));
$items = array_map(function ($row) {
// Live user info (avatar/role/email) rather than freezing it into each
// log row at record time — reflects the account's CURRENT role/email
// (correct if either changes later) and avoids duplicating PII into
// every single event row when user_id is already a foreign key back to
// it. Batch-fetched once for every distinct user_id on this page,
// rather than one get_userdata() call per row.
$user_ids = array_values(array_unique(array_filter(array_map(function ($row) {
return $row->user_id ? (int) $row->user_id : 0;
}, $rows))));
$users_by_id = [];
if ($user_ids) {
// $roles[0] is a role SLUG (e.g. "shop_manager") — its translated
// display name has to come from wp_roles()'s own registered name for
// that slug, not a guess like ucfirst($slug); that only happens to
// work for the single-word built-in roles.
$all_roles = wp_roles()->roles;
foreach (get_users(["include" => $user_ids, "fields" => "all_with_meta"]) as $u) {
$slug = $u->roles ? $u->roles[0] : "";
$role_name = $slug && isset($all_roles[$slug]) ? translate_user_role($all_roles[$slug]["name"]) : "";
$users_by_id[$u->ID] = [
"avatar" => get_avatar_url($u->ID, ["size" => 64]),
"role" => $role_name,
"email" => $u->user_email,
"editUrl" => esc_url_raw(admin_url("user-edit.php?user_id=" . $u->ID)),
];
}
}
$items = array_map(function ($row) use ($users_by_id) {
$uid = $row->user_id ? (int) $row->user_id : null;
$u = $uid && isset($users_by_id[$uid]) ? $users_by_id[$uid] : null;
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,
"userId" => $uid,
"userName" => $row->user_name,
"userAvatar" => $u ? $u["avatar"] : null,
"userRole" => $u ? $u["role"] : "",
"userEmail" => $u ? $u["email"] : "",
"userEditUrl" => $u ? $u["editUrl"] : "",
"ipAddress" => $row->ip_address,
"details" => $row->details ? json_decode($row->details, true) : null,
"createdAt" => $row->created_at,
];
+10
View File
@@ -31,6 +31,9 @@ use AuroraAdmin\Pages\NativeSettingsPages;
use AuroraAdmin\Rest\PermalinksData;
use AuroraAdmin\Rest\DiscussionData;
use AuroraAdmin\Pages\AppearancePages;
use AuroraAdmin\Pages\ToolsPage;
use AuroraAdmin\Pages\ExportPage;
use AuroraAdmin\Pages\ImportPage;
use AuroraAdmin\Rest\NavMenusData;
use AuroraAdmin\Rest\ThemesData;
use AuroraAdmin\Rest\WidgetsData;
@@ -43,6 +46,8 @@ use AuroraAdmin\Roles\RolesData;
use AuroraAdmin\ActivityLog\ActivityLog;
use AuroraAdmin\ActivityLog\ActivityLogData;
use AuroraAdmin\Login\LoginStyle;
use AuroraAdmin\Login\LoginRedirect;
use AuroraAdmin\Editor\DisableGutenberg;
use AuroraAdmin\Media\SvgUploads;
use AuroraAdmin\Media\MediaFolders;
use AuroraAdmin\ImageFormats\ImageFormats;
@@ -84,6 +89,9 @@ class AuroraAdmin
new PermalinksData();
new DiscussionData();
new AppearancePages();
new ToolsPage();
new ExportPage();
new ImportPage();
new NavMenusData();
new ThemesData();
new WidgetsData();
@@ -96,6 +104,8 @@ class AuroraAdmin
new ActivityLog();
new ActivityLogData();
new LoginStyle();
new LoginRedirect();
new DisableGutenberg();
new SvgUploads();
new MediaFolders();
new ImageFormats();
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace AuroraAdmin\Editor;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Settings > Screens > Editor: "Disable Gutenberg" — opt-in, off by default.
* Falls back to WordPress core's own classic (meta-box/TinyMCE) post editor
* via the same core-supported filter the official Classic Editor plugin
* uses, rather than framing or rebuilding the block editor itself — the
* block editor stays one of the two deliberate "still native" screens in
* the admin takeover (alongside the Theme/Plugin File Editor), so this is
* an escape hatch around it, not a takeover of it.
*/
class DisableGutenberg
{
public function __construct()
{
add_filter("use_block_editor_for_post_type", [self::class, "maybe_disable"], 100);
}
public static function maybe_disable($use_block_editor)
{
if ((bool) Settings::get("disable_gutenberg", false)) {
return false;
}
return $use_block_editor;
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace AuroraAdmin\Login;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Settings > Login > "Login Redirect": optionally sends logged-out wp-admin
* visitors to a custom URL instead of the login screen, and/or sends
* specific roles to a custom URL after logging in instead of the dashboard.
* Both are opt-in and empty by default (normal WordPress behavior).
*
* Deliberately does NOT touch wp-login.php itself — it is never hidden,
* blocked, or moved, so this can only change where someone lands
* before/after that point, never whether they can reach the login form.
* Changing/hiding the login URL itself is a separate, higher-risk feature
* not implemented here.
*/
class LoginRedirect
{
public function __construct()
{
add_filter("login_redirect", [self::class, "after_login"], 10, 3);
add_filter("allowed_redirect_hosts", [self::class, "allow_configured_hosts"]);
// wp_loaded, not admin_init: WordPress's own auth_redirect() (called
// from wp-admin/admin.php, before admin_init fires) already redirects
// an unauthenticated visitor to wp-login.php and exits — admin_init
// would never run for the case this needs to catch. wp_loaded fires
// from inside wp-settings.php, which wp-admin/admin.php requires and
// waits on BEFORE calling auth_redirect(), so it reliably runs first.
add_action("wp_loaded", [self::class, "guard_admin"]);
}
private static function enabled()
{
return (bool) Settings::get("login_redirect_enabled", false);
}
/** Destination after a successful login, for the configured roles. */
public static function after_login($redirect_to, $requested_redirect_to, $user)
{
if (!self::enabled() || !($user instanceof \WP_User) || !$user->exists()) {
return $redirect_to;
}
$roles = (array) Settings::get("redirect_roles", []);
if (!$roles || !array_intersect((array) $user->roles, $roles)) {
return $redirect_to;
}
$target = trim((string) Settings::get("redirect_after_login_url", ""));
return $target !== "" ? esc_url_raw($target) : $redirect_to;
}
/**
* Sends logged-out visitors to a custom URL instead of the default
* wp-login.php screen when they try to open wp-admin. Scoped tightly to
* real browser page loads under wp-admin — admin-ajax.php and
* admin-post.php (both used by logged-out requests via the *_nopriv_*
* hooks other plugins rely on for public form handling), async uploads,
* REST, and multisite's network admin are all left untouched, so nothing
* outside the login screen itself is affected.
*/
public static function guard_admin()
{
if (!self::enabled() || is_user_logged_in()) {
return;
}
if (!is_admin() || is_network_admin() || wp_doing_ajax()) {
return;
}
if (defined("REST_REQUEST") && REST_REQUEST) {
return;
}
$excluded_scripts = ["admin-post.php", "admin-ajax.php", "async-upload.php"];
if (in_array($GLOBALS["pagenow"] ?? "", $excluded_scripts, true)) {
return;
}
$target = trim((string) Settings::get("redirect_unauthenticated_url", ""));
// Empty, or would send a logged-out visitor straight back into
// wp-admin — the latter is almost certainly a misconfiguration and
// would otherwise create a redirect loop.
if ($target === "" || stripos($target, "wp-admin") !== false) {
return;
}
wp_safe_redirect(esc_url_raw($target));
exit();
}
/**
* Lets wp_safe_redirect() honor an external target the admin configured
* for either redirect above, instead of silently falling back to the site
* home URL — both settings come from manage_options-gated Settings, never
* from request input, so there's no open-redirect exposure in trusting
* their host.
*/
public static function allow_configured_hosts($hosts)
{
foreach (["redirect_unauthenticated_url", "redirect_after_login_url"] as $key) {
$host = wp_parse_url(trim((string) Settings::get($key, "")), PHP_URL_HOST);
if ($host) {
$hosts[] = $host;
}
}
return $hosts;
}
}
+95 -23
View File
@@ -14,14 +14,38 @@ class MenuCreator
const DB_VERSION = "1";
const DB_OPTION = "aurora_admin_menus_db_version";
/** Cached merged item list for the current user (per request). */
private static $items_cache = null;
/** Raw top-level menu snapshot, captured before apply() hides anything. */
private static $native_snapshot = null;
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.
// Snapshot the full native menu just before apply() runs, so the editor
// can list (and re-enable) items this config hides. Both run after every
// other plugin has registered its menu items.
add_action("admin_menu", [self::class, "capture_native"], PHP_INT_MAX - 1);
add_action("admin_menu", [self::class, "apply"], PHP_INT_MAX);
}
/** Captures the raw top-level menu for the editor (see native_snapshot()). */
public static function capture_native()
{
self::$native_snapshot = \AuroraAdmin\Shell\MenuSerializer::top_level_items();
}
/**
* The raw top-level menu items for the current admin page load. The Menu
* Creator page feeds this into its serverData (SubApps.php) because the
* REST route can't build the admin menu.
*/
public static function native_snapshot()
{
return is_array(self::$native_snapshot) ? self::$native_snapshot : [];
}
public static function table_name()
{
global $wpdb;
@@ -57,44 +81,62 @@ class MenuCreator
}
/**
* Applies every published config whose target roles match the current
* user: hides items marked hidden and relabels items with a custom label.
* Merged item list from every published config whose target roles match
* the current user. Cached for the request (read by both apply() and
* ordered_slugs() on every admin page load).
*/
public static function apply()
private static function current_user_items()
{
global $menu, $wpdb;
if (self::$items_cache !== null) {
return self::$items_cache;
}
self::$items_cache = [];
global $wpdb;
if (!is_admin() || !function_exists("wp_get_current_user")) {
return;
return self::$items_cache;
}
$user = wp_get_current_user();
if (!$user || !$user->exists()) {
return;
return self::$items_cache;
}
$table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is an internal, hardcoded {$wpdb->prefix}-based table name, not user input; no user-supplied values in this query. Runs on every admin page load to build the sidebar menu, so a per-request cache wouldn't help; a persistent cache would need its own invalidation on every config save, more complexity than this read is worth.
$configs = $wpdb->get_results("SELECT roles, items FROM {$table} WHERE status = 'published'");
if (!$configs) {
return self::$items_cache;
}
$items = [];
foreach ($configs as $config) {
$roles = json_decode($config->roles ?: "[]", true);
if (!is_array($roles) || !$roles || !array_intersect((array) $user->roles, $roles)) {
continue;
}
$decoded = json_decode($config->items ?: "[]", true);
if (is_array($decoded)) {
$items = array_merge($items, $decoded);
}
}
self::$items_cache = $items;
return $items;
}
/**
* Hides items marked hidden and relabels items with a custom label, on the
* native top-level $menu (which Aurora's MenuSerializer then reads).
*/
public static function apply()
{
global $menu;
if (!is_array($menu)) {
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) {
foreach (self::current_user_items() as $item) {
$native_id = isset($item["native_id"]) ? $item["native_id"] : "";
if ($native_id === "") {
continue;
@@ -116,5 +158,35 @@ class MenuCreator
}
}
}
/**
* Desired top-level slug order for the current user, from the saved config
* (items sorted by their 'order' field). Empty when no config applies.
*
* Consumed by MenuSerializer so Aurora's own sidebar reflects the order —
* WordPress's native `menu_order` filter only reorders the core admin menu,
* which Aurora hides, so it can't be used here.
*/
public static function ordered_slugs()
{
$items = self::current_user_items();
if (!$items) {
return [];
}
usort($items, function ($a, $b) {
$ao = isset($a["order"]) ? (int) $a["order"] : 0;
$bo = isset($b["order"]) ? (int) $b["order"] : 0;
return $ao <=> $bo;
});
$slugs = [];
foreach ($items as $item) {
$slug = isset($item["native_id"]) ? $item["native_id"] : "";
if ($slug !== "" && !in_array($slug, $slugs, true)) {
$slugs[] = $slug;
}
}
return $slugs;
}
}
+4 -6
View File
@@ -82,17 +82,15 @@ class MenuCreatorData
{
$menu = \AuroraAdmin\Shell\MenuSerializer::serialize();
$flat = [];
// Top-level items only. These are what the Menu Creator can reorder,
// hide, and rename today; submenu editing isn't applied yet, so it isn't
// offered (showing controls that do nothing is worse than not showing
// them).
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);
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace AuroraAdmin\Pages;
defined("ABSPATH") || exit();
/**
* Registers the Export screen (export.php) for the "full admin takeover"
* initiative — a real Aurora-built form, not a reframed native page.
*
* Safe to fully rebuild (unlike Tools/Site Health): export.php's actual file
* generation (the `?download=true` request) calls export_wp() and die()s
* before ever reaching in_admin_header/admin_footer — the two hooks
* ScreenHijack's buffering relies on — so that request is never touched by
* the hijack at all and runs completely native, unmodified. Only the HTML
* form (the plain GET view with no `download` param) is replaced; Aurora's
* form submits via a real browser navigation to the same
* export.php?download=true&... URL, so the download itself is byte-for-byte
* what core would have produced.
*/
class ExportPage
{
public function __construct()
{
ScreenHijack::register(
"load-export.php",
"export", // Matches core's own capability for this screen.
"full_admin_takeover",
"export",
"aurora-admin-export-root",
null,
null,
function () {
return [
"exportUrl" => esc_url_raw(admin_url("export.php")),
];
}
);
}
}
+137
View File
@@ -0,0 +1,137 @@
<?php
namespace AuroraAdmin\Pages;
defined("ABSPATH") || exit();
/**
* Registers the Import screen (import.php) for the "full admin takeover"
* initiative — a real Aurora-built listing, not a reframed native page.
*
* Only the LISTING page is rebuilt. Running an already-active importer
* (native "Run Importer" link) still goes to admin.php?import={id} — a
* screen registered and entirely rendered by whatever third-party importer
* plugin owns that id (register_importer()'s own callback). That page is
* arbitrary, unpredictable third-party UI outside this hijack's scope
* (a different load-{hook}), and stays native, same as e.g. the block
* editor stays native beyond its frame.
*
* Install/activate reuses existing, already-proven mechanisms instead of
* new logic: WordPress core's own admin-ajax "install-plugin" action (same
* one native's "Install Now" button triggers) for installing, and Aurora's
* own aurora-admin/v1/plugins/activate route (see PluginsData.php) for
* activation.
*/
class ImportPage
{
public function __construct()
{
ScreenHijack::register(
"load-import.php",
"import", // Matches core's own capability for this screen.
"full_admin_takeover",
"import",
"aurora-admin-import-root",
null,
null,
function () {
return self::build_data();
}
);
}
private static function build_data()
{
if (!function_exists("get_importers")) {
require_once ABSPATH . "wp-admin/includes/import.php";
}
// Real, already-active importers (registered via register_importer() by
// an active importer plugin) — global $wp_importers. get_importers()
// returns that global as-is, which is null (not an empty array) until
// something actually calls register_importer() — true whenever no
// importer plugin is active, e.g. a fresh install. Native import.php
// never hits this directly: it only ever reads $importers via isset(),
// and PHP auto-vivifies null into an array the moment popular_importers
// gets merged in via `$importers[$id] = [...]`, so by the time IT
// foreach()es, an assignment has always already happened. This code
// foreach()es the raw result directly, so it needs its own guard.
$active = get_importers();
if (!is_array($active)) {
$active = [];
}
$can_install = current_user_can("install_plugins");
$popular = $can_install ? wp_get_popular_importers() : [];
$merged = [];
foreach ($active as $id => $data) {
$merged[] = [
"id" => $id,
"name" => $data[0],
"description" => $data[1],
"status" => "active",
"runUrl" => esc_url_raw(self_admin_url("admin.php?import=" . $id)),
];
}
// Same de-dup + dummy-registration logic as native import.php: a
// popular importer already covered by an active one is skipped.
foreach ($popular as $pop_key => $pop) {
$importer_id = isset($pop["importer-id"]) ? $pop["importer-id"] : $pop_key;
if (isset($active[$pop_key]) || isset($active[$importer_id])) {
continue;
}
$plugin_slug = isset($pop["plugin-slug"]) ? $pop["plugin-slug"] : "";
$entry = [
"id" => $importer_id,
"name" => $pop["name"],
"description" => $pop["description"],
"slug" => $plugin_slug,
// Deterministic regardless of current status — provided for every
// entry so the front end never has to construct an admin URL
// itself (this respects any custom/network admin path).
"runUrl" => esc_url_raw(self_admin_url("admin.php?import=" . $importer_id)),
];
if ($plugin_slug !== "" && file_exists(WP_PLUGIN_DIR . "/" . $plugin_slug)) {
if (!function_exists("get_plugins")) {
require_once ABSPATH . "wp-admin/includes/plugin.php";
}
$plugins = get_plugins("/" . $plugin_slug);
if ($plugins) {
$keys = array_keys($plugins);
$entry["status"] = "installed_inactive";
$entry["pluginFile"] = $plugin_slug . "/" . $keys[0];
} else {
$entry["status"] = "not_installed";
}
} else {
$entry["status"] = "not_installed";
}
$merged[] = $entry;
}
usort($merged, function ($a, $b) {
return strcasecmp($a["name"], $b["name"]);
});
// Any third-party output hooked to import_filters (a genuine, if rarely
// used, extension point) — same capture-and-relay approach as Tools'
// tool_box. Empty on a site with nothing hooked to it.
ob_start();
do_action("import_filters");
$native_filters_html = trim((string) ob_get_clean());
return [
"importers" => $merged,
"canInstallPlugins" => $can_install,
"installNonce" => wp_create_nonce("updates"),
"ajaxUrl" => esc_url_raw(admin_url("admin-ajax.php")),
"pluginSearchUrl" => esc_url_raw(network_admin_url("plugin-install.php?tab=search&type=tag&s=importer")),
"nativeFiltersHtml" => $native_filters_html,
];
}
}
+8
View File
@@ -35,6 +35,7 @@ class SubApps
public static function pages()
{
return [
"aurora-admin-modules" => ["title" => __("Modules", "aurora-admin"), "entry" => "modules"],
"aurora-admin-menu-creator" => ["title" => __("Menu Creator", "aurora-admin"), "entry" => "menu-creator"],
"aurora-admin-notices" => ["title" => __("Admin Notices", "aurora-admin"), "entry" => "admin-notices"],
"aurora-admin-roles" => ["title" => __("Role Editor", "aurora-admin"), "entry" => "role-editor"],
@@ -164,6 +165,13 @@ class SubApps
$data["theme"] = wp_get_theme()->get("Name");
}
// The Menu Creator editor needs the raw top-level menu, which can't be
// read over REST (the admin $menu isn't built there). Pass the snapshot
// captured during this admin page load instead.
if ($page === "aurora-admin-menu-creator") {
$data["nativeItems"] = \AuroraAdmin\MenuCreator\MenuCreator::native_snapshot();
}
$root_id = "aurora-admin-" . $pages[$page]["entry"] . "-root";
?>
<div
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace AuroraAdmin\Pages;
defined("ABSPATH") || exit();
/**
* Registers the "Available Tools" screen (tools.php) for the "full admin
* takeover" initiative — a real hijacked replacement, not just a reframed
* native page.
*
* tools.php's whole purpose is being an extension point: other plugins add
* their own cards to it via the `tool_box` action (WooCommerce, migration
* tools, etc. have historically used it). A pure from-scratch rebuild would
* silently drop that content the moment Aurora takes over the screen, so
* this still captures and re-renders whatever `tool_box` produces — Aurora
* owns the page shell (header, card styling, layout) but not the content of
* hooks it doesn't control.
*
* Import, Export, Site Health, Export/Erase Personal Data, and the Theme/
* Plugin File Editors are separate screens (their own load-{file} hooks) and
* are not covered by this — Available Tools only.
*
* Native tools.php's "Categories and Tags Converter" card is NOT produced by
* the tool_box hook — it's hardcoded directly in core's own template, gated
* on the `import` capability plus category/tag manage_terms. Replicated here
* with the identical gate so visibility exactly matches what core itself
* would have shown.
*/
class ToolsPage
{
public function __construct()
{
ScreenHijack::register(
"load-tools.php",
"edit_posts", // Matches core's own capability for this screen.
"full_admin_takeover",
"tools",
"aurora-admin-tools-root",
null,
null,
function () {
// Same gate core's own tools.php applies before showing the
// Categories and Tags Converter card.
$show_converter = false;
if (current_user_can("import")) {
$cats = get_taxonomy("category");
$tags = get_taxonomy("post_tag");
$show_converter =
($cats && current_user_can($cats->cap->manage_terms)) ||
($tags && current_user_can($tags->cap->manage_terms));
}
// The native call to do_action('tool_box') already happened during
// this same request (inside the buffer ScreenHijack discards) — this
// is a second, independent invocation solely to capture its output
// for Aurora's own page, so any OTHER plugin's tool_box card still
// shows up here. Standard "buffer a hook to capture its markup"
// pattern; safe for the well-behaved, output-only callbacks this
// hook is meant for.
ob_start();
do_action("tool_box");
$native_tools_html = trim((string) ob_get_clean());
return [
"showCategoriesConverter" => $show_converter,
"importUrl" => esc_url_raw(admin_url("import.php")),
"nativeToolsHtml" => $native_tools_html,
];
}
);
}
}
+1
View File
@@ -111,6 +111,7 @@ class Security
// Drop the s.w.org DNS-prefetch the emoji script adds.
add_filter("wp_resource_hints", function ($hints, $relation_type) {
if ($relation_type === "dns-prefetch") {
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- "emoji_svg_url" is a WordPress core filter (wp-includes/formatting.php), not a hook this plugin owns; it is applied here only to read core's own emoji SVG base URL so the matching s.w.org dns-prefetch hint can be identified and removed.
$emoji_svg = apply_filters("emoji_svg_url", "https://s.w.org/images/core/emoji/");
$hints = array_filter($hints, function ($h) use ($emoji_svg) {
return strpos((string) $h, $emoji_svg) === false && strpos((string) $h, "s.w.org") === false;
+56
View File
@@ -102,6 +102,62 @@ class MenuSerializer
$items[] = $entry;
}
// Apply the Menu Creator's saved order (if any) for the current user.
// Configured items come first in their saved order; anything not in the
// config — separators, and newly installed plugins' menu items — keeps
// its original position after them, so new menu items never disappear.
$order = \AuroraAdmin\MenuCreator\MenuCreator::ordered_slugs();
if ($order) {
$rank = array_flip($order);
$tail = count($order);
$decorated = [];
foreach ($items as $i => $entry) {
$slug = isset($entry["id"]) ? $entry["id"] : "";
$r = $slug !== "" && isset($rank[$slug]) ? $rank[$slug] : $tail + $i;
$decorated[] = ["r" => $r, "i" => $i, "entry" => $entry];
}
usort($decorated, function ($a, $b) {
return $a["r"] <=> $b["r"] ?: $a["i"] <=> $b["i"];
});
$items = array_map(function ($d) {
return $d["entry"];
}, $decorated);
}
return $items;
}
/**
* Raw top-level menu items ({native_id,title,icon}) for the Menu Creator
* editor. Unlike serialize() this must be captured BEFORE MenuCreator hides
* anything (so hidden items still appear and can be toggled back on) — and
* it can't run over REST, where the admin $menu isn't built. See
* MenuCreator::capture_native().
*/
public static function top_level_items()
{
global $menu;
if (!is_array($menu)) {
return [];
}
$items = [];
foreach ($menu as $item) {
$classes = isset($item[4]) ? $item[4] : "";
if (strpos($classes, "wp-menu-separator") !== false) {
continue;
}
$cap = isset($item[1]) ? $item[1] : "";
$slug = isset($item[2]) ? $item[2] : "";
if ($slug === "" || $slug === "aurora-admin" || empty($cap) || !current_user_can($cap)) {
continue;
}
$title = self::clean_title(isset($item[0]) ? $item[0] : "");
$items[] = [
"native_id" => $slug,
"title" => $title["title"],
"icon" => self::resolve_icon(isset($item[6]) ? $item[6] : ""),
];
}
return $items;
}
+1
View File
@@ -250,6 +250,7 @@ class Shell
{
$items = [
["id" => "aurora-admin", "title" => __("Settings", "aurora-admin"), "icon" => "dashicons-admin-generic"],
["id" => "aurora-admin-modules", "title" => __("Modules", "aurora-admin"), "icon" => "dashicons-screenoptions"],
["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"],
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Export from '@/pages/export/Export.vue';
const mountEl = document.getElementById('aurora-admin-export-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Export, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Import from '@/pages/import/Import.vue';
const mountEl = document.getElementById('aurora-admin-import-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Import, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Modules from '@/pages/modules/Modules.vue';
const mountEl = document.getElementById('aurora-admin-modules-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Modules, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Tools from '@/pages/tools/Tools.vue';
const mountEl = document.getElementById('aurora-admin-tools-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Tools, { serverData: data }).mount(mountEl);
}
+4 -1
View File
@@ -80,7 +80,10 @@ const search = defineModel('search', { type: String, default: '' });
current scroll position, looking like nothing happened until you
scrolled all the way back up. */
height: calc(100vh - var(--aurora-toolbar-h) - 60px);
margin: -32px;
/* -20px cancels the scroller's 20px padding (shell-frame.css's two-layer
scroll rule) so the shell still bleeds to the rounded frame's inner
edge. Was -32px back when #wpbody-content padded the content directly. */
margin: -20px;
padding: 22px;
box-sizing: border-box;
/* No background — html/body already paint the page gradient; this was
+247 -76
View File
@@ -1,7 +1,5 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import AppListShell from '../_shared/AppListShell.vue';
import AppListItem from '../_shared/AppListItem.vue';
const props = defineProps({
serverData: { type: Object, required: true },
@@ -15,28 +13,67 @@ const api = (path) =>
return r.json();
});
const ACTION_TYPES = [
// Two tabs: "Admin" = site/content activity, "User" = account activity
// (login/logout/registration/profile updates) — object_type 'user' is the
// dividing line the REST route's `scope` param filters on.
const TABS = [
{ key: 'admin', label: 'Admin' },
{ key: 'user', label: 'User' },
];
const ADMIN_ACTIONS = [
['', 'All Actions'],
['post_created', 'Post created'],
['post_updated', 'Post updated'],
['post_deleted', 'Post deleted'],
['user_registered', 'User registered'],
['user_updated', 'User updated'],
['login', 'Login'],
['plugin_activated', 'Plugin activated'],
['plugin_deactivated', 'Plugin deactivated'],
['theme_switched', 'Theme switched'],
['settings_updated', 'Settings updated'],
['created', 'Created'],
['updated', 'Updated'],
['deleted', 'Deleted'],
['activated', 'Activated'],
['deactivated', 'Deactivated'],
['switched', 'Switched'],
];
const USER_ACTIONS = [
['', 'All Actions'],
['registered', 'Registered'],
['profile_updated', 'Profile updated'],
['login', 'Logged in'],
['logout', 'Logged out'],
];
const OBJECT_TYPES = [
['', 'All Types'],
['post', 'Post'],
['user', 'User'],
['page', 'Page'],
['attachment', 'Attachment'],
['plugin', 'Plugin'],
['theme', 'Theme'],
['settings', 'Settings'],
];
const TYPE_LABELS = {
post: 'Post',
page: 'Page',
attachment: 'Attachment',
user: 'User',
plugin: 'Plugin',
theme: 'Theme',
settings: 'Settings',
};
const ACTION_LABELS = {
created: 'Created',
updated: 'Updated',
deleted: 'Deleted',
registered: 'Registered',
profile_updated: 'Profile updated',
login: 'Logged in',
logout: 'Logged out',
activated: 'Activated',
deactivated: 'Deactivated',
switched: 'Switched',
};
const typeLabel = (t) => TYPE_LABELS[t] || (t ? t.charAt(0).toUpperCase() + t.slice(1) : '—');
const actionLabel = (a) => ACTION_LABELS[a] || a;
const scope = ref('admin');
const actionOptions = computed(() => (scope.value === 'user' ? USER_ACTIONS : ADMIN_ACTIONS));
const items = ref([]);
const loading = ref(true);
const total = ref(0);
@@ -45,14 +82,13 @@ const totalPages = ref(1);
const actionFilter = ref('');
const objectFilter = ref('');
const search = ref('');
const selectedId = ref(null);
const load = async () => {
loading.value = true;
try {
const params = new URLSearchParams();
const params = new URLSearchParams({ scope: scope.value });
if (actionFilter.value) params.set('action_type', actionFilter.value);
if (objectFilter.value) params.set('object_type', objectFilter.value);
if (scope.value === 'admin' && objectFilter.value) params.set('object_type', objectFilter.value);
if (search.value.trim()) params.set('search', search.value.trim());
params.set('page', page.value);
const data = await api(`/activity-log?${params.toString()}`);
@@ -71,83 +107,218 @@ watch([actionFilter, objectFilter, search], () => {
});
watch(page, load);
const selected = computed(() => items.value.find((i) => i.id === selectedId.value) || null);
const setScope = (key) => {
if (scope.value === key) return;
scope.value = key;
actionFilter.value = '';
objectFilter.value = '';
page.value = 1;
load();
};
const formatDate = (s) => (s ? new Date(s.replace(' ', 'T')).toLocaleString() : '');
const rowRange = computed(() => {
if (!total.value) return '0';
const start = (page.value - 1) * 20 + 1;
const end = Math.min(page.value * 20, total.value);
return `${start}–${end}`;
});
const goPage = (p) => {
page.value = Math.min(Math.max(1, p), totalPages.value);
};
</script>
<template>
<AppListShell
v-model:search="search"
title="Activity Log"
search-placeholder="Search activities…"
:count-label="`${total} ITEMS`"
:page="page"
:has-prev="page > 1"
:has-next="page < totalPages"
detail-icon="dashicons-clock"
detail-title="Activity Details"
detail-text="Select an activity from the list to view detailed information."
:has-selection="!!selected"
@prev-page="page--"
@next-page="page++"
>
<template #header-actions>
<button type="button" @click="load">
<main class="activity-page">
<header class="activity-page__header">
<div>
<p class="activity-page__eyebrow">Aurora Admin</p>
<h1>Activity Log</h1>
</div>
<button type="button" class="icon-button" title="Refresh" :disabled="loading" @click="load">
<span class="dashicons dashicons-update" />
</button>
</template>
</header>
<template #filters>
<select v-model="actionFilter" class="activity-log__select">
<option v-for="[v, l] in ACTION_TYPES" :key="v" :value="v">{{ l }}</option>
<div class="activity-page__tabs">
<button
v-for="tab in TABS"
:key="tab.key"
type="button"
class="filter-pill"
:class="{ 'is-active': scope === tab.key }"
@click="setScope(tab.key)"
>
{{ tab.label }}
</button>
</div>
<section class="activity-page__toolbar" aria-label="Activity filters and search">
<div class="activity-page__filters">
<select v-model="actionFilter" class="activity-page__select">
<option v-for="[v, l] in actionOptions" :key="v" :value="v">{{ l }}</option>
</select>
<select v-model="objectFilter" class="activity-log__select">
<select v-if="scope === 'admin'" v-model="objectFilter" class="activity-page__select">
<option v-for="[v, l] in OBJECT_TYPES" :key="v" :value="v">{{ l }}</option>
</select>
</template>
</div>
<label class="activity-page__search">
<span class="dashicons dashicons-search" />
<input v-model="search" type="search" placeholder="Search activity…" />
</label>
</section>
<template #list>
<p v-if="!loading && !items.length" style="font-size: 0.8125rem; color: var(--aurora-text-muted); padding: 20px 4px; text-align: center;">
No activities found
</p>
<AppListItem
v-for="i in items"
:key="i.id"
icon="dashicons-clock"
:title="i.objectLabel || i.actionType"
:subtitle="`${i.userName || 'System'} · ${formatDate(i.createdAt)}`"
:active="i.id === selectedId"
@click="selectedId = i.id"
/>
</template>
<section class="activity-table" aria-label="Activity Log">
<div class="activity-table__head">
<div>Date</div>
<div>Author</div>
<div>IP Address</div>
<div>Type</div>
<div>Action</div>
<div>Description</div>
</div>
<template #detail>
<div v-if="selected" class="activity-detail">
<h2>{{ selected.objectLabel || selected.actionType }}</h2>
<dl>
<dt>Action</dt><dd>{{ selected.actionType }}</dd>
<dt>Object type</dt><dd>{{ selected.objectType }}</dd>
<dt>User</dt><dd>{{ selected.userName || 'System' }}</dd>
<dt>Date</dt><dd>{{ formatDate(selected.createdAt) }}</dd>
</dl>
<pre v-if="selected.details">{{ JSON.stringify(selected.details, null, 2) }}</pre>
<div v-if="loading" class="activity-table__empty">Loading activity...</div>
<div v-else-if="!items.length" class="activity-table__empty">No activity found.</div>
<article v-for="i in items" v-else :key="i.id" class="activity-row">
<div class="activity-row__cell" data-label="Date">{{ formatDate(i.createdAt) }}</div>
<div class="activity-row__cell activity-row__author" data-label="Author">
<template v-if="i.userId">
<img class="activity-row__avatar" :src="i.userAvatar" :alt="i.userName" width="32" height="32" />
<div class="activity-row__author-info">
<a :href="i.userEditUrl">{{ i.userName }}</a>
<span v-if="i.userRole" class="activity-row__role">{{ i.userRole }}</span>
<span v-if="i.userEmail" class="activity-row__email">{{ i.userEmail }}</span>
</div>
</template>
</AppListShell>
<span v-else>System</span>
</div>
<div class="activity-row__cell" data-label="IP Address">{{ i.ipAddress || '—' }}</div>
<div class="activity-row__cell" data-label="Type">
<span class="type-badge">{{ typeLabel(i.objectType) }}</span>
</div>
<div class="activity-row__cell" data-label="Action">{{ actionLabel(i.actionType) }}</div>
<div class="activity-row__cell activity-row__desc" data-label="Description">{{ i.objectLabel || '—' }}</div>
</article>
<footer v-if="!loading && items.length" class="activity-table__footer">
<div>{{ rowRange }} of {{ total }}</div>
<div class="activity-table__pager">
<button type="button" :disabled="page <= 1" @click="goPage(1)">
<span class="dashicons dashicons-controls-skipback" />
</button>
<button type="button" :disabled="page <= 1" @click="goPage(page - 1)">
<span class="dashicons dashicons-arrow-left-alt2" />
</button>
<span>Page {{ page }} of {{ totalPages }}</span>
<button type="button" :disabled="page >= totalPages" @click="goPage(page + 1)">
<span class="dashicons dashicons-arrow-right-alt2" />
</button>
<button type="button" :disabled="page >= totalPages" @click="goPage(totalPages)">
<span class="dashicons dashicons-controls-skipforward" />
</button>
</div>
</footer>
</section>
</main>
</template>
<style scoped>
.activity-log__select {
padding: 5px 10px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border);
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.75rem;
.activity-page { min-height: calc(100vh - 112px); padding: 0; color: var(--aurora-text); background: transparent; }
.activity-page__header {
display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 18px;
}
.activity-detail h2 { margin: 0 0 16px; font-size: 1.1rem; color: var(--aurora-text); }
.activity-detail dl { display: grid; grid-template-columns: 120px 1fr; gap: 8px 12px; font-size: 0.8125rem; margin: 0; }
.activity-detail dt { color: var(--aurora-text-muted); }
.activity-detail dd { margin: 0; color: var(--aurora-text); }
.activity-detail pre {
margin-top: 18px; padding: 12px; background: var(--aurora-bg-subtle);
border-radius: var(--aurora-radius-sm); font-size: 0.75rem; overflow-x: auto; color: var(--aurora-text);
.activity-page__eyebrow {
margin: 0 0 7px; color: var(--aurora-text-muted); font-size: 0.72rem;
font-weight: 800; letter-spacing: 0; text-transform: uppercase;
}
.activity-page h1 { margin: 0; color: var(--aurora-text); font-size: 1.45rem; line-height: 1.15; }
.icon-button {
width: 42px; min-height: 42px; border: 1px solid var(--aurora-border); border-radius: var(--aurora-radius-sm);
color: var(--aurora-text); background: var(--aurora-bg-subtle); display: inline-flex;
align-items: center; justify-content: center; cursor: pointer;
}
.icon-button:hover:not(:disabled) { border-color: var(--aurora-text-muted); }
.icon-button:disabled { opacity: 0.45; cursor: not-allowed; }
.activity-page__tabs { display: flex; gap: 8px; margin-bottom: 14px; }
.filter-pill {
display: inline-flex; align-items: center; gap: 8px; min-height: 34px;
border: 1px solid var(--aurora-border); border-radius: 999px; background: var(--aurora-bg-subtle);
color: var(--aurora-text-muted); padding: 0 16px; font-size: 0.78rem; font-weight: 800; cursor: pointer;
}
.filter-pill.is-active { border-color: var(--aurora-text-muted); background: var(--aurora-frame-bg); color: var(--aurora-text); }
.activity-page__toolbar { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-bottom: 12px; }
.activity-page__filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.activity-page__select {
padding: 7px 10px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border);
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.78rem;
}
.activity-page__search {
width: min(280px, 100%); min-height: 38px; border: 1px solid var(--aurora-border); border-radius: var(--aurora-radius-sm);
background: var(--aurora-bg-subtle); display: flex; align-items: center; gap: 8px; padding: 0 12px; color: var(--aurora-text-muted);
}
.activity-page__search input {
width: 100%; min-width: 0; border: 0; outline: 0; box-shadow: none; background: transparent;
color: var(--aurora-text); font-size: 0.84rem;
}
.activity-page__search input::placeholder { color: var(--aurora-text-muted); }
.activity-table { overflow: hidden; border: 1px solid var(--aurora-frame-border); border-radius: var(--aurora-radius-sm); background: var(--aurora-frame-bg); }
.activity-table__head, .activity-row {
display: grid;
grid-template-columns: 190px minmax(160px, 0.8fr) 130px 110px 140px minmax(180px, 1.6fr);
align-items: center;
}
.activity-table__head {
min-height: 44px; border-bottom: 1px solid var(--aurora-border); color: var(--aurora-text);
font-size: 0.79rem; font-weight: 850;
}
.activity-table__head > div { min-height: 44px; padding: 0 16px; display: flex; align-items: center; }
.activity-row { min-height: 76px; border-bottom: 1px solid var(--aurora-border); }
.activity-row:hover { background: var(--aurora-bg-subtle); }
.activity-row__cell {
min-width: 0; padding: 12px 16px; color: var(--aurora-text-muted); font-size: 0.8rem;
line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.activity-row__desc { color: var(--aurora-text); white-space: normal; }
.activity-row__author { display: flex; align-items: center; gap: 10px; white-space: normal; }
.activity-row__avatar { border-radius: 50%; flex-shrink: 0; display: block; }
.activity-row__author-info { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.activity-row__author-info a {
color: var(--aurora-accent); font-weight: 700; font-size: 0.8125rem; text-decoration: none;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.activity-row__author-info a:hover { text-decoration: underline; }
.activity-row__role, .activity-row__email {
font-size: 0.71rem; color: var(--aurora-text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.type-badge {
display: inline-block; border-radius: 999px; background: var(--aurora-bg-subtle); color: var(--aurora-text-muted);
padding: 2px 10px; font-size: 0.68rem; font-weight: 850; box-shadow: inset 0 0 0 1px var(--aurora-border);
}
.activity-table__empty { padding: 38px 18px; color: var(--aurora-text-muted); font-size: 0.85rem; text-align: center; }
.activity-table__footer {
min-height: 50px; border-top: 1px solid var(--aurora-border); display: flex; align-items: center;
justify-content: flex-end; gap: 26px; padding: 0 16px; color: var(--aurora-text-muted); font-size: 0.8rem;
}
.activity-table__pager { display: flex; align-items: center; gap: 8px; }
.activity-table__pager button {
width: 28px; height: 28px; border: 0; border-radius: var(--aurora-radius-sm);
background: transparent; color: var(--aurora-text-muted); cursor: pointer;
}
.activity-table__pager button:not(:disabled):hover { background: var(--aurora-bg-subtle); color: var(--aurora-text); }
.activity-table__pager button:disabled { opacity: 0.4; cursor: not-allowed; }
@media (max-width: 1100px) {
.activity-table__head { display: none; }
.activity-row { grid-template-columns: 1fr; padding: 10px 0; }
.activity-row__cell { white-space: normal; }
.activity-row__cell::before { content: attr(data-label) ': '; font-weight: 700; color: var(--aurora-text); }
}
</style>
+222
View File
@@ -0,0 +1,222 @@
<script setup>
import { reactive, ref, onMounted } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const contentType = ref('all');
const filters = reactive({
category: '',
post_author: '',
post_start_date: '',
post_end_date: '',
post_status: '',
page_author: '',
page_start_date: '',
page_end_date: '',
page_status: '',
attachment_start_date: '',
attachment_end_date: '',
});
// Standard WP post statuses (matches core's own get_post_stati({internal: false})).
const STATUSES = [
{ value: '', label: 'All' },
{ value: 'publish', label: 'Published' },
{ value: 'future', label: 'Scheduled' },
{ value: 'draft', label: 'Draft' },
{ value: 'pending', label: 'Pending Review' },
{ value: 'private', label: 'Private' },
{ value: 'trash', label: 'Trash' },
];
const categories = ref([]);
const authors = ref([]);
const loadingOptions = ref(true);
onMounted(async () => {
try {
const headers = { 'X-WP-Nonce': props.serverData.restNonce };
const [catsRes, usersRes] = await Promise.all([
fetch(`${props.serverData.restUrl}wp/v2/categories?per_page=100`, { headers }),
fetch(`${props.serverData.restUrl}wp/v2/users?per_page=100`, { headers }),
]);
categories.value = catsRes.ok ? await catsRes.json() : [];
authors.value = usersRes.ok ? await usersRes.json() : [];
} finally {
loadingOptions.value = false;
}
});
// Building and navigating to a real export.php?download=true&... URL (not a
// fetch) so the browser's normal file-download flow handles the response —
// this is the exact same request native WordPress's own form would submit,
// so the file it returns is unmodified.
//
// Every relevant param is always sent, even when blank. Native's own HTML
// <select> elements always submit a name=value pair regardless of which
// option is picked, and export.php's own PHP reads $_GET['cat'] etc.
// directly with no isset() guard — omitting a param when "All" is selected
// (as this used to) triggers PHP "Undefined array key" warnings that, with
// display_errors on (any site with debugging enabled), get echoed straight
// into the downloaded file before the XML, corrupting it.
const download = () => {
const params = new URLSearchParams({ download: 'true', content: contentType.value });
if (contentType.value === 'posts') {
params.set('cat', filters.category);
params.set('post_author', filters.post_author);
params.set('post_start_date', filters.post_start_date);
params.set('post_end_date', filters.post_end_date);
params.set('post_status', filters.post_status);
} else if (contentType.value === 'pages') {
params.set('page_author', filters.page_author);
params.set('page_start_date', filters.page_start_date);
params.set('page_end_date', filters.page_end_date);
params.set('page_status', filters.page_status);
} else if (contentType.value === 'attachment') {
params.set('attachment_start_date', filters.attachment_start_date);
params.set('attachment_end_date', filters.attachment_end_date);
}
window.location.href = `${props.serverData.exportUrl}?${params.toString()}`;
};
</script>
<template>
<main class="export-page">
<header class="export-page__header">
<p class="export-page__eyebrow">Aurora Admin</p>
<h1>Export</h1>
<p class="export-page__lead">
Create an XML file (WordPress eXtended RSS, or WXR) containing your posts, pages, comments,
custom fields, categories, and tags. Import it into another WordPress site to bring this
content over.
</p>
</header>
<section class="export-page__card">
<h2>Choose what to export</h2>
<div class="export-page__options">
<label class="export-page__radio">
<input type="radio" value="all" v-model="contentType" />
<div>
<strong>All content</strong>
<p>All of your posts, pages, comments, custom fields, terms, navigation menus, and custom posts.</p>
</div>
</label>
<label class="export-page__radio">
<input type="radio" value="posts" v-model="contentType" />
<strong>Posts</strong>
</label>
<div v-if="contentType === 'posts'" class="export-page__filters">
<label>
Category
<select v-model="filters.category" :disabled="loadingOptions">
<option value="">All</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</label>
<label>
Author
<select v-model="filters.post_author" :disabled="loadingOptions">
<option value="">All</option>
<option v-for="a in authors" :key="a.id" :value="a.id">{{ a.name }}</option>
</select>
</label>
<label>Start date <input type="date" v-model="filters.post_start_date" /></label>
<label>End date <input type="date" v-model="filters.post_end_date" /></label>
<label>
Status
<select v-model="filters.post_status">
<option v-for="s in STATUSES" :key="s.value" :value="s.value">{{ s.label }}</option>
</select>
</label>
</div>
<label class="export-page__radio">
<input type="radio" value="pages" v-model="contentType" />
<strong>Pages</strong>
</label>
<div v-if="contentType === 'pages'" class="export-page__filters">
<label>
Author
<select v-model="filters.page_author" :disabled="loadingOptions">
<option value="">All</option>
<option v-for="a in authors" :key="a.id" :value="a.id">{{ a.name }}</option>
</select>
</label>
<label>Start date <input type="date" v-model="filters.page_start_date" /></label>
<label>End date <input type="date" v-model="filters.page_end_date" /></label>
<label>
Status
<select v-model="filters.page_status">
<option v-for="s in STATUSES" :key="s.value" :value="s.value">{{ s.label }}</option>
</select>
</label>
</div>
<label class="export-page__radio">
<input type="radio" value="attachment" v-model="contentType" />
<strong>Media</strong>
</label>
<div v-if="contentType === 'attachment'" class="export-page__filters">
<label>Start date <input type="date" v-model="filters.attachment_start_date" /></label>
<label>End date <input type="date" v-model="filters.attachment_end_date" /></label>
</div>
</div>
<button type="button" class="export-page__submit" @click="download">Download Export File</button>
</section>
</main>
</template>
<style scoped>
.export-page { min-height: calc(100vh - 112px); color: var(--aurora-text); background: transparent; }
.export-page__eyebrow {
margin: 0 0 7px; color: var(--aurora-text-muted); font-size: 0.72rem;
font-weight: 800; text-transform: uppercase;
}
.export-page__header h1 { margin: 0; font-size: 1.45rem; line-height: 1.15; }
.export-page__lead {
margin: 12px 0 0; max-width: 640px; color: var(--aurora-text-muted);
font-size: 0.875rem; line-height: 1.6;
}
.export-page__card {
margin-top: 24px; background: var(--aurora-card-bg); border: 1px solid var(--aurora-card-border);
border-radius: var(--aurora-radius-lg); padding: 22px 24px; max-width: 560px;
}
.export-page__card h2 { margin: 0 0 16px; font-size: 1rem; font-weight: 700; }
.export-page__options { display: flex; flex-direction: column; gap: 4px; }
.export-page__radio {
display: flex; align-items: flex-start; gap: 10px; padding: 8px 4px;
cursor: pointer; font-size: 0.875rem;
}
.export-page__radio input { margin-top: 3px; accent-color: var(--aurora-accent); }
.export-page__radio p { margin: 4px 0 0; font-size: 0.8125rem; color: var(--aurora-text-muted); font-weight: 400; }
.export-page__filters {
display: grid; grid-template-columns: 1fr 1fr; gap: 10px;
margin: 0 0 8px 30px; padding: 12px; background: var(--aurora-bg-subtle);
border-radius: var(--aurora-radius-md);
}
.export-page__filters label {
display: flex; flex-direction: column; gap: 4px; font-size: 0.75rem;
color: var(--aurora-text-muted); font-weight: 600;
}
.export-page__filters select, .export-page__filters input[type='date'] {
padding: 6px 8px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border);
background: var(--aurora-card-bg); color: var(--aurora-text); font-size: 0.8125rem; font-weight: 400;
}
.export-page__submit {
margin-top: 20px; border: none; border-radius: var(--aurora-radius-sm); padding: 10px 20px;
font-size: 0.875rem; font-weight: 600; cursor: pointer; background: var(--aurora-accent); color: #fff;
}
</style>
+178
View File
@@ -0,0 +1,178 @@
<script setup>
import { reactive } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const api = (path, options = {}) =>
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': props.serverData.restNonce,
...(options.headers || {}),
},
}).then(async (r) => {
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.message || 'Request failed');
return data;
});
// Local, mutable copy so a row's status/action can update in place once
// installed/activated, without needing a full page reload.
const importers = reactive(props.serverData.importers.map((i) => ({ ...i, busy: false, error: '' })));
const goToImporter = (importer) => {
window.location.href = importer.runUrl;
};
const activate = async (importer) => {
importer.busy = true;
importer.error = '';
try {
await api('/plugins/activate', {
method: 'POST',
body: JSON.stringify({ file: importer.pluginFile }),
});
goToImporter(importer);
} catch (e) {
importer.error = e.message || 'Could not activate the importer.';
} finally {
importer.busy = false;
}
};
const installAndActivate = async (importer) => {
importer.busy = true;
importer.error = '';
try {
// Reuses WordPress core's own admin-ajax "install-plugin" action — the
// exact same request native's "Install Now" button triggers — rather
// than reimplementing plugin installation.
const body = new URLSearchParams({
action: 'install-plugin',
slug: importer.slug,
_ajax_nonce: props.serverData.installNonce,
});
const res = await fetch(props.serverData.ajaxUrl, { method: 'POST', body });
const data = await res.json().catch(() => ({}));
if (!data.success) {
throw new Error(data.data?.errorMessage || 'Installation failed.');
}
// Find the freshly-installed plugin's file path (rather than assuming a
// naming convention) via Aurora's own plugins list, then activate it
// through the same route the Plugins screen uses.
const list = await api('/plugins');
const installed = (Array.isArray(list) ? list : []).find((p) => p.slug === importer.slug);
if (!installed) {
throw new Error('Installed, but the plugin could not be found afterward.');
}
await api('/plugins/activate', {
method: 'POST',
body: JSON.stringify({ file: installed.file }),
});
goToImporter(importer);
} catch (e) {
importer.error = e.message || 'Could not install the importer.';
} finally {
importer.busy = false;
}
};
</script>
<template>
<main class="import-page">
<header class="import-page__header">
<p class="import-page__eyebrow">Aurora Admin</p>
<h1>Import</h1>
<p class="import-page__lead">
If you have posts or comments in another system, WordPress can import those into this
site. Choose a system to import from below.
</p>
</header>
<div class="import-page__list">
<article v-for="imp in importers" :key="imp.id" class="import-card">
<div class="import-card__info">
<h3>{{ imp.name }}</h3>
<p>{{ imp.description }}</p>
<p v-if="imp.error" class="import-card__error">{{ imp.error }}</p>
</div>
<div class="import-card__action">
<a v-if="imp.status === 'active'" class="import-card__btn" @click.prevent="goToImporter(imp)" href="#">
Run Importer
</a>
<button
v-else-if="imp.status === 'installed_inactive'"
type="button"
class="import-card__btn"
:disabled="imp.busy"
@click="activate(imp)"
>
{{ imp.busy ? 'Activating…' : 'Run Importer' }}
</button>
<button
v-else
type="button"
class="import-card__btn"
:disabled="imp.busy"
@click="installAndActivate(imp)"
>
{{ imp.busy ? 'Installing…' : 'Install Now' }}
</button>
</div>
</article>
<p v-if="!importers.length" class="import-page__empty">No importers are available.</p>
</div>
<p v-if="serverData.canInstallPlugins" class="import-page__more">
If the importer you need is not listed,
<a :href="serverData.pluginSearchUrl">search the plugin directory</a>
to see if an importer is available.
</p>
<!-- Rendered from WordPress's own `import_filters` hook — whatever other
active plugins add there. Not Aurora-authored content, only
Aurora-styled, so nothing another plugin adds here disappears. -->
<div v-if="serverData.nativeFiltersHtml" class="import-page__native" v-html="serverData.nativeFiltersHtml" />
</main>
</template>
<style scoped>
.import-page { min-height: calc(100vh - 112px); color: var(--aurora-text); background: transparent; }
.import-page__eyebrow {
margin: 0 0 7px; color: var(--aurora-text-muted); font-size: 0.72rem;
font-weight: 800; text-transform: uppercase;
}
.import-page__header h1 { margin: 0; font-size: 1.45rem; line-height: 1.15; }
.import-page__lead {
margin: 12px 0 0; max-width: 640px; color: var(--aurora-text-muted);
font-size: 0.875rem; line-height: 1.6;
}
.import-page__list { margin-top: 24px; display: flex; flex-direction: column; gap: 10px; max-width: 720px; }
.import-card {
display: flex; align-items: center; justify-content: space-between; gap: 20px;
background: var(--aurora-card-bg); border: 1px solid var(--aurora-card-border);
border-radius: var(--aurora-radius-lg); padding: 16px 20px;
}
.import-card__info h3 { margin: 0 0 4px; font-size: 0.9375rem; font-weight: 700; }
.import-card__info p { margin: 0; font-size: 0.8125rem; color: var(--aurora-text-muted); line-height: 1.5; }
.import-card__error { color: #e5484d !important; margin-top: 6px !important; }
.import-card__action { flex-shrink: 0; }
.import-card__btn {
display: inline-block; border: none; border-radius: var(--aurora-radius-sm); padding: 8px 16px;
font-size: 0.8125rem; font-weight: 600; cursor: pointer; background: var(--aurora-accent);
color: #fff; text-decoration: none; white-space: nowrap;
}
.import-card__btn:disabled { opacity: 0.6; cursor: default; }
.import-page__empty { color: var(--aurora-text-muted); font-size: 0.875rem; }
.import-page__more { margin-top: 18px; font-size: 0.8125rem; color: var(--aurora-text-muted); }
.import-page__more a { color: var(--aurora-accent); }
.import-page__native { margin-top: 18px; }
</style>
+94 -40
View File
@@ -37,10 +37,12 @@ const load = async () => {
}
};
onMounted(async () => {
onMounted(() => {
load();
api('/menus/native-items').then((items) => (nativeItems.value = items));
roleOptions.value = props.serverData.roles || [];
// The native top-level menu is provided in serverData (captured server-side
// at page load); the REST route can't build the admin menu.
nativeItems.value = props.serverData.nativeItems || [];
});
const filteredMenus = computed(() => {
@@ -55,48 +57,84 @@ const filteredMenus = computed(() => {
const selected = computed(() => menus.value.find((m) => m.id === selectedId.value) || null);
const draft = reactive({ name: '', status: 'draft', roles: [], items: [] });
const draft = reactive({ name: '', status: 'draft', roles: [] });
// The full ordered list of top-level items being edited. Row order IS the
// menu order (saved as each item's `order` index). Each row carries its
// native title/icon plus the per-item overrides (custom label, hidden).
const orderedItems = ref([]);
// Merge a config's saved items with the live native top-level list: saved
// items first, in their saved order, then any native items the config
// doesn't mention yet (e.g. a newly installed plugin) appended at the end,
// so new menu items are never silently dropped.
const buildOrdered = (savedItems = []) => {
const byId = Object.fromEntries(nativeItems.value.map((n) => [n.native_id, n]));
const saved = [...savedItems].sort((a, b) => (a.order || 0) - (b.order || 0));
const seen = new Set();
const result = [];
for (const s of saved) {
const n = byId[s.native_id];
if (!n) continue; // saved item no longer exists in the admin menu
result.push({ native_id: n.native_id, title: n.title, icon: n.icon, label: s.label || '', hidden: !!s.hidden });
seen.add(s.native_id);
}
for (const n of nativeItems.value) {
if (seen.has(n.native_id)) continue;
result.push({ native_id: n.native_id, title: n.title, icon: n.icon, label: '', hidden: false });
}
orderedItems.value = result;
};
const selectMenu = (menu) => {
selectedId.value = menu.id;
Object.assign(draft, {
name: menu.name,
status: menu.status,
roles: [...menu.roles],
items: menu.items.map((i) => ({ ...i })),
});
Object.assign(draft, { name: menu.name, status: menu.status, roles: [...menu.roles] });
buildOrdered(menu.items);
};
const isCreating = ref(false);
const newMenu = () => {
selectedId.value = null;
isCreating.value = true;
Object.assign(draft, { name: 'New menu', status: 'draft', roles: [], items: [] });
Object.assign(draft, { name: 'New menu', status: 'draft', roles: [] });
buildOrdered([]);
};
const itemState = (nativeId) => draft.items.find((i) => i.native_id === nativeId);
const toggleHidden = (nativeId) => {
let item = itemState(nativeId);
if (!item) {
item = { native_id: nativeId, label: '', hidden: false, order: 0 };
draft.items.push(item);
}
item.hidden = !item.hidden;
// Reorder controls: drag-and-drop plus keyboard-friendly up/down buttons.
const dragIndex = ref(-1);
const onDragStart = (i) => { dragIndex.value = i; };
const onDrop = (i) => {
const from = dragIndex.value;
dragIndex.value = -1;
if (from < 0 || from === i) return;
const arr = orderedItems.value;
const [moved] = arr.splice(from, 1);
arr.splice(i, 0, moved);
};
const setLabel = (nativeId, label) => {
let item = itemState(nativeId);
if (!item) {
item = { native_id: nativeId, label: '', hidden: false, order: 0 };
draft.items.push(item);
}
item.label = label;
const move = (i, dir) => {
const j = i + dir;
const arr = orderedItems.value;
if (j < 0 || j >= arr.length) return;
[arr[i], arr[j]] = [arr[j], arr[i]];
};
const saving = ref(false);
const save = async () => {
saving.value = true;
try {
const payload = { name: draft.name, status: draft.status, roles: draft.roles, items: draft.items };
const payload = {
name: draft.name,
status: draft.status,
roles: draft.roles,
// Row order becomes each item's `order`; only send items that actually
// override something (renamed or hidden) or that need an explicit order.
items: orderedItems.value.map((it, index) => ({
native_id: it.native_id,
label: it.label || '',
hidden: !!it.hidden,
order: index,
})),
};
if (isCreating.value) {
const created = await api('/menus', { method: 'POST', body: JSON.stringify(payload) });
await load();
@@ -188,23 +226,29 @@ const toggleRole = (slug) => {
</div>
<div class="menu-editor__section">
<h3>Menu items</h3>
<h3>Menu items — drag to reorder</h3>
<div class="menu-editor__items">
<div v-for="ni in nativeItems" :key="ni.native_id" class="menu-editor__item">
<span class="dashicons" :class="ni.icon?.value || 'dashicons-admin-generic'" />
<input
type="text"
:value="itemState(ni.native_id)?.label || ni.title"
@input="setLabel(ni.native_id, $event.target.value)"
/>
<div
v-for="(it, i) in orderedItems"
:key="it.native_id"
class="menu-editor__item"
:class="{ 'is-hidden': it.hidden }"
draggable="true"
@dragstart="onDragStart(i)"
@dragover.prevent
@drop="onDrop(i)"
>
<span class="menu-editor__drag dashicons dashicons-move" title="Drag to reorder" />
<span class="dashicons" :class="it.icon?.value || 'dashicons-admin-generic'" />
<input type="text" v-model="it.label" :placeholder="it.title" />
<label class="menu-editor__hide">
<input
type="checkbox"
:checked="!!itemState(ni.native_id)?.hidden"
@change="toggleHidden(ni.native_id)"
/>
<input type="checkbox" v-model="it.hidden" />
Hide
</label>
<div class="menu-editor__move">
<button type="button" :disabled="i === 0" title="Move up" @click="move(i, -1)">▲</button>
<button type="button" :disabled="i === orderedItems.length - 1" title="Move down" @click="move(i, 1)">▼</button>
</div>
</div>
</div>
</div>
@@ -243,11 +287,21 @@ const toggleRole = (slug) => {
display: flex; align-items: center; gap: 10px; padding: 6px 4px;
border-bottom: 1px solid var(--aurora-frame-border);
}
.menu-editor__item.is-hidden { opacity: 0.5; }
.menu-editor__drag { cursor: grab; color: var(--aurora-text-muted); flex-shrink: 0; }
.menu-editor__drag:active { cursor: grabbing; }
.menu-editor__item input[type='text'] {
flex: 1; padding: 6px 8px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border);
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.8125rem;
}
.menu-editor__hide { display: flex; align-items: center; gap: 4px; font-size: 0.75rem; color: var(--aurora-text-muted); }
.menu-editor__hide { display: flex; align-items: center; gap: 4px; font-size: 0.75rem; color: var(--aurora-text-muted); flex-shrink: 0; }
.menu-editor__move { display: flex; flex-direction: column; gap: 2px; flex-shrink: 0; }
.menu-editor__move button {
border: none; background: var(--aurora-bg-subtle); color: var(--aurora-text-muted);
border-radius: 4px; width: 20px; height: 15px; line-height: 1; font-size: 9px; cursor: pointer; padding: 0;
}
.menu-editor__move button:disabled { opacity: 0.35; cursor: default; }
.menu-editor__move button:not(:disabled):hover { color: var(--aurora-accent); }
.menu-editor__actions { display: flex; justify-content: flex-end; gap: 8px; }
.menu-editor__actions button {
border: none; border-radius: var(--aurora-radius-sm); padding: 9px 18px; font-size: 0.875rem; cursor: pointer;
+144
View File
@@ -0,0 +1,144 @@
<script setup>
import { reactive, onMounted } from 'vue';
import { companionPlugins } from './companionPlugins.js';
const props = defineProps({
serverData: { type: Object, required: true },
});
const api = (path, options = {}) =>
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': props.serverData.restNonce,
...(options.headers || {}),
},
}).then(async (r) => {
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.message || 'Request failed');
return data;
});
// Per-plugin UI state, keyed by slug: { status: {installed,active}|null, busy, error }
const state = reactive(
Object.fromEntries(companionPlugins.map((p) => [p.slug, { status: null, busy: false, error: '' }]))
);
const loadStatus = async (plugin) => {
try {
state[plugin.slug].status = await api(plugin.statusEndpoint);
} catch {
state[plugin.slug].status = { installed: false, active: false };
}
};
onMounted(() => {
companionPlugins.forEach(loadStatus);
});
const install = async (plugin) => {
const s = state[plugin.slug];
if (s.busy) return;
s.busy = true;
s.error = '';
try {
const result = await api(plugin.installEndpoint, { method: 'POST' });
if (!result.success) {
s.error = result.message || 'Installation failed.';
}
await loadStatus(plugin);
} catch (e) {
s.error = e.message || 'Installation failed.';
} finally {
s.busy = false;
}
};
</script>
<template>
<main class="modules">
<header class="modules__head">
<p class="modules__eyebrow">Aurora Admin</p>
<h1>Modules</h1>
<p class="modules__lead">
Install additional modules to extend Aurora Admin. Each is a separate, standalone plugin —
install and activate it here with one click.
</p>
</header>
<div class="modules__grid">
<article v-for="p in companionPlugins" :key="p.slug" class="module-card">
<div class="module-card__top">
<span class="module-card__icon dashicons" :class="p.icon" />
<span
v-if="state[p.slug].status"
class="module-card__badge"
:class="{ 'is-active': state[p.slug].status.active }"
>
{{ state[p.slug].status.active ? 'Active' : state[p.slug].status.installed ? 'Installed' : 'Not installed' }}
</span>
</div>
<h3 class="module-card__title">{{ p.label }}</h3>
<p class="module-card__desc">{{ p.description }}</p>
<div class="module-card__action">
<div v-if="!state[p.slug].status" class="module-card__checking">Checking…</div>
<a v-else-if="state[p.slug].status.active" class="module-card__manage" :href="p.manageUrl">
Open {{ p.label }} →
</a>
<template v-else>
<button
type="button"
class="module-card__install"
:disabled="state[p.slug].busy"
@click="install(p)"
>
{{ state[p.slug].busy ? 'Installing…' : state[p.slug].status.installed ? 'Activate' : 'Install & Activate' }}
</button>
<p v-if="state[p.slug].error" class="module-card__error">{{ state[p.slug].error }}</p>
</template>
</div>
</article>
</div>
</main>
</template>
<style scoped>
.modules { min-height: calc(100vh - 112px); color: var(--aurora-text); background: transparent; }
.modules__eyebrow {
margin: 0 0 7px; color: var(--aurora-text-muted); font-size: 0.72rem;
font-weight: 800; text-transform: uppercase;
}
.modules__head h1 { margin: 0; font-size: 1.45rem; line-height: 1.15; }
.modules__lead { margin: 8px 0 0; color: var(--aurora-text-muted); font-size: 0.9rem; max-width: 640px; line-height: 1.6; }
.modules__grid {
margin-top: 24px; display: grid; gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
.module-card {
background: var(--aurora-card-bg); border: 1px solid var(--aurora-card-border);
border-radius: var(--aurora-radius-lg); padding: 20px 22px;
display: flex; flex-direction: column; gap: 8px;
}
.module-card__top { display: flex; align-items: center; justify-content: space-between; }
.module-card__icon { font-size: 26px; width: 26px; height: 26px; color: var(--aurora-accent); }
.module-card__badge {
font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em;
color: var(--aurora-text-muted); background: var(--aurora-bg-subtle);
border-radius: 999px; padding: 3px 10px;
}
.module-card__badge.is-active { color: var(--aurora-accent); }
.module-card__title { margin: 4px 0 0; font-size: 1rem; font-weight: 700; }
.module-card__desc { margin: 0; font-size: 0.8125rem; color: var(--aurora-text-muted); line-height: 1.55; flex: 1; }
.module-card__action { margin-top: 6px; }
.module-card__checking { font-size: 0.8125rem; color: var(--aurora-text-muted); }
.module-card__manage { font-size: 0.8125rem; font-weight: 600; color: var(--aurora-accent); text-decoration: none; }
.module-card__manage:hover { text-decoration: underline; }
.module-card__install {
border: none; border-radius: var(--aurora-radius-sm); padding: 9px 16px; font-size: 0.8125rem;
font-weight: 600; cursor: pointer; background: var(--aurora-accent); color: #fff;
}
.module-card__install:disabled { opacity: 0.6; cursor: default; }
.module-card__error { margin: 8px 0 0; font-size: 0.8125rem; color: #e5484d; }
</style>
+39
View File
@@ -0,0 +1,39 @@
/**
* Aurora's standalone companion plugins, installable from the Modules page.
* Single source of truth for both the card metadata and the REST endpoints
* that back them (admin/src/Rest/CompanionPluginData.php) — a new companion
* plugin only needs an entry here plus a matching PHP-side entry, no other
* wiring.
*/
export const companionPlugins = [
{
slug: 'file-manager',
icon: 'dashicons-portfolio',
label: 'Aurora File Manager',
description:
'Browse, edit, and manage your site’s files (themes, plugins, uploads, and more) directly from wp-admin.',
statusEndpoint: '/companion-plugins/file-manager/status',
installEndpoint: '/companion-plugins/file-manager/install',
manageUrl: 'admin.php?page=aurora-file-manager',
},
{
slug: 'database-explorer',
icon: 'dashicons-editor-table',
label: 'Aurora Database Explorer',
description:
'A Supabase-style database browser inside wp-admin — see every table with row counts and sizes, browse paginated data, and run your own read-only queries.',
statusEndpoint: '/companion-plugins/database-explorer/status',
installEndpoint: '/companion-plugins/database-explorer/install',
manageUrl: 'admin.php?page=aurora-database-explorer',
},
{
slug: 'site-backup',
icon: 'dashicons-migrate',
label: 'Aurora Site Backup',
description:
'Full site backup (database + files) and restore-in-place, on demand or on a recurring schedule, stored locally or on your own S3-compatible or Google Drive remote storage.',
statusEndpoint: '/companion-plugins/site-backup/status',
installEndpoint: '/companion-plugins/site-backup/install',
manageUrl: 'admin.php?page=aurora-site-backup',
},
];
+9 -1
View File
@@ -81,6 +81,7 @@ const runAction = async (action, plugin) => {
busyFile.value = plugin.file;
error.value = '';
notice.value = '';
let redirecting = false;
try {
const data = await request(`/plugins/${action}`, {
method: action === 'delete' ? 'DELETE' : 'POST',
@@ -88,14 +89,21 @@ const runAction = async (action, plugin) => {
});
notice.value = data.message || 'Done';
if (data.redirect) {
redirecting = true;
window.location.href = data.redirect;
return;
}
await load();
} catch (err) {
error.value = err.message || 'Action failed';
} finally {
busyFile.value = '';
// Always re-sync the list with the real backend state, even after an
// error: activate_plugin() can return a WP_Error for benign "unexpected
// output" from a plugin's own activation while still leaving the plugin
// active, which previously left the row showing its stale pre-click
// state (you clicked Activate but it still read "deactivated"). Skipped
// only when navigating away (deactivating Aurora itself redirects).
if (!redirecting) await load();
}
};
-17
View File
@@ -10,22 +10,6 @@ const props = defineProps({
const visibleCategories = settingsCategories;
// Shared REST fetcher for field types that need their own API calls beyond
// the generic wp/v2/settings save flow (currently just 'companion-plugin').
const api = (path, options = {}) =>
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
...options,
headers: {
'X-WP-Nonce': props.serverData.restNonce,
'Content-Type': 'application/json',
...(options.headers || {}),
},
}).then(async (r) => {
const body = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(body.message || 'Request failed');
return body;
});
// Working copy of the settings, seeded from the server + field defaults.
const settings = reactive({ ...(props.serverData.settings || {}) });
visibleCategories.forEach((cat) =>
@@ -264,7 +248,6 @@ const importSettings = (e) => {
:field="f"
:options="optionsFor(f)"
:disabled="!!f.dependsOn && !settings[f.dependsOn]"
:api="api"
v-model="settings[f.key]"
/>
</div>
@@ -14,9 +14,6 @@ const props = defineProps({
// True when this field's `dependsOn` key is falsy — greys out the
// control without hiding it, so the relationship stays visible.
disabled: { type: Boolean, default: false },
// Shared REST fetcher, only used by field types that need their own API
// calls beyond the generic wp/v2/settings save flow (companion-plugin).
api: { type: Function, default: null },
});
const emit = defineEmits(['update:modelValue']);
@@ -76,43 +73,14 @@ const pickFont = (name) => {
fontOpen.value = false;
};
const openFontList = () => {
fontQuery.value = props.modelValue || '';
// Start with an empty query so the whole font list is shown on focus.
// Seeding it with the current font filtered the list down to just that
// one font, making it impossible to pick a different one without first
// clearing the box. The active font stays highlighted via `val === name`.
fontQuery.value = '';
fontOpen.value = true;
};
// Companion plugin (Aurora File Manager) install card -----------------------
const companionStatus = ref(null); // { installed, active } | null while loading
const companionBusy = ref(false);
const companionError = ref('');
const loadCompanionStatus = async () => {
if (props.field.type !== 'companion-plugin' || !props.api) return;
try {
companionStatus.value = await props.api(props.field.statusEndpoint);
} catch {
companionStatus.value = { installed: false, active: false };
}
};
if (props.field.type === 'companion-plugin') {
loadCompanionStatus();
}
const installCompanion = async () => {
if (!props.api) return;
companionBusy.value = true;
companionError.value = '';
try {
const result = await props.api(props.field.installEndpoint, { method: 'POST' });
if (!result.success) {
companionError.value = result.message || 'Installation failed.';
}
await loadCompanionStatus();
} catch (e) {
companionError.value = e.message || 'Installation failed.';
} finally {
companionBusy.value = false;
}
};
</script>
<template>
@@ -273,31 +241,6 @@ const installCompanion = async () => {
</div>
</div>
<!-- Companion plugin install card -->
<div v-else-if="field.type === 'companion-plugin'" class="field__companion">
<div v-if="!companionStatus" class="field__companion-status">Checking…</div>
<template v-else>
<a
v-if="companionStatus.active"
class="field__companion-manage"
:href="field.manageUrl"
>
Installed &amp; active — open File Manager →
</a>
<template v-else>
<button
type="button"
class="field__companion-install"
:disabled="companionBusy"
@click="installCompanion"
>
{{ companionBusy ? 'Installing…' : (companionStatus.installed ? 'Activate' : 'Install & Activate') }}
</button>
<p v-if="companionError" class="field__companion-error">{{ companionError }}</p>
</template>
</template>
</div>
<!-- Repeater -->
<div v-else-if="field.type === 'repeater'" class="field__repeater">
<div v-for="(row, i) in rows" :key="i" class="field__repeater-row">
@@ -460,20 +403,6 @@ const installCompanion = async () => {
.field__font-item:hover { background: var(--aurora-bg-subtle); }
.field__font-item--active { color: var(--aurora-accent); }
/* Companion plugin install card */
.field__companion-status { font-size: 0.8125rem; color: var(--aurora-text-muted); }
.field__companion-install {
border: none; border-radius: var(--aurora-radius-sm); background: var(--aurora-accent); color: #fff;
padding: 8px 16px; font-size: 0.8125rem; font-weight: 600; cursor: pointer;
}
.field__companion-install:disabled { opacity: 0.6; cursor: default; }
.field__companion-manage {
display: inline-block; font-size: 0.8125rem; font-weight: 600;
color: var(--aurora-accent); text-decoration: none;
}
.field__companion-manage:hover { text-decoration: underline; }
.field__companion-error { margin: 8px 0 0; font-size: 0.8125rem; color: #e5484d; }
@media (max-width: 782px) {
.field { grid-template-columns: 1fr; gap: 12px; }
}
+59 -46
View File
@@ -99,7 +99,9 @@ export const settingsGroups = [
key: 'font_family',
type: 'font-picker',
label: 'Font family',
description: 'Google Font used across the Aurora interface.',
description:
'Optional. Leave empty to use your system font (no external requests). ' +
'Choosing a font loads it from Google Fonts (fonts.googleapis.com) on each admin page.',
},
],
},
@@ -195,6 +197,46 @@ export const settingsGroups = [
},
],
},
{
id: 'login-redirect',
title: 'Login Redirect',
subtitle: 'Control where visitors and users land, before and after login',
icon: 'dashicons-redo',
fields: [
{
key: 'login_redirect_enabled',
type: 'toggle',
label: 'Enable login redirects',
description:
'Turns on the redirects below. This never changes, hides, or blocks your login page — wp-login.php always stays reachable at its normal address, so this can’t lock you out of logging in.',
},
{
key: 'redirect_unauthenticated_url',
type: 'text',
label: 'Redirect logged-out wp-admin visitors',
description:
'Send visitors who try to access wp-admin without being logged in to this URL instead of the login screen. Leave empty to keep the normal WordPress behavior.',
placeholder: 'https://example.com/',
dependsOn: 'login_redirect_enabled',
},
{
key: 'redirect_roles',
type: 'multiselect',
label: 'Redirect these roles after login',
description: 'Users with these roles are sent to the URL below after logging in, instead of the dashboard.',
optionsSource: 'roles',
dependsOn: 'login_redirect_enabled',
},
{
key: 'redirect_after_login_url',
type: 'text',
label: 'Redirect to',
description: 'Where the roles above land after logging in.',
placeholder: 'https://example.com/welcome',
dependsOn: 'login_redirect_enabled',
},
],
},
{
id: 'menu',
title: 'Menu',
@@ -281,6 +323,22 @@ export const settingsGroups = [
},
],
},
{
id: 'editor',
title: 'Editor',
subtitle: 'Choose the editor used for posts and pages',
icon: 'dashicons-edit',
fields: [
{
key: 'disable_gutenberg',
type: 'toggle',
label: 'Disable Gutenberg',
description:
'Use WordPress’s classic editor instead of the block editor for all post types. Off by default.',
default: false,
},
],
},
{
id: 'admin-takeover',
title: 'Admin Takeover',
@@ -493,51 +551,6 @@ export const settingsGroups = [
},
],
},
{
id: 'add-ons',
title: 'Add-ons',
icon: 'dashicons-admin-plugins',
categories: [
{
id: 'add-ons',
title: 'Add-ons',
subtitle: 'Standalone companion plugins',
icon: 'dashicons-admin-plugins',
fields: [
{
key: 'file_manager_addon',
type: 'companion-plugin',
label: 'Aurora File Manager',
description:
'Browse, edit, and manage your site’s files (themes, plugins, uploads, and more) directly from wp-admin. Distributed as a separate plugin rather than bundled here, since WordPress.org’s Plugin Directory policy doesn’t allow this category of functionality in a directory-listed plugin — restrictions or not.',
statusEndpoint: '/companion-plugins/file-manager/status',
installEndpoint: '/companion-plugins/file-manager/install',
manageUrl: 'admin.php?page=aurora-file-manager',
},
{
key: 'database_explorer_addon',
type: 'companion-plugin',
label: 'Aurora Database Explorer',
description:
'A Supabase-style database browser inside wp-admin — see every table with row counts and sizes, browse paginated data, and run your own read-only SELECT/SHOW/DESCRIBE/EXPLAIN queries. Distributed as a separate plugin rather than bundled here, since WordPress.org’s Plugin Directory review treats direct database-query tooling the same way it treats file-management functionality.',
statusEndpoint: '/companion-plugins/database-explorer/status',
installEndpoint: '/companion-plugins/database-explorer/install',
manageUrl: 'admin.php?page=aurora-database-explorer',
},
{
key: 'site_backup_addon',
type: 'companion-plugin',
label: 'Aurora Site Backup',
description:
'Full site backup (database + files) and restore-in-place, on demand or on a recurring schedule, stored locally or on your own S3-compatible or Google Drive remote storage. Distributed as a separate plugin rather than bundled here, to keep Aurora Admin’s own footprint away from arbitrary file-write functionality.',
statusEndpoint: '/companion-plugins/site-backup/status',
installEndpoint: '/companion-plugins/site-backup/install',
manageUrl: 'admin.php?page=aurora-site-backup',
},
],
},
],
},
];
/**
+3 -2
View File
@@ -31,7 +31,9 @@ const steps = [
key: 'font_family',
type: 'font-picker',
label: 'Font family',
description: 'A Google Font used across the Aurora interface.',
description:
'Optional. Leave empty for your system font (no external requests). ' +
'Choosing a font loads it from Google Fonts.',
},
],
},
@@ -207,7 +209,6 @@ const finish = async (target) => {
v-for="f in current.fields"
:key="f.key"
:field="f"
:api="null"
v-model="settings[f.key]"
/>
</div>
+76
View File
@@ -0,0 +1,76 @@
<script setup>
const props = defineProps({
serverData: { type: Object, required: true },
});
</script>
<template>
<main class="tools-page">
<header class="tools-page__header">
<p class="tools-page__eyebrow">Aurora Admin</p>
<h1>Tools</h1>
</header>
<div class="tools-page__grid">
<!-- Core's Categories and Tags Converter card — not produced by a
hook, so it's reproduced here directly (same visibility gate as
native tools.php: the `import` capability plus category/tag
manage_terms). -->
<article v-if="serverData.showCategoriesConverter" class="card">
<h2 class="title">Categories and Tags Converter</h2>
<p>
If you want to convert your categories to tags (or vice versa), use the
<a :href="serverData.importUrl">Categories and Tags Converter</a> available from the Import screen.
</p>
</article>
<!-- Rendered from WordPress's own `tool_box` hook — whatever other
active plugins add there (WooCommerce, migration tools, etc.).
Not Aurora-authored content, only Aurora-styled, so nothing
another plugin adds to this screen silently disappears. -->
<div v-if="serverData.nativeToolsHtml" class="tools-page__native" v-html="serverData.nativeToolsHtml" />
</div>
<p v-if="!serverData.showCategoriesConverter && !serverData.nativeToolsHtml" class="tools-page__empty">
No tools are available right now.
</p>
</main>
</template>
<style scoped>
.tools-page { min-height: calc(100vh - 112px); color: var(--aurora-text); background: transparent; }
.tools-page__eyebrow {
margin: 0 0 7px; color: var(--aurora-text-muted); font-size: 0.72rem;
font-weight: 800; text-transform: uppercase;
}
.tools-page__header h1 { margin: 0 0 24px; font-size: 1.45rem; line-height: 1.15; }
.tools-page__empty { color: var(--aurora-text-muted); font-size: 0.875rem; }
.tools-page__grid {
display: grid; gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
/* The v-html'd wrapper participates as if it weren't there, so cards inside
it (there may be several, one per plugin hooked to tool_box) lay out in
the same grid as Aurora's own card above instead of stacking inside one
grid cell. */
.tools-page__native { display: contents; }
/* Native tool_box markup is `.card > h2.title + p` (core's own Categories &
Tags Converter shape), but other plugins' cards may not match exactly —
styled broadly by element, with .card/.title as the primary targets. The
plain selectors below cover Aurora's own card (real template markup, gets
Vue's scoping attribute automatically); :deep() additionally reaches into
the v-html'd content, which the scoping attribute can't be applied to. */
.card, .tools-page__native :deep(.card) {
background: var(--aurora-card-bg); border: 1px solid var(--aurora-card-border);
border-radius: var(--aurora-radius-lg); padding: 20px 22px; margin: 0;
}
.card h2, .card h3, .card .title, .tools-page__native :deep(h2), .tools-page__native :deep(h3), .tools-page__native :deep(.title) {
margin: 0 0 10px; font-size: 1rem; font-weight: 700; color: var(--aurora-text);
}
.card p, .tools-page__native :deep(p) {
margin: 0; font-size: 0.8125rem; color: var(--aurora-text-muted); line-height: 1.6;
}
.card a, .tools-page__native :deep(a) { color: var(--aurora-accent); text-decoration: none; }
.card a:hover, .tools-page__native :deep(a:hover) { text-decoration: underline; }
</style>
+13 -1
View File
@@ -16,6 +16,18 @@ const highlighted = ref(0);
let debounceTimer;
let requestSeq = 0;
// wp/v2/search returns titles with HTML entities (e.g. "Ben &amp; Jerry").
// Decode them for display WITHOUT rendering markup: a <textarea>'s innerHTML
// is RCDATA, so tags stay literal text and only entities are decoded — then
// we read them back as a plain string and render with text interpolation.
// This replaces a v-html bind on item.title, which would have executed any
// HTML an unfiltered_html user put in a post title (admin→admin XSS).
const decodeEntities = (str) => {
const el = document.createElement('textarea');
el.innerHTML = String(str ?? '');
return el.value;
};
const search = async () => {
const q = query.value.trim();
if (q.length < 2) {
@@ -121,7 +133,7 @@ onUnmounted(() => document.removeEventListener('keydown', onKeydown));
@mouseenter="highlighted = i"
@click="openResult(item)"
>
<span class="smodal__result-title" v-html="item.title" />
<span class="smodal__result-title">{{ decodeEntities(item.title) }}</span>
<span class="smodal__result-type">{{ item.subtype }}</span>
</button>
</li>
+87 -4
View File
@@ -195,7 +195,11 @@ html.aurora-ready #wpbody-content {
color: var(--aurora-text);
border: 1px solid var(--aurora-frame-border);
border-radius: var(--aurora-radius-xl);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
/* A real soft shadow (not the old barely-there 0.06 one) so the rounded
card edge reads against dense, full-bleed page content — otherwise the
frame is technically rounded but visually invisible except on sparse
pages. Follows the border-radius, defining the card shape. */
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
margin: 16px var(--aurora-gutter-r) 24px 24px;
padding: 32px;
min-height: calc(100vh - var(--aurora-toolbar-h) - 40px);
@@ -323,7 +327,10 @@ html.aurora-ready .theme-install-overlay .wp-full-overlay-main {
background: var(--aurora-frame-bg);
border: 1px solid var(--aurora-frame-border);
border-radius: var(--aurora-radius-xl);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
/* Match #wpbody-content's shadow so the dashboard's rounded card edge
reads the same way — the dashboard is dense and full-bleed, so the old
0.06 shadow left it looking un-rounded. */
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
margin: 16px var(--aurora-gutter-r) 24px 24px;
box-sizing: border-box;
/* Same unconditional height cap as #wpbody-content's base rule — this
@@ -335,8 +342,15 @@ html.aurora-ready .theme-install-overlay .wp-full-overlay-main {
no document
scroll left to fall back on either. */
max-height: calc(100vh - var(--aurora-toolbar-h) - 40px);
overflow-y: auto;
overflow-anchor: none;
/* The frame itself clips (rounded corners) and lays out as a column; the
actual scrolling happens on the inner app-root (see the shared scroll
rule below), so the native scrollbar lives INSIDE the rounded card
rather than squaring off its right corners. The 12px padding is the
gutter that keeps the scrollbar clear of the corner radius. */
overflow: hidden;
display: flex;
flex-direction: column;
padding: 12px;
}
/* Custom admin notices (Notices::render_for_dashboard(), reparented in
@@ -366,6 +380,75 @@ html.aurora-ready body.toplevel_page_aurora-admin #wpbody-content {
box-shadow: none;
}
/* --- Scroll INSIDE the rounded frame -------------------------------------
A single element can't both scroll and keep its own rounded corners: the
native scrollbar sits in the top/bottom-right corners and squares them
off. So the frame clips (overflow:hidden + border-radius) and its Aurora
app-root child becomes the real scroll container — the scrollbar then
lives inside the rounded card, inset by the frame's 12px padding so it
clears the corner radius.
`:has()` limits the #wpbody-content half to Aurora's own hijacked pages
(their mount div is a direct #aurora-admin-*-root child); native WP pages
keep their normal single-layer 32px-padded scroll, untouched. Content
padding is split 12px (frame) + 20px (scroller) so it still totals the
original 32px — pages that assume that (e.g. AppListShell's -32px bleed)
keep working. */
/* `body:not(.toplevel_page_aurora-admin)` excludes the Settings page — it
deliberately drops the frame and manages its own -32px/scroll-as-a-whole
layout (see Settings.vue), so the two-layer treatment must not touch it. */
html.aurora-ready body:not(.toplevel_page_aurora-admin) #wpbody-content:has(> [id^="aurora-admin-"][id$="-root"]) {
overflow: hidden;
display: flex;
flex-direction: column;
padding: 12px;
}
.aurora-dashboard-frame > [id^="aurora-admin-"][id$="-root"],
html.aurora-ready body:not(.toplevel_page_aurora-admin) #wpbody-content > [id^="aurora-admin-"][id$="-root"] {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-anchor: none;
}
/* The hijacked/list pages need their content padding on the scroller (the
dashboard's own content already pads itself). */
html.aurora-ready body:not(.toplevel_page_aurora-admin) #wpbody-content > [id^="aurora-admin-"][id$="-root"] {
padding: 20px;
box-sizing: border-box;
}
/* --- Themed scrollbars -----------------------------------------------------
Skin every scrollbar inside the Aurora shell in a translucent shade of the
active theme's accent (derived from --aurora-accent, so it tracks whatever
preset is selected) instead of the OS default. The plain accent-soft token
is a fallback for engines without color-mix; the 3px transparent border +
background-clip:padding-box keeps the thumb slim with a little breathing
room around it. */
html.aurora-ready {
scrollbar-width: thin;
scrollbar-color: var(--aurora-accent-soft) transparent;
scrollbar-color: color-mix(in srgb, var(--aurora-accent) 55%, transparent) transparent;
}
html.aurora-ready ::-webkit-scrollbar {
width: 12px;
height: 12px;
}
html.aurora-ready ::-webkit-scrollbar-track,
html.aurora-ready ::-webkit-scrollbar-corner {
background: transparent;
}
html.aurora-ready ::-webkit-scrollbar-thumb {
border-radius: 999px;
border: 3px solid transparent;
background-clip: padding-box;
background-color: var(--aurora-accent-soft);
background-color: color-mix(in srgb, var(--aurora-accent) 45%, transparent);
}
html.aurora-ready ::-webkit-scrollbar-thumb:hover {
background-color: var(--aurora-accent);
background-color: color-mix(in srgb, var(--aurora-accent) 72%, transparent);
}
/*
* Native WordPress pages are fully dark-themed by a dedicated stylesheet
* (admin/assets/native-dark.css), enqueued only on native pages and gated
+4
View File
@@ -36,6 +36,7 @@ export default defineConfig({
settings: fileURLToPath(new URL('./src/entries/settings.js', import.meta.url)),
'setup-wizard': fileURLToPath(new URL('./src/entries/setup-wizard.js', import.meta.url)),
'menu-creator': fileURLToPath(new URL('./src/entries/menu-creator.js', import.meta.url)),
modules: fileURLToPath(new URL('./src/entries/modules.js', import.meta.url)),
'admin-notices': fileURLToPath(new URL('./src/entries/admin-notices.js', import.meta.url)),
'role-editor': fileURLToPath(new URL('./src/entries/role-editor.js', import.meta.url)),
'activity-log': fileURLToPath(new URL('./src/entries/activity-log.js', import.meta.url)),
@@ -51,6 +52,9 @@ export default defineConfig({
'nav-menus': fileURLToPath(new URL('./src/entries/nav-menus.js', import.meta.url)),
'themes': fileURLToPath(new URL('./src/entries/themes.js', import.meta.url)),
'widgets': fileURLToPath(new URL('./src/entries/widgets.js', import.meta.url)),
'tools': fileURLToPath(new URL('./src/entries/tools.js', import.meta.url)),
'export': fileURLToPath(new URL('./src/entries/export.js', import.meta.url)),
'import': fileURLToPath(new URL('./src/entries/import.js', import.meta.url)),
'profile': fileURLToPath(new URL('./src/entries/profile.js', import.meta.url)),
'bug-report': fileURLToPath(new URL('./src/entries/bug-report.js', import.meta.url)),
},
+7 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: Aurora Admin
* Plugin URI: https://aurora.auroradragon.studio
* Description: A modern, fast WordPress admin interface.
* Version: 3.6.1
* Version: 1.0
* Author: Aurora Dragon Studio
* Author URI: https://auroradragon.studio
* Text Domain: aurora-admin
@@ -15,7 +15,7 @@
defined("ABSPATH") || exit();
define("AURORA_ADMIN_VERSION", "3.6.1");
define("AURORA_ADMIN_VERSION", "1.0");
define("AURORA_ADMIN_PATH", plugin_dir_path(__FILE__));
define("AURORA_ADMIN_URL", plugins_url("aurora-admin/"));
@@ -45,6 +45,9 @@ require AURORA_ADMIN_PATH . "admin/src/Pages/NativeSettingsPages.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/PermalinksData.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/DiscussionData.php";
require AURORA_ADMIN_PATH . "admin/src/Pages/AppearancePages.php";
require AURORA_ADMIN_PATH . "admin/src/Pages/ToolsPage.php";
require AURORA_ADMIN_PATH . "admin/src/Pages/ExportPage.php";
require AURORA_ADMIN_PATH . "admin/src/Pages/ImportPage.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/NavMenusData.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/ThemesData.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/WidgetsData.php";
@@ -57,6 +60,8 @@ require AURORA_ADMIN_PATH . "admin/src/Roles/RolesData.php";
require AURORA_ADMIN_PATH . "admin/src/ActivityLog/ActivityLog.php";
require AURORA_ADMIN_PATH . "admin/src/ActivityLog/ActivityLogData.php";
require AURORA_ADMIN_PATH . "admin/src/Login/LoginStyle.php";
require AURORA_ADMIN_PATH . "admin/src/Login/LoginRedirect.php";
require AURORA_ADMIN_PATH . "admin/src/Editor/DisableGutenberg.php";
require AURORA_ADMIN_PATH . "admin/src/Media/SvgUploads.php";
require AURORA_ADMIN_PATH . "admin/src/Media/MediaFolders.php";
require AURORA_ADMIN_PATH . "admin/src/ImageFormats/ImageFormats.php";
+22 -62
View File
@@ -1,10 +1,10 @@
=== Aurora Admin ===
Contributors: TODO-WPORG-USERNAME
Contributors: Aurora Dragon Studio, KrakenRT
Tags: admin, dashboard, admin theme, dark mode, admin redesign
Requires at least: 5.5
Tested up to: 7.0
Requires PHP: 7.4
Stable tag: 3.6.1
Stable tag: 1.0
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
@@ -32,20 +32,23 @@ Aurora Admin replaces the default WordPress admin with a faster, cleaner interfa
**Look and feel**
* A real dark mode covering the entire admin, not just a few screens
* 10 color presets plus 2 flat neutrals
* macOS-style window chrome for the block editor and other full-screen views
* 10 color presets plus 2 flat neutrals, applied across the whole interface
* Rounded, framed content cards with theme-tinted scrollbars; the block editor and other full-screen views get the same treatment
* Optional Google Font for the interface typeface (off by default — see Privacy below)
Everything in Aurora Admin is free — there's no license key, no locked features, and no upsell.
**Aurora File Manager, Aurora Database Explorer, and Aurora Site Backup (separate plugins)**
File Manager (browse/edit/manage your site's files from wp-admin), Database Explorer (browse your database tables and run read-only queries), and Site Backup (full site backup/restore, optionally to your own S3-compatible or Google Drive storage) previously shipped bundled inside Aurora Admin. All three are now distributed as their own standalone plugins — [Aurora File Manager](https://aurora.auroradragon.studio/file-manager), [Aurora Database Explorer](https://aurora.auroradragon.studio/database-explorer), and [Aurora Site Backup](https://aurora.auroradragon.studio/site-backup) — each installable with one click from Aurora's Settings → Add-ons screen. File-management/code-editing and direct database-query functionality of this kind isn't accepted into the WordPress.org Plugin Directory (see the [Plugin Developer FAQ](https://developer.wordpress.org/plugins/wordpress-org/plugin-developer-faq/#are-there-plugins-you-dont-accept)); Site Backup was split out proactively for the same class of reason, since restoring a backup means writing arbitrary files back into the site. Aurora Admin itself never contains any of that functionality.
File Manager (browse/edit/manage your site's files from wp-admin), Database Explorer (browse your database tables and run read-only queries), and Site Backup (full site backup/restore, optionally to your own S3-compatible or Google Drive storage) are distributed as their own standalone plugins — [Aurora File Manager](https://aurora.auroradragon.studio/file-manager), [Aurora Database Explorer](https://aurora.auroradragon.studio/database-explorer), and [Aurora Site Backup](https://aurora.auroradragon.studio/site-backup) — each installable with one click from Aurora's Modules screen. File-management/code-editing and direct database-query functionality of this kind isn't accepted into the WordPress.org Plugin Directory (see the [Plugin Developer FAQ](https://developer.wordpress.org/plugins/wordpress-org/plugin-developer-faq/#are-there-plugins-you-dont-accept)); Site Backup is separate for the same class of reason, since restoring a backup means writing arbitrary files back into the site. Aurora Admin itself never contains any of that functionality.
= Privacy / external service =
= Privacy / external services =
The optional "Report a Bug" screen sends your bug description and basic diagnostics (plugin version, WordPress version, PHP version, active theme name, and your site's domain) to a feedback endpoint on auroraplugins.com when you choose to submit it. Nothing is sent automatically or in the background — this only happens if you fill out and submit that form yourself.
Settings → Add-ons' "Install & Activate" buttons for Aurora File Manager, Aurora Database Explorer, and Aurora Site Backup download that plugin's zip file from aurora.auroradragon.studio using WordPress's own plugin-installer API, the same mechanism the native Plugins → Add New screen uses for WordPress.org-hosted plugins. This only happens when you click one of those buttons — nothing is downloaded automatically or in the background. (Aurora Site Backup's own remote-storage options, if you install and configure it, are documented in that plugin's own readme.)
The interface font setting (Settings → Appearance) is empty by default, in which case Aurora uses your system font and makes no external requests. If you choose a Google Font, that font file is loaded from Google Fonts (fonts.googleapis.com) on admin pages, which sends a request to Google's servers. Leave the setting empty to avoid any external font requests.
The Modules page's "Install & Activate" buttons for Aurora File Manager, Aurora Database Explorer, and Aurora Site Backup download that plugin's zip file from aurora.auroradragon.studio using WordPress's own plugin-installer API, the same mechanism the native Plugins → Add New screen uses for WordPress.org-hosted plugins. This only happens when you click one of those buttons — nothing is downloaded automatically or in the background. (Aurora Site Backup's own remote-storage options, if you install and configure it, are documented in that plugin's own readme.)
== Installation ==
@@ -65,16 +68,16 @@ Yes. Every feature listed above is available with no license key and no restrict
= Will this work with my other plugins? =
Aurora Admin only replaces the built-in WordPress admin screens it explicitly targets (Posts, Pages, Media, Users, Comments, Nav Menus, Widgets, Themes, core Settings screens, Profile, the block editor frame). Screens added by other plugins are untouched. Each replacement can also be toggled off individually in Aurora's settings if you'd rather keep the native screen for something specific.
Aurora Admin only replaces the built-in WordPress admin screens it explicitly targets (Posts, Pages, Media, Users, Comments, Nav Menus, Widgets, Themes, Tools, Export, Import, core Settings screens, Profile, the block editor frame). Running an already-active importer on the Import screen still opens that importer's own native screen, since its content is entirely owned by whichever plugin registered it. Screens added by other plugins are untouched. Each replacement can also be toggled off individually in Aurora's settings if you'd rather keep the native screen for something specific.
= Does Aurora load anything from external servers? =
Not by default. It only makes an external request if you opt in: choosing a Google Font loads it from Google Fonts, submitting the "Report a Bug" form sends it to auroraplugins.com, and the Modules page's install buttons download the companion plugins on click. See "Privacy / external services" above for details.
= Where do I report a bug? =
Use the "Report a Bug" item in the Aurora sidebar menu, or open a thread in this plugin's support forum.
= Where did File Manager, Database Explorer, and Site Backup go? =
File Manager is now Aurora File Manager, Database Explorer is now Aurora Database Explorer, and Site Backup is now Aurora Site Backup — three separate standalone plugins, each installable with one click from Settings → Add-ons in Aurora Admin, or downloadable directly from [aurora.auroradragon.studio/file-manager](https://aurora.auroradragon.studio/file-manager), [aurora.auroradragon.studio/database-explorer](https://aurora.auroradragon.studio/database-explorer), and [aurora.auroradragon.studio/site-backup](https://aurora.auroradragon.studio/site-backup). File Manager and Database Explorer are distributed separately because WordPress.org's Plugin Directory policy doesn't allow file-management/code-editing or direct database-query functionality in a directory-listed plugin; Site Backup was split out proactively for the same class of reason (restoring a backup writes arbitrary files back into the site).
== Screenshots ==
1. The Aurora dashboard.
@@ -83,53 +86,10 @@ File Manager is now Aurora File Manager, Database Explorer is now Aurora Databas
== Changelog ==
= 3.6.1 =
* Fixed another round of Plugin Check findings the 3.5.1 fix didn't fully cover: `WordPress.DB.DirectDatabaseQuery.DirectQuery`/`NoCaching` are attributed by PHPCS to the *outer* `$wpdb->get_results()`/`get_row()` call line, while `PreparedSQL.NotPrepared` is attributed to whichever *inner* line the interpolated table name appears on — two different lines in a multi-line call, so a single `phpcs:ignore` comment can only ever cover one of them. Collapsed the remaining multi-line calls in Menu Creator and Admin Notices to single physical lines so one ignore comment covers both sniffs' attributed lines at once. Re-verified with an automated scan plus live REST-dispatch round-trips (menu create/list/get/delete, notice create/render/delete).
= 3.6.0 =
* Site Backup has moved to its own standalone plugin, Aurora Site Backup — install it with one click from the Settings → Add-ons screen, alongside Aurora File Manager and Aurora Database Explorer. Restoring a backup means writing arbitrary files back into the site, the same class of risk that's kept file-management functionality out of Aurora Admin proper, so it now lives in a separately-distributed companion plugin.
= 3.5.1 =
* Fixed a fresh WordPress.org Plugin Check report: the previous compliance pass's `phpcs:ignore` annotations were placed above the *opening* line of several multi-line `$wpdb->get_results()`/`get_row()`/`get_var()` calls, but PHPCS attributes violations to the exact physical line where the interpolated tokens appear — often one or more lines further down inside the call — so those suppressions were never actually taking effect. Corrected across Menu Creator, Admin Notices, Site Backup, Media Folders, Analytics, and Media Library. Also fixed a real, unrelated bug found during verification: creating a menu in Menu Creator returned "Menu not found" instead of the newly created menu, because of how the internal follow-up request object was constructed.
* Compressed files, hidden files, and non-standard markdown files flagged by Plugin Check are scanner noise from scanning the whole development repository (build zips, `.gitignore`, `.claude/`, `PROJECT_STATUS.md`) — confirmed none of these exist in the actual submission zip.
= 3.5.0 =
* Database Explorer has moved to its own standalone plugin, Aurora Database Explorer — install it with one click from the Settings → Add-ons screen, alongside Aurora File Manager. Direct database-query tooling is treated by WordPress.org's Plugin Directory review the same way as file-management functionality, so rather than remove the capability outright, it now lives in a separately-distributed companion plugin, same as File Manager did in 3.4.0.
= 3.4.0 =
* File Manager has moved to its own standalone plugin, Aurora File Manager — install it with one click from the new Settings → Add-ons screen. WordPress.org's Plugin Directory policy doesn't allow file-management/code-editing functionality in a directory-listed plugin regardless of the safeguards in place, so rather than remove the capability outright, it now lives in a separately-distributed companion plugin.
= 3.3.2 =
* Compliance pass against the official WordPress.org Plugin Check tool: fixed a readme.txt version mismatch, a missing input-sanitization call, and annotated ~230 custom-database-table query warnings with the justification the review process expects (Aurora's backup/analytics/activity-log/menu/notices/media-folders features all use their own tables, which necessarily means direct, non-core-wrapped database access).
= 3.3.1 =
* Fixed the Settings page: the category list and the settings panel now scroll independently, so picking a category from a scrolled-down spot in the list no longer leaves the panel showing stale, off-screen content above the fold.
= 3.3.0 =
* Added Modern Image Formats: automatically generates a WebP or AVIF copy of every uploaded JPEG/PNG image and serves it via a <picture> element with the original as a fallback, configurable under Settings.
= 3.2.1 =
* Security hardening: File Manager no longer allows opening or saving executable file types (PHP and similar) for a file that already exists — only creating, uploading, and renaming to those types were previously blocked. File Manager now also honors the DISALLOW_FILE_EDIT and DISALLOW_FILE_MODS constants, the same host-level lockdown WordPress core's own file-editing and update/install flows respect.
= 3.2.0 =
* Added Google Drive as a second remote storage option for Site Backup, alongside S3-compatible storage. Connect your own Google Cloud OAuth client in Settings to store backups in your own Google Drive account.
= 3.1.0 =
* Added Site Backup: full site backup (database + files) and restore-in-place, on demand or on a recurring daily/weekly/monthly schedule, stored locally or on your own S3-compatible remote storage (AWS S3, Backblaze B2, Cloudflare R2, DigitalOcean Spaces, and similar).
= 3.0.2 =
* Fixed a plugin-header validation error: Plugin URI and Author URI were identical, which the WordPress.org submission checker requires to be distinct. Plugin URI now points to the plugin's own site (aurora.auroradragon.studio); Author URI stays on the studio's site.
= 3.0.1 =
* Compliance and hardening pass ahead of the WordPress.org submission: replaced a forbidden core function in the Widgets screen, converted File Manager's file operations to use the WP_Filesystem API, added nonce/sanitization coverage to a few read paths, fixed missing translator comments, and annotated custom-table SQL queries for the plugin review process.
= 3.0.0 =
* Aurora Admin is now completely free — the entire licensing system was removed, and every previously-paid feature (Role Editor, Activity Log, Database Explorer, File Manager, Analytics, White Label, Full Admin Takeover, Media Folders) is now available to everyone.
= 2.27.0 and earlier =
* Pre-release beta versions, not distributed on WordPress.org.
== Upgrade Notice ==
= 3.0.0 =
Aurora Admin is now 100% free. If you were on an earlier beta with a license key, it's no longer needed — all features are unlocked automatically.
= 1.0 =
* First public release of Aurora Admin.
* Modern admin shell — fixed sidebar, toolbar, and global search — replacing the default WordPress admin chrome.
* Real replacement screens for Dashboard, Posts, Pages, Media, Users, Comments, Plugins, Nav Menus, Widgets, Themes, the core Settings screens (General, Writing, Reading, Discussion, Permalinks), Tools, Export, and Import, plus Add User, Media Upload, and Profile.
* Power tools: Role Editor, an Activity Log with Admin/User tabs and IP tracking, Media Folders, Menu Creator with drag-and-drop reordering, Admin Notices, White Label, Login Redirect, a Disable Gutenberg toggle, and a Modules page for one-click companion-plugin installs.
* Full dark mode — including native screens Aurora frames rather than rebuilds (Site Health, Export/Erase Personal Data, Updates, the Theme/Plugin File Editor) — with 10 color presets plus 2 flat neutrals, rounded framed content cards, theme-tinted scrollbars, and an optional (off-by-default) Google Font for the interface.
* Security & performance hardening, custom admin CSS/JS, and a login-screen customizer.