38 Commits
Author SHA1 Message Date
NixReaperandClaude Opus 4.8 f0bc04a110 Add Security & performance settings group + hardening module
New Settings > Security & performance group (Hardening + Performance) with
toggles wired by admin/src/Security/Security.php — each gated on its own
setting, adding/removing core hooks so anything left off behaves exactly like
stock WordPress:

- Disable XML-RPC (xmlrpc_enabled false + drop X-Pingback header)
- Disable comments site-wide (close everywhere, strip post-type support,
  remove the Comments menu/dashboard-widget/toolbar node)
- Clean up <head> (generator, RSD, WLW manifest, shortlink)
- Disable emoji scripts/styles + the s.w.org dns-prefetch
- Disable oEmbeds (discovery links, host js, embed REST route, wp-embed) —
  discovery links are removed at whatever priority core hooks them (loops,
  since they can be registered at more than one)
- Heartbeat API: default / slow (60s) / disabled

New setting keys need no schema change (Settings::merge_on_save +
additionalProperties). Every toggle verified at the hook level on PHP 8.5.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-12 14:26:59 -05:00
NixReaperandClaude Opus 4.8 2fec4cf34b Add first-run setup wizard
Full-screen onboarding at admin.php?page=aurora-admin-setup: Welcome →
Appearance (theme + font) → Branding (logo/favicon/rename) → Features
(replacement-screen toggles) → Done. Reuses SettingsField for every control
and applyLiveTheme for instant preview; finish/skip saves via wp/v2/settings
and marks completion.

- admin/src/Pages/SetupWizard.php: hidden submenu page, disables the shell on
  its own page (aurora_admin_shell_enabled filter) + hides native chrome,
  register_activation_hook sets a one-time redirect flag, admin_init redirects
  once, and a POST /aurora-admin/v1/setup/complete route marks it done.
- app/src/pages/setup-wizard/SetupWizard.vue + entries/setup-wizard.js + vite
  input.
- Wired into aurora-admin.php + AuroraAdmin.php.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-12 13:55:03 -05:00
NixReaperandClaude Opus 4.8 2da3883d20 Indent settings category items under their group header
Add a small left indent to .settings__list so each group's categories read
as a nested submenu rather than sitting flush with the group header.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-12 13:24:17 -05:00
NixReaperandClaude Opus 4.8 b3ff365483 Round corners a notch; tokenize all radii onto an sm/md/lg/xl scale
Consolidate ~140 scattered hardcoded border-radius values (and the old
--aurora-radius/--aurora-radius-lg vars) onto one four-step scale defined in
shell-frame.css:
  --aurora-radius-sm 10px  (chips, small/icon controls; was 5-8px)
  --aurora-radius-md 14px  (buttons, inputs, search, list rows; was 9-14px)
  --aurora-radius-lg 20px  (cards, panels, larger buttons; was 15-20px)
  --aurora-radius-xl 28px  (page frames: content card, dashboard, sidebar,
                            editor; was the 24px --aurora-radius-lg)
Everything is one notch rounder, and the whole UI's roundness is now tunable
from four lines instead of 35 files.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-12 13:21:22 -05:00
NixReaperandClaude Opus 4.8 53ca6f62b9 Remove macOS window-chrome (traffic-light dots, minimize taskbar)
Strip the whole window metaphor as UI baggage: the traffic-light dots
injected at the top of every page, the minimize taskbar, and the
minimize/maximize state and all their CSS. windowChrome.js and taskbar.js
are gone; the only structural DOM the shell still does — wrapping the
takeover Dashboard in .aurora-dashboard-frame and reparenting its notices
into the card — moved to a focused dashboardFrame.js (initDashboardFrame()).

SiteShell.vue drops the taskbar rendering/state; shell-frame.css drops the
.aurora-winbar*/.aurora-taskbar*/minimized/maximized rules and the
--aurora-taskbar-h / --aurora-editor-winbar-h vars, and zeroes the editor
and theme-install frame offsets that reserved a top strip for the dots.
shell.js 16.4->13.4 kB. The editor "back to list" crumb lived in the bar
and was removed with it (Gutenberg's own W-logo still navigates back).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-12 11:44:57 -05:00
NixReaperandClaude Opus 4.8 dc2a4435c5 Remove unused CodeMirror dependencies
@codemirror/* (8 language packages) and codemirror were declared but
imported nowhere in src — leftovers from the removed File Manager and
Database Explorer. Dropped all nine; removes 30 packages from the install
(node_modules ~44 MB -> ~39 MB). Not bundled before (unused), so the built
output is unchanged. If the future custom CSS/JS editor needs an in-browser
code editor, add back the specific packages it uses at that point.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-12 09:57:59 -05:00
NixReaperandClaude Opus 4.8 da856dc853 Split Vue into a stable vendor chunk; make sourcemaps opt-out
- Pin `vue` to a named `vendor-vue` chunk (manualChunks) so its hash stays
  stable across app-code changes — returning users keep it cached across
  plugin releases instead of re-downloading ~31 kB gzip each update. Also
  replaces the misleadingly-named `_plugin-vue_export-helper` shared chunk.
- Gate sourcemap emission on AURORA_SOURCEMAP (default on for the local
  hot-patch dev loop; the distribution build sets it to false). Cuts a
  from-scratch dist build's app/dist from ~1.7 MB to ~502 kB by dropping
  27 .map files that ship no value to end users.
- Refresh the stale header comment (was "one entry in this first slice").

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-12 09:38:16 -05:00
NixReaperandClaude Opus 4.8 1c8c351654 Regroup settings into collapsible sections
Restructure the flat settingsCategories list into five top-level groups
(General, Appearance, Screens, Productivity, Add-ons), each a collapsible
nav section with persisted open/closed state. Consolidate the six
single-toggle modern_* categories (posts/pages/media/users/comments/
plugins) into one "Content tables" screen. Search now matches on group +
category + subtitle and hides empty groups. Field definitions, save flow,
and setting keys are unchanged.

settingsConfig.js exports settingsGroups plus a flattened settingsCategories
for default-seeding and deep-link lookups.

(package-lock.json: version field synced 2.16.0 -> 3.1.0 to match package.json.)

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-12 09:27:09 -05:00
NixReaperandClaude Opus 4.8 ce8fcc8fdd Remove uiXpress origin framing from PROJECT_STATUS
Reworded "What this project is" to describe Aurora on its own terms and
dropped the stale uixpress/ folder archaeology from the Repo section.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-12 09:26:34 -05:00
NixReaperandClaude Opus 4.8 e2db2ad4b5 Snapshot synced v3.6.1 working state as baseline
Machine-sync snapshot: the working tree carried the full 3.0.0-3.6.1 arc
(licensing/FileManager/DatabaseExplorer removal, all new replacement screens
and REST controllers) that had not been committed on this machine. Captured
here as a safety baseline before starting the Adminify-parity revamp.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-12 08:41:18 -05:00
nixpanelandClaude Sonnet 5 07d77bda57 Fix Plugins dropping to native page after activating a plugin (v2.7.2)
Unlike Posts/Pages/Media/Users/Comments, Plugins only ever had its
sidebar link rewritten (MenuSerializer.php) — there was no
redirect_native_pages() case for direct visits to plugins.php itself.
WordPress's own post-activate/post-install redirect
(wp_redirect(self_admin_url('plugins.php?activate=true'))) is exactly
that: a direct visit with no ?page= param, landing on the true native
page until the sidebar link was clicked again.

Added the missing case, gated by modern_plugins like the others,
redirecting to plugins.php?page=aurora-admin-plugins to match the URL
the sidebar link itself already uses.

Verified live by visiting plugins.php?activate=true directly (the
exact URL WP's own redirect produces) — now lands on the Aurora page
correctly, confirmed with FileBird Lite showing Active in the list.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-05 07:17:47 -05:00
nixpanelandClaude Sonnet 5 fdd252cfe2 Fix white native form fields on classic edit screens (v2.7.1)
Native form fields (title inputs, textareas, selects) on screens that
don't use the block editor — most visibly Edit Media's title field —
were showing stark white, breaking the dark theme. WP core styles some
fields via a 2-ID selector (#titlediv #title { background: #fff }),
which beats native-dark.css's existing form-field rule regardless of
class count, same specificity trap as an earlier block-editor
background bug. Added !important to the whole form-field rule block —
safe everywhere, no legitimate case for a native admin field to stay
white.

Verified this one rule fixes Edit Media, Edit Post (classic fields),
and Comment edit simultaneously; Add User was already clean. The
WYSIWYG editor body staying white is a separate, deliberate decision
(matches the block editor's own canvas) and wasn't touched.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-05 07:12:38 -05:00
nixpanelandClaude Sonnet 5 15d6060ae3 Add File Manager sub-app (Pro feature) (v2.7.0)
New browse/edit interface for the WordPress install's own files, built
using the installed "WP File Manager" plugin (elFinder-based) as a
functional reference rather than embedding it. Own REST controller
(FileManagerData.php) + Vue page, same pattern as every other sub-app.

Security is the whole point here (highest-risk surface in the plugin —
arbitrary filesystem read/write):
- Root is ABSPATH, manage_options-gated throughout.
- Every client path is resolved via realpath() and checked for root
  containment against the *resolved* path, not string-matched against
  the raw input — catches symlink tricks and encoded traversal that a
  naive "../" check would miss.
- Uploads/new files/renames reject a blocklist of server-executable
  extensions (.php, .cgi, .sh, etc.) — the single most important
  control, since uploading a webshell is the classic exploit path for
  this class of feature.
- Deliberately not built: zip/unzip (zip-slip vulnerability class),
  chmod/permissions editing, cross-directory move/copy.

Gated as a paid feature (riskier than Database Explorer, which is
already paid) — a judgment call flagged to the user, not something they
specified.

Also fixed a real bug found while wiring this up: SubApps.php's
page-level lock doesn't stop someone with manage_options from calling
the REST endpoints directly, bypassing the license check entirely.
File Manager's REST permission callbacks now also check
License::is_feature_available(). The same gap exists in Database
Explorer, Role Editor, and Activity Log's REST controllers and is
flagged as a follow-up task, not fixed here (out of scope for what was
asked, affects three unrelated features).

Verified live via direct REST calls: path traversal blocked (404),
blocked extensions rejected (400), root deletion blocked (400), and a
full create/write/read/rename/delete round trip succeeded cleanly.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-05 06:59:24 -05:00
nixpanelandClaude Sonnet 5 204c4365e2 Add real replacement pages for Pages, Media Library, and Users (v2.6.0)
Completes the "skinned vs. replaced" conversion started with Posts —
every native list-table screen the user asked about now has a genuine
custom Vue page behind it instead of dark-themed CSS over the native
markup. All three follow the same pattern as Posts: own REST controller
in admin/src/Rest/, own Vue page matching the Comments layout, native
screen redirected away (direct visits too, not just the sidebar link),
gated by its own Settings toggle defaulting to on.

- Pages (PagesData.php + Pages.vue): same as Posts, parent page shown
  instead of categories. edit.php?post_type=page redirects; other post
  types untouched.
- Media Library (MediaData.php + Media.vue): thumbnail grid with type
  filters (Images/Audio/Video/Documents/Unattached). Replaces the
  browse/library experience only — media-new.php (the actual upload
  screen) is untouched, same reasoning as leaving the block editor
  alone for Posts/Pages.
- Users (UsersData.php + Users.vue): filterable by role. Deleting a
  user always reassigns their content to whoever performs the deletion
  rather than exposing WP core's reassign-or-delete-everything choice
  as a one-click action — deliberately simplified to avoid a surprising,
  highly destructive default. user-new.php untouched.

Verified live: Media's grid renders real thumbnails/dimensions/file
sizes with working filters and search; Users shows real accounts with
role filters and correctly hides delete for the current user; both
redirect correctly from their native URLs.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-05 06:40:22 -05:00
nixpanelandClaude Sonnet 5 56b6674527 Add a real replacement Posts management page (v2.5.0)
Posts was previously just dark-themed skinning over the native
edit.php list table, unlike Comments/Plugins which are genuine
replacements. Built the same way: new PostsData.php REST controller
(list with status/search filters matching native tabs, trash, restore,
permanent delete) and a Vue page matching the Comments layout.

- SubApps.php registers it as aurora-admin-posts, parented under
  edit.php (same pattern as Plugins under plugins.php).
- redirect_native_pages() redirects direct edit.php visits (typed URL,
  not just the sidebar link) to the Aurora page — gated to the default
  post type only, so Pages and other custom post types are untouched.
- MenuSerializer rewrites the sidebar's Posts/All Posts links the same
  way Plugins/Comments already were.
- Gated by Settings > Posts' "Modern posts table" toggle, re-added now
  that it has a real implementation (it was removed a few commits ago
  for describing a feature that didn't exist).
- Fixed modern_plugins/modern_comments/modern_posts all defaulting to
  visually "on" in the Settings UI to match their actual PHP default.

Verified live: trash/restore/status filters/search all work correctly,
edit.php redirects to the Aurora page, edit.php?post_type=page (Pages)
correctly does not.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-05 05:59:11 -05:00
nixpanelandClaude Sonnet 5 d8bf1ae049 Fix Admin Notices not showing on the Dashboard (v2.4.2)
admin_notices fires everywhere, but prints inside #wpbody, which
DashboardTakeover hides entirely on the Dashboard (to hide the *native*
dashboard, not ours) — notices went down with it as collateral damage.
Notices::render_for_dashboard() now renders directly inside the visible
dashboard frame instead, called from DashboardTakeover::render_mount();
the normal admin_notices path bails on the Dashboard so it isn't
rendered twice.

Second, independent bug found along the way: wp-admin/js/common.js
unconditionally relocates every .notice element on the page to right
after the page's <h1>, regardless of where it was placed server-side —
without opting out, it would've silently moved our notice right back
into the hidden #wpbody. Used core's own documented `below-h2` class to
opt out, only for the dashboard render path.

Verified live: notice now shows correctly inside the dashboard frame
with proper dark styling, and dismisses correctly.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-04 10:27:58 -05:00
nixpanelandClaude Sonnet 5 03c3c4fc89 Fix broken dashboard scrolling (v2.4.1)
.aurora-dashboard-frame never got the height-cap + internal-scroll
treatment #wpbody-content got in an earlier session (it bypasses
#wpbody-content entirely, using its own element) — only the
taskbar-present variant applied to it. Combined with html/body being
locked to overflow:hidden in that same earlier fix, dashboard content
below ~1500px became completely unreachable: no internal scroll, and
no document scroll left to fall back on. Mirrored the same
max-height/overflow-y/overflow-anchor treatment onto the dashboard
frame. Verified live — Server Health, Media Analytics, and the rest of
the dashboard scroll into view correctly now.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-04 09:45:11 -05:00
nixpanelandClaude Sonnet 5 fb93888d3f Wire up remaining placeholder settings, implement menu search (v2.4.0)
- Custom dashboard, Modern plugins page, and Modern comments page
  toggles previously had no effect at all — their respective takeovers/
  redirects (DashboardTakeover, SubApps' Plugins/Comments handling,
  MenuSerializer's link rewrites) ran unconditionally regardless of the
  setting. All three now default to their previous always-on behavior
  but can actually be turned off.
- Menu search had zero implementation. Sidebar.vue now shows a real
  search box (gated on the setting) that filters both the WP-native menu
  and the Aurora nav section by title, auto-expanding matching sections.
- Removed "Modern posts table" and "Modern users page" toggles (and
  their now-empty Posts/Users settings categories) — both described a
  custom management page that was never built. A checkbox with nothing
  behind it is worse than no checkbox.

Verified all four live: dashboard/plugins/comments toggles correctly
flip between the Aurora and native experience and back; menu search
filters correctly and clears back to the full menu.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-04 09:35:00 -05:00
nixpanelandClaude Sonnet 5 1578481106 Include Aurora nav section in the sidebar's single-open accordion (v2.3.1)
Aurora's branded nav section had its own independent expand/collapse
state, so it stayed open regardless of which native menu section was
expanded — the one exception to the single-open accordion. Unified
under one openId ref (a sentinel value for Aurora alongside real
native-menu item ids) so expanding either collapses the other.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-04 08:40:04 -05:00
nixpanelandClaude Sonnet 5 39af8d650d Implement Settings > Media's "Allow SVG uploads" toggle (v2.3.0)
The toggle existed in the UI with no implementation behind it, so
uploads were rejected regardless of the setting. New SvgUploads class:
- Allowlists the SVG mime type and fixes WP's real-mime sniff, which
  otherwise nulls out the file type for SVGs specifically even after
  the mime allowlist is updated.
- Populates width/height metadata (parsed from the SVG's own
  viewBox/width/height) — without this, wp_get_missing_image_subsizes()
  throws undefined-array-key warnings that can corrupt the upload
  response's JSON on environments with warnings displayed inline.
- Basic sanitization on upload (strips <script> tags, inline event
  handlers, javascript: URIs).

Verified live via the REST media endpoint: clean upload with correct
dimensions, and a malicious test SVG had its script/handlers stripped
while staying valid markup.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-04 07:46:56 -05:00
nixpanelandClaude Sonnet 5 077e9b8d3c Implement Settings > Login's page styling (v2.2.0)
"Style login page" and "Login logo" existed in the Settings UI with no
implementation behind them at all. New LoginStyle class hooks
login_enqueue_scripts to apply a dark theme (background, form card,
inputs, button) and swap in the configured logo, linked to the site's
home URL instead of wordpress.org.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-04 04:22:10 -05:00
nixpanelandClaude Sonnet 5 deab644e38 Fix image fields to open the real media library picker (v2.1.1)
wp_enqueue_media() was never called on the Settings page, so wp.media
was undefined client-side and every image field (Logo, Dark mode logo,
Login logo, Admin favicon) silently fell back to a plain URL prompt
instead of the WordPress media library. Also relabeled the picker
button ("Choose from media library" instead of "{label} url") so it
reads as a picker, not a URL field.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-04 03:45:28 -05:00
nixpanelandClaude Sonnet 5 55d1788471 Bump version to 2.1.0, add CHANGELOG.md
Covers this session's work: free/paid feature gating, real
auroraplugins.com license validation, and file-integrity tamper
protection for the license gate.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-04 03:38:13 -05:00
nixpanelandClaude Sonnet 5 cd66b70735 Add file-integrity check to protect the license gate from tampering
- New Integrity class checksums the files that implement/enforce the
  free/paid split (SubApps, Analytics, AnalyticsData, WhiteLabel, Shell,
  License itself, and the two bootstrap files) against a manifest
  generated at zip-build time. No manifest present (a dev checkout) is
  never treated as tampered.
- License::is_feature_available() checks Integrity::is_tampered() before
  checking the actual license, so a distributed copy with the check
  stripped/commented out re-locks every paid feature even with an
  otherwise-valid license, rather than the plugin trusting whatever code
  happens to still be there.
- bin/build-integrity-manifest.php generates the manifest against a
  staged zip-build copy (gitignored build artifact, same pattern as
  app/dist) — now a required step in the zip-build workflow.
- Verified live: untampered+unlicensed stays locked, untampered+licensed
  unlocks, one tampered file re-locks regardless of license status
  (tested via a temporary mu-plugin faking an active license, kept
  outside any protected file so it didn't itself trip the check).

This raises the bar against the realistic low-effort case (finding and
deleting/inverting the one license-check line) but can't stop someone who
rewrites a file's logic to never call the gate, or who edits this
integrity check itself — an inherent limit of protection living in plain,
editable PHP.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-03 20:44:18 -05:00
nixpanelandClaude Sonnet 5 540083e6e4 Connect License class to the real auroraplugins.com license server
- License.php now calls the aurora-licensing plugin's REST API
  (/wp-json/aurora-licensing/v1/validate|activate|deactivate) instead of
  treating any non-empty license_key as active. Result is cached in the
  aurora_admin_license_status option for 12 hours; is_active() and
  is_feature_available() read the cache instead of hitting the network on
  every admin page load.
- Hooks update_option_aurora_admin_settings to activate/deactivate
  immediately when the license_key setting actually changes, rather than
  waiting out the cache. Instantiated in AuroraAdmin.php so that hook
  registers.
- Settings page now surfaces the real status (active/invalid/unreachable,
  with the server's own message) as a status line under the License key
  field, instead of a field that silently does nothing.
- Verified live against the real server: a fake test key correctly came
  back locked with the server's own "License key not found." message.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-03 20:26:00 -05:00
nixpanelandClaude Sonnet 5 eb03d3dd6c Document the free/paid feature-gating architecture
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-03 20:00:17 -05:00
nixpanelandClaude Sonnet 5 6d502604d7 Add free/paid feature gating (Role Editor, Activity Log, DB Explorer,
Analytics, White Label, remote site switcher are paid)

- New License class is the single gate every paid feature checks —
  today it's a placeholder (any non-empty license_key counts as active)
  since there's no license server yet, but every gated feature already
  funnels through it so swapping in real remote validation later won't
  touch anything else.
- Role Editor, Activity Log, and Database Explorer stay registered/
  capability-checked as before, but render an UpgradeNotice screen
  instead of the real feature when unlicensed (and skip loading the
  real JS bundle/REST data). Menu Creator, Admin Notices, Comments,
  and Plugins stay free.
- Analytics' tracking hooks and REST endpoint, and White Label's
  favicon/rename/text-replace hooks, simply don't register without a
  license. Settings fields for both stay visible/editable either way.
- Remote site switcher gated alongside its existing capability check.
- Sidebar shows a small lock badge on locked Aurora nav items; the
  Dashboard's Analytics tab shows the same upgrade prompt when locked.
- Fixed an unrelated bug surfaced while testing Role Editor: RolesData's
  REST callback called get_editable_roles() without loading
  wp-admin/includes/user.php first, which REST requests don't get for
  free (only real wp-admin page loads do) — 500'd every time.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-03 19:59:00 -05:00
nixpanelandClaude Sonnet 5 73591e0ca4 Cap content-area height on every page, fix scroll bugs, sidebar accordion
- Apply the taskbar's max-height/overflow-y:auto treatment to
  #wpbody-content unconditionally, so tall native/Aurora pages (Add
  Plugins, Add User, Profile, Fonts, file editors, Settings, Activity Log,
  Database Explorer, Comments) scroll internally within their frame
  instead of growing past the viewport and scrolling the whole document.
- Fix a Chrome scroll-anchoring bug this surfaced on Add Plugins (page
  loaded scrolled ~1700px into content instead of at the top) via
  overflow-anchor: none.
- Fix a phantom document-level scrollbar with dead space below the frame
  by locking html/body's own overflow, since the sidebar/toolbar are fixed
  and content is only ever meant to scroll internally.
- Make the sidebar's native-menu sections a single-open accordion:
  expanding one now collapses whichever else was open.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-03 19:19:17 -05:00
nixpanelandClaude Sonnet 5 8153d4397c Fix sub-app permissions bug, add Comments app, support Site Editor
- Root-cause fix for "Sorry, you are not allowed to access this page" on
  Menu Creator/Activity Log/Role Editor: remove_submenu_page() was
  corrupting WP core's user_can_access_admin_page() capability lookup.
  Sub-apps now stay registered in $submenu; MenuSerializer filters them
  out of the sidebar instead.
- Add a 6th sub-app, Comments, with a redirect from native edit-comments.php.
- Extend the window-chrome/taskbar framing to the Site Editor
  (site-editor.php), including a fix for a CSS position:fixed
  over-constraint bug that let its frame run past the viewport.
- Rewrite the taskbar from a single sessionStorage slot to a list so
  multiple minimized windows are each independently restorable.
- Extend native-dark.css coverage to the block editor's own chrome.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-03 18:48:25 -05:00
nixpanelandClaude Sonnet 5 b0b0a9999a Update project status doc for the theme system rebuild and window chrome
Documents this session's work: the new fixed-preset theme system
(gradients, the recurring "independently-painted gradient" seam bug
pattern), the macOS-style window chrome + taskbar feature, extended
native-page dark styling coverage, and the still-open sub-app permissions
bug.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-03 12:32:56 -05:00
nixpanelandClaude Sonnet 5 4a547d2c50 Add theme presets, gradient backgrounds, and macOS-style window chrome
Replaces the old light/dark toggle with 10 fixed color themes (plus
Black & Grey and White) that drive a coordinated gradient palette across
the whole admin UI, a Google Fonts picker, and a native-page dark-mode
stylesheet that now covers modals and plugin/theme card grids. Fixes
several background/gradient seams where the sidebar, toolbar, and content
frame were each painting their own independent copy of the page gradient
instead of sharing one continuous plane.

Also adds a macOS-style traffic-light title bar to every page (close,
minimize, maximize) and a persistent bottom taskbar so a minimized page
can be restored from anywhere, plus a Plugins management page.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-03 11:49:15 -05:00
Kris Parish db07e21f9e Update project status doc with sub-apps, sidebar restructure, and user menu work 2026-07-03 03:31:01 -05:00
Kris Parish e1bd095682 Use color swatches for the theme control instead of icons 2026-07-03 03:13:22 -05:00
Kris Parish 49bb4cf4f4 Move account menu to a floating bottom-left card with light/dark/system theme control 2026-07-03 03:09:06 -05:00
Kris Parish 4f122a00df Further tighten sidebar item spacing 2026-07-03 03:02:29 -05:00
Kris Parish fab8f912da Tighten vertical spacing between sidebar menu items 2026-07-03 02:59:47 -05:00
Kris Parish aff229b01a Style Aurora nav submenu items to match native WP submenu style 2026-07-03 02:58:48 -05:00
Kris Parish 03cabe2fe4 Initial commit: Aurora Admin plugin 2026-07-03 02:53:29 -05:00
41 changed files with 3750 additions and 2290 deletions
+1285 -45
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
+2 -71
View File
@@ -51,11 +51,6 @@ html.aurora-dark.aurora-ready #wpbody-content .postbox,
html.aurora-dark.aurora-ready #wpbody-content .card, html.aurora-dark.aurora-ready #wpbody-content .card,
html.aurora-dark.aurora-ready #wpbody-content .welcome-panel, html.aurora-dark.aurora-ready #wpbody-content .welcome-panel,
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table, 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 { html.aurora-dark.aurora-ready #wpbody-content .stuffbox {
background: var(--aurora-card-bg); background: var(--aurora-card-bg);
border-color: var(--aurora-card-border); border-color: var(--aurora-card-border);
@@ -84,53 +79,17 @@ html.aurora-dark.aurora-ready #wpbody-content .postbox .handle-order-lower {
/* Tables ---------------------------------------------------------------- */ /* Tables ---------------------------------------------------------------- */
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table th, 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); 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 thead th,
html.aurora-dark.aurora-ready #wpbody-content .wp-list-table tfoot 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 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); 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 .wp-list-table.striped > tbody > :nth-child(odd),
html.aurora-dark.aurora-ready #wpbody-content .striped > tbody > :nth-child(odd) { html.aurora-dark.aurora-ready #wpbody-content .striped > tbody > :nth-child(odd) {
background: var(--aurora-bg-subtle); background: var(--aurora-bg-subtle);
@@ -533,31 +492,3 @@ html.aurora-dark.aurora-ready #wpbody-content .font-library__font-card {
border-color: var(--aurora-card-border); border-color: var(--aurora-card-border);
color: var(--aurora-text); 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);
}
+18 -99
View File
@@ -10,7 +10,7 @@ defined("ABSPATH") || exit();
class ActivityLog class ActivityLog
{ {
const TABLE = "aurora_admin_activity_log"; const TABLE = "aurora_admin_activity_log";
const DB_VERSION = "2"; const DB_VERSION = "1";
const DB_OPTION = "aurora_admin_activity_log_db_version"; const DB_OPTION = "aurora_admin_activity_log_db_version";
public function __construct() public function __construct()
@@ -19,12 +19,9 @@ class ActivityLog
add_action("save_post", [self::class, "on_save_post"], 10, 3); add_action("save_post", [self::class, "on_save_post"], 10, 3);
add_action("before_delete_post", [self::class, "on_delete_post"]); 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("user_register", [self::class, "on_user_register"]);
add_action("profile_update", [self::class, "on_profile_update"]); add_action("profile_update", [self::class, "on_profile_update"]);
add_action("wp_login", [self::class, "on_login"], 10, 2); 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("activated_plugin", [self::class, "on_plugin_activated"]);
add_action("deactivated_plugin", [self::class, "on_plugin_deactivated"]); add_action("deactivated_plugin", [self::class, "on_plugin_deactivated"]);
add_action("switch_theme", [self::class, "on_theme_switch"]); add_action("switch_theme", [self::class, "on_theme_switch"]);
@@ -55,7 +52,6 @@ class ActivityLog
object_label VARCHAR(191) NOT NULL DEFAULT '', object_label VARCHAR(191) NOT NULL DEFAULT '',
user_id BIGINT UNSIGNED NULL, user_id BIGINT UNSIGNED NULL,
user_name VARCHAR(191) NOT NULL DEFAULT '', user_name VARCHAR(191) NOT NULL DEFAULT '',
ip_address VARCHAR(45) NULL,
details LONGTEXT NULL, details LONGTEXT NULL,
created_at DATETIME NOT NULL, created_at DATETIME NOT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
@@ -70,23 +66,10 @@ class ActivityLog
update_option(self::DB_OPTION, self::DB_VERSION); 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; global $wpdb;
if ($actor === null) { $user = wp_get_current_user();
$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. // 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(), [ $wpdb->insert(self::table_name(), [
@@ -94,133 +77,69 @@ class ActivityLog
"object_type" => $object_type, "object_type" => $object_type,
"object_id" => $object_id, "object_id" => $object_id,
"object_label" => substr((string) $object_label, 0, 191), "object_label" => substr((string) $object_label, 0, 191),
"user_id" => $actor["id"], "user_id" => $user && $user->exists() ? $user->ID : null,
"user_name" => $actor["name"], "user_name" => $user && $user->exists() ? $user->display_name : "",
"ip_address" => self::client_ip(),
"details" => $details ? wp_json_encode($details) : null, "details" => $details ? wp_json_encode($details) : null,
"created_at" => current_time("mysql"), "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) public static function on_save_post($post_id, $post, $update)
{ {
// save_post fires on autosaves, revisions, and attachments too — none // save_post fires on autosaves and revisions too — neither represents a
// represent a real user-visible content change (attachments get their // real user-visible change, so both are skipped here.
// own add_attachment/delete_attachment hooks below), so all three are if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
// skipped here.
if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id) || $post->post_type === "attachment") {
return; return;
} }
if (!in_array($post->post_status, ["publish", "draft", "pending", "future", "private"], true)) { if (!in_array($post->post_status, ["publish", "draft", "pending", "future", "private"], true)) {
return; return;
} }
// object_type is the post's real type (post/page/a custom post type), self::record($update ? "post_updated" : "post_created", "post", $post_id, $post->post_title ?: "(no title)");
// 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) public static function on_delete_post($post_id)
{ {
$post = get_post($post_id); $post = get_post($post_id);
if (!$post || $post->post_type === "revision" || $post->post_type === "attachment") { if (!$post || $post->post_type === "revision") {
return; return;
} }
self::record("deleted", $post->post_type, $post_id, $post->post_title ?: "(no title)"); self::record("post_deleted", "post", $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) public static function on_user_register($user_id)
{ {
$user = get_userdata($user_id); $user = get_userdata($user_id);
// Self-registration means the visitor isn't authenticated yet at this self::record("user_registered", "user", $user_id, $user ? $user->user_login : "");
// 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) public static function on_profile_update($user_id)
{ {
$user = get_userdata($user_id); $user = get_userdata($user_id);
self::record("profile_updated", "user", $user_id, $user ? $user->user_login : ""); self::record("user_updated", "user", $user_id, $user ? $user->user_login : "");
} }
public static function on_login($user_login, $user) public static function on_login($user_login, $user)
{ {
// Not wp_get_current_user(): wp_login fires from inside wp_signon(), self::record("login", "user", $user->ID, $user_login);
// 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) public static function on_plugin_activated($plugin)
{ {
self::record("activated", "plugin", null, $plugin); self::record("plugin_activated", "plugin", null, $plugin);
} }
public static function on_plugin_deactivated($plugin) public static function on_plugin_deactivated($plugin)
{ {
self::record("deactivated", "plugin", null, $plugin); self::record("plugin_deactivated", "plugin", null, $plugin);
} }
public static function on_theme_switch($new_name) public static function on_theme_switch($new_name)
{ {
self::record("switched", "theme", null, $new_name); self::record("theme_switched", "theme", null, $new_name);
} }
public static function on_settings_updated() public static function on_settings_updated()
{ {
self::record("updated", "settings", null, "Aurora Admin settings"); self::record("settings_updated", "settings", null, "Aurora Admin settings");
} }
} }
+2 -49
View File
@@ -23,11 +23,6 @@ class ActivityLogData
return current_user_can("manage_options"); return current_user_can("manage_options");
}, },
"args" => [ "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], "action_type" => ["type" => "string", "required" => false],
"object_type" => ["type" => "string", "required" => false], "object_type" => ["type" => "string", "required" => false],
"search" => ["type" => "string", "required" => false], "search" => ["type" => "string", "required" => false],
@@ -45,13 +40,6 @@ class ActivityLogData
$where = ["1=1"]; $where = ["1=1"];
$params = []; $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")) { if ($request->get_param("action_type")) {
$where[] = "action_type = %s"; $where[] = "action_type = %s";
$params[] = sanitize_text_field($request->get_param("action_type")); $params[] = sanitize_text_field($request->get_param("action_type"));
@@ -85,50 +73,15 @@ 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. // 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]))); $rows = $wpdb->get_results($wpdb->prepare($sql, array_merge($params, [$per_page, $offset])));
// Live user info (avatar/role/email) rather than freezing it into each $items = array_map(function ($row) {
// 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 [ return [
"id" => (int) $row->id, "id" => (int) $row->id,
"actionType" => $row->action_type, "actionType" => $row->action_type,
"objectType" => $row->object_type, "objectType" => $row->object_type,
"objectId" => $row->object_id ? (int) $row->object_id : null, "objectId" => $row->object_id ? (int) $row->object_id : null,
"objectLabel" => $row->object_label, "objectLabel" => $row->object_label,
"userId" => $uid, "userId" => $row->user_id ? (int) $row->user_id : null,
"userName" => $row->user_name, "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, "details" => $row->details ? json_decode($row->details, true) : null,
"createdAt" => $row->created_at, "createdAt" => $row->created_at,
]; ];
-12
View File
@@ -31,9 +31,6 @@ use AuroraAdmin\Pages\NativeSettingsPages;
use AuroraAdmin\Rest\PermalinksData; use AuroraAdmin\Rest\PermalinksData;
use AuroraAdmin\Rest\DiscussionData; use AuroraAdmin\Rest\DiscussionData;
use AuroraAdmin\Pages\AppearancePages; use AuroraAdmin\Pages\AppearancePages;
use AuroraAdmin\Pages\ToolsPage;
use AuroraAdmin\Pages\ExportPage;
use AuroraAdmin\Pages\ImportPage;
use AuroraAdmin\Rest\NavMenusData; use AuroraAdmin\Rest\NavMenusData;
use AuroraAdmin\Rest\ThemesData; use AuroraAdmin\Rest\ThemesData;
use AuroraAdmin\Rest\WidgetsData; use AuroraAdmin\Rest\WidgetsData;
@@ -46,13 +43,10 @@ use AuroraAdmin\Roles\RolesData;
use AuroraAdmin\ActivityLog\ActivityLog; use AuroraAdmin\ActivityLog\ActivityLog;
use AuroraAdmin\ActivityLog\ActivityLogData; use AuroraAdmin\ActivityLog\ActivityLogData;
use AuroraAdmin\Login\LoginStyle; use AuroraAdmin\Login\LoginStyle;
use AuroraAdmin\Login\LoginRedirect;
use AuroraAdmin\Editor\DisableGutenberg;
use AuroraAdmin\Media\SvgUploads; use AuroraAdmin\Media\SvgUploads;
use AuroraAdmin\Media\MediaFolders; use AuroraAdmin\Media\MediaFolders;
use AuroraAdmin\ImageFormats\ImageFormats; use AuroraAdmin\ImageFormats\ImageFormats;
use AuroraAdmin\Security\Security; use AuroraAdmin\Security\Security;
use AuroraAdmin\Code\CodeInjection;
use AuroraAdmin\Rest\MediaFolderData; use AuroraAdmin\Rest\MediaFolderData;
use AuroraAdmin\Rest\BugReportData; use AuroraAdmin\Rest\BugReportData;
use AuroraAdmin\Rest\CompanionPluginData; use AuroraAdmin\Rest\CompanionPluginData;
@@ -89,9 +83,6 @@ class AuroraAdmin
new PermalinksData(); new PermalinksData();
new DiscussionData(); new DiscussionData();
new AppearancePages(); new AppearancePages();
new ToolsPage();
new ExportPage();
new ImportPage();
new NavMenusData(); new NavMenusData();
new ThemesData(); new ThemesData();
new WidgetsData(); new WidgetsData();
@@ -104,13 +95,10 @@ class AuroraAdmin
new ActivityLog(); new ActivityLog();
new ActivityLogData(); new ActivityLogData();
new LoginStyle(); new LoginStyle();
new LoginRedirect();
new DisableGutenberg();
new SvgUploads(); new SvgUploads();
new MediaFolders(); new MediaFolders();
new ImageFormats(); new ImageFormats();
new Security(); new Security();
new CodeInjection();
new MediaFolderData(); new MediaFolderData();
new BugReportData(); new BugReportData();
new CompanionPluginData(); new CompanionPluginData();
-72
View File
@@ -1,72 +0,0 @@
<?php
namespace AuroraAdmin\Code;
use AuroraAdmin\Options\Settings;
defined("ABSPATH") || exit();
/**
* Custom code injection (Settings > Code).
*
* Four admin-authored fields, only settable by manage_options users on the
* settings page:
* - Admin CSS → wrapped in a <style> in the admin <head>
* - Admin JS → wrapped in a <script> in the admin footer
* - Header scripts → printed raw in the front-end <head>
* - Footer scripts → printed raw before the front-end </body>
*
* Header/footer scripts are printed verbatim (they're meant to hold full
* <script>/<meta> tags — analytics, verification, etc.), the same trusted-
* admin model as any "insert headers and footers" tool. Admin CSS/JS is the
* user's own code too, but is wrapped in the right tag for them and guarded
* against accidentally breaking out of that tag.
*/
class CodeInjection
{
public function __construct()
{
add_action("admin_head", [self::class, "admin_css"], 999);
add_action("admin_print_footer_scripts", [self::class, "admin_js"], 999);
add_action("wp_head", [self::class, "header_scripts"], 999);
add_action("wp_footer", [self::class, "footer_scripts"], 999);
}
public static function admin_css()
{
$css = trim((string) Settings::get("custom_admin_css", ""));
if ($css === "") {
return;
}
// Keep the CSS from breaking out of its own <style> element.
$css = str_ireplace("</style>", "", $css);
echo "\n<style id=\"aurora-custom-admin-css\">\n" . $css . "\n</style>\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- admin-authored stylesheet, output raw inside a <style> tag by design; </style> stripped above to prevent breakout.
}
public static function admin_js()
{
$js = trim((string) Settings::get("custom_admin_js", ""));
if ($js === "") {
return;
}
$js = str_ireplace("</script>", "<\\/script>", $js);
echo "\n<script id=\"aurora-custom-admin-js\">\n" . $js . "\n</script>\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- admin-authored script, output raw inside a <script> tag by design.
}
public static function header_scripts()
{
$code = trim((string) Settings::get("header_scripts", ""));
if ($code === "") {
return;
}
echo "\n" . $code . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- admin-authored header markup (scripts/meta), printed verbatim by design.
}
public static function footer_scripts()
{
$code = trim((string) Settings::get("footer_scripts", ""));
if ($code === "") {
return;
}
echo "\n" . $code . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- admin-authored footer markup (scripts), printed verbatim by design.
}
}
-31
View File
@@ -1,31 +0,0 @@
<?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
@@ -1,110 +0,0 @@
<?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;
}
}
+9 -24
View File
@@ -42,25 +42,10 @@ class LoginStyle
} }
$logo = trim((string) Settings::get("login_logo", "")); $logo = trim((string) Settings::get("login_logo", ""));
$bg_image = trim((string) Settings::get("login_bg_image", ""));
// Colors fall back to the original fixed dark theme when unset; invalid
// values are dropped by sanitize_hex_color (returns null) then fall back.
$bg_color = sanitize_hex_color((string) Settings::get("login_bg_color", "")) ?: "";
$form_bg = sanitize_hex_color((string) Settings::get("login_form_bg", "")) ?: "#1c2333";
$accent = sanitize_hex_color((string) Settings::get("login_button_color", "")) ?: "#4f7cff";
$custom_css = str_ireplace("</style>", "", (string) Settings::get("login_custom_css", ""));
if ($bg_image) {
$background = "url(" . esc_url($bg_image) . ") center / cover no-repeat";
} elseif ($bg_color) {
$background = $bg_color;
} else {
$background = "linear-gradient(135deg, #1b2333 0%, #0f1420 100%)";
}
?> ?>
<style> <style>
body.login { body.login {
background: <?php echo $background; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- composed from a sanitize_hex_color() color or esc_url()'d image above. ?>; background: linear-gradient(135deg, #1b2333 0%, #0f1420 100%);
} }
body.login #login h1 a { body.login #login h1 a {
<?php if ($logo): ?> <?php if ($logo): ?>
@@ -79,7 +64,7 @@ class LoginStyle
body.login form#loginform, body.login form#loginform,
body.login form#registerform, body.login form#registerform,
body.login form#lostpasswordform { body.login form#lostpasswordform {
background: <?php echo esc_attr($form_bg); ?>; background: #1c2333;
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35); box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35);
@@ -99,8 +84,8 @@ class LoginStyle
body.login input[type="text"]:focus, body.login input[type="text"]:focus,
body.login input[type="password"]:focus, body.login input[type="password"]:focus,
body.login input[type="email"]:focus { body.login input[type="email"]:focus {
border-color: <?php echo esc_attr($accent); ?>; border-color: #4f7cff;
box-shadow: 0 0 0 1px <?php echo esc_attr($accent); ?>; box-shadow: 0 0 0 1px #4f7cff;
} }
body.login .forgetmenot label { body.login .forgetmenot label {
color: #9fb0d0; color: #9fb0d0;
@@ -114,23 +99,23 @@ class LoginStyle
color: #fff; color: #fff;
} }
body.login .wp-core-ui .button-primary { body.login .wp-core-ui .button-primary {
background: <?php echo esc_attr($accent); ?>; background: #4f7cff;
border-color: <?php echo esc_attr($accent); ?>; border-color: #4f7cff;
box-shadow: none; box-shadow: none;
text-shadow: none; text-shadow: none;
} }
body.login .wp-core-ui .button-primary:hover, body.login .wp-core-ui .button-primary:hover,
body.login .wp-core-ui .button-primary:focus { body.login .wp-core-ui .button-primary:focus {
filter: brightness(0.92); background: #3b63e0;
border-color: #3b63e0;
} }
body.login #login_error, body.login #login_error,
body.login .message, body.login .message,
body.login .success { body.login .success {
background: #232b40; background: #232b40;
border-left-color: <?php echo esc_attr($accent); ?>; border-left-color: #4f7cff;
color: #e2e8f4; color: #e2e8f4;
} }
<?php echo $custom_css; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- admin-authored login CSS, output raw by design; </style> stripped above. ?>
</style> </style>
<?php <?php
} }
+34 -106
View File
@@ -14,38 +14,14 @@ class MenuCreator
const DB_VERSION = "1"; const DB_VERSION = "1";
const DB_OPTION = "aurora_admin_menus_db_version"; 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() public function __construct()
{ {
add_action("init", [self::class, "maybe_install"]); add_action("init", [self::class, "maybe_install"]);
// Snapshot the full native menu just before apply() runs, so the editor // Late priority so it runs after all other plugins have registered
// can list (and re-enable) items this config hides. Both run after every // their own menu items — there's nothing left to reorder/hide otherwise.
// 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); 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() public static function table_name()
{ {
global $wpdb; global $wpdb;
@@ -81,112 +57,64 @@ class MenuCreator
} }
/** /**
* Merged item list from every published config whose target roles match * Applies every published config whose target roles match the current
* the current user. Cached for the request (read by both apply() and * user: hides items marked hidden and relabels items with a custom label.
* ordered_slugs() on every admin page load).
*/ */
private static function current_user_items() public static function apply()
{ {
if (self::$items_cache !== null) { global $menu, $wpdb;
return self::$items_cache;
}
self::$items_cache = [];
global $wpdb;
if (!is_admin() || !function_exists("wp_get_current_user")) { if (!is_admin() || !function_exists("wp_get_current_user")) {
return self::$items_cache; return;
} }
$user = wp_get_current_user(); $user = wp_get_current_user();
if (!$user || !$user->exists()) { if (!$user || !$user->exists()) {
return self::$items_cache; return;
} }
$table = self::table_name(); $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. // 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'"); $configs = $wpdb->get_results("SELECT roles, items FROM {$table} WHERE status = 'published'");
if (!$configs) { 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; return;
} }
foreach (self::current_user_items() as $item) { foreach ($configs as $config) {
$native_id = isset($item["native_id"]) ? $item["native_id"] : ""; $roles = json_decode($config->roles ?: "[]", true);
if ($native_id === "") { if (!is_array($roles) || !$roles) {
continue;
}
if (!array_intersect((array) $user->roles, $roles)) {
continue; continue;
} }
foreach ($menu as $key => $entry) { $items = json_decode($config->items ?: "[]", true);
if (!isset($entry[2]) || $entry[2] !== $native_id) { if (!is_array($items)) {
continue;
}
foreach ($items as $item) {
$native_id = isset($item["native_id"]) ? $item["native_id"] : "";
if ($native_id === "") {
continue; continue;
} }
if (!empty($item["hidden"])) { foreach ($menu as $key => $entry) {
unset($menu[$key]); if (!isset($entry[2]) || $entry[2] !== $native_id) {
continue 2; continue;
} }
if (!empty($item["label"])) { if (!empty($item["hidden"])) {
$menu[$key][0] = sanitize_text_field($item["label"]); unset($menu[$key]);
continue 2;
}
if (!empty($item["label"])) {
$menu[$key][0] = sanitize_text_field($item["label"]);
}
} }
} }
} }
} }
/**
* 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;
}
} }
+6 -4
View File
@@ -82,15 +82,17 @@ class MenuCreatorData
{ {
$menu = \AuroraAdmin\Shell\MenuSerializer::serialize(); $menu = \AuroraAdmin\Shell\MenuSerializer::serialize();
$flat = []; $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) { foreach ($menu as $item) {
if (!empty($item["separator"]) || empty($item["id"])) { if (!empty($item["separator"]) || empty($item["id"])) {
continue; continue;
} }
$flat[] = ["native_id" => $item["id"], "title" => $item["title"], "icon" => $item["icon"]]; $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); return new \WP_REST_Response($flat, 200);
} }
-39
View File
@@ -1,39 +0,0 @@
<?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
@@ -1,137 +0,0 @@
<?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,7 +35,6 @@ class SubApps
public static function pages() public static function pages()
{ {
return [ return [
"aurora-admin-modules" => ["title" => __("Modules", "aurora-admin"), "entry" => "modules"],
"aurora-admin-menu-creator" => ["title" => __("Menu Creator", "aurora-admin"), "entry" => "menu-creator"], "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-notices" => ["title" => __("Admin Notices", "aurora-admin"), "entry" => "admin-notices"],
"aurora-admin-roles" => ["title" => __("Role Editor", "aurora-admin"), "entry" => "role-editor"], "aurora-admin-roles" => ["title" => __("Role Editor", "aurora-admin"), "entry" => "role-editor"],
@@ -165,13 +164,6 @@ class SubApps
$data["theme"] = wp_get_theme()->get("Name"); $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"; $root_id = "aurora-admin-" . $pages[$page]["entry"] . "-root";
?> ?>
<div <div
-72
View File
@@ -1,72 +0,0 @@
<?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,7 +111,6 @@ class Security
// Drop the s.w.org DNS-prefetch the emoji script adds. // Drop the s.w.org DNS-prefetch the emoji script adds.
add_filter("wp_resource_hints", function ($hints, $relation_type) { add_filter("wp_resource_hints", function ($hints, $relation_type) {
if ($relation_type === "dns-prefetch") { 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/"); $emoji_svg = apply_filters("emoji_svg_url", "https://s.w.org/images/core/emoji/");
$hints = array_filter($hints, function ($h) use ($emoji_svg) { $hints = array_filter($hints, function ($h) use ($emoji_svg) {
return strpos((string) $h, $emoji_svg) === false && strpos((string) $h, "s.w.org") === false; return strpos((string) $h, $emoji_svg) === false && strpos((string) $h, "s.w.org") === false;
-56
View File
@@ -102,62 +102,6 @@ class MenuSerializer
$items[] = $entry; $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; return $items;
} }
-1
View File
@@ -250,7 +250,6 @@ class Shell
{ {
$items = [ $items = [
["id" => "aurora-admin", "title" => __("Settings", "aurora-admin"), "icon" => "dashicons-admin-generic"], ["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-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-notices", "title" => __("Admin Notices", "aurora-admin"), "icon" => "dashicons-megaphone"],
["id" => "aurora-admin-roles", "title" => __("Role Editor", "aurora-admin"), "icon" => "dashicons-groups"], ["id" => "aurora-admin-roles", "title" => __("Role Editor", "aurora-admin"), "icon" => "dashicons-groups"],
-10
View File
@@ -1,10 +0,0 @@
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
@@ -1,10 +0,0 @@
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
@@ -1,10 +0,0 @@
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
@@ -1,10 +0,0 @@
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);
}
+1 -4
View File
@@ -80,10 +80,7 @@ const search = defineModel('search', { type: String, default: '' });
current scroll position, looking like nothing happened until you current scroll position, looking like nothing happened until you
scrolled all the way back up. */ scrolled all the way back up. */
height: calc(100vh - var(--aurora-toolbar-h) - 60px); height: calc(100vh - var(--aurora-toolbar-h) - 60px);
/* -20px cancels the scroller's 20px padding (shell-frame.css's two-layer margin: -32px;
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; padding: 22px;
box-sizing: border-box; box-sizing: border-box;
/* No background — html/body already paint the page gradient; this was /* No background — html/body already paint the page gradient; this was
+81 -252
View File
@@ -1,5 +1,7 @@
<script setup> <script setup>
import { ref, computed, watch, onMounted } from 'vue'; import { ref, computed, watch, onMounted } from 'vue';
import AppListShell from '../_shared/AppListShell.vue';
import AppListItem from '../_shared/AppListItem.vue';
const props = defineProps({ const props = defineProps({
serverData: { type: Object, required: true }, serverData: { type: Object, required: true },
@@ -13,67 +15,28 @@ const api = (path) =>
return r.json(); return r.json();
}); });
// Two tabs: "Admin" = site/content activity, "User" = account activity const ACTION_TYPES = [
// (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'], ['', 'All Actions'],
['created', 'Created'], ['post_created', 'Post created'],
['updated', 'Updated'], ['post_updated', 'Post updated'],
['deleted', 'Deleted'], ['post_deleted', 'Post deleted'],
['activated', 'Activated'], ['user_registered', 'User registered'],
['deactivated', 'Deactivated'], ['user_updated', 'User updated'],
['switched', 'Switched'], ['login', 'Login'],
]; ['plugin_activated', 'Plugin activated'],
const USER_ACTIONS = [ ['plugin_deactivated', 'Plugin deactivated'],
['', 'All Actions'], ['theme_switched', 'Theme switched'],
['registered', 'Registered'], ['settings_updated', 'Settings updated'],
['profile_updated', 'Profile updated'],
['login', 'Logged in'],
['logout', 'Logged out'],
]; ];
const OBJECT_TYPES = [ const OBJECT_TYPES = [
['', 'All Types'], ['', 'All Types'],
['post', 'Post'], ['post', 'Post'],
['page', 'Page'], ['user', 'User'],
['attachment', 'Attachment'],
['plugin', 'Plugin'], ['plugin', 'Plugin'],
['theme', 'Theme'], ['theme', 'Theme'],
['settings', 'Settings'], ['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 items = ref([]);
const loading = ref(true); const loading = ref(true);
const total = ref(0); const total = ref(0);
@@ -82,13 +45,14 @@ const totalPages = ref(1);
const actionFilter = ref(''); const actionFilter = ref('');
const objectFilter = ref(''); const objectFilter = ref('');
const search = ref(''); const search = ref('');
const selectedId = ref(null);
const load = async () => { const load = async () => {
loading.value = true; loading.value = true;
try { try {
const params = new URLSearchParams({ scope: scope.value }); const params = new URLSearchParams();
if (actionFilter.value) params.set('action_type', actionFilter.value); if (actionFilter.value) params.set('action_type', actionFilter.value);
if (scope.value === 'admin' && objectFilter.value) params.set('object_type', objectFilter.value); if (objectFilter.value) params.set('object_type', objectFilter.value);
if (search.value.trim()) params.set('search', search.value.trim()); if (search.value.trim()) params.set('search', search.value.trim());
params.set('page', page.value); params.set('page', page.value);
const data = await api(`/activity-log?${params.toString()}`); const data = await api(`/activity-log?${params.toString()}`);
@@ -107,218 +71,83 @@ watch([actionFilter, objectFilter, search], () => {
}); });
watch(page, load); watch(page, load);
const setScope = (key) => { const selected = computed(() => items.value.find((i) => i.id === selectedId.value) || null);
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 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> </script>
<template> <template>
<main class="activity-page"> <AppListShell
<header class="activity-page__header"> v-model:search="search"
<div> title="Activity Log"
<p class="activity-page__eyebrow">Aurora Admin</p> search-placeholder="Search activities…"
<h1>Activity Log</h1> :count-label="`${total} ITEMS`"
</div> :page="page"
<button type="button" class="icon-button" title="Refresh" :disabled="loading" @click="load"> :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">
<span class="dashicons dashicons-update" /> <span class="dashicons dashicons-update" />
</button> </button>
</header> </template>
<div class="activity-page__tabs"> <template #filters>
<button <select v-model="actionFilter" class="activity-log__select">
v-for="tab in TABS" <option v-for="[v, l] in ACTION_TYPES" :key="v" :value="v">{{ l }}</option>
:key="tab.key" </select>
type="button" <select v-model="objectFilter" class="activity-log__select">
class="filter-pill" <option v-for="[v, l] in OBJECT_TYPES" :key="v" :value="v">{{ l }}</option>
:class="{ 'is-active': scope === tab.key }" </select>
@click="setScope(tab.key)" </template>
>
{{ tab.label }}
</button>
</div>
<section class="activity-page__toolbar" aria-label="Activity filters and search"> <template #list>
<div class="activity-page__filters"> <p v-if="!loading && !items.length" style="font-size: 0.8125rem; color: var(--aurora-text-muted); padding: 20px 4px; text-align: center;">
<select v-model="actionFilter" class="activity-page__select"> No activities found
<option v-for="[v, l] in actionOptions" :key="v" :value="v">{{ l }}</option> </p>
</select> <AppListItem
<select v-if="scope === 'admin'" v-model="objectFilter" class="activity-page__select"> v-for="i in items"
<option v-for="[v, l] in OBJECT_TYPES" :key="v" :value="v">{{ l }}</option> :key="i.id"
</select> icon="dashicons-clock"
:title="i.objectLabel || i.actionType"
:subtitle="`${i.userName || 'System'} · ${formatDate(i.createdAt)}`"
:active="i.id === selectedId"
@click="selectedId = i.id"
/>
</template>
<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> </div>
<label class="activity-page__search"> </template>
<span class="dashicons dashicons-search" /> </AppListShell>
<input v-model="search" type="search" placeholder="Search activity…" />
</label>
</section>
<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>
<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>
<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> </template>
<style scoped> <style scoped>
.activity-page { min-height: calc(100vh - 112px); padding: 0; color: var(--aurora-text); background: transparent; } .activity-log__select {
.activity-page__header { padding: 5px 10px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border);
display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 18px; background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.75rem;
} }
.activity-page__eyebrow { .activity-detail h2 { margin: 0 0 16px; font-size: 1.1rem; color: var(--aurora-text); }
margin: 0 0 7px; color: var(--aurora-text-muted); font-size: 0.72rem; .activity-detail dl { display: grid; grid-template-columns: 120px 1fr; gap: 8px 12px; font-size: 0.8125rem; margin: 0; }
font-weight: 800; letter-spacing: 0; text-transform: uppercase; .activity-detail dt { color: var(--aurora-text-muted); }
} .activity-detail dd { margin: 0; color: var(--aurora-text); }
.activity-page h1 { margin: 0; color: var(--aurora-text); font-size: 1.45rem; line-height: 1.15; } .activity-detail pre {
margin-top: 18px; padding: 12px; background: var(--aurora-bg-subtle);
.icon-button { border-radius: var(--aurora-radius-sm); font-size: 0.75rem; overflow-x: auto; color: var(--aurora-text);
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> </style>
-222
View File
@@ -1,222 +0,0 @@
<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
@@ -1,178 +0,0 @@
<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>
+40 -94
View File
@@ -37,12 +37,10 @@ const load = async () => {
} }
}; };
onMounted(() => { onMounted(async () => {
load(); load();
api('/menus/native-items').then((items) => (nativeItems.value = items));
roleOptions.value = props.serverData.roles || []; 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(() => { const filteredMenus = computed(() => {
@@ -57,84 +55,48 @@ const filteredMenus = computed(() => {
const selected = computed(() => menus.value.find((m) => m.id === selectedId.value) || null); const selected = computed(() => menus.value.find((m) => m.id === selectedId.value) || null);
const draft = reactive({ name: '', status: 'draft', roles: [] }); const draft = reactive({ name: '', status: 'draft', roles: [], items: [] });
// 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) => { const selectMenu = (menu) => {
selectedId.value = menu.id; selectedId.value = menu.id;
Object.assign(draft, { name: menu.name, status: menu.status, roles: [...menu.roles] }); Object.assign(draft, {
buildOrdered(menu.items); name: menu.name,
status: menu.status,
roles: [...menu.roles],
items: menu.items.map((i) => ({ ...i })),
});
}; };
const isCreating = ref(false); const isCreating = ref(false);
const newMenu = () => { const newMenu = () => {
selectedId.value = null; selectedId.value = null;
isCreating.value = true; isCreating.value = true;
Object.assign(draft, { name: 'New menu', status: 'draft', roles: [] }); Object.assign(draft, { name: 'New menu', status: 'draft', roles: [], items: [] });
buildOrdered([]);
}; };
// Reorder controls: drag-and-drop plus keyboard-friendly up/down buttons. const itemState = (nativeId) => draft.items.find((i) => i.native_id === nativeId);
const dragIndex = ref(-1); const toggleHidden = (nativeId) => {
const onDragStart = (i) => { dragIndex.value = i; }; let item = itemState(nativeId);
const onDrop = (i) => { if (!item) {
const from = dragIndex.value; item = { native_id: nativeId, label: '', hidden: false, order: 0 };
dragIndex.value = -1; draft.items.push(item);
if (from < 0 || from === i) return; }
const arr = orderedItems.value; item.hidden = !item.hidden;
const [moved] = arr.splice(from, 1);
arr.splice(i, 0, moved);
}; };
const move = (i, dir) => { const setLabel = (nativeId, label) => {
const j = i + dir; let item = itemState(nativeId);
const arr = orderedItems.value; if (!item) {
if (j < 0 || j >= arr.length) return; item = { native_id: nativeId, label: '', hidden: false, order: 0 };
[arr[i], arr[j]] = [arr[j], arr[i]]; draft.items.push(item);
}
item.label = label;
}; };
const saving = ref(false); const saving = ref(false);
const save = async () => { const save = async () => {
saving.value = true; saving.value = true;
try { try {
const payload = { const payload = { name: draft.name, status: draft.status, roles: draft.roles, items: draft.items };
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) { if (isCreating.value) {
const created = await api('/menus', { method: 'POST', body: JSON.stringify(payload) }); const created = await api('/menus', { method: 'POST', body: JSON.stringify(payload) });
await load(); await load();
@@ -226,29 +188,23 @@ const toggleRole = (slug) => {
</div> </div>
<div class="menu-editor__section"> <div class="menu-editor__section">
<h3>Menu items — drag to reorder</h3> <h3>Menu items</h3>
<div class="menu-editor__items"> <div class="menu-editor__items">
<div <div v-for="ni in nativeItems" :key="ni.native_id" class="menu-editor__item">
v-for="(it, i) in orderedItems" <span class="dashicons" :class="ni.icon?.value || 'dashicons-admin-generic'" />
:key="it.native_id" <input
class="menu-editor__item" type="text"
:class="{ 'is-hidden': it.hidden }" :value="itemState(ni.native_id)?.label || ni.title"
draggable="true" @input="setLabel(ni.native_id, $event.target.value)"
@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"> <label class="menu-editor__hide">
<input type="checkbox" v-model="it.hidden" /> <input
type="checkbox"
:checked="!!itemState(ni.native_id)?.hidden"
@change="toggleHidden(ni.native_id)"
/>
Hide Hide
</label> </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> </div>
</div> </div>
@@ -287,21 +243,11 @@ const toggleRole = (slug) => {
display: flex; align-items: center; gap: 10px; padding: 6px 4px; display: flex; align-items: center; gap: 10px; padding: 6px 4px;
border-bottom: 1px solid var(--aurora-frame-border); 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'] { .menu-editor__item input[type='text'] {
flex: 1; padding: 6px 8px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border); 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; 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); flex-shrink: 0; } .menu-editor__hide { display: flex; align-items: center; gap: 4px; font-size: 0.75rem; color: var(--aurora-text-muted); }
.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 { display: flex; justify-content: flex-end; gap: 8px; }
.menu-editor__actions button { .menu-editor__actions button {
border: none; border-radius: var(--aurora-radius-sm); padding: 9px 18px; font-size: 0.875rem; cursor: pointer; border: none; border-radius: var(--aurora-radius-sm); padding: 9px 18px; font-size: 0.875rem; cursor: pointer;
-144
View File
@@ -1,144 +0,0 @@
<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
@@ -1,39 +0,0 @@
/**
* 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',
},
];
+1 -9
View File
@@ -81,7 +81,6 @@ const runAction = async (action, plugin) => {
busyFile.value = plugin.file; busyFile.value = plugin.file;
error.value = ''; error.value = '';
notice.value = ''; notice.value = '';
let redirecting = false;
try { try {
const data = await request(`/plugins/${action}`, { const data = await request(`/plugins/${action}`, {
method: action === 'delete' ? 'DELETE' : 'POST', method: action === 'delete' ? 'DELETE' : 'POST',
@@ -89,21 +88,14 @@ const runAction = async (action, plugin) => {
}); });
notice.value = data.message || 'Done'; notice.value = data.message || 'Done';
if (data.redirect) { if (data.redirect) {
redirecting = true;
window.location.href = data.redirect; window.location.href = data.redirect;
return; return;
} }
await load();
} catch (err) { } catch (err) {
error.value = err.message || 'Action failed'; error.value = err.message || 'Action failed';
} finally { } finally {
busyFile.value = ''; 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,6 +10,22 @@ const props = defineProps({
const visibleCategories = settingsCategories; 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. // Working copy of the settings, seeded from the server + field defaults.
const settings = reactive({ ...(props.serverData.settings || {}) }); const settings = reactive({ ...(props.serverData.settings || {}) });
visibleCategories.forEach((cat) => visibleCategories.forEach((cat) =>
@@ -248,6 +264,7 @@ const importSettings = (e) => {
:field="f" :field="f"
:options="optionsFor(f)" :options="optionsFor(f)"
:disabled="!!f.dependsOn && !settings[f.dependsOn]" :disabled="!!f.dependsOn && !settings[f.dependsOn]"
:api="api"
v-model="settings[f.key]" v-model="settings[f.key]"
/> />
</div> </div>
@@ -14,6 +14,9 @@ const props = defineProps({
// True when this field's `dependsOn` key is falsy — greys out the // True when this field's `dependsOn` key is falsy — greys out the
// control without hiding it, so the relationship stays visible. // control without hiding it, so the relationship stays visible.
disabled: { type: Boolean, default: false }, 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']); const emit = defineEmits(['update:modelValue']);
@@ -73,14 +76,43 @@ const pickFont = (name) => {
fontOpen.value = false; fontOpen.value = false;
}; };
const openFontList = () => { const openFontList = () => {
// Start with an empty query so the whole font list is shown on focus. fontQuery.value = props.modelValue || '';
// 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; 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> </script>
<template> <template>
@@ -106,7 +138,6 @@ const openFontList = () => {
<textarea <textarea
v-else-if="field.type === 'textarea'" v-else-if="field.type === 'textarea'"
class="field__input field__textarea" class="field__input field__textarea"
:class="{ 'field__textarea--mono': field.mono }"
:placeholder="field.placeholder || ''" :placeholder="field.placeholder || ''"
:rows="field.rows || 6" :rows="field.rows || 6"
:value="val || ''" :value="val || ''"
@@ -241,6 +272,31 @@ const openFontList = () => {
</div> </div>
</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 --> <!-- Repeater -->
<div v-else-if="field.type === 'repeater'" class="field__repeater"> <div v-else-if="field.type === 'repeater'" class="field__repeater">
<div v-for="(row, i) in rows" :key="i" class="field__repeater-row"> <div v-for="(row, i) in rows" :key="i" class="field__repeater-row">
@@ -290,11 +346,6 @@ const openFontList = () => {
} }
.field__input:focus { outline: none; border-color: var(--aurora-accent); } .field__input:focus { outline: none; border-color: var(--aurora-accent); }
.field__textarea { resize: vertical; font-family: inherit; line-height: 1.5; } .field__textarea { resize: vertical; font-family: inherit; line-height: 1.5; }
.field__textarea--mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
font-size: 0.8125rem;
tab-size: 2;
}
.field__color { display: flex; align-items: center; gap: 10px; } .field__color { display: flex; align-items: center; gap: 10px; }
.field__color input[type='color'] { .field__color input[type='color'] {
@@ -403,6 +454,20 @@ const openFontList = () => {
.field__font-item:hover { background: var(--aurora-bg-subtle); } .field__font-item:hover { background: var(--aurora-bg-subtle); }
.field__font-item--active { color: var(--aurora-accent); } .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) { @media (max-width: 782px) {
.field { grid-template-columns: 1fr; gap: 12px; } .field { grid-template-columns: 1fr; gap: 12px; }
} }
+29 -147
View File
@@ -99,9 +99,7 @@ export const settingsGroups = [
key: 'font_family', key: 'font_family',
type: 'font-picker', type: 'font-picker',
label: 'Font family', label: 'Font family',
description: description: 'Google Font used across the Aurora interface.',
'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.',
}, },
], ],
}, },
@@ -152,88 +150,6 @@ export const settingsGroups = [
type: 'image', type: 'image',
label: 'Login logo', label: 'Login logo',
description: 'Replace the WordPress logo on the login screen.', description: 'Replace the WordPress logo on the login screen.',
dependsOn: 'style_login',
},
{
key: 'login_bg_image',
type: 'image',
label: 'Background image',
description: 'Full-screen background for the login page. Overrides the background color below.',
dependsOn: 'style_login',
},
{
key: 'login_bg_color',
type: 'color',
label: 'Background color',
description: 'Solid background color for the login page (used when no background image is set).',
default: '#0f1420',
dependsOn: 'style_login',
},
{
key: 'login_form_bg',
type: 'color',
label: 'Form background',
description: 'Background color of the login form panel.',
default: '#1c2333',
dependsOn: 'style_login',
},
{
key: 'login_button_color',
type: 'color',
label: 'Button color',
description: 'Color of the primary “Log In” button and the field focus accent.',
default: '#4f7cff',
dependsOn: 'style_login',
},
{
key: 'login_custom_css',
type: 'textarea',
mono: true,
rows: 8,
label: 'Custom login CSS',
description: 'Extra CSS applied to the login screen, after Aurora’s own styles.',
placeholder: 'body.login { … }',
dependsOn: 'style_login',
},
],
},
{
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',
}, },
], ],
}, },
@@ -323,22 +239,6 @@ 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', id: 'admin-takeover',
title: 'Admin Takeover', title: 'Admin Takeover',
@@ -489,63 +389,45 @@ export const settingsGroups = [
], ],
}, },
{ {
id: 'code', id: 'add-ons',
title: 'Code', title: 'Add-ons',
icon: 'dashicons-editor-code', icon: 'dashicons-admin-plugins',
categories: [ categories: [
{ {
id: 'custom-code', id: 'add-ons',
title: 'Custom CSS & JS', title: 'Add-ons',
subtitle: 'Inject your own CSS and JavaScript into the WordPress admin', subtitle: 'Standalone companion plugins',
icon: 'dashicons-admin-customizer', icon: 'dashicons-admin-plugins',
fields: [ fields: [
{ {
key: 'custom_admin_css', key: 'file_manager_addon',
type: 'textarea', type: 'companion-plugin',
mono: true, label: 'Aurora File Manager',
rows: 10,
label: 'Admin CSS',
description: description:
'CSS added to every wp-admin page, inside a <style> tag in the admin <head>. Enter CSS only — no <style> tags needed.', '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.',
placeholder: '.wrap { max-width: 1200px; }', statusEndpoint: '/companion-plugins/file-manager/status',
installEndpoint: '/companion-plugins/file-manager/install',
manageUrl: 'admin.php?page=aurora-file-manager',
}, },
{ {
key: 'custom_admin_js', key: 'database_explorer_addon',
type: 'textarea', type: 'companion-plugin',
mono: true, label: 'Aurora Database Explorer',
rows: 10,
label: 'Admin JavaScript',
description: description:
'JavaScript added to the admin footer, inside a <script> tag. Enter JS only — no <script> tags needed.', '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.',
placeholder: "console.log('Hello from Aurora');", statusEndpoint: '/companion-plugins/database-explorer/status',
}, installEndpoint: '/companion-plugins/database-explorer/install',
], manageUrl: 'admin.php?page=aurora-database-explorer',
},
{
id: 'header-footer',
title: 'Header & footer scripts',
subtitle: 'Inject raw code into your site’s front-end <head> and footer',
icon: 'dashicons-editor-code',
fields: [
{
key: 'header_scripts',
type: 'textarea',
mono: true,
rows: 8,
label: 'Header scripts',
description:
'Raw HTML/JS printed in the front-end <head> — analytics snippets, verification meta tags, etc. Include the full <script>/<meta> tags.',
placeholder: '<!-- e.g. analytics -->\n<script>…</script>',
}, },
{ {
key: 'footer_scripts', key: 'site_backup_addon',
type: 'textarea', type: 'companion-plugin',
mono: true, label: 'Aurora Site Backup',
rows: 8,
label: 'Footer scripts',
description: description:
'Raw HTML/JS printed just before </body> on the front end. Include the full <script> tags.', '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.',
placeholder: '<script>…</script>', statusEndpoint: '/companion-plugins/site-backup/status',
installEndpoint: '/companion-plugins/site-backup/install',
manageUrl: 'admin.php?page=aurora-site-backup',
}, },
], ],
}, },
+2 -3
View File
@@ -31,9 +31,7 @@ const steps = [
key: 'font_family', key: 'font_family',
type: 'font-picker', type: 'font-picker',
label: 'Font family', label: 'Font family',
description: description: 'A Google Font used across the Aurora interface.',
'Optional. Leave empty for your system font (no external requests). ' +
'Choosing a font loads it from Google Fonts.',
}, },
], ],
}, },
@@ -209,6 +207,7 @@ const finish = async (target) => {
v-for="f in current.fields" v-for="f in current.fields"
:key="f.key" :key="f.key"
:field="f" :field="f"
:api="null"
v-model="settings[f.key]" v-model="settings[f.key]"
/> />
</div> </div>
-76
View File
@@ -1,76 +0,0 @@
<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>
+1 -13
View File
@@ -16,18 +16,6 @@ const highlighted = ref(0);
let debounceTimer; let debounceTimer;
let requestSeq = 0; 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 search = async () => {
const q = query.value.trim(); const q = query.value.trim();
if (q.length < 2) { if (q.length < 2) {
@@ -133,7 +121,7 @@ onUnmounted(() => document.removeEventListener('keydown', onKeydown));
@mouseenter="highlighted = i" @mouseenter="highlighted = i"
@click="openResult(item)" @click="openResult(item)"
> >
<span class="smodal__result-title">{{ decodeEntities(item.title) }}</span> <span class="smodal__result-title" v-html="item.title" />
<span class="smodal__result-type">{{ item.subtype }}</span> <span class="smodal__result-type">{{ item.subtype }}</span>
</button> </button>
</li> </li>
+4 -87
View File
@@ -195,11 +195,7 @@ html.aurora-ready #wpbody-content {
color: var(--aurora-text); color: var(--aurora-text);
border: 1px solid var(--aurora-frame-border); border: 1px solid var(--aurora-frame-border);
border-radius: var(--aurora-radius-xl); border-radius: var(--aurora-radius-xl);
/* A real soft shadow (not the old barely-there 0.06 one) so the rounded box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
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; margin: 16px var(--aurora-gutter-r) 24px 24px;
padding: 32px; padding: 32px;
min-height: calc(100vh - var(--aurora-toolbar-h) - 40px); min-height: calc(100vh - var(--aurora-toolbar-h) - 40px);
@@ -327,10 +323,7 @@ html.aurora-ready .theme-install-overlay .wp-full-overlay-main {
background: var(--aurora-frame-bg); background: var(--aurora-frame-bg);
border: 1px solid var(--aurora-frame-border); border: 1px solid var(--aurora-frame-border);
border-radius: var(--aurora-radius-xl); border-radius: var(--aurora-radius-xl);
/* Match #wpbody-content's shadow so the dashboard's rounded card edge box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
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; margin: 16px var(--aurora-gutter-r) 24px 24px;
box-sizing: border-box; box-sizing: border-box;
/* Same unconditional height cap as #wpbody-content's base rule — this /* Same unconditional height cap as #wpbody-content's base rule — this
@@ -342,15 +335,8 @@ html.aurora-ready .theme-install-overlay .wp-full-overlay-main {
no document no document
scroll left to fall back on either. */ scroll left to fall back on either. */
max-height: calc(100vh - var(--aurora-toolbar-h) - 40px); max-height: calc(100vh - var(--aurora-toolbar-h) - 40px);
/* The frame itself clips (rounded corners) and lays out as a column; the overflow-y: auto;
actual scrolling happens on the inner app-root (see the shared scroll overflow-anchor: none;
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 /* Custom admin notices (Notices::render_for_dashboard(), reparented in
@@ -380,75 +366,6 @@ html.aurora-ready body.toplevel_page_aurora-admin #wpbody-content {
box-shadow: none; 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 * Native WordPress pages are fully dark-themed by a dedicated stylesheet
* (admin/assets/native-dark.css), enqueued only on native pages and gated * (admin/assets/native-dark.css), enqueued only on native pages and gated
-4
View File
@@ -36,7 +36,6 @@ export default defineConfig({
settings: fileURLToPath(new URL('./src/entries/settings.js', import.meta.url)), settings: fileURLToPath(new URL('./src/entries/settings.js', import.meta.url)),
'setup-wizard': fileURLToPath(new URL('./src/entries/setup-wizard.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)), '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)), '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)), '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)), 'activity-log': fileURLToPath(new URL('./src/entries/activity-log.js', import.meta.url)),
@@ -52,9 +51,6 @@ export default defineConfig({
'nav-menus': fileURLToPath(new URL('./src/entries/nav-menus.js', import.meta.url)), 'nav-menus': fileURLToPath(new URL('./src/entries/nav-menus.js', import.meta.url)),
'themes': fileURLToPath(new URL('./src/entries/themes.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)), '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)), 'profile': fileURLToPath(new URL('./src/entries/profile.js', import.meta.url)),
'bug-report': fileURLToPath(new URL('./src/entries/bug-report.js', import.meta.url)), 'bug-report': fileURLToPath(new URL('./src/entries/bug-report.js', import.meta.url)),
}, },
+2 -8
View File
@@ -3,7 +3,7 @@
* Plugin Name: Aurora Admin * Plugin Name: Aurora Admin
* Plugin URI: https://aurora.auroradragon.studio * Plugin URI: https://aurora.auroradragon.studio
* Description: A modern, fast WordPress admin interface. * Description: A modern, fast WordPress admin interface.
* Version: 1.0 * Version: 3.6.1
* Author: Aurora Dragon Studio * Author: Aurora Dragon Studio
* Author URI: https://auroradragon.studio * Author URI: https://auroradragon.studio
* Text Domain: aurora-admin * Text Domain: aurora-admin
@@ -15,7 +15,7 @@
defined("ABSPATH") || exit(); defined("ABSPATH") || exit();
define("AURORA_ADMIN_VERSION", "1.0"); define("AURORA_ADMIN_VERSION", "3.6.1");
define("AURORA_ADMIN_PATH", plugin_dir_path(__FILE__)); define("AURORA_ADMIN_PATH", plugin_dir_path(__FILE__));
define("AURORA_ADMIN_URL", plugins_url("aurora-admin/")); define("AURORA_ADMIN_URL", plugins_url("aurora-admin/"));
@@ -45,9 +45,6 @@ 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/PermalinksData.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/DiscussionData.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/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/NavMenusData.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/ThemesData.php"; require AURORA_ADMIN_PATH . "admin/src/Rest/ThemesData.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/WidgetsData.php"; require AURORA_ADMIN_PATH . "admin/src/Rest/WidgetsData.php";
@@ -60,13 +57,10 @@ 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/ActivityLog.php";
require AURORA_ADMIN_PATH . "admin/src/ActivityLog/ActivityLogData.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/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/SvgUploads.php";
require AURORA_ADMIN_PATH . "admin/src/Media/MediaFolders.php"; require AURORA_ADMIN_PATH . "admin/src/Media/MediaFolders.php";
require AURORA_ADMIN_PATH . "admin/src/ImageFormats/ImageFormats.php"; require AURORA_ADMIN_PATH . "admin/src/ImageFormats/ImageFormats.php";
require AURORA_ADMIN_PATH . "admin/src/Security/Security.php"; require AURORA_ADMIN_PATH . "admin/src/Security/Security.php";
require AURORA_ADMIN_PATH . "admin/src/Code/CodeInjection.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/MediaFolderData.php"; require AURORA_ADMIN_PATH . "admin/src/Rest/MediaFolderData.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/BugReportData.php"; require AURORA_ADMIN_PATH . "admin/src/Rest/BugReportData.php";
require AURORA_ADMIN_PATH . "admin/src/Rest/CompanionPluginData.php"; require AURORA_ADMIN_PATH . "admin/src/Rest/CompanionPluginData.php";
+62 -22
View File
@@ -1,10 +1,10 @@
=== Aurora Admin === === Aurora Admin ===
Contributors: Aurora Dragon Studio, KrakenRT Contributors: TODO-WPORG-USERNAME
Tags: admin, dashboard, admin theme, dark mode, admin redesign Tags: admin, dashboard, admin theme, dark mode, admin redesign
Requires at least: 5.5 Requires at least: 5.5
Tested up to: 7.0 Tested up to: 7.0
Requires PHP: 7.4 Requires PHP: 7.4
Stable tag: 1.0 Stable tag: 3.6.1
License: GPLv2 or later License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html License URI: https://www.gnu.org/licenses/gpl-2.0.html
@@ -32,23 +32,20 @@ Aurora Admin replaces the default WordPress admin with a faster, cleaner interfa
**Look and feel** **Look and feel**
* A real dark mode covering the entire admin, not just a few screens * A real dark mode covering the entire admin, not just a few screens
* 10 color presets plus 2 flat neutrals, applied across the whole interface * 10 color presets plus 2 flat neutrals
* Rounded, framed content cards with theme-tinted scrollbars; the block editor and other full-screen views get the same treatment * macOS-style window chrome for the block editor and other full-screen views
* 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. 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)** **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) 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. 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.
= Privacy / external services = = Privacy / external service =
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. 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.
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. 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 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 == == Installation ==
@@ -68,16 +65,16 @@ Yes. Every feature listed above is available with no license key and no restrict
= Will this work with my other plugins? = = 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, 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. 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.
= 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? = = 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. 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 == == Screenshots ==
1. The Aurora dashboard. 1. The Aurora dashboard.
@@ -86,10 +83,53 @@ Use the "Report a Bug" item in the Aurora sidebar menu, or open a thread in this
== Changelog == == Changelog ==
= 1.0 = = 3.6.1 =
* First public release of Aurora Admin. * 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).
* 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. = 3.6.0 =
* 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. * 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.
* 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. = 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.