Advance full admin takeover from v1.1 to v1.12

Batches everything built since the 1.0 WP.org submission reset: Login
Redirect, the Tools/Export/Import screen takeovers, Modules rebuilt as
an addon marketplace, Menu Creator's reorder fix, Activity Log
redesigned into a filterable table with Admin/User tabs, dark-theme
fixes for Site Health/Personal Data/Updates, and an opt-in Disable
Gutenberg toggle. Continues the "nothing stays native" takeover goal
and closes gaps found via live testing and the WordPress Plugin Check
tool. See CHANGELOG.md for the full per-version breakdown (1.1-1.12).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
NixReaper
2026-07-26 13:37:35 -05:00
co-authored by Claude Sonnet 5
parent faf9f35992
commit 24be114f9a
39 changed files with 3462 additions and 1653 deletions
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Export from '@/pages/export/Export.vue';
const mountEl = document.getElementById('aurora-admin-export-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Export, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Import from '@/pages/import/Import.vue';
const mountEl = document.getElementById('aurora-admin-import-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Import, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Modules from '@/pages/modules/Modules.vue';
const mountEl = document.getElementById('aurora-admin-modules-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Modules, { serverData: data }).mount(mountEl);
}
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue';
import '@/shell/shell-frame.css';
import Tools from '@/pages/tools/Tools.vue';
const mountEl = document.getElementById('aurora-admin-tools-root');
if (mountEl) {
const data = JSON.parse(mountEl.dataset.auroraAdmin || '{}');
createApp(Tools, { serverData: data }).mount(mountEl);
}
+4 -1
View File
@@ -80,7 +80,10 @@ const search = defineModel('search', { type: String, default: '' });
current scroll position, looking like nothing happened until you
scrolled all the way back up. */
height: calc(100vh - var(--aurora-toolbar-h) - 60px);
margin: -32px;
/* -20px cancels the scroller's 20px padding (shell-frame.css's two-layer
scroll rule) so the shell still bleeds to the rounded frame's inner
edge. Was -32px back when #wpbody-content padded the content directly. */
margin: -20px;
padding: 22px;
box-sizing: border-box;
/* No background — html/body already paint the page gradient; this was
+252 -81
View File
@@ -1,7 +1,5 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import AppListShell from '../_shared/AppListShell.vue';
import AppListItem from '../_shared/AppListItem.vue';
const props = defineProps({
serverData: { type: Object, required: true },
@@ -15,28 +13,67 @@ const api = (path) =>
return r.json();
});
const ACTION_TYPES = [
// Two tabs: "Admin" = site/content activity, "User" = account activity
// (login/logout/registration/profile updates) — object_type 'user' is the
// dividing line the REST route's `scope` param filters on.
const TABS = [
{ key: 'admin', label: 'Admin' },
{ key: 'user', label: 'User' },
];
const ADMIN_ACTIONS = [
['', 'All Actions'],
['post_created', 'Post created'],
['post_updated', 'Post updated'],
['post_deleted', 'Post deleted'],
['user_registered', 'User registered'],
['user_updated', 'User updated'],
['login', 'Login'],
['plugin_activated', 'Plugin activated'],
['plugin_deactivated', 'Plugin deactivated'],
['theme_switched', 'Theme switched'],
['settings_updated', 'Settings updated'],
['created', 'Created'],
['updated', 'Updated'],
['deleted', 'Deleted'],
['activated', 'Activated'],
['deactivated', 'Deactivated'],
['switched', 'Switched'],
];
const USER_ACTIONS = [
['', 'All Actions'],
['registered', 'Registered'],
['profile_updated', 'Profile updated'],
['login', 'Logged in'],
['logout', 'Logged out'],
];
const OBJECT_TYPES = [
['', 'All Types'],
['post', 'Post'],
['user', 'User'],
['page', 'Page'],
['attachment', 'Attachment'],
['plugin', 'Plugin'],
['theme', 'Theme'],
['settings', 'Settings'],
];
const TYPE_LABELS = {
post: 'Post',
page: 'Page',
attachment: 'Attachment',
user: 'User',
plugin: 'Plugin',
theme: 'Theme',
settings: 'Settings',
};
const ACTION_LABELS = {
created: 'Created',
updated: 'Updated',
deleted: 'Deleted',
registered: 'Registered',
profile_updated: 'Profile updated',
login: 'Logged in',
logout: 'Logged out',
activated: 'Activated',
deactivated: 'Deactivated',
switched: 'Switched',
};
const typeLabel = (t) => TYPE_LABELS[t] || (t ? t.charAt(0).toUpperCase() + t.slice(1) : '—');
const actionLabel = (a) => ACTION_LABELS[a] || a;
const scope = ref('admin');
const actionOptions = computed(() => (scope.value === 'user' ? USER_ACTIONS : ADMIN_ACTIONS));
const items = ref([]);
const loading = ref(true);
const total = ref(0);
@@ -45,14 +82,13 @@ const totalPages = ref(1);
const actionFilter = ref('');
const objectFilter = ref('');
const search = ref('');
const selectedId = ref(null);
const load = async () => {
loading.value = true;
try {
const params = new URLSearchParams();
const params = new URLSearchParams({ scope: scope.value });
if (actionFilter.value) params.set('action_type', actionFilter.value);
if (objectFilter.value) params.set('object_type', objectFilter.value);
if (scope.value === 'admin' && objectFilter.value) params.set('object_type', objectFilter.value);
if (search.value.trim()) params.set('search', search.value.trim());
params.set('page', page.value);
const data = await api(`/activity-log?${params.toString()}`);
@@ -71,83 +107,218 @@ watch([actionFilter, objectFilter, search], () => {
});
watch(page, load);
const selected = computed(() => items.value.find((i) => i.id === selectedId.value) || null);
const setScope = (key) => {
if (scope.value === key) return;
scope.value = key;
actionFilter.value = '';
objectFilter.value = '';
page.value = 1;
load();
};
const formatDate = (s) => (s ? new Date(s.replace(' ', 'T')).toLocaleString() : '');
const rowRange = computed(() => {
if (!total.value) return '0';
const start = (page.value - 1) * 20 + 1;
const end = Math.min(page.value * 20, total.value);
return `${start}–${end}`;
});
const goPage = (p) => {
page.value = Math.min(Math.max(1, p), totalPages.value);
};
</script>
<template>
<AppListShell
v-model:search="search"
title="Activity Log"
search-placeholder="Search activities…"
:count-label="`${total} ITEMS`"
:page="page"
:has-prev="page > 1"
:has-next="page < totalPages"
detail-icon="dashicons-clock"
detail-title="Activity Details"
detail-text="Select an activity from the list to view detailed information."
:has-selection="!!selected"
@prev-page="page--"
@next-page="page++"
>
<template #header-actions>
<button type="button" @click="load">
<main class="activity-page">
<header class="activity-page__header">
<div>
<p class="activity-page__eyebrow">Aurora Admin</p>
<h1>Activity Log</h1>
</div>
<button type="button" class="icon-button" title="Refresh" :disabled="loading" @click="load">
<span class="dashicons dashicons-update" />
</button>
</template>
</header>
<template #filters>
<select v-model="actionFilter" class="activity-log__select">
<option v-for="[v, l] in ACTION_TYPES" :key="v" :value="v">{{ l }}</option>
</select>
<select v-model="objectFilter" class="activity-log__select">
<option v-for="[v, l] in OBJECT_TYPES" :key="v" :value="v">{{ l }}</option>
</select>
</template>
<div class="activity-page__tabs">
<button
v-for="tab in TABS"
:key="tab.key"
type="button"
class="filter-pill"
:class="{ 'is-active': scope === tab.key }"
@click="setScope(tab.key)"
>
{{ tab.label }}
</button>
</div>
<template #list>
<p v-if="!loading && !items.length" style="font-size: 0.8125rem; color: var(--aurora-text-muted); padding: 20px 4px; text-align: center;">
No activities found
</p>
<AppListItem
v-for="i in items"
:key="i.id"
icon="dashicons-clock"
:title="i.objectLabel || i.actionType"
:subtitle="`${i.userName || 'System'} · ${formatDate(i.createdAt)}`"
:active="i.id === selectedId"
@click="selectedId = i.id"
/>
</template>
<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>
<section class="activity-page__toolbar" aria-label="Activity filters and search">
<div class="activity-page__filters">
<select v-model="actionFilter" class="activity-page__select">
<option v-for="[v, l] in actionOptions" :key="v" :value="v">{{ l }}</option>
</select>
<select v-if="scope === 'admin'" v-model="objectFilter" class="activity-page__select">
<option v-for="[v, l] in OBJECT_TYPES" :key="v" :value="v">{{ l }}</option>
</select>
</div>
</template>
</AppListShell>
<label class="activity-page__search">
<span class="dashicons dashicons-search" />
<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>
<style scoped>
.activity-log__select {
padding: 5px 10px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border);
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.75rem;
.activity-page { min-height: calc(100vh - 112px); padding: 0; color: var(--aurora-text); background: transparent; }
.activity-page__header {
display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 18px;
}
.activity-detail h2 { margin: 0 0 16px; font-size: 1.1rem; color: var(--aurora-text); }
.activity-detail dl { display: grid; grid-template-columns: 120px 1fr; gap: 8px 12px; font-size: 0.8125rem; margin: 0; }
.activity-detail dt { color: var(--aurora-text-muted); }
.activity-detail dd { margin: 0; color: var(--aurora-text); }
.activity-detail pre {
margin-top: 18px; padding: 12px; background: var(--aurora-bg-subtle);
border-radius: var(--aurora-radius-sm); font-size: 0.75rem; overflow-x: auto; color: var(--aurora-text);
.activity-page__eyebrow {
margin: 0 0 7px; color: var(--aurora-text-muted); font-size: 0.72rem;
font-weight: 800; letter-spacing: 0; text-transform: uppercase;
}
.activity-page h1 { margin: 0; color: var(--aurora-text); font-size: 1.45rem; line-height: 1.15; }
.icon-button {
width: 42px; min-height: 42px; border: 1px solid var(--aurora-border); border-radius: var(--aurora-radius-sm);
color: var(--aurora-text); background: var(--aurora-bg-subtle); display: inline-flex;
align-items: center; justify-content: center; cursor: pointer;
}
.icon-button:hover:not(:disabled) { border-color: var(--aurora-text-muted); }
.icon-button:disabled { opacity: 0.45; cursor: not-allowed; }
.activity-page__tabs { display: flex; gap: 8px; margin-bottom: 14px; }
.filter-pill {
display: inline-flex; align-items: center; gap: 8px; min-height: 34px;
border: 1px solid var(--aurora-border); border-radius: 999px; background: var(--aurora-bg-subtle);
color: var(--aurora-text-muted); padding: 0 16px; font-size: 0.78rem; font-weight: 800; cursor: pointer;
}
.filter-pill.is-active { border-color: var(--aurora-text-muted); background: var(--aurora-frame-bg); color: var(--aurora-text); }
.activity-page__toolbar { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-bottom: 12px; }
.activity-page__filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.activity-page__select {
padding: 7px 10px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border);
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.78rem;
}
.activity-page__search {
width: min(280px, 100%); min-height: 38px; border: 1px solid var(--aurora-border); border-radius: var(--aurora-radius-sm);
background: var(--aurora-bg-subtle); display: flex; align-items: center; gap: 8px; padding: 0 12px; color: var(--aurora-text-muted);
}
.activity-page__search input {
width: 100%; min-width: 0; border: 0; outline: 0; box-shadow: none; background: transparent;
color: var(--aurora-text); font-size: 0.84rem;
}
.activity-page__search input::placeholder { color: var(--aurora-text-muted); }
.activity-table { overflow: hidden; border: 1px solid var(--aurora-frame-border); border-radius: var(--aurora-radius-sm); background: var(--aurora-frame-bg); }
.activity-table__head, .activity-row {
display: grid;
grid-template-columns: 190px minmax(160px, 0.8fr) 130px 110px 140px minmax(180px, 1.6fr);
align-items: center;
}
.activity-table__head {
min-height: 44px; border-bottom: 1px solid var(--aurora-border); color: var(--aurora-text);
font-size: 0.79rem; font-weight: 850;
}
.activity-table__head > div { min-height: 44px; padding: 0 16px; display: flex; align-items: center; }
.activity-row { min-height: 76px; border-bottom: 1px solid var(--aurora-border); }
.activity-row:hover { background: var(--aurora-bg-subtle); }
.activity-row__cell {
min-width: 0; padding: 12px 16px; color: var(--aurora-text-muted); font-size: 0.8rem;
line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.activity-row__desc { color: var(--aurora-text); white-space: normal; }
.activity-row__author { display: flex; align-items: center; gap: 10px; white-space: normal; }
.activity-row__avatar { border-radius: 50%; flex-shrink: 0; display: block; }
.activity-row__author-info { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.activity-row__author-info a {
color: var(--aurora-accent); font-weight: 700; font-size: 0.8125rem; text-decoration: none;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.activity-row__author-info a:hover { text-decoration: underline; }
.activity-row__role, .activity-row__email {
font-size: 0.71rem; color: var(--aurora-text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.type-badge {
display: inline-block; border-radius: 999px; background: var(--aurora-bg-subtle); color: var(--aurora-text-muted);
padding: 2px 10px; font-size: 0.68rem; font-weight: 850; box-shadow: inset 0 0 0 1px var(--aurora-border);
}
.activity-table__empty { padding: 38px 18px; color: var(--aurora-text-muted); font-size: 0.85rem; text-align: center; }
.activity-table__footer {
min-height: 50px; border-top: 1px solid var(--aurora-border); display: flex; align-items: center;
justify-content: flex-end; gap: 26px; padding: 0 16px; color: var(--aurora-text-muted); font-size: 0.8rem;
}
.activity-table__pager { display: flex; align-items: center; gap: 8px; }
.activity-table__pager button {
width: 28px; height: 28px; border: 0; border-radius: var(--aurora-radius-sm);
background: transparent; color: var(--aurora-text-muted); cursor: pointer;
}
.activity-table__pager button:not(:disabled):hover { background: var(--aurora-bg-subtle); color: var(--aurora-text); }
.activity-table__pager button:disabled { opacity: 0.4; cursor: not-allowed; }
@media (max-width: 1100px) {
.activity-table__head { display: none; }
.activity-row { grid-template-columns: 1fr; padding: 10px 0; }
.activity-row__cell { white-space: normal; }
.activity-row__cell::before { content: attr(data-label) ': '; font-weight: 700; color: var(--aurora-text); }
}
</style>
+222
View File
@@ -0,0 +1,222 @@
<script setup>
import { reactive, ref, onMounted } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const contentType = ref('all');
const filters = reactive({
category: '',
post_author: '',
post_start_date: '',
post_end_date: '',
post_status: '',
page_author: '',
page_start_date: '',
page_end_date: '',
page_status: '',
attachment_start_date: '',
attachment_end_date: '',
});
// Standard WP post statuses (matches core's own get_post_stati({internal: false})).
const STATUSES = [
{ value: '', label: 'All' },
{ value: 'publish', label: 'Published' },
{ value: 'future', label: 'Scheduled' },
{ value: 'draft', label: 'Draft' },
{ value: 'pending', label: 'Pending Review' },
{ value: 'private', label: 'Private' },
{ value: 'trash', label: 'Trash' },
];
const categories = ref([]);
const authors = ref([]);
const loadingOptions = ref(true);
onMounted(async () => {
try {
const headers = { 'X-WP-Nonce': props.serverData.restNonce };
const [catsRes, usersRes] = await Promise.all([
fetch(`${props.serverData.restUrl}wp/v2/categories?per_page=100`, { headers }),
fetch(`${props.serverData.restUrl}wp/v2/users?per_page=100`, { headers }),
]);
categories.value = catsRes.ok ? await catsRes.json() : [];
authors.value = usersRes.ok ? await usersRes.json() : [];
} finally {
loadingOptions.value = false;
}
});
// Building and navigating to a real export.php?download=true&... URL (not a
// fetch) so the browser's normal file-download flow handles the response —
// this is the exact same request native WordPress's own form would submit,
// so the file it returns is unmodified.
//
// Every relevant param is always sent, even when blank. Native's own HTML
// <select> elements always submit a name=value pair regardless of which
// option is picked, and export.php's own PHP reads $_GET['cat'] etc.
// directly with no isset() guard — omitting a param when "All" is selected
// (as this used to) triggers PHP "Undefined array key" warnings that, with
// display_errors on (any site with debugging enabled), get echoed straight
// into the downloaded file before the XML, corrupting it.
const download = () => {
const params = new URLSearchParams({ download: 'true', content: contentType.value });
if (contentType.value === 'posts') {
params.set('cat', filters.category);
params.set('post_author', filters.post_author);
params.set('post_start_date', filters.post_start_date);
params.set('post_end_date', filters.post_end_date);
params.set('post_status', filters.post_status);
} else if (contentType.value === 'pages') {
params.set('page_author', filters.page_author);
params.set('page_start_date', filters.page_start_date);
params.set('page_end_date', filters.page_end_date);
params.set('page_status', filters.page_status);
} else if (contentType.value === 'attachment') {
params.set('attachment_start_date', filters.attachment_start_date);
params.set('attachment_end_date', filters.attachment_end_date);
}
window.location.href = `${props.serverData.exportUrl}?${params.toString()}`;
};
</script>
<template>
<main class="export-page">
<header class="export-page__header">
<p class="export-page__eyebrow">Aurora Admin</p>
<h1>Export</h1>
<p class="export-page__lead">
Create an XML file (WordPress eXtended RSS, or WXR) containing your posts, pages, comments,
custom fields, categories, and tags. Import it into another WordPress site to bring this
content over.
</p>
</header>
<section class="export-page__card">
<h2>Choose what to export</h2>
<div class="export-page__options">
<label class="export-page__radio">
<input type="radio" value="all" v-model="contentType" />
<div>
<strong>All content</strong>
<p>All of your posts, pages, comments, custom fields, terms, navigation menus, and custom posts.</p>
</div>
</label>
<label class="export-page__radio">
<input type="radio" value="posts" v-model="contentType" />
<strong>Posts</strong>
</label>
<div v-if="contentType === 'posts'" class="export-page__filters">
<label>
Category
<select v-model="filters.category" :disabled="loadingOptions">
<option value="">All</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</label>
<label>
Author
<select v-model="filters.post_author" :disabled="loadingOptions">
<option value="">All</option>
<option v-for="a in authors" :key="a.id" :value="a.id">{{ a.name }}</option>
</select>
</label>
<label>Start date <input type="date" v-model="filters.post_start_date" /></label>
<label>End date <input type="date" v-model="filters.post_end_date" /></label>
<label>
Status
<select v-model="filters.post_status">
<option v-for="s in STATUSES" :key="s.value" :value="s.value">{{ s.label }}</option>
</select>
</label>
</div>
<label class="export-page__radio">
<input type="radio" value="pages" v-model="contentType" />
<strong>Pages</strong>
</label>
<div v-if="contentType === 'pages'" class="export-page__filters">
<label>
Author
<select v-model="filters.page_author" :disabled="loadingOptions">
<option value="">All</option>
<option v-for="a in authors" :key="a.id" :value="a.id">{{ a.name }}</option>
</select>
</label>
<label>Start date <input type="date" v-model="filters.page_start_date" /></label>
<label>End date <input type="date" v-model="filters.page_end_date" /></label>
<label>
Status
<select v-model="filters.page_status">
<option v-for="s in STATUSES" :key="s.value" :value="s.value">{{ s.label }}</option>
</select>
</label>
</div>
<label class="export-page__radio">
<input type="radio" value="attachment" v-model="contentType" />
<strong>Media</strong>
</label>
<div v-if="contentType === 'attachment'" class="export-page__filters">
<label>Start date <input type="date" v-model="filters.attachment_start_date" /></label>
<label>End date <input type="date" v-model="filters.attachment_end_date" /></label>
</div>
</div>
<button type="button" class="export-page__submit" @click="download">Download Export File</button>
</section>
</main>
</template>
<style scoped>
.export-page { min-height: calc(100vh - 112px); color: var(--aurora-text); background: transparent; }
.export-page__eyebrow {
margin: 0 0 7px; color: var(--aurora-text-muted); font-size: 0.72rem;
font-weight: 800; text-transform: uppercase;
}
.export-page__header h1 { margin: 0; font-size: 1.45rem; line-height: 1.15; }
.export-page__lead {
margin: 12px 0 0; max-width: 640px; color: var(--aurora-text-muted);
font-size: 0.875rem; line-height: 1.6;
}
.export-page__card {
margin-top: 24px; background: var(--aurora-card-bg); border: 1px solid var(--aurora-card-border);
border-radius: var(--aurora-radius-lg); padding: 22px 24px; max-width: 560px;
}
.export-page__card h2 { margin: 0 0 16px; font-size: 1rem; font-weight: 700; }
.export-page__options { display: flex; flex-direction: column; gap: 4px; }
.export-page__radio {
display: flex; align-items: flex-start; gap: 10px; padding: 8px 4px;
cursor: pointer; font-size: 0.875rem;
}
.export-page__radio input { margin-top: 3px; accent-color: var(--aurora-accent); }
.export-page__radio p { margin: 4px 0 0; font-size: 0.8125rem; color: var(--aurora-text-muted); font-weight: 400; }
.export-page__filters {
display: grid; grid-template-columns: 1fr 1fr; gap: 10px;
margin: 0 0 8px 30px; padding: 12px; background: var(--aurora-bg-subtle);
border-radius: var(--aurora-radius-md);
}
.export-page__filters label {
display: flex; flex-direction: column; gap: 4px; font-size: 0.75rem;
color: var(--aurora-text-muted); font-weight: 600;
}
.export-page__filters select, .export-page__filters input[type='date'] {
padding: 6px 8px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border);
background: var(--aurora-card-bg); color: var(--aurora-text); font-size: 0.8125rem; font-weight: 400;
}
.export-page__submit {
margin-top: 20px; border: none; border-radius: var(--aurora-radius-sm); padding: 10px 20px;
font-size: 0.875rem; font-weight: 600; cursor: pointer; background: var(--aurora-accent); color: #fff;
}
</style>
+178
View File
@@ -0,0 +1,178 @@
<script setup>
import { reactive } from 'vue';
const props = defineProps({
serverData: { type: Object, required: true },
});
const api = (path, options = {}) =>
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': props.serverData.restNonce,
...(options.headers || {}),
},
}).then(async (r) => {
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.message || 'Request failed');
return data;
});
// Local, mutable copy so a row's status/action can update in place once
// installed/activated, without needing a full page reload.
const importers = reactive(props.serverData.importers.map((i) => ({ ...i, busy: false, error: '' })));
const goToImporter = (importer) => {
window.location.href = importer.runUrl;
};
const activate = async (importer) => {
importer.busy = true;
importer.error = '';
try {
await api('/plugins/activate', {
method: 'POST',
body: JSON.stringify({ file: importer.pluginFile }),
});
goToImporter(importer);
} catch (e) {
importer.error = e.message || 'Could not activate the importer.';
} finally {
importer.busy = false;
}
};
const installAndActivate = async (importer) => {
importer.busy = true;
importer.error = '';
try {
// Reuses WordPress core's own admin-ajax "install-plugin" action — the
// exact same request native's "Install Now" button triggers — rather
// than reimplementing plugin installation.
const body = new URLSearchParams({
action: 'install-plugin',
slug: importer.slug,
_ajax_nonce: props.serverData.installNonce,
});
const res = await fetch(props.serverData.ajaxUrl, { method: 'POST', body });
const data = await res.json().catch(() => ({}));
if (!data.success) {
throw new Error(data.data?.errorMessage || 'Installation failed.');
}
// Find the freshly-installed plugin's file path (rather than assuming a
// naming convention) via Aurora's own plugins list, then activate it
// through the same route the Plugins screen uses.
const list = await api('/plugins');
const installed = (Array.isArray(list) ? list : []).find((p) => p.slug === importer.slug);
if (!installed) {
throw new Error('Installed, but the plugin could not be found afterward.');
}
await api('/plugins/activate', {
method: 'POST',
body: JSON.stringify({ file: installed.file }),
});
goToImporter(importer);
} catch (e) {
importer.error = e.message || 'Could not install the importer.';
} finally {
importer.busy = false;
}
};
</script>
<template>
<main class="import-page">
<header class="import-page__header">
<p class="import-page__eyebrow">Aurora Admin</p>
<h1>Import</h1>
<p class="import-page__lead">
If you have posts or comments in another system, WordPress can import those into this
site. Choose a system to import from below.
</p>
</header>
<div class="import-page__list">
<article v-for="imp in importers" :key="imp.id" class="import-card">
<div class="import-card__info">
<h3>{{ imp.name }}</h3>
<p>{{ imp.description }}</p>
<p v-if="imp.error" class="import-card__error">{{ imp.error }}</p>
</div>
<div class="import-card__action">
<a v-if="imp.status === 'active'" class="import-card__btn" @click.prevent="goToImporter(imp)" href="#">
Run Importer
</a>
<button
v-else-if="imp.status === 'installed_inactive'"
type="button"
class="import-card__btn"
:disabled="imp.busy"
@click="activate(imp)"
>
{{ imp.busy ? 'Activating…' : 'Run Importer' }}
</button>
<button
v-else
type="button"
class="import-card__btn"
:disabled="imp.busy"
@click="installAndActivate(imp)"
>
{{ imp.busy ? 'Installing…' : 'Install Now' }}
</button>
</div>
</article>
<p v-if="!importers.length" class="import-page__empty">No importers are available.</p>
</div>
<p v-if="serverData.canInstallPlugins" class="import-page__more">
If the importer you need is not listed,
<a :href="serverData.pluginSearchUrl">search the plugin directory</a>
to see if an importer is available.
</p>
<!-- Rendered from WordPress's own `import_filters` hook — whatever other
active plugins add there. Not Aurora-authored content, only
Aurora-styled, so nothing another plugin adds here disappears. -->
<div v-if="serverData.nativeFiltersHtml" class="import-page__native" v-html="serverData.nativeFiltersHtml" />
</main>
</template>
<style scoped>
.import-page { min-height: calc(100vh - 112px); color: var(--aurora-text); background: transparent; }
.import-page__eyebrow {
margin: 0 0 7px; color: var(--aurora-text-muted); font-size: 0.72rem;
font-weight: 800; text-transform: uppercase;
}
.import-page__header h1 { margin: 0; font-size: 1.45rem; line-height: 1.15; }
.import-page__lead {
margin: 12px 0 0; max-width: 640px; color: var(--aurora-text-muted);
font-size: 0.875rem; line-height: 1.6;
}
.import-page__list { margin-top: 24px; display: flex; flex-direction: column; gap: 10px; max-width: 720px; }
.import-card {
display: flex; align-items: center; justify-content: space-between; gap: 20px;
background: var(--aurora-card-bg); border: 1px solid var(--aurora-card-border);
border-radius: var(--aurora-radius-lg); padding: 16px 20px;
}
.import-card__info h3 { margin: 0 0 4px; font-size: 0.9375rem; font-weight: 700; }
.import-card__info p { margin: 0; font-size: 0.8125rem; color: var(--aurora-text-muted); line-height: 1.5; }
.import-card__error { color: #e5484d !important; margin-top: 6px !important; }
.import-card__action { flex-shrink: 0; }
.import-card__btn {
display: inline-block; border: none; border-radius: var(--aurora-radius-sm); padding: 8px 16px;
font-size: 0.8125rem; font-weight: 600; cursor: pointer; background: var(--aurora-accent);
color: #fff; text-decoration: none; white-space: nowrap;
}
.import-card__btn:disabled { opacity: 0.6; cursor: default; }
.import-page__empty { color: var(--aurora-text-muted); font-size: 0.875rem; }
.import-page__more { margin-top: 18px; font-size: 0.8125rem; color: var(--aurora-text-muted); }
.import-page__more a { color: var(--aurora-accent); }
.import-page__native { margin-top: 18px; }
</style>
+94 -40
View File
@@ -37,10 +37,12 @@ const load = async () => {
}
};
onMounted(async () => {
onMounted(() => {
load();
api('/menus/native-items').then((items) => (nativeItems.value = items));
roleOptions.value = props.serverData.roles || [];
// The native top-level menu is provided in serverData (captured server-side
// at page load); the REST route can't build the admin menu.
nativeItems.value = props.serverData.nativeItems || [];
});
const filteredMenus = computed(() => {
@@ -55,48 +57,84 @@ const filteredMenus = computed(() => {
const selected = computed(() => menus.value.find((m) => m.id === selectedId.value) || null);
const draft = reactive({ name: '', status: 'draft', roles: [], items: [] });
const draft = reactive({ name: '', status: 'draft', roles: [] });
// The full ordered list of top-level items being edited. Row order IS the
// menu order (saved as each item's `order` index). Each row carries its
// native title/icon plus the per-item overrides (custom label, hidden).
const orderedItems = ref([]);
// Merge a config's saved items with the live native top-level list: saved
// items first, in their saved order, then any native items the config
// doesn't mention yet (e.g. a newly installed plugin) appended at the end,
// so new menu items are never silently dropped.
const buildOrdered = (savedItems = []) => {
const byId = Object.fromEntries(nativeItems.value.map((n) => [n.native_id, n]));
const saved = [...savedItems].sort((a, b) => (a.order || 0) - (b.order || 0));
const seen = new Set();
const result = [];
for (const s of saved) {
const n = byId[s.native_id];
if (!n) continue; // saved item no longer exists in the admin menu
result.push({ native_id: n.native_id, title: n.title, icon: n.icon, label: s.label || '', hidden: !!s.hidden });
seen.add(s.native_id);
}
for (const n of nativeItems.value) {
if (seen.has(n.native_id)) continue;
result.push({ native_id: n.native_id, title: n.title, icon: n.icon, label: '', hidden: false });
}
orderedItems.value = result;
};
const selectMenu = (menu) => {
selectedId.value = menu.id;
Object.assign(draft, {
name: menu.name,
status: menu.status,
roles: [...menu.roles],
items: menu.items.map((i) => ({ ...i })),
});
Object.assign(draft, { name: menu.name, status: menu.status, roles: [...menu.roles] });
buildOrdered(menu.items);
};
const isCreating = ref(false);
const newMenu = () => {
selectedId.value = null;
isCreating.value = true;
Object.assign(draft, { name: 'New menu', status: 'draft', roles: [], items: [] });
Object.assign(draft, { name: 'New menu', status: 'draft', roles: [] });
buildOrdered([]);
};
const itemState = (nativeId) => draft.items.find((i) => i.native_id === nativeId);
const toggleHidden = (nativeId) => {
let item = itemState(nativeId);
if (!item) {
item = { native_id: nativeId, label: '', hidden: false, order: 0 };
draft.items.push(item);
}
item.hidden = !item.hidden;
// Reorder controls: drag-and-drop plus keyboard-friendly up/down buttons.
const dragIndex = ref(-1);
const onDragStart = (i) => { dragIndex.value = i; };
const onDrop = (i) => {
const from = dragIndex.value;
dragIndex.value = -1;
if (from < 0 || from === i) return;
const arr = orderedItems.value;
const [moved] = arr.splice(from, 1);
arr.splice(i, 0, moved);
};
const setLabel = (nativeId, label) => {
let item = itemState(nativeId);
if (!item) {
item = { native_id: nativeId, label: '', hidden: false, order: 0 };
draft.items.push(item);
}
item.label = label;
const move = (i, dir) => {
const j = i + dir;
const arr = orderedItems.value;
if (j < 0 || j >= arr.length) return;
[arr[i], arr[j]] = [arr[j], arr[i]];
};
const saving = ref(false);
const save = async () => {
saving.value = true;
try {
const payload = { name: draft.name, status: draft.status, roles: draft.roles, items: draft.items };
const payload = {
name: draft.name,
status: draft.status,
roles: draft.roles,
// Row order becomes each item's `order`; only send items that actually
// override something (renamed or hidden) or that need an explicit order.
items: orderedItems.value.map((it, index) => ({
native_id: it.native_id,
label: it.label || '',
hidden: !!it.hidden,
order: index,
})),
};
if (isCreating.value) {
const created = await api('/menus', { method: 'POST', body: JSON.stringify(payload) });
await load();
@@ -188,23 +226,29 @@ const toggleRole = (slug) => {
</div>
<div class="menu-editor__section">
<h3>Menu items</h3>
<h3>Menu items — drag to reorder</h3>
<div class="menu-editor__items">
<div v-for="ni in nativeItems" :key="ni.native_id" class="menu-editor__item">
<span class="dashicons" :class="ni.icon?.value || 'dashicons-admin-generic'" />
<input
type="text"
:value="itemState(ni.native_id)?.label || ni.title"
@input="setLabel(ni.native_id, $event.target.value)"
/>
<div
v-for="(it, i) in orderedItems"
:key="it.native_id"
class="menu-editor__item"
:class="{ 'is-hidden': it.hidden }"
draggable="true"
@dragstart="onDragStart(i)"
@dragover.prevent
@drop="onDrop(i)"
>
<span class="menu-editor__drag dashicons dashicons-move" title="Drag to reorder" />
<span class="dashicons" :class="it.icon?.value || 'dashicons-admin-generic'" />
<input type="text" v-model="it.label" :placeholder="it.title" />
<label class="menu-editor__hide">
<input
type="checkbox"
:checked="!!itemState(ni.native_id)?.hidden"
@change="toggleHidden(ni.native_id)"
/>
<input type="checkbox" v-model="it.hidden" />
Hide
</label>
<div class="menu-editor__move">
<button type="button" :disabled="i === 0" title="Move up" @click="move(i, -1)">▲</button>
<button type="button" :disabled="i === orderedItems.length - 1" title="Move down" @click="move(i, 1)">▼</button>
</div>
</div>
</div>
</div>
@@ -243,11 +287,21 @@ const toggleRole = (slug) => {
display: flex; align-items: center; gap: 10px; padding: 6px 4px;
border-bottom: 1px solid var(--aurora-frame-border);
}
.menu-editor__item.is-hidden { opacity: 0.5; }
.menu-editor__drag { cursor: grab; color: var(--aurora-text-muted); flex-shrink: 0; }
.menu-editor__drag:active { cursor: grabbing; }
.menu-editor__item input[type='text'] {
flex: 1; padding: 6px 8px; border-radius: var(--aurora-radius-sm); border: 1px solid var(--aurora-border);
background: var(--aurora-bg-subtle); color: var(--aurora-text); font-size: 0.8125rem;
}
.menu-editor__hide { display: flex; align-items: center; gap: 4px; font-size: 0.75rem; color: var(--aurora-text-muted); }
.menu-editor__hide { display: flex; align-items: center; gap: 4px; font-size: 0.75rem; color: var(--aurora-text-muted); flex-shrink: 0; }
.menu-editor__move { display: flex; flex-direction: column; gap: 2px; flex-shrink: 0; }
.menu-editor__move button {
border: none; background: var(--aurora-bg-subtle); color: var(--aurora-text-muted);
border-radius: 4px; width: 20px; height: 15px; line-height: 1; font-size: 9px; cursor: pointer; padding: 0;
}
.menu-editor__move button:disabled { opacity: 0.35; cursor: default; }
.menu-editor__move button:not(:disabled):hover { color: var(--aurora-accent); }
.menu-editor__actions { display: flex; justify-content: flex-end; gap: 8px; }
.menu-editor__actions button {
border: none; border-radius: var(--aurora-radius-sm); padding: 9px 18px; font-size: 0.875rem; cursor: pointer;
+144
View File
@@ -0,0 +1,144 @@
<script setup>
import { reactive, onMounted } from 'vue';
import { companionPlugins } from './companionPlugins.js';
const props = defineProps({
serverData: { type: Object, required: true },
});
const api = (path, options = {}) =>
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': props.serverData.restNonce,
...(options.headers || {}),
},
}).then(async (r) => {
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.message || 'Request failed');
return data;
});
// Per-plugin UI state, keyed by slug: { status: {installed,active}|null, busy, error }
const state = reactive(
Object.fromEntries(companionPlugins.map((p) => [p.slug, { status: null, busy: false, error: '' }]))
);
const loadStatus = async (plugin) => {
try {
state[plugin.slug].status = await api(plugin.statusEndpoint);
} catch {
state[plugin.slug].status = { installed: false, active: false };
}
};
onMounted(() => {
companionPlugins.forEach(loadStatus);
});
const install = async (plugin) => {
const s = state[plugin.slug];
if (s.busy) return;
s.busy = true;
s.error = '';
try {
const result = await api(plugin.installEndpoint, { method: 'POST' });
if (!result.success) {
s.error = result.message || 'Installation failed.';
}
await loadStatus(plugin);
} catch (e) {
s.error = e.message || 'Installation failed.';
} finally {
s.busy = false;
}
};
</script>
<template>
<main class="modules">
<header class="modules__head">
<p class="modules__eyebrow">Aurora Admin</p>
<h1>Modules</h1>
<p class="modules__lead">
Install additional modules to extend Aurora Admin. Each is a separate, standalone plugin —
install and activate it here with one click.
</p>
</header>
<div class="modules__grid">
<article v-for="p in companionPlugins" :key="p.slug" class="module-card">
<div class="module-card__top">
<span class="module-card__icon dashicons" :class="p.icon" />
<span
v-if="state[p.slug].status"
class="module-card__badge"
:class="{ 'is-active': state[p.slug].status.active }"
>
{{ state[p.slug].status.active ? 'Active' : state[p.slug].status.installed ? 'Installed' : 'Not installed' }}
</span>
</div>
<h3 class="module-card__title">{{ p.label }}</h3>
<p class="module-card__desc">{{ p.description }}</p>
<div class="module-card__action">
<div v-if="!state[p.slug].status" class="module-card__checking">Checking…</div>
<a v-else-if="state[p.slug].status.active" class="module-card__manage" :href="p.manageUrl">
Open {{ p.label }} →
</a>
<template v-else>
<button
type="button"
class="module-card__install"
:disabled="state[p.slug].busy"
@click="install(p)"
>
{{ state[p.slug].busy ? 'Installing…' : state[p.slug].status.installed ? 'Activate' : 'Install & Activate' }}
</button>
<p v-if="state[p.slug].error" class="module-card__error">{{ state[p.slug].error }}</p>
</template>
</div>
</article>
</div>
</main>
</template>
<style scoped>
.modules { min-height: calc(100vh - 112px); color: var(--aurora-text); background: transparent; }
.modules__eyebrow {
margin: 0 0 7px; color: var(--aurora-text-muted); font-size: 0.72rem;
font-weight: 800; text-transform: uppercase;
}
.modules__head h1 { margin: 0; font-size: 1.45rem; line-height: 1.15; }
.modules__lead { margin: 8px 0 0; color: var(--aurora-text-muted); font-size: 0.9rem; max-width: 640px; line-height: 1.6; }
.modules__grid {
margin-top: 24px; display: grid; gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
.module-card {
background: var(--aurora-card-bg); border: 1px solid var(--aurora-card-border);
border-radius: var(--aurora-radius-lg); padding: 20px 22px;
display: flex; flex-direction: column; gap: 8px;
}
.module-card__top { display: flex; align-items: center; justify-content: space-between; }
.module-card__icon { font-size: 26px; width: 26px; height: 26px; color: var(--aurora-accent); }
.module-card__badge {
font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em;
color: var(--aurora-text-muted); background: var(--aurora-bg-subtle);
border-radius: 999px; padding: 3px 10px;
}
.module-card__badge.is-active { color: var(--aurora-accent); }
.module-card__title { margin: 4px 0 0; font-size: 1rem; font-weight: 700; }
.module-card__desc { margin: 0; font-size: 0.8125rem; color: var(--aurora-text-muted); line-height: 1.55; flex: 1; }
.module-card__action { margin-top: 6px; }
.module-card__checking { font-size: 0.8125rem; color: var(--aurora-text-muted); }
.module-card__manage { font-size: 0.8125rem; font-weight: 600; color: var(--aurora-accent); text-decoration: none; }
.module-card__manage:hover { text-decoration: underline; }
.module-card__install {
border: none; border-radius: var(--aurora-radius-sm); padding: 9px 16px; font-size: 0.8125rem;
font-weight: 600; cursor: pointer; background: var(--aurora-accent); color: #fff;
}
.module-card__install:disabled { opacity: 0.6; cursor: default; }
.module-card__error { margin: 8px 0 0; font-size: 0.8125rem; color: #e5484d; }
</style>
+39
View File
@@ -0,0 +1,39 @@
/**
* Aurora's standalone companion plugins, installable from the Modules page.
* Single source of truth for both the card metadata and the REST endpoints
* that back them (admin/src/Rest/CompanionPluginData.php) — a new companion
* plugin only needs an entry here plus a matching PHP-side entry, no other
* wiring.
*/
export const companionPlugins = [
{
slug: 'file-manager',
icon: 'dashicons-portfolio',
label: 'Aurora File Manager',
description:
'Browse, edit, and manage your site’s files (themes, plugins, uploads, and more) directly from wp-admin.',
statusEndpoint: '/companion-plugins/file-manager/status',
installEndpoint: '/companion-plugins/file-manager/install',
manageUrl: 'admin.php?page=aurora-file-manager',
},
{
slug: 'database-explorer',
icon: 'dashicons-editor-table',
label: 'Aurora Database Explorer',
description:
'A Supabase-style database browser inside wp-admin — see every table with row counts and sizes, browse paginated data, and run your own read-only queries.',
statusEndpoint: '/companion-plugins/database-explorer/status',
installEndpoint: '/companion-plugins/database-explorer/install',
manageUrl: 'admin.php?page=aurora-database-explorer',
},
{
slug: 'site-backup',
icon: 'dashicons-migrate',
label: 'Aurora Site Backup',
description:
'Full site backup (database + files) and restore-in-place, on demand or on a recurring schedule, stored locally or on your own S3-compatible or Google Drive remote storage.',
statusEndpoint: '/companion-plugins/site-backup/status',
installEndpoint: '/companion-plugins/site-backup/install',
manageUrl: 'admin.php?page=aurora-site-backup',
},
];
+9 -1
View File
@@ -81,6 +81,7 @@ const runAction = async (action, plugin) => {
busyFile.value = plugin.file;
error.value = '';
notice.value = '';
let redirecting = false;
try {
const data = await request(`/plugins/${action}`, {
method: action === 'delete' ? 'DELETE' : 'POST',
@@ -88,14 +89,21 @@ const runAction = async (action, plugin) => {
});
notice.value = data.message || 'Done';
if (data.redirect) {
redirecting = true;
window.location.href = data.redirect;
return;
}
await load();
} catch (err) {
error.value = err.message || 'Action failed';
} finally {
busyFile.value = '';
// Always re-sync the list with the real backend state, even after an
// error: activate_plugin() can return a WP_Error for benign "unexpected
// output" from a plugin's own activation while still leaving the plugin
// active, which previously left the row showing its stale pre-click
// state (you clicked Activate but it still read "deactivated"). Skipped
// only when navigating away (deactivating Aurora itself redirects).
if (!redirecting) await load();
}
};
-17
View File
@@ -10,22 +10,6 @@ const props = defineProps({
const visibleCategories = settingsCategories;
// Shared REST fetcher for field types that need their own API calls beyond
// the generic wp/v2/settings save flow (currently just 'companion-plugin').
const api = (path, options = {}) =>
fetch(`${props.serverData.restUrl}aurora-admin/v1${path}`, {
...options,
headers: {
'X-WP-Nonce': props.serverData.restNonce,
'Content-Type': 'application/json',
...(options.headers || {}),
},
}).then(async (r) => {
const body = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(body.message || 'Request failed');
return body;
});
// Working copy of the settings, seeded from the server + field defaults.
const settings = reactive({ ...(props.serverData.settings || {}) });
visibleCategories.forEach((cat) =>
@@ -264,7 +248,6 @@ const importSettings = (e) => {
:field="f"
:options="optionsFor(f)"
:disabled="!!f.dependsOn && !settings[f.dependsOn]"
:api="api"
v-model="settings[f.key]"
/>
</div>
@@ -14,9 +14,6 @@ const props = defineProps({
// True when this field's `dependsOn` key is falsy — greys out the
// control without hiding it, so the relationship stays visible.
disabled: { type: Boolean, default: false },
// Shared REST fetcher, only used by field types that need their own API
// calls beyond the generic wp/v2/settings save flow (companion-plugin).
api: { type: Function, default: null },
});
const emit = defineEmits(['update:modelValue']);
@@ -76,43 +73,14 @@ const pickFont = (name) => {
fontOpen.value = false;
};
const openFontList = () => {
fontQuery.value = props.modelValue || '';
// Start with an empty query so the whole font list is shown on focus.
// Seeding it with the current font filtered the list down to just that
// one font, making it impossible to pick a different one without first
// clearing the box. The active font stays highlighted via `val === name`.
fontQuery.value = '';
fontOpen.value = true;
};
// Companion plugin (Aurora File Manager) install card -----------------------
const companionStatus = ref(null); // { installed, active } | null while loading
const companionBusy = ref(false);
const companionError = ref('');
const loadCompanionStatus = async () => {
if (props.field.type !== 'companion-plugin' || !props.api) return;
try {
companionStatus.value = await props.api(props.field.statusEndpoint);
} catch {
companionStatus.value = { installed: false, active: false };
}
};
if (props.field.type === 'companion-plugin') {
loadCompanionStatus();
}
const installCompanion = async () => {
if (!props.api) return;
companionBusy.value = true;
companionError.value = '';
try {
const result = await props.api(props.field.installEndpoint, { method: 'POST' });
if (!result.success) {
companionError.value = result.message || 'Installation failed.';
}
await loadCompanionStatus();
} catch (e) {
companionError.value = e.message || 'Installation failed.';
} finally {
companionBusy.value = false;
}
};
</script>
<template>
@@ -273,31 +241,6 @@ const installCompanion = async () => {
</div>
</div>
<!-- Companion plugin install card -->
<div v-else-if="field.type === 'companion-plugin'" class="field__companion">
<div v-if="!companionStatus" class="field__companion-status">Checking…</div>
<template v-else>
<a
v-if="companionStatus.active"
class="field__companion-manage"
:href="field.manageUrl"
>
Installed &amp; active — open File Manager →
</a>
<template v-else>
<button
type="button"
class="field__companion-install"
:disabled="companionBusy"
@click="installCompanion"
>
{{ companionBusy ? 'Installing…' : (companionStatus.installed ? 'Activate' : 'Install & Activate') }}
</button>
<p v-if="companionError" class="field__companion-error">{{ companionError }}</p>
</template>
</template>
</div>
<!-- Repeater -->
<div v-else-if="field.type === 'repeater'" class="field__repeater">
<div v-for="(row, i) in rows" :key="i" class="field__repeater-row">
@@ -460,20 +403,6 @@ const installCompanion = async () => {
.field__font-item:hover { background: var(--aurora-bg-subtle); }
.field__font-item--active { color: var(--aurora-accent); }
/* Companion plugin install card */
.field__companion-status { font-size: 0.8125rem; color: var(--aurora-text-muted); }
.field__companion-install {
border: none; border-radius: var(--aurora-radius-sm); background: var(--aurora-accent); color: #fff;
padding: 8px 16px; font-size: 0.8125rem; font-weight: 600; cursor: pointer;
}
.field__companion-install:disabled { opacity: 0.6; cursor: default; }
.field__companion-manage {
display: inline-block; font-size: 0.8125rem; font-weight: 600;
color: var(--aurora-accent); text-decoration: none;
}
.field__companion-manage:hover { text-decoration: underline; }
.field__companion-error { margin: 8px 0 0; font-size: 0.8125rem; color: #e5484d; }
@media (max-width: 782px) {
.field { grid-template-columns: 1fr; gap: 12px; }
}
+59 -46
View File
@@ -99,7 +99,9 @@ export const settingsGroups = [
key: 'font_family',
type: 'font-picker',
label: 'Font family',
description: 'Google Font used across the Aurora interface.',
description:
'Optional. Leave empty to use your system font (no external requests). ' +
'Choosing a font loads it from Google Fonts (fonts.googleapis.com) on each admin page.',
},
],
},
@@ -195,6 +197,46 @@ export const settingsGroups = [
},
],
},
{
id: 'login-redirect',
title: 'Login Redirect',
subtitle: 'Control where visitors and users land, before and after login',
icon: 'dashicons-redo',
fields: [
{
key: 'login_redirect_enabled',
type: 'toggle',
label: 'Enable login redirects',
description:
'Turns on the redirects below. This never changes, hides, or blocks your login page — wp-login.php always stays reachable at its normal address, so this can’t lock you out of logging in.',
},
{
key: 'redirect_unauthenticated_url',
type: 'text',
label: 'Redirect logged-out wp-admin visitors',
description:
'Send visitors who try to access wp-admin without being logged in to this URL instead of the login screen. Leave empty to keep the normal WordPress behavior.',
placeholder: 'https://example.com/',
dependsOn: 'login_redirect_enabled',
},
{
key: 'redirect_roles',
type: 'multiselect',
label: 'Redirect these roles after login',
description: 'Users with these roles are sent to the URL below after logging in, instead of the dashboard.',
optionsSource: 'roles',
dependsOn: 'login_redirect_enabled',
},
{
key: 'redirect_after_login_url',
type: 'text',
label: 'Redirect to',
description: 'Where the roles above land after logging in.',
placeholder: 'https://example.com/welcome',
dependsOn: 'login_redirect_enabled',
},
],
},
{
id: 'menu',
title: 'Menu',
@@ -281,6 +323,22 @@ export const settingsGroups = [
},
],
},
{
id: 'editor',
title: 'Editor',
subtitle: 'Choose the editor used for posts and pages',
icon: 'dashicons-edit',
fields: [
{
key: 'disable_gutenberg',
type: 'toggle',
label: 'Disable Gutenberg',
description:
'Use WordPress’s classic editor instead of the block editor for all post types. Off by default.',
default: false,
},
],
},
{
id: 'admin-takeover',
title: 'Admin Takeover',
@@ -493,51 +551,6 @@ export const settingsGroups = [
},
],
},
{
id: 'add-ons',
title: 'Add-ons',
icon: 'dashicons-admin-plugins',
categories: [
{
id: 'add-ons',
title: 'Add-ons',
subtitle: 'Standalone companion plugins',
icon: 'dashicons-admin-plugins',
fields: [
{
key: 'file_manager_addon',
type: 'companion-plugin',
label: 'Aurora File Manager',
description:
'Browse, edit, and manage your site’s files (themes, plugins, uploads, and more) directly from wp-admin. Distributed as a separate plugin rather than bundled here, since WordPress.org’s Plugin Directory policy doesn’t allow this category of functionality in a directory-listed plugin — restrictions or not.',
statusEndpoint: '/companion-plugins/file-manager/status',
installEndpoint: '/companion-plugins/file-manager/install',
manageUrl: 'admin.php?page=aurora-file-manager',
},
{
key: 'database_explorer_addon',
type: 'companion-plugin',
label: 'Aurora Database Explorer',
description:
'A Supabase-style database browser inside wp-admin — see every table with row counts and sizes, browse paginated data, and run your own read-only SELECT/SHOW/DESCRIBE/EXPLAIN queries. Distributed as a separate plugin rather than bundled here, since WordPress.org’s Plugin Directory review treats direct database-query tooling the same way it treats file-management functionality.',
statusEndpoint: '/companion-plugins/database-explorer/status',
installEndpoint: '/companion-plugins/database-explorer/install',
manageUrl: 'admin.php?page=aurora-database-explorer',
},
{
key: 'site_backup_addon',
type: 'companion-plugin',
label: 'Aurora Site Backup',
description:
'Full site backup (database + files) and restore-in-place, on demand or on a recurring schedule, stored locally or on your own S3-compatible or Google Drive remote storage. Distributed as a separate plugin rather than bundled here, to keep Aurora Admin’s own footprint away from arbitrary file-write functionality.',
statusEndpoint: '/companion-plugins/site-backup/status',
installEndpoint: '/companion-plugins/site-backup/install',
manageUrl: 'admin.php?page=aurora-site-backup',
},
],
},
],
},
];
/**
+3 -2
View File
@@ -31,7 +31,9 @@ const steps = [
key: 'font_family',
type: 'font-picker',
label: 'Font family',
description: 'A Google Font used across the Aurora interface.',
description:
'Optional. Leave empty for your system font (no external requests). ' +
'Choosing a font loads it from Google Fonts.',
},
],
},
@@ -207,7 +209,6 @@ const finish = async (target) => {
v-for="f in current.fields"
:key="f.key"
:field="f"
:api="null"
v-model="settings[f.key]"
/>
</div>
+76
View File
@@ -0,0 +1,76 @@
<script setup>
const props = defineProps({
serverData: { type: Object, required: true },
});
</script>
<template>
<main class="tools-page">
<header class="tools-page__header">
<p class="tools-page__eyebrow">Aurora Admin</p>
<h1>Tools</h1>
</header>
<div class="tools-page__grid">
<!-- Core's Categories and Tags Converter card — not produced by a
hook, so it's reproduced here directly (same visibility gate as
native tools.php: the `import` capability plus category/tag
manage_terms). -->
<article v-if="serverData.showCategoriesConverter" class="card">
<h2 class="title">Categories and Tags Converter</h2>
<p>
If you want to convert your categories to tags (or vice versa), use the
<a :href="serverData.importUrl">Categories and Tags Converter</a> available from the Import screen.
</p>
</article>
<!-- Rendered from WordPress's own `tool_box` hook — whatever other
active plugins add there (WooCommerce, migration tools, etc.).
Not Aurora-authored content, only Aurora-styled, so nothing
another plugin adds to this screen silently disappears. -->
<div v-if="serverData.nativeToolsHtml" class="tools-page__native" v-html="serverData.nativeToolsHtml" />
</div>
<p v-if="!serverData.showCategoriesConverter && !serverData.nativeToolsHtml" class="tools-page__empty">
No tools are available right now.
</p>
</main>
</template>
<style scoped>
.tools-page { min-height: calc(100vh - 112px); color: var(--aurora-text); background: transparent; }
.tools-page__eyebrow {
margin: 0 0 7px; color: var(--aurora-text-muted); font-size: 0.72rem;
font-weight: 800; text-transform: uppercase;
}
.tools-page__header h1 { margin: 0 0 24px; font-size: 1.45rem; line-height: 1.15; }
.tools-page__empty { color: var(--aurora-text-muted); font-size: 0.875rem; }
.tools-page__grid {
display: grid; gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
/* The v-html'd wrapper participates as if it weren't there, so cards inside
it (there may be several, one per plugin hooked to tool_box) lay out in
the same grid as Aurora's own card above instead of stacking inside one
grid cell. */
.tools-page__native { display: contents; }
/* Native tool_box markup is `.card > h2.title + p` (core's own Categories &
Tags Converter shape), but other plugins' cards may not match exactly —
styled broadly by element, with .card/.title as the primary targets. The
plain selectors below cover Aurora's own card (real template markup, gets
Vue's scoping attribute automatically); :deep() additionally reaches into
the v-html'd content, which the scoping attribute can't be applied to. */
.card, .tools-page__native :deep(.card) {
background: var(--aurora-card-bg); border: 1px solid var(--aurora-card-border);
border-radius: var(--aurora-radius-lg); padding: 20px 22px; margin: 0;
}
.card h2, .card h3, .card .title, .tools-page__native :deep(h2), .tools-page__native :deep(h3), .tools-page__native :deep(.title) {
margin: 0 0 10px; font-size: 1rem; font-weight: 700; color: var(--aurora-text);
}
.card p, .tools-page__native :deep(p) {
margin: 0; font-size: 0.8125rem; color: var(--aurora-text-muted); line-height: 1.6;
}
.card a, .tools-page__native :deep(a) { color: var(--aurora-accent); text-decoration: none; }
.card a:hover, .tools-page__native :deep(a:hover) { text-decoration: underline; }
</style>
+13 -1
View File
@@ -16,6 +16,18 @@ const highlighted = ref(0);
let debounceTimer;
let requestSeq = 0;
// wp/v2/search returns titles with HTML entities (e.g. "Ben &amp; Jerry").
// Decode them for display WITHOUT rendering markup: a <textarea>'s innerHTML
// is RCDATA, so tags stay literal text and only entities are decoded — then
// we read them back as a plain string and render with text interpolation.
// This replaces a v-html bind on item.title, which would have executed any
// HTML an unfiltered_html user put in a post title (admin→admin XSS).
const decodeEntities = (str) => {
const el = document.createElement('textarea');
el.innerHTML = String(str ?? '');
return el.value;
};
const search = async () => {
const q = query.value.trim();
if (q.length < 2) {
@@ -121,7 +133,7 @@ onUnmounted(() => document.removeEventListener('keydown', onKeydown));
@mouseenter="highlighted = i"
@click="openResult(item)"
>
<span class="smodal__result-title" v-html="item.title" />
<span class="smodal__result-title">{{ decodeEntities(item.title) }}</span>
<span class="smodal__result-type">{{ item.subtype }}</span>
</button>
</li>
+87 -4
View File
@@ -195,7 +195,11 @@ html.aurora-ready #wpbody-content {
color: var(--aurora-text);
border: 1px solid var(--aurora-frame-border);
border-radius: var(--aurora-radius-xl);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
/* A real soft shadow (not the old barely-there 0.06 one) so the rounded
card edge reads against dense, full-bleed page content — otherwise the
frame is technically rounded but visually invisible except on sparse
pages. Follows the border-radius, defining the card shape. */
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
margin: 16px var(--aurora-gutter-r) 24px 24px;
padding: 32px;
min-height: calc(100vh - var(--aurora-toolbar-h) - 40px);
@@ -323,7 +327,10 @@ html.aurora-ready .theme-install-overlay .wp-full-overlay-main {
background: var(--aurora-frame-bg);
border: 1px solid var(--aurora-frame-border);
border-radius: var(--aurora-radius-xl);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
/* Match #wpbody-content's shadow so the dashboard's rounded card edge
reads the same way — the dashboard is dense and full-bleed, so the old
0.06 shadow left it looking un-rounded. */
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
margin: 16px var(--aurora-gutter-r) 24px 24px;
box-sizing: border-box;
/* Same unconditional height cap as #wpbody-content's base rule — this
@@ -335,8 +342,15 @@ html.aurora-ready .theme-install-overlay .wp-full-overlay-main {
no document
scroll left to fall back on either. */
max-height: calc(100vh - var(--aurora-toolbar-h) - 40px);
overflow-y: auto;
overflow-anchor: none;
/* The frame itself clips (rounded corners) and lays out as a column; the
actual scrolling happens on the inner app-root (see the shared scroll
rule below), so the native scrollbar lives INSIDE the rounded card
rather than squaring off its right corners. The 12px padding is the
gutter that keeps the scrollbar clear of the corner radius. */
overflow: hidden;
display: flex;
flex-direction: column;
padding: 12px;
}
/* Custom admin notices (Notices::render_for_dashboard(), reparented in
@@ -366,6 +380,75 @@ html.aurora-ready body.toplevel_page_aurora-admin #wpbody-content {
box-shadow: none;
}
/* --- Scroll INSIDE the rounded frame -------------------------------------
A single element can't both scroll and keep its own rounded corners: the
native scrollbar sits in the top/bottom-right corners and squares them
off. So the frame clips (overflow:hidden + border-radius) and its Aurora
app-root child becomes the real scroll container — the scrollbar then
lives inside the rounded card, inset by the frame's 12px padding so it
clears the corner radius.
`:has()` limits the #wpbody-content half to Aurora's own hijacked pages
(their mount div is a direct #aurora-admin-*-root child); native WP pages
keep their normal single-layer 32px-padded scroll, untouched. Content
padding is split 12px (frame) + 20px (scroller) so it still totals the
original 32px — pages that assume that (e.g. AppListShell's -32px bleed)
keep working. */
/* `body:not(.toplevel_page_aurora-admin)` excludes the Settings page — it
deliberately drops the frame and manages its own -32px/scroll-as-a-whole
layout (see Settings.vue), so the two-layer treatment must not touch it. */
html.aurora-ready body:not(.toplevel_page_aurora-admin) #wpbody-content:has(> [id^="aurora-admin-"][id$="-root"]) {
overflow: hidden;
display: flex;
flex-direction: column;
padding: 12px;
}
.aurora-dashboard-frame > [id^="aurora-admin-"][id$="-root"],
html.aurora-ready body:not(.toplevel_page_aurora-admin) #wpbody-content > [id^="aurora-admin-"][id$="-root"] {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-anchor: none;
}
/* The hijacked/list pages need their content padding on the scroller (the
dashboard's own content already pads itself). */
html.aurora-ready body:not(.toplevel_page_aurora-admin) #wpbody-content > [id^="aurora-admin-"][id$="-root"] {
padding: 20px;
box-sizing: border-box;
}
/* --- Themed scrollbars -----------------------------------------------------
Skin every scrollbar inside the Aurora shell in a translucent shade of the
active theme's accent (derived from --aurora-accent, so it tracks whatever
preset is selected) instead of the OS default. The plain accent-soft token
is a fallback for engines without color-mix; the 3px transparent border +
background-clip:padding-box keeps the thumb slim with a little breathing
room around it. */
html.aurora-ready {
scrollbar-width: thin;
scrollbar-color: var(--aurora-accent-soft) transparent;
scrollbar-color: color-mix(in srgb, var(--aurora-accent) 55%, transparent) transparent;
}
html.aurora-ready ::-webkit-scrollbar {
width: 12px;
height: 12px;
}
html.aurora-ready ::-webkit-scrollbar-track,
html.aurora-ready ::-webkit-scrollbar-corner {
background: transparent;
}
html.aurora-ready ::-webkit-scrollbar-thumb {
border-radius: 999px;
border: 3px solid transparent;
background-clip: padding-box;
background-color: var(--aurora-accent-soft);
background-color: color-mix(in srgb, var(--aurora-accent) 45%, transparent);
}
html.aurora-ready ::-webkit-scrollbar-thumb:hover {
background-color: var(--aurora-accent);
background-color: color-mix(in srgb, var(--aurora-accent) 72%, transparent);
}
/*
* Native WordPress pages are fully dark-themed by a dedicated stylesheet
* (admin/assets/native-dark.css), enqueued only on native pages and gated