From d56d53da3d261e9e7f6eb4eff4ffff3e20959580 Mon Sep 17 00:00:00 2001 From: FlyingArowana <16946913+TheSCREWEDSoftware@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:54:31 +0100 Subject: [PATCH 01/18] Profile theming: dark mode toggle, shared CSS, character colour helper --- .../src/Hooks/Various/DarkMode.php | 149 ++++ .../src/Utils/AcoreCharColors.php | 109 +++ src/acore-wp-plugin/src/boot.php | 2 + .../web/assets/css/dark-mode.css | 706 ++++++++++++++++++ src/acore-wp-plugin/web/assets/css/main.css | 606 +++++++++++++-- src/acore-wp-plugin/web/assets/css/theme.css | 616 +++++++++++++++ 6 files changed, 2129 insertions(+), 59 deletions(-) create mode 100644 src/acore-wp-plugin/src/Hooks/Various/DarkMode.php create mode 100644 src/acore-wp-plugin/src/Utils/AcoreCharColors.php create mode 100644 src/acore-wp-plugin/web/assets/css/dark-mode.css create mode 100644 src/acore-wp-plugin/web/assets/css/theme.css diff --git a/src/acore-wp-plugin/src/Hooks/Various/DarkMode.php b/src/acore-wp-plugin/src/Hooks/Various/DarkMode.php new file mode 100644 index 000000000..2cebc0915 --- /dev/null +++ b/src/acore-wp-plugin/src/Hooks/Various/DarkMode.php @@ -0,0 +1,149 @@ +add_node([ + 'id' => 'acore-dark-mode', + 'parent' => 'top-secondary', + 'title' => '' . ($is_dark ? '☀' : '☾') . '', + 'href' => '#', + 'meta' => ['title' => $is_dark ? 'Switch to light mode' : 'Switch to dark mode'], + ]); +} + +add_filter('admin_body_class', __NAMESPACE__ . '\acore_dark_mode_body_class'); + +function acore_dark_mode_body_class($classes) { + if (get_user_meta(get_current_user_id(), 'acore_dark_mode', true) === '1') { + $classes .= ' acore-dark-mode'; + } + return $classes; +} + +add_action('admin_enqueue_scripts', __NAMESPACE__ . '\acore_dark_mode_enqueue'); + +function acore_dark_mode_enqueue() { + wp_enqueue_style('acore-css', ACORE_URL_PLG . 'web/assets/css/main.css', [], '3.0'); + wp_enqueue_style('acore-dark-mode', ACORE_URL_PLG . 'web/assets/css/dark-mode.css', ['acore-css'], '3.7'); + // Central light/dark theme layer (edit colours here). Loaded last so it wins. + wp_enqueue_style('acore-theme', ACORE_URL_PLG . 'web/assets/css/theme.css', ['acore-dark-mode'], '3.7'); + + $nonce = wp_create_nonce('acore_dark_mode'); + wp_add_inline_script('jquery-core', acore_dark_mode_js($nonce)); +} + +/* + * Late inline + + + $new === '1']); +} + +function acore_dark_mode_js(string $nonce): string { + return << a', function(e){ + e.preventDefault(); + $.post(ajaxurl, { action: 'acore_toggle_dark_mode', nonce: nonce }, function(res){ + if (!res.success) return; + var dark = res.data.dark; + $('body').toggleClass('acore-dark-mode', dark); + $('#acore-dm-icon').html(dark ? '☀' : '☾'); + $('#wp-admin-bar-acore-dark-mode > a').attr('title', dark ? 'Switch to light mode' : 'Switch to dark mode'); + }); + }); +})(jQuery); +JS; +} + +/* + * The WP 2FA setup wizard (profile.php?page=wp-2fa-setup) renders a sealed + * full-page template that only prints its own stylesheets. It exposes + * `wp_2fa_setup_page_scripts` inside its ; we use it to inject theme.css + * (whose body.wp2fa-setup rules are dark) only when the user has dark mode on. + */ +add_action('wp_2fa_setup_page_scripts', __NAMESPACE__ . '\acore_dark_mode_wizard_css'); + +function acore_dark_mode_wizard_css() { + if (get_user_meta(get_current_user_id(), 'acore_dark_mode', true) !== '1') { + return; + } + echo '' . "\n"; +} diff --git a/src/acore-wp-plugin/src/Utils/AcoreCharColors.php b/src/acore-wp-plugin/src/Utils/AcoreCharColors.php new file mode 100644 index 000000000..13620f841 --- /dev/null +++ b/src/acore-wp-plugin/src/Utils/AcoreCharColors.php @@ -0,0 +1,109 @@ + 'Warrior', + 2 => 'Paladin', + 3 => 'Hunter', + 4 => 'Rogue', + 5 => 'Priest', + 6 => 'Death Knight', + 7 => 'Shaman', + 8 => 'Mage', + 9 => 'Warlock', + 11 => 'Druid', + ]; + + const CLASS_COLORS = [ + 1 => ['light' => '#C69B6D', 'dark' => '#C69B6D'], // Warrior + 2 => ['light' => '#F48CBA', 'dark' => '#F48CBA'], // Paladin + 3 => ['light' => '#AAD372', 'dark' => '#AAD372'], // Hunter + 4 => ['light' => '#C8A800', 'dark' => '#FFF468'], // Rogue (yellow → darkened for light bg) + 5 => ['light' => '#909090', 'dark' => '#E0E0E0'], // Priest (white → grey for light bg) + 6 => ['light' => '#C41E3A', 'dark' => '#FF3355'], // Death Knight + 7 => ['light' => '#0070DD', 'dark' => '#3399FF'], // Shaman + 8 => ['light' => '#3FC7EB', 'dark' => '#3FC7EB'], // Mage + 9 => ['light' => '#8788EE', 'dark' => '#9A9BFF'], // Warlock + 11 => ['light' => '#FF7C0A', 'dark' => '#FF7C0A'], // Druid + ]; + + const FALLBACK_LIGHT = '#646970'; + const FALLBACK_DARK = '#8b949e'; + + const RACE_NAMES = [ + 1 => 'Human', + 2 => 'Orc', + 3 => 'Dwarf', + 4 => 'Night Elf', + 5 => 'Undead', + 6 => 'Tauren', + 7 => 'Gnome', + 8 => 'Troll', + 10 => 'Blood Elf', + 11 => 'Draenei', + ]; + + /** Maps race ID → faction. */ + const RACE_FACTION = [ + 1 => 'alliance', // Human + 2 => 'horde', // Orc + 3 => 'alliance', // Dwarf + 4 => 'alliance', // Night Elf + 5 => 'horde', // Undead + 6 => 'horde', // Tauren + 7 => 'alliance', // Gnome + 8 => 'horde', // Troll + 10 => 'horde', // Blood Elf + 11 => 'alliance', // Draenei + ]; + + public static function getClassName(int $classId): string { + return self::CLASS_NAMES[$classId] ?? 'Unknown'; + } + + public static function getRaceName(int $raceId): string { + return self::RACE_NAMES[$raceId] ?? 'Unknown'; + } + + public static function getFaction(int $raceId): string { + return self::RACE_FACTION[$raceId] ?? 'unknown'; + } + + /** + * Returns inline style string for a character row. + * Uses CSS custom properties so dark-mode.css can override via color-mix. + */ + public static function rowStyle(int $classId, int $raceId): string { + $cls = self::CLASS_COLORS[$classId] ?? ['light' => self::FALLBACK_LIGHT, 'dark' => self::FALLBACK_DARK]; + $faction = self::RACE_FACTION[$raceId] ?? 'unknown'; + $fLight = $faction === 'alliance' ? '#3FACF4' : '#FF653D'; + $fDark = $faction === 'alliance' ? '#3FACF4' : '#FF653D'; + return sprintf( + '--cls-light:%s; --cls-dark:%s; --faction-color:%s; border-top:2px solid %s; border-right:2px solid %s; border-bottom:2px solid %s; border-left:4px solid %s;', + $cls['light'], $cls['dark'], $fLight, + $fLight, $fLight, $fLight, $cls['light'] + ); + } + + /** Returns the expansion slug for a level, used as data-exp attribute. */ + public static function expansionSlug(int $level): string { + if ($level <= 60) return 'vanilla'; + if ($level <= 70) return 'tbc'; + return 'wrath'; + } + + /** Returns the expansion label for a level, used as title attribute. */ + public static function expansionLabel(int $level): string { + if ($level <= 60) return 'Classic'; + if ($level <= 70) return 'The Burning Crusade'; + return 'Wrath of the Lich King'; + } +} diff --git a/src/acore-wp-plugin/src/boot.php b/src/acore-wp-plugin/src/boot.php index 7a280f60a..a7b832cf3 100644 --- a/src/acore-wp-plugin/src/boot.php +++ b/src/acore-wp-plugin/src/boot.php @@ -3,6 +3,7 @@ define('FS_METHOD', 'direct'); require_once ACORE_PATH_PLG . 'src/Utils/AcoreUtils.php'; +require_once ACORE_PATH_PLG . 'src/Utils/AcoreCharColors.php'; require_once ACORE_PATH_PLG . 'src/Deps/class-tgm-plugin-activation.php'; @@ -18,6 +19,7 @@ require_once ACORE_PATH_PLG . 'src/Hooks/Subscriptions/sync_subscription.php'; require_once ACORE_PATH_PLG . 'src/Hooks/Various/tgmplugin_activator.php'; +require_once ACORE_PATH_PLG . 'src/Hooks/Various/DarkMode.php'; require_once ACORE_PATH_PLG . 'src/Hooks/User/Include.php'; diff --git a/src/acore-wp-plugin/web/assets/css/dark-mode.css b/src/acore-wp-plugin/web/assets/css/dark-mode.css new file mode 100644 index 000000000..ebc10dfe0 --- /dev/null +++ b/src/acore-wp-plugin/web/assets/css/dark-mode.css @@ -0,0 +1,706 @@ +/* +Acore dark mode overrides +*/ + +/* ============================================================ + Profile page & sub-tabs — dark mode appearance overrides only + (layout/spacing lives in main.css and loads for all modes) + ============================================================ */ + +body.acore-dark-mode.profile-php #wpbody-content h2, +body.acore-dark-mode.profile-php #wpbody-content h3 { + border-bottom-color: #30363d; +} + +/* dark mode tweaks for security page */ +body.acore-dark-mode #acore-security-page .postbox { + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); +} + +/* Fix hardcoded dark thead in item restoration — let dark mode handle it */ +body.acore-dark-mode #acore-item-restoration-page .table thead { + background: #1d2327; + color: #fff; +} + +/* --- Item Restoration dark mode --- */ +body.acore-dark-mode #acore-item-restoration-page .table { + color: #c9d1d9; + border-color: #30363d; + --bs-table-bg: transparent; + --bs-table-striped-bg: transparent; + --bs-table-border-color: #30363d; +} + +body.acore-dark-mode #acore-item-restoration-page .table > :not(caption) > * > * { + background-color: #161b22 !important; + border-bottom-color: #30363d !important; + color: #c9d1d9 !important; + --bs-table-bg: #161b22; + --bs-table-color: #c9d1d9; +} + +body.acore-dark-mode #acore-item-restoration-page .table thead tr th { + background: #21262d !important; + color: #e6edf3 !important; + border-color: #30363d !important; +} + +body.acore-dark-mode #acore-item-restoration-page .table tbody td, +body.acore-dark-mode #acore-item-restoration-page .table tbody th { + background: #161b22 !important; + color: #c9d1d9 !important; + border-color: #30363d !important; +} + +/* alert-info (no items / info messages) */ +body.acore-dark-mode #acore-item-restoration-page .alert-info { + background-color: #1a2f1a !important; + border-color: #2d6a2d !important; + color: #56d364 !important; +} + +/* active character row */ +body.acore-dark-mode #acore-characters-item-restore .acore-char-row.active { + background: #21262d !important; + border-top-color: var(--faction-color, #58a6ff) !important; + border-right-color: var(--faction-color, #58a6ff) !important; + border-bottom-color: var(--faction-color, #58a6ff) !important; + border-top-width: 3px !important; + border-right-width: 3px !important; + border-bottom-width: 3px !important; +} + +/* ============================================================ */ + +/* --- expansion selector (profile page) --- */ +.acore-expansion-wrapper { + position: relative; + padding-top: 14px; + margin-bottom: 8px; +} + +.acore-expansion-arrow { + position: absolute; + top: 0; + left: 0; + width: 0; + height: 0; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-top: 10px solid #646970; + transform: translateX(-50%); + transition: left 0.15s ease; +} + +.acore-expansion-selector { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.acore-expansion-option { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 14px 7px 10px; + background: #fff; + border: 1px solid #dcdcde; + border-left: 4px solid var(--exp-color, #646970); + border-radius: 2px; + cursor: pointer; +} + +.acore-expansion-option input[type="radio"] { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + border: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +.acore-expansion-option .acore-expansion-label { + font-weight: 600; + font-size: 13px; + color: var(--exp-color, #646970); +} + +.acore-expansion-option.is-selected { + box-shadow: 0 0 0 2px var(--exp-color, #646970); +} + +.acore-expansion-option:hover { + background: #f6f7f7; +} + +.acore-expansion-warning { + color: #8a6d3b; + background: #fcf8e3; + border-left: 4px solid #f0ad4e; + border-radius: 2px; + padding: 8px 12px; + margin: 0; + font-size: 12px; + max-width: 520px; +} + +/* dark mode */ +body.acore-dark-mode .acore-expansion-option { + background: #1c2128 !important; + border-top-color: #30363d !important; + border-right-color: #30363d !important; + border-bottom-color: #30363d !important; + border-left-color: var(--exp-color, #8b949e) !important; +} + +body.acore-dark-mode .acore-expansion-option:hover { + background: #262d36 !important; +} + +body.acore-dark-mode .acore-expansion-option.is-selected { + box-shadow: 0 0 0 2px var(--exp-color, #8b949e) !important; +} + +body.acore-dark-mode .acore-expansion-warning { + background: #2d2208 !important; + border-left-color: #f0ad4e !important; + color: #d4a843 !important; +} + +/* --- admin bar toggle icon --- */ +#wp-admin-bar-acore-dark-mode > .ab-item { + padding: 0 8px !important; + cursor: pointer; + display: flex !important; + align-items: center; + height: 32px; +} + +#wp-admin-bar-acore-dark-mode > .ab-item #acore-dm-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + font-size: 18px; + line-height: 1; + border: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 5px; + padding: 2px; +} + +/* --- Bootstrap 5 CSS variable overrides (must come before class rules) --- */ +body.acore-dark-mode { + --bs-body-bg: #0d1117; + --bs-body-color: #c9d1d9; + --bs-card-bg: #161b22; + --bs-card-border-color: #30363d; + --bs-card-cap-bg: #0d1117; + --bs-border-color: #30363d; + --bs-secondary-color: #8b949e; + --bs-link-color: #58a6ff; + --bs-link-hover-color: #79c0ff; + --bs-list-group-bg: #161b22; + --bs-list-group-border-color: #30363d; + --bs-list-group-color: #c9d1d9; +} + +/* --- base --- */ +body.acore-dark-mode, +body.acore-dark-mode #wpwrap { + background: #0d1117; + color: #c9d1d9; +} + +body.acore-dark-mode #wpcontent, +body.acore-dark-mode #wpfooter { + background: #0d1117; +} + +body.acore-dark-mode #wpbody-content { + background: #0d1117; +} + +/* --- headings & text --- */ +body.acore-dark-mode h1, +body.acore-dark-mode h2, +body.acore-dark-mode h3, +body.acore-dark-mode h4, +body.acore-dark-mode h5, +body.acore-dark-mode p, +body.acore-dark-mode label, +body.acore-dark-mode span, +body.acore-dark-mode li { + color: #c9d1d9; +} + +body.acore-dark-mode .description, +body.acore-dark-mode .text-muted { + color: #8b949e !important; +} + +body.acore-dark-mode hr { + border-color: #30363d; +} + +/* --- postboxes (WP core) --- */ +body.acore-dark-mode .postbox, +body.acore-dark-mode #poststuff .postbox { + background: #161b22; + border-color: #30363d; +} + +body.acore-dark-mode .postbox-header { + background: #161b22; + border-bottom-color: #30363d; +} + +body.acore-dark-mode .postbox-header h2, +body.acore-dark-mode .postbox-header .hndle { + color: #c9d1d9; +} + +body.acore-dark-mode .postbox .inside { + color: #c9d1d9; +} + +/* --- Bootstrap card --- */ +body.acore-dark-mode .card { + background-color: #161b22 !important; + border-color: #30363d !important; + color: #c9d1d9 !important; +} + +body.acore-dark-mode .card-body { + background-color: #161b22 !important; + color: #c9d1d9 !important; +} + +body.acore-dark-mode .card-header { + background-color: #0d1117 !important; + border-bottom-color: #30363d !important; + color: #c9d1d9 !important; +} + +body.acore-dark-mode .card-title { + color: #c9d1d9 !important; +} + +/* --- Character list rows --- */ +body.acore-dark-mode .acore-char-row { + background: #1c2128 !important; + /* faction color for top/right/bottom, dimmed for dark mode */ + border-top-color: color-mix(in srgb, var(--faction-color, #30363d) 55%, #0d1117) !important; + border-right-color: color-mix(in srgb, var(--faction-color, #30363d) 55%, #0d1117) !important; + border-bottom-color: color-mix(in srgb, var(--faction-color, #30363d) 55%, #0d1117) !important; + border-left-color: var(--cls-dark, #8b949e) !important; +} + +body.acore-dark-mode .acore-char-pos { + color: #e6edf3 !important; + background: rgba(255, 255, 255, 0.12) !important; +} + +body.acore-dark-mode .acore-char-name { + color: #c9d1d9 !important; +} + +body.acore-dark-mode .acore-char-meta { + color: #8b949e !important; +} + +body.acore-dark-mode .acore-char-row:hover { + background: #262d36 !important; +} + +body.acore-dark-mode #acore-characters-mail .acore-char-row.active { + background: #21262d !important; + border-top-color: var(--faction-color, #58a6ff) !important; + border-right-color: var(--faction-color, #58a6ff) !important; + border-bottom-color: var(--faction-color, #58a6ff) !important; + border-top-width: 3px !important; + border-right-width: 3px !important; + border-bottom-width: 3px !important; +} + +/* --- Bootstrap form-select --- */ +body.acore-dark-mode .form-select { + background-color: #0d1117 !important; + border-color: #30363d !important; + color: #c9d1d9 !important; +} + +body.acore-dark-mode .form-select:focus { + border-color: #58a6ff !important; + box-shadow: 0 0 0 0.2rem rgba(88, 166, 255, 0.25) !important; +} + +/* --- form elements --- */ +body.acore-dark-mode input[type="text"], +body.acore-dark-mode input[type="password"], +body.acore-dark-mode input[type="email"], +body.acore-dark-mode input[type="number"], +body.acore-dark-mode input[type="search"], +body.acore-dark-mode input[type="url"], +body.acore-dark-mode select, +body.acore-dark-mode textarea { + background-color: #0d1117; + border-color: #30363d; + color: #c9d1d9; + box-shadow: none; +} + +body.acore-dark-mode input[type="text"]:focus, +body.acore-dark-mode input[type="password"]:focus, +body.acore-dark-mode select:focus, +body.acore-dark-mode textarea:focus { + border-color: #58a6ff; + box-shadow: 0 0 0 1px #58a6ff; + outline: none; +} + +body.acore-dark-mode .form-table th { + color: #8b949e; +} + +body.acore-dark-mode .form-table td { + color: #c9d1d9; +} + +/* --- mail entries — border color comes from JS inline (faction/class), only override bg --- */ +body.acore-dark-mode .mail-entry { + background: #161b22 !important; + color: #c9d1d9 !important; +} + +body.acore-dark-mode .mail-recipient, +body.acore-dark-mode .mail-items { + background: #0d1117 !important; + color: #c9d1d9 !important; +} + +body.acore-dark-mode .mail-header, +body.acore-dark-mode .mail-meta, +body.acore-dark-mode .mail-subject, +body.acore-dark-mode .mail-money, +body.acore-dark-mode .recipient-name { + color: #c9d1d9 !important; +} + +/* --- tables --- */ +body.acore-dark-mode .widefat, +body.acore-dark-mode .wp-list-table { + background: #161b22; + border-color: #30363d; + color: #c9d1d9; +} + +body.acore-dark-mode .widefat thead th, +body.acore-dark-mode .widefat tfoot th, +body.acore-dark-mode .wp-list-table thead th { + background: #0d1117; + color: #8b949e; + border-bottom-color: #30363d; +} + +body.acore-dark-mode .widefat td, +body.acore-dark-mode .widefat th, +body.acore-dark-mode .wp-list-table td { + color: #c9d1d9; + border-bottom-color: #21262d; +} + +body.acore-dark-mode .striped > tbody > :nth-child(odd) > td, +body.acore-dark-mode .striped > tbody > :nth-child(odd) > th, +body.acore-dark-mode .alternate { + background: #1c2128; +} + +/* --- buttons --- */ +body.acore-dark-mode .button, +body.acore-dark-mode .button-secondary { + background: #21262d; + border-color: #30363d; + color: #c9d1d9; + text-shadow: none; + box-shadow: none; +} + +body.acore-dark-mode .button:hover, +body.acore-dark-mode .button-secondary:hover { + background: #30363d; + border-color: #8b949e; + color: #f0f6fc; +} + +body.acore-dark-mode .button-primary { + background: #1f6feb; + border-color: #1f6feb; + color: #fff; + text-shadow: none; + box-shadow: none; +} + +body.acore-dark-mode .button-primary:hover { + background: #388bfd; + border-color: #388bfd; +} + +body.acore-dark-mode .button-primary:disabled, +body.acore-dark-mode .button-primary[disabled] { + background: #1f6feb; + opacity: 0.5; +} + +/* --- Bootstrap buttons --- */ +body.acore-dark-mode .btn-primary { + background-color: #1f6feb !important; + border-color: #1f6feb !important; +} + +body.acore-dark-mode .btn-primary:hover { + background-color: #388bfd !important; + border-color: #388bfd !important; +} + +body.acore-dark-mode .btn-outline-secondary, +body.acore-dark-mode .btn-secondary { + background-color: #21262d !important; + border-color: #30363d !important; + color: #c9d1d9 !important; +} + +/* --- notices --- */ +body.acore-dark-mode .notice, +body.acore-dark-mode div.updated, +body.acore-dark-mode div.error { + background: #161b22; + border-color: #30363d; + color: #c9d1d9; +} + +body.acore-dark-mode .notice-success, +body.acore-dark-mode .updated { + border-left-color: #3fb950; +} + +body.acore-dark-mode .notice-error, +body.acore-dark-mode div.error { + border-left-color: #f85149; +} + +body.acore-dark-mode .notice-warning { + border-left-color: #d29922; +} + +body.acore-dark-mode .notice p, +body.acore-dark-mode div.updated p, +body.acore-dark-mode div.error p { + color: #c9d1d9; +} + +/* --- links --- */ +body.acore-dark-mode a { + color: #58a6ff; +} + +body.acore-dark-mode a:hover { + color: #79c0ff; +} + +/* --- footer --- */ +body.acore-dark-mode #wpfooter { + border-top-color: #30363d; + color: #8b949e; +} + +body.acore-dark-mode #wpfooter a { + color: #58a6ff; +} + +/* --- myCred widgets --- */ +body.acore-dark-mode [id^="mycred"], +body.acore-dark-mode [class^="mycred"], +body.acore-dark-mode .mycred-wrap, +body.acore-dark-mode .mycred-widget { + background: #161b22 !important; + border-color: #30363d !important; + color: #c9d1d9 !important; +} + +body.acore-dark-mode .mycred-history, +body.acore-dark-mode .mycred-history *, +body.acore-dark-mode #mycred-log-wrap, +body.acore-dark-mode #mycred-log-wrap * { + background: #161b22 !important; + color: #c9d1d9 !important; + border-color: #30363d !important; +} + +/* WP profile page balance box */ +body.acore-dark-mode #mycred-user-balance, +body.acore-dark-mode .user-profile-mycred, +body.acore-dark-mode td.mycred-field, +body.acore-dark-mode th.mycred-field { + color: #c9d1d9 !important; +} + +/* --- Scroll of Resurrection page --- */ +body.acore-dark-mode .acore-rscroll .card { + background: #161b22; + border-color: #30363d; + color: #c9d1d9; +} + +body.acore-dark-mode .acore-rscroll .card-body { + color: #c9d1d9; +} + +body.acore-dark-mode .acore-rscroll h5 { + color: #e6edf3; +} + +body.acore-dark-mode .acore-rscroll hr { + border-color: #30363d; + opacity: 1; +} + +body.acore-dark-mode .acore-rscroll .table { + color: #c9d1d9; + border-color: #30363d; + --bs-table-bg: transparent; + --bs-table-striped-bg: transparent; + --bs-table-border-color: #30363d; +} + +body.acore-dark-mode .acore-rscroll .table > :not(caption) > * > * { + background-color: transparent; + border-bottom-color: #30363d; + color: #ffffff; +} + +/* Row label column (th inside tbody) */ +body.acore-dark-mode .acore-rscroll .table tbody th { + background: #21262d !important; + color: #e6edf3 !important; + font-weight: 600; + border-color: #30363d !important; +} + +body.acore-dark-mode .acore-rscroll .table tbody td { + background: #161b22 !important; + color: #ffffff !important; + border-color: #30363d !important; +} + +/* thead row (Player Commands table) */ +body.acore-dark-mode .acore-rscroll .table thead tr th, +body.acore-dark-mode .acore-rscroll .table-light thead tr th { + background: #21262d !important; + color: #e6edf3 !important; + border-color: #30363d !important; +} + +body.acore-dark-mode .acore-rscroll code { + background: #0d1117; + color: #f85149; + border: 1px solid #30363d; + padding: 1px 5px; + border-radius: 3px; +} + +body.acore-dark-mode .acore-rscroll .text-muted { + color: #8b949e !important; +} + +body.acore-dark-mode .acore-rscroll strong { + color: #818cf8; +} + +body.acore-dark-mode .acore-rscroll ol, +body.acore-dark-mode .acore-rscroll p, +body.acore-dark-mode .acore-rscroll li { + color: #ffffff; +} + +body.acore-dark-mode .acore-rscroll .text-muted { + color: #b0b8c4 !important; +} + +/* Force white text on all badges */ +body.acore-dark-mode .acore-rscroll .badge { + color: #ffffff !important; +} + +/* Warning badge: yellow is unreadable with white text — shift to solid orange */ +body.acore-dark-mode .acore-rscroll .badge.bg-warning { + background-color: #c27400 !important; +} + +/* --- Expansion level badge — dark mode --- */ +body.acore-dark-mode .acore-level { color: #8b949e; } +body.acore-dark-mode .acore-level[data-exp="vanilla"] { background: rgba(195, 147, 97, 0.18); border-color: #C39361; color: #c9d1d9; } +body.acore-dark-mode .acore-level[data-exp="tbc"] { background: rgba(98, 201, 7, 0.18); border-color: #62C907; color: #c9d1d9; } +body.acore-dark-mode .acore-level[data-exp="wrath"] { background: rgba(93, 172, 235, 0.18); border-color: #5DACEB; color: #c9d1d9; } + +/* --- Mail Return dark mode --- */ +body.acore-dark-mode #mail-return-heading { + color: #e6edf3 !important; +} + +body.acore-dark-mode #mail-return-items .mail-entry { + background: #161b22; + border-color: #30363d; +} + +body.acore-dark-mode #mail-return-items .mail-header { + background: #0d1117; + border-bottom-color: #30363d; +} + +body.acore-dark-mode #mail-return-items .mail-recipient, +body.acore-dark-mode #mail-return-items .mail-subject, +body.acore-dark-mode #mail-return-items .mail-meta { + color: #c9d1d9; +} + +body.acore-dark-mode #mail-return-items .mail-to-label, +body.acore-dark-mode #mail-return-items .mail-subject-label { + color: #8b949e; +} + +body.acore-dark-mode #mail-return-items .mail-items-grid { + background: #0d1117; + border-top-color: #30363d; +} + +body.acore-dark-mode #mail-return-items .mail-item-slot { + background: #161b22; +} + +body.acore-dark-mode #mail-return-items .mail-item-slot a { + color: #c9d1d9; +} + +body.acore-dark-mode #mail-return-items .mail-cod-label { + color: #8b949e; +} + +body.acore-dark-mode .mail-return-button { + background: #21262d !important; + border-color: #30363d !important; + color: #c9d1d9 !important; +} + +body.acore-dark-mode .mail-return-button:hover { + background: #30363d !important; + color: #e6edf3 !important; +} + +body.acore-dark-mode #mail-return-empty { + color: #8b949e; +} diff --git a/src/acore-wp-plugin/web/assets/css/main.css b/src/acore-wp-plugin/web/assets/css/main.css index f36f2ab4e..f12f03ed6 100644 --- a/src/acore-wp-plugin/web/assets/css/main.css +++ b/src/acore-wp-plugin/web/assets/css/main.css @@ -17,62 +17,187 @@ body { background-color: var(--bs-teal) !important; } -/* start char-order */ -#acore-characters-order .menu-item-handle { +/* start acore-char-list — shared character row layout */ +.acore-char-list { + list-style: none; + padding: 0; + margin: 0; } -#acore-characters-order .item-type { - padding: 0px; - margin-top: 5px; +.acore-char-list li { + margin-bottom: 4px; } -#acore-characters-order .item-type img { - display: inline-block; - vertical-align: middle; - height: 32px; - transform: translateZ(0); /* prevent blurry image on chrome */ +.acore-char-list input[type="hidden"], +.acore-char-list input[name="characterorder[]"] { + display: none; } -#acore-characters-order input { - display: none; +button.acore-char-row { + font: inherit; + color: inherit; + text-align: left; + cursor: pointer; + -webkit-appearance: none; + appearance: none; } -/* end char order */ +/* Clicking a card/button shouldn't select its text (name, number, label). */ +button.acore-char-row, +button.acore-char-row *, +.item-restore-card, +.item-restore-card * { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} -/* start char-unstuck */ -#acore-characters-unstuck .menu-item-handle { - cursor: default; +/* Tools setting labels: informational only (no control activation), help cursor. */ +.acore-help-label { + cursor: help; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; } -.unstuck-button { - background-size: cover; - border: none; - color: transparent; - cursor: pointer; +.acore-char-row { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + min-height: 46px; + padding: 6px 12px; + box-sizing: border-box; + background: #fff; + /* top/right/bottom use faction color; left uses class color */ + border-top: 2px solid var(--faction-color, #dcdcde); + border-right: 2px solid var(--faction-color, #dcdcde); + border-bottom: 2px solid var(--faction-color, #dcdcde); + border-left: 4px solid var(--cls-light, #646970); + border-radius: 2px; } -.unstuck-button:disabled { - opacity: 0.5; /* Adjust the opacity for disabled state */ - cursor: not-allowed; +.acore-char-pos { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 22px; + font-size: 11px; + font-weight: 700; + color: #3c434a; + background: rgba(0, 0, 0, 0.10); + border-radius: 4px; + margin-right: 8px; + flex-shrink: 0; } -#acore-characters-unstuck .item-type { - padding: 0px; - margin-top: 5px; +.acore-char-name { + flex: 1; + font-weight: 500; } -#acore-characters-unstuck .item-type img { +.acore-char-meta { + display: flex; + align-items: center; + gap: 6px; + color: #646970; +} + +.acore-level { + display: inline-block; + text-transform: uppercase; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + min-width: 52px; + text-align: center; + /* badge shape */ + padding: 2px 6px; + border-radius: 3px; + border: 1.5px solid currentcolor; + color: #50575e; +} + +/* Expansion badge: filled background + matching border */ +.acore-level[data-exp="vanilla"] { + background: rgba(195, 147, 97, 0.12); + border-color: #C39361; + color: #50575e; +} +.acore-level[data-exp="tbc"] { + background: rgba(98, 201, 7, 0.12); + border-color: #62C907; + color: #50575e; +} +.acore-level[data-exp="wrath"] { + background: rgba(93, 172, 235, 0.12); + border-color: #5DACEB; + color: #50575e; +} + +.acore-char-meta img { display: inline-block; vertical-align: middle; height: 32px; + width: 32px; transform: translateZ(0); /* prevent blurry image on chrome */ + border: 3px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; } -#acore-characters-unstuck input { - display: none; +/* Race icon: faction color border */ +.acore-char-meta img.race-icon { + border-color: var(--faction-color, rgba(0, 0, 0, 0.12)); +} + +/* Class icon: class color border */ +.acore-char-meta img.class-icon { + border-color: var(--cls-light, rgba(0, 0, 0, 0.12)); +} + +/* sortable drag cursor on order list */ +#acore-characters-order .acore-char-row { + cursor: grab; +} + +#acore-characters-order .acore-char-row:active { + cursor: grabbing; } -/* end char unstuck */ +/* click cursor on mail return */ +#acore-characters-mail .acore-char-row { + cursor: pointer; +} + +#acore-characters-mail .acore-char-row.active { + background: #f0f4f8; + /* thicken faction borders to signal selection; border-left (class color) unchanged */ + border-top-width: 3px; + border-right-width: 3px; + border-bottom-width: 3px; +} +/* end acore-char-list */ + +/* start unstuck button */ +.unstuck-button { + border: none; + color: transparent; + cursor: pointer; + background-color: transparent; + background-repeat: no-repeat; + background-position: center; + background-size: cover; + padding: 0; +} + +.unstuck-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} +/* end unstuck button */ /* start raf progress view */ @@ -206,66 +331,198 @@ body { /* end raf progress view */ +/* start mail-return layout */ +#acore-mail-return-page { + max-width: 100%; +} + +#mail-return-layout { + display: grid; + grid-template-columns: 460px 1fr 1fr; + gap: 16px; + align-items: start; +} + +/* col 1 — always the same width */ +#mail-return-sidebar { + grid-column: 1; +} + +/* cols 2–3 — hidden until mails exist */ +#mail-return-content { + display: none; + grid-column: 2 / 4; +} + +/* "Unread Sent Mails" heading — inside card-header */ +#mail-return-heading { + font-size: 14px; + font-weight: 600; +} + +/* mail entries fill cols 2–3 as a 2-column inner grid */ +#mail-return-items { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +@media (max-width: 800px) { + #mail-return-layout { + grid-template-columns: 1fr; + } + #mail-return-content { + grid-column: 1; + } + #mail-return-items { + grid-template-columns: 1fr; + } +} +/* end mail-return layout */ + /* start mail-return */ #mail-return-items .mail-entry { background: #fff; - border: 1px solid #ccd0d4; + /* JS overrides colors per-mail; thickness mirrors character selector rows */ + border-top: 2px solid #ccd0d4; + border-right: 2px solid #ccd0d4; + border-bottom: 2px solid #ccd0d4; + border-left: 4px solid #646970; border-radius: 4px; padding: 12px; - margin-bottom: 10px; } +/* Row 1: recipient left, return button right — class-colored border-left */ #mail-return-items .mail-header { display: flex; align-items: center; justify-content: space-between; -} - -#mail-return-items .mail-subject { - font-weight: 600; - font-size: 14px; + gap: 8px; + margin-bottom: 8px; + /* avoid border shorthand so JS inline border-left-color wins */ + border-top: 1px solid #dcdcde; + border-right: 1px solid #dcdcde; + border-bottom: 1px solid #dcdcde; + border-left: 4px solid #646970; /* JS overrides color per card */ + border-radius: 2px; + padding: 7px 10px; + background: #f9f9f9; } #mail-return-items .mail-recipient { display: flex; align-items: center; gap: 6px; - margin-top: 8px; - padding: 6px 10px; - background: #f6f7f7; - border-radius: 4px; + flex-wrap: wrap; + background: none; + padding: 0; + margin: 0; } -#mail-return-items .mail-recipient img { +#mail-return-items .mail-recipient-icon { display: inline-block; - vertical-align: middle; - height: 24px; - width: 24px; + height: 28px; + width: 28px; transform: translateZ(0); + border: 3px solid rgba(0, 0, 0, 0.18); + border-radius: 4px; } -#mail-return-items .mail-recipient .recipient-name { - font-weight: 500; +#mail-return-items .mail-to-label { + font-size: 12px; + color: #646970; + text-transform: uppercase; + font-weight: 600; + letter-spacing: 0.04em; +} + +#mail-return-items .recipient-name { + font-weight: 600; + font-size: 14px; } -#mail-return-items .mail-meta { +#mail-return-items .recipient-level { + font-size: 11px; color: #646970; - font-size: 12px; - margin-top: 6px; + text-transform: uppercase; + font-weight: 600; + letter-spacing: 0.04em; } -#mail-return-items .mail-items { - margin-top: 8px; - padding: 6px 10px; - background: #f6f7f7; - border-radius: 4px; +/* Row 2: subject */ +#mail-return-items .mail-subject { font-size: 13px; + color: #50575e; + margin-bottom: 8px; +} +#mail-return-items .mail-subject-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #8b949e; + margin-right: 3px; +} + +.mail-cod-label { + font-size: 12px; + color: #a00; +} + +/* Row 3: item icon grid — centered in card */ +.mail-items-grid { + display: grid; + grid-template-columns: repeat(6, 56px); + gap: 6px; + justify-content: center; } -#mail-return-items .mail-money { - margin-top: 6px; +@media (max-width: 480px) { + .mail-items-grid { + grid-template-columns: repeat(4, 56px); + } +} + +.mail-item-slot { + position: relative; + width: 56px; + height: 56px; + background: #1c1c1c; + border: 3px solid rgba(255, 255, 255, 0.22); + border-radius: 3px; + overflow: hidden; +} + +.mail-item-slot > a { + position: absolute; + inset: 0; + display: block; + font-size: 0; + line-height: 0; + color: transparent !important; + overflow: hidden; + /* WowHead sets background-image inline on the — override size/position */ + background-size: cover !important; + background-position: center !important; + background-repeat: no-repeat !important; +} + +/* Quantity — bottom strip with semi-transparent overlay */ +.mail-item-qty { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: rgba(0, 0, 0, 0.55); + color: #fff; font-size: 13px; - color: #646970; + font-weight: 700; + text-align: center; + padding: 2px 0 1px; + line-height: 1.3; + letter-spacing: 0.02em; + pointer-events: none; + z-index: 2; } #mail-return-items .mail-return-button { @@ -277,6 +534,7 @@ body { cursor: pointer; white-space: nowrap; font-size: 13px; + flex-shrink: 0; } #mail-return-items .mail-return-button:hover { @@ -289,3 +547,233 @@ body { cursor: not-allowed; } /* end mail-return */ + +/* ============================================================ + Profile page & sub-tabs layout (applies to both light + dark) + ============================================================ */ + +body.profile-php #wpbody-content > .wrap { + max-width: 900px; +} + +body.profile-php .form-table th { + width: 180px; + padding: 12px 10px 12px 0; + vertical-align: top; + font-size: 13px; +} + +body.profile-php .form-table td { + padding: 10px 0; +} + +/* --- Security sub-page --- */ +#acore-security-page { + max-width: 760px; +} + +#acore-security-page > h1 { + margin-bottom: 4px; + padding-bottom: 0; + border-bottom: none; +} + +#acore-security-page .postbox { + border-radius: 3px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.07); + margin-top: 16px; + margin-bottom: 0; +} + +#acore-security-page .postbox .inside { + padding: 16px 20px; +} + +/* Postbox header — padding, cursor, typography */ +#acore-security-page .postbox-header { + cursor: default; + border-bottom: 1px solid #dcdcde; +} + +#acore-security-page .postbox-header h2, +#acore-security-page .postbox-header .hndle { + cursor: default; + font-size: 13px; + font-weight: 600; + padding: 10px 16px; + margin: 0; + border: none; +} + +#acore-security-page .form-table th { + width: 180px; + padding: 10px 10px 10px 0; +} + +#acore-security-page .form-table td { + padding: 8px 0; +} + +/* --- RAF sub-page --- */ +#acore-raf-page { + max-width: 860px; +} + +#acore-raf-page > h1 { + margin-bottom: 6px; +} + +.acore-raf-intro { + color: #646970; + margin-bottom: 12px; +} + +.acore-raf-notice { + max-width: 700px; + margin-bottom: 16px !important; +} + +.acore-raf-row { + margin-top: 4px; +} + +#acore-raf-page .card { + margin-bottom: 16px; +} + +/* --- Item Restoration sub-page --- */ +#acore-item-restoration-page { + max-width: 100%; +} + +#acore-item-restoration-page > h1 { + margin-bottom: 16px; +} + +/* Layout: sidebar + content (mirrors mail-return) */ +#item-restore-layout { + display: grid; + grid-template-columns: 460px 1fr; + gap: 16px; + align-items: start; +} + +#item-restore-sidebar { + grid-column: 1; +} + +#item-restore-content { + grid-column: 2; +} + +@media (max-width: 800px) { + #item-restore-layout { + grid-template-columns: 1fr; + } + #item-restore-content { + grid-column: 1; + } +} + +/* click cursor + active state (mirrors mail return) */ +#acore-characters-item-restore .acore-char-row { + cursor: pointer; +} + +#acore-characters-item-restore .acore-char-row.active { + background: #f0f4f8; + border-top-width: 3px; + border-right-width: 3px; + border-bottom-width: 3px; +} + +/* Item restore: 4-column card grid */ +.item-restore-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; +} + +/* Card: white bg, quality-coloured border (set by JS after wowhead loads) */ +.item-restore-card { + position: relative; + background: #fff; + border: 3px solid #9d9d9d; + border-radius: 4px; + padding: 20px 12px 12px; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} + +/* Number badge — top-left corner */ +.item-restore-num { + position: absolute; + top: 6px; + left: 8px; + font-size: 11px; + font-weight: 600; + color: #646970; + line-height: 1; +} + +/* Icon container */ +.item-restore-icon { + position: relative; + width: 80px; + height: 80px; + background: #1c1c1c; + border-radius: 3px; + overflow: hidden; + flex-shrink: 0; +} + +/* WowHead puts icon as background-image on the . + font-size/color hide the item-name text wowhead injects. */ +.item-restore-icon > a { + position: absolute !important; + inset: 0 !important; + display: block !important; + width: 100% !important; + height: 100% !important; + font-size: 0 !important; + line-height: 0 !important; + color: transparent !important; + text-indent: -9999px !important; + overflow: hidden !important; + background-size: cover !important; + background-position: center !important; + background-repeat: no-repeat !important; + padding: 0 !important; +} + +/* Deleted date */ +.item-restore-date { + font-size: 11px; + color: #646970; + text-align: center; + line-height: 1.3; +} + +.item-restore-btn { + background: #2271b1; + color: #fff; + border: 1px solid #2271b1; + border-radius: 3px; + padding: 5px 0; + cursor: pointer; + font-size: 13px; + width: 100%; + text-align: center; +} + +.item-restore-btn:hover { + background: #135e96; + border-color: #135e96; +} + +.item-restore-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} diff --git a/src/acore-wp-plugin/web/assets/css/theme.css b/src/acore-wp-plugin/web/assets/css/theme.css new file mode 100644 index 000000000..0a1570d88 --- /dev/null +++ b/src/acore-wp-plugin/web/assets/css/theme.css @@ -0,0 +1,616 @@ +/* ===================================================================== + * acore-theme.css + * Single place to edit light/dark theming for the AzerothCore plugin. + * + * Colours are driven by CSS custom properties. Edit the values in the + * two blocks below (":root" = light, "body.acore-dark-mode" = dark) and + * every rule further down updates automatically. + * + * Loaded after main.css and dark-mode.css, so rules here win. + * ===================================================================== */ + +:root { + --acore-bg: #ffffff; + --acore-surface: #ffffff; + --acore-surface-2: #f3f4f5; + --acore-text: #1d2327; + --acore-muted: #646970; + --acore-border: #c3c4c7; + --acore-border-strong: #8c8f94; + --acore-accent: #2271b1; + --acore-danger: #d63638; + --acore-danger-bg: #fbeaea; + --acore-danger-text: #b32d2e; + + /* WoW item-quality colours (shared by both themes) */ + --q0: #9d9d9d; --q1: #ffffff; --q2: #1eff00; --q3: #0070dd; + --q4: #a335ee; --q5: #ff8000; --q6: #e6cc80; --q7: #e6cc80; +} + +body.acore-dark-mode { + --acore-bg: #0d1117; + --acore-surface: #161b22; + --acore-surface-2: #21262d; + --acore-text: #c9d1d9; + --acore-muted: #9aa4af; + --acore-border: #30363d; + --acore-border-strong: #484f58; + --acore-accent: #58a6ff; + --acore-danger: #d63638; + --acore-danger-bg: transparent; + --acore-danger-text: #ea5a5c; + + --q1: #c0c0c0; /* pure white reads poorly on dark; soften common quality */ +} + +/* ── Danger buttons (Reset Order, Remove, Reset, …) ─────────────────── */ +body.acore-dark-mode .acore-btn-danger .dashicons { + color: var(--acore-danger-text) !important; +} +.acore-btn-danger { + border: 1px solid var(--acore-danger) !important; + color: var(--acore-danger-text) !important; + background: var(--acore-danger-bg) !important; + transition: background .12s ease, color .12s ease; +} +.acore-btn-danger:hover, +.acore-btn-danger:focus { + background: var(--acore-danger) !important; + color: #ffffff !important; +} +.acore-btn-danger .dashicons { margin-top: 3px; } + +/* ── Item Restoration: cards ────────────────────────────────────────── */ +/* Outer card: neutral border + theme surface (works light & dark). */ +.item-restore-card { + background: var(--acore-surface) !important; + border: 1px solid var(--acore-border) !important; + color: var(--acore-text) !important; + border-radius: 6px; +} +.item-restore-card .item-restore-date { color: var(--acore-muted) !important; } +.item-restore-card .item-restore-num { color: var(--acore-muted) !important; } +/* Inner icon shows the item quality as its border. wowhead adds q0..q7 */ +/* to the icon link; we colour the icon frame from that. */ +.item-restore-icon a { + display: block; + border: 2px solid var(--acore-border-strong); + border-radius: 4px; + box-sizing: border-box; +} +.item-restore-icon a.q0 { border-color: var(--q0) !important; } +.item-restore-icon a.q1 { border-color: var(--q1) !important; } +.item-restore-icon a.q2 { border-color: var(--q2) !important; } +.item-restore-icon a.q3 { border-color: var(--q3) !important; } +.item-restore-icon a.q4 { border-color: var(--q4) !important; } +.item-restore-icon a.q5 { border-color: var(--q5) !important; } +.item-restore-icon a.q6 { border-color: var(--q6) !important; } +.item-restore-icon a.q7 { border-color: var(--q7) !important; } + +/* ── Security page: readability in dark mode ────────────────────────── */ +/* Inline muted greys (#646970) are too dark on a dark surface. */ +body.acore-dark-mode #acore-security-page [style*="646970"], +body.acore-dark-mode #acore-security-page [style*="#646970"], +body.acore-dark-mode #acore-security-page .description, +body.acore-dark-mode #acore-security-page .text-muted { + color: var(--acore-muted) !important; +} +body.acore-dark-mode #acore-security-page, +body.acore-dark-mode #acore-security-page p, +body.acore-dark-mode #acore-security-page li, +body.acore-dark-mode #acore-security-page h3 { color: var(--acore-text); } +body.acore-dark-mode #acore-security-page code { + background: var(--acore-surface-2) !important; + color: var(--acore-text) !important; + border: 1px solid var(--acore-border); + border-radius: 3px; + padding: 1px 5px; +} +/* Recent Connections table - stripe rows at the level so WP's light + .striped odd-row background doesn't show through transparent cells. */ +body.acore-dark-mode #acore-security-page .wp-list-table { + background: var(--acore-surface) !important; + border-color: var(--acore-border) !important; +} +body.acore-dark-mode #acore-security-page .wp-list-table > thead > tr, +body.acore-dark-mode #acore-security-page .wp-list-table > tbody > tr:nth-child(odd) { + background: var(--acore-surface-2) !important; +} +body.acore-dark-mode #acore-security-page .wp-list-table > tbody > tr:nth-child(even) { + background: var(--acore-surface) !important; +} +body.acore-dark-mode #acore-security-page .wp-list-table th, +body.acore-dark-mode #acore-security-page .wp-list-table td { + color: var(--acore-text) !important; + border-color: var(--acore-border) !important; + background: transparent !important; +} + +/* ── Mail Return surfaces ───────────────────────────────────────────── */ +body.acore-dark-mode #acore-mail-return-page .mail-entry { background: var(--acore-surface) !important; } +body.acore-dark-mode #acore-mail-return-page .mail-recipient, +body.acore-dark-mode #acore-mail-return-page .mail-items { background: var(--acore-bg) !important; } +body.acore-dark-mode #acore-mail-return-page .mail-entry *, +body.acore-dark-mode #acore-mail-return-page .mail-meta { color: var(--acore-text) !important; } +body.acore-dark-mode #acore-mail-return-page .text-muted { color: var(--acore-muted) !important; } + +/* ── WP 2FA plugin UI (embedded on the Security page) ───────────────── */ +body.acore-dark-mode #acore-security-page [class*="wp2fa"], +body.acore-dark-mode #acore-security-page [class*="wp-2fa"] { color: var(--acore-text) !important; } +body.acore-dark-mode #acore-security-page [class*="wp2fa"] input, +body.acore-dark-mode #acore-security-page [class*="wp2fa"] select, +body.acore-dark-mode #acore-security-page [class*="wp2fa"] textarea { + background: var(--acore-surface-2) !important; + color: var(--acore-text) !important; + border: 1px solid var(--acore-border) !important; +} +body.acore-dark-mode #acore-security-page [class*="wp2fa"] code, +body.acore-dark-mode #acore-security-page [class*="wp2fa"] pre { + background: var(--acore-surface-2) !important; + color: var(--acore-text) !important; +} + +/* ── myCRED points / history page (profile.php?page=mycred_default-history) ── */ +body.acore-dark-mode.mycred-log-page #wpbody-content { color: var(--acore-text); } + +/* Filter tabs (All / Today / This Week …) */ +body.acore-dark-mode.mycred-log-page .subsubsub, +body.acore-dark-mode.mycred-log-page .subsubsub a { color: var(--acore-muted) !important; } +body.acore-dark-mode.mycred-log-page .subsubsub a.current, +body.acore-dark-mode.mycred-log-page .subsubsub a:hover { color: var(--acore-accent) !important; } + +/* Search box + pagination chrome */ +body.acore-dark-mode.mycred-log-page .search-box input, +body.acore-dark-mode.mycred-log-page .tablenav input, +body.acore-dark-mode.mycred-log-page .tablenav select { + background: var(--acore-surface-2) !important; + color: var(--acore-text) !important; + border: 1px solid var(--acore-border) !important; +} +body.acore-dark-mode.mycred-log-page .tablenav, +body.acore-dark-mode.mycred-log-page .tablenav * { color: var(--acore-text) !important; } +body.acore-dark-mode.mycred-log-page .tablenav .tablenav-pages a, +body.acore-dark-mode.mycred-log-page .tablenav .tablenav-pages span.tablenav-pages-navspan { + background: var(--acore-surface-2) !important; + border-color: var(--acore-border) !important; + color: var(--acore-text) !important; +} + +/* The points-history list table */ +body.acore-dark-mode.mycred-log-page .wp-list-table { + background: var(--acore-surface) !important; + border-color: var(--acore-border) !important; + color: var(--acore-text) !important; +} +body.acore-dark-mode.mycred-log-page .wp-list-table thead th, +body.acore-dark-mode.mycred-log-page .wp-list-table tfoot th, +body.acore-dark-mode.mycred-log-page .wp-list-table thead th a, +body.acore-dark-mode.mycred-log-page .wp-list-table thead th span { + background: var(--acore-surface-2) !important; + color: var(--acore-text) !important; + border-color: var(--acore-border) !important; +} +body.acore-dark-mode.mycred-log-page .wp-list-table td, +body.acore-dark-mode.mycred-log-page .wp-list-table th { + color: var(--acore-text) !important; + border-color: var(--acore-border) !important; + background: transparent !important; +} +body.acore-dark-mode.mycred-log-page .wp-list-table > tbody > tr:nth-child(odd) { + background: var(--acore-surface-2) !important; +} +body.acore-dark-mode.mycred-log-page .wp-list-table a { color: var(--acore-accent) !important; } + +/* Export button row */ +body.acore-dark-mode.mycred-log-page #export-log-history, +body.acore-dark-mode.mycred-log-page #export-log-history * { color: var(--acore-text) !important; } + +/* Generic myCRED widgets elsewhere (points balance boxes, etc.) */ +body.acore-dark-mode [class*="mycred"] th, +body.acore-dark-mode [class*="mycred"] td { color: var(--acore-text) !important; border-color: var(--acore-border) !important; } +body.acore-dark-mode [class*="mycred"] a { color: var(--acore-accent) !important; } + +/* myCRED page outer wrapper (#myCRED-wrap is white) -> blend into dark admin bg */ +body.acore-dark-mode.mycred-log-page #myCRED-wrap { + background: transparent !important; +} +body.acore-dark-mode.mycred-log-page #myCRED-wrap, +body.acore-dark-mode.mycred-log-page #myCRED-wrap h1, +body.acore-dark-mode.mycred-log-page #myCRED-wrap h2, +body.acore-dark-mode.mycred-log-page #myCRED-wrap p { + color: var(--acore-text) !important; +} + +/* ── WP 2FA standalone setup wizard (profile.php?page=wp-2fa-setup) ────── + * The wizard renders its own with no admin/dark + * class, and only prints its own stylesheets. We inject theme.css into its + * via the `wp_2fa_setup_page_scripts` hook ONLY when dark mode is on + * (see DarkMode.php), so these body.wp2fa-setup rules are dark-only by load. + * The dark palette is redeclared here because .acore-dark-mode isn't present. */ +body.wp2fa-setup { + --acore-bg: #0d1117; + --acore-surface: #161b22; + --acore-surface-2: #21262d; + --acore-text: #c9d1d9; + --acore-muted: #9aa4af; + --acore-border: #30363d; + --acore-accent: #58a6ff; + + background: var(--acore-bg) !important; + color: var(--acore-text) !important; +} +body.wp2fa-setup .setup-wizard-wrapper, +body.wp2fa-setup .wp2fa-setup-content, +body.wp2fa-setup .modal__container { + background: var(--acore-surface) !important; + color: var(--acore-text) !important; + border-color: var(--acore-border) !important; +} +body.wp2fa-setup h1, body.wp2fa-setup h2, body.wp2fa-setup h3, +body.wp2fa-setup h4, body.wp2fa-setup p, body.wp2fa-setup label, +body.wp2fa-setup li, body.wp2fa-setup span, body.wp2fa-setup strong { + color: var(--acore-text) !important; +} +body.wp2fa-setup .description, +body.wp2fa-setup .wp2fa-setup-footer a { color: var(--acore-muted) !important; } +body.wp2fa-setup input[type="text"], +body.wp2fa-setup input[type="email"], +body.wp2fa-setup input[type="number"], +body.wp2fa-setup input[type="password"], +body.wp2fa-setup select, +body.wp2fa-setup textarea, +body.wp2fa-setup .select2-selection { + background: var(--acore-surface-2) !important; + color: var(--acore-text) !important; + border: 1px solid var(--acore-border) !important; +} +body.wp2fa-setup .steps li { color: var(--acore-muted) !important; } +body.wp2fa-setup .steps li.is-active { color: var(--acore-accent) !important; font-weight: 600; } +body.wp2fa-setup .button-secondary, +body.wp2fa-setup .wp-2fa-button-secondary { + background: var(--acore-surface-2) !important; + color: var(--acore-text) !important; + border-color: var(--acore-border) !important; +} +body.wp2fa-setup code, +body.wp2fa-setup pre { + background: var(--acore-surface-2) !important; + color: var(--acore-text) !important; + border: 1px solid var(--acore-border); +} +body.wp2fa-setup .modal__overlay { background: rgba(0,0,0,0.6) !important; } + +/* ── WP 2FA micromodal on normal admin pages (Security page "Configure 2FA") ── + * The modal is appended at level (outside #acore-security-page), so it is + * scoped by the body dark class instead. */ +body.acore-dark-mode .wp2fa-modal .modal__container { + background: var(--acore-surface) !important; + color: var(--acore-text) !important; +} +body.acore-dark-mode .wp2fa-modal .modal__content, +body.acore-dark-mode .wp2fa-modal .modal__content h1, +body.acore-dark-mode .wp2fa-modal .modal__content h2, +body.acore-dark-mode .wp2fa-modal .modal__content h3, +body.acore-dark-mode .wp2fa-modal .modal__content h4, +body.acore-dark-mode .wp2fa-modal .modal__content p, +body.acore-dark-mode .wp2fa-modal .modal__content label, +body.acore-dark-mode .wp2fa-modal .modal__content span, +body.acore-dark-mode .wp2fa-modal .modal__content li, +body.acore-dark-mode .wp2fa-modal .modal__content strong { + color: var(--acore-text) !important; +} +body.acore-dark-mode .wp2fa-modal .description { color: var(--acore-muted) !important; } +body.acore-dark-mode .wp2fa-modal .option-pill, +body.acore-dark-mode .wp2fa-modal .radio-cells .option-pill { + background: var(--acore-surface-2) !important; + border-color: var(--acore-border) !important; + color: var(--acore-text) !important; +} +body.acore-dark-mode .wp2fa-modal .option-pill.selected, +body.acore-dark-mode .wp2fa-modal .option-pill:hover { + border-color: var(--acore-accent) !important; +} +body.acore-dark-mode .wp2fa-modal input, +body.acore-dark-mode .wp2fa-modal select, +body.acore-dark-mode .wp2fa-modal textarea { + background: var(--acore-surface-2) !important; + color: var(--acore-text) !important; + border: 1px solid var(--acore-border) !important; +} +body.acore-dark-mode .wp2fa-modal .modal__close { color: var(--acore-text) !important; } +body.acore-dark-mode .wp2fa-modal code, +body.acore-dark-mode .wp2fa-modal pre { + background: var(--acore-surface-2) !important; + color: var(--acore-text) !important; + border: 1px solid var(--acore-border); +} + +/* WP 2FA TOTP secret-key box (shown in both the modal and the wizard) */ +body.acore-dark-mode .wp2fa-modal .app-key-wrapper, +body.wp2fa-setup .app-key-wrapper { + background: var(--acore-surface-2) !important; + border-color: var(--acore-border) !important; +} +body.acore-dark-mode .wp2fa-modal .app-key-wrapper .app-key, +body.acore-dark-mode .wp2fa-modal .app-key-wrapper input, +body.wp2fa-setup .app-key-wrapper .app-key, +body.wp2fa-setup .app-key-wrapper input { + color: var(--acore-text) !important; +} + + +/* ── "Disabled" sections (e.g. In-game 2FA before Website 2FA is set up) ── + * A greyed background box signals "disabled" while text stays fully legible + * (no opacity wash-out). Works in both light and dark themes. */ +.acore-2fa-disabled { + position: relative; + pointer-events: none; + user-select: none; + background: var(--acore-surface-2); + border: 1px solid var(--acore-border); + border-radius: 6px; + padding: 10px 16px 12px; + margin-top: 8px; +} +/* Light theme: keep the intro + steps at readable contrast on the grey box */ +.acore-2fa-disabled p, +.acore-2fa-disabled li { color: #3c434a !important; } +.acore-2fa-disabled p:first-child { color: #50575e !important; } +/* Dark theme */ +body.acore-dark-mode .acore-2fa-disabled, +body.acore-dark-mode .acore-2fa-disabled p, +body.acore-dark-mode .acore-2fa-disabled li, +body.acore-dark-mode .acore-2fa-disabled span { color: var(--acore-text) !important; } +body.acore-dark-mode .acore-2fa-disabled p:first-child { color: var(--acore-muted) !important; } + +/* ── "Website 2FA required" callout (bright & visible in both themes) ── */ +.acore-2fa-required-note { + display: flex; + align-items: center; + gap: 8px; + margin: 10px 0 0; + padding: 8px 12px; + font-size: 13px; + font-weight: 600; + line-height: 1.4; + border-radius: 6px; + color: #9a5b00; + background: #fff4e0; + border: 1px solid #f0b429; +} +.acore-2fa-required-note .dashicons { color: #d98300; flex: 0 0 auto; } +body.acore-dark-mode .acore-2fa-required-note { + color: #f5c451 !important; + background: rgba(240, 180, 41, 0.12) !important; + border-color: #8a6d1f !important; +} +body.acore-dark-mode .acore-2fa-required-note .dashicons { color: #f5c451 !important; } + +/* ── Highlight the connection row matching the viewer's current IP ── */ +#acore-security-page .wp-list-table > tbody > tr.acore-conn-current > td { + background: #fff3cd !important; + border-color: #f0d488 !important; +} +#acore-security-page .wp-list-table > tbody > tr.acore-conn-current > td:first-child { + box-shadow: inset 3px 0 0 0 #e0a800; + font-weight: 600; +} +body.acore-dark-mode #acore-security-page .wp-list-table > tbody > tr.acore-conn-current > td { + background: rgba(240, 180, 41, 0.16) !important; + color: #f5d98a !important; + border-color: #6e5a1e !important; +} +body.acore-dark-mode #acore-security-page .wp-list-table > tbody > tr.acore-conn-current > td:first-child { + box-shadow: inset 3px 0 0 0 #f0b429; +} + +/* ── Shared connection tables (Security page + Profile page) ── */ +body.acore-dark-mode .acore-conn-table { + background: var(--acore-surface) !important; + border-color: var(--acore-border) !important; +} +body.acore-dark-mode .acore-conn-table > thead > tr, +body.acore-dark-mode .acore-conn-table > tbody > tr:nth-child(odd) { + background: var(--acore-surface-2) !important; +} +body.acore-dark-mode .acore-conn-table > tbody > tr:nth-child(even) { + background: var(--acore-surface) !important; +} +body.acore-dark-mode .acore-conn-table th, +body.acore-dark-mode .acore-conn-table td { + color: var(--acore-text) !important; + border-color: var(--acore-border) !important; + background: transparent !important; +} +.acore-conn-table > tbody > tr.acore-conn-current > td { + background: #fff3cd !important; + border-color: #f0d488 !important; +} +.acore-conn-table > tbody > tr.acore-conn-current > td:first-child { + box-shadow: inset 3px 0 0 0 #e0a800; + font-weight: 600; +} +body.acore-dark-mode .acore-conn-table > tbody > tr.acore-conn-current > td { + background: rgba(240, 180, 41, 0.16) !important; + color: #f5d98a !important; + border-color: #6e5a1e !important; +} +body.acore-dark-mode .acore-conn-table > tbody > tr.acore-conn-current > td:first-child { + box-shadow: inset 3px 0 0 0 #f0b429; +} + +/* "Your IPv4:" label (right-aligned) + connection notes - readable both themes */ +.acore-conn-heading { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; + max-width: 860px; + flex-wrap: wrap; +} +.acore-conn-myip { + font-size: 13px; + font-weight: 400; + white-space: nowrap; + color: #50575e; +} +body.acore-dark-mode .acore-conn-myip { color: #c9d1d9; } +.acore-conn-note { color: #50575e; font-size: 13px; } +body.acore-dark-mode .acore-conn-note { color: #9aa4af !important; } + +/* ── Dark-mode toggle in the admin bar (visible in both themes) ────────── */ +#wp-admin-bar-acore-dark-mode > .ab-item { + background: rgba(255, 255, 255, 0.08); + border-radius: 4px; +} +#wp-admin-bar-acore-dark-mode:hover > .ab-item { + background: rgba(255, 255, 255, 0.18) !important; +} +#acore-dm-icon { + font-size: 18px !important; + line-height: 1 !important; + color: #ffc83d !important; + vertical-align: middle; +} + +/* ── Native "> + "> - +
+ + +
@@ -62,12 +67,23 @@ public function getHomeRender($characters) { - - Date: Tue, 23 Jun 2026 16:54:31 +0100 Subject: [PATCH 03/18] Mail Return: redesigned layout, subject escaping, empty-state reset --- .../MailReturnMenu/MailReturnController.php | 4 +- .../MailReturnMenu/MailReturnView.php | 108 ++++++---- .../web/assets/mail-return/mail-return.js | 198 +++++++++++++----- 3 files changed, 210 insertions(+), 100 deletions(-) diff --git a/src/acore-wp-plugin/src/Components/MailReturnMenu/MailReturnController.php b/src/acore-wp-plugin/src/Components/MailReturnMenu/MailReturnController.php index 3d54a2227..ba9241125 100644 --- a/src/acore-wp-plugin/src/Components/MailReturnMenu/MailReturnController.php +++ b/src/acore-wp-plugin/src/Components/MailReturnMenu/MailReturnController.php @@ -64,7 +64,7 @@ public static function getSentUnreadMails($charGuid) // Get mails sent by this character that are unread by the receiver // messageType = 0 means player mail - $query = "SELECT m.`id`, m.`subject`, m.`has_items`, m.`money`, + $query = "SELECT m.`id`, m.`subject`, m.`has_items`, m.`money`, m.`cod`, m.`expire_time`, m.`deliver_time`, rc.`name` AS receiver_name, rc.`race` AS receiver_race, rc.`class` AS receiver_class, rc.`gender` AS receiver_gender, @@ -94,7 +94,7 @@ public static function getSentUnreadMails($charGuid) $placeholders = implode(',', array_fill(0, count($mailIds), '?')); $worldDb = Opts::I()->acore_db_world_name; $itemQuery = "SELECT mi.`mail_id`, ii.`itemEntry`, ii.`count`, - it.`name` AS item_name + it.`name` AS item_name, it.`Quality` AS item_quality FROM `mail_items` mi JOIN `item_instance` ii ON mi.`item_guid` = ii.`guid` JOIN `$worldDb`.`item_template` it ON ii.`itemEntry` = it.`entry` diff --git a/src/acore-wp-plugin/src/Components/MailReturnMenu/MailReturnView.php b/src/acore-wp-plugin/src/Components/MailReturnMenu/MailReturnView.php index 5a882e5c2..74af3c7ed 100644 --- a/src/acore-wp-plugin/src/Components/MailReturnMenu/MailReturnView.php +++ b/src/acore-wp-plugin/src/Components/MailReturnMenu/MailReturnView.php @@ -2,6 +2,8 @@ namespace ACore\Components\MailReturnMenu; +use ACore\Utils\AcoreCharColors; + class MailReturnView { @@ -17,58 +19,80 @@ public function getMailReturnRender($chars) ob_start(); wp_enqueue_style('bootstrap-css', '//cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css', array(), '5.1.3'); - wp_enqueue_style('acore-css', ACORE_URL_PLG . 'web/assets/css/main.css', array(), '0.1'); + wp_enqueue_style('acore-css', ACORE_URL_PLG . 'web/assets/css/main.css', array(), '0.5'); wp_enqueue_script('bootstrap-js', '//cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js', array(), '5.1.3'); wp_enqueue_script('jquery'); - wp_enqueue_script('acore-mail-return-js', ACORE_URL_PLG . 'web/assets/mail-return/mail-return.js', array('jquery'), null, true); + wp_enqueue_script('power-js', 'https://wow.zamimg.com/widgets/power.js', array(), null, false); + wp_enqueue_script('acore-mail-return-js', ACORE_URL_PLG . 'web/assets/mail-return/mail-return.js', array('jquery'), '2.3', true); + wp_localize_script('acore-mail-return-js', 'mailReturnData', [ + 'mailsUrl' => rest_url(ACORE_SLUG . '/v1/mail-return/list'), + 'returnUrl' => rest_url(ACORE_SLUG . '/v1/mail-return'), + 'assetsUrl' => ACORE_URL_PLG . 'web/assets/', + 'nonce' => wp_create_nonce('wp_rest'), + ]); ?> -
-
-
-
-

Mail Return

-

You can return sent mails that have not yet been read by the recipient in this page. Select the character, the sent mail and hit return.

-
- - - - -
+ + + + +
diff --git a/src/acore-wp-plugin/src/Manager/ACoreServices.php b/src/acore-wp-plugin/src/Manager/ACoreServices.php index fbdd2ebcd..7701e4884 100644 --- a/src/acore-wp-plugin/src/Manager/ACoreServices.php +++ b/src/acore-wp-plugin/src/Manager/ACoreServices.php @@ -402,16 +402,44 @@ public function getUserNameByUserId($usedId) { } public function getRestorableItemsByCharacter($character) { - $query = "SELECT `Id`, `ItemEntry` - FROM `recovery_item` - WHERE `Guid` = ? - "; + $conn = $this->getCharacterEm()->getConnection(); + $accId = $this->getAcoreAccountId(); + + if (!is_numeric($character) || !$accId) { + throw new \InvalidArgumentException('Invalid character'); + } + + $ownership = $conn->prepare( + "SELECT 1 FROM `characters` WHERE `guid` = ? AND `account` = ? AND `deleteDate` IS NULL" + ); + $ownership->bindValue(1, intval($character)); + $ownership->bindValue(2, intval($accId)); + if (!$ownership->executeQuery()->fetchOne()) { + throw new \InvalidArgumentException('Character not found'); + } + + $stmt = $conn->prepare( + "SELECT `Id`, `ItemEntry`, `Count`, `DeleteDate` + FROM `recovery_item` + WHERE `Guid` = ? + ORDER BY `DeleteDate` DESC" + ); + $stmt->bindValue(1, intval($character)); + return $stmt->executeQuery()->fetchAllAssociative(); + } + + public function currentAccountOwnsCharacterName($name): bool { + $accId = $this->getAcoreAccountId(); + if (!$accId || $name === '' || $name === null) { + return false; + } $conn = $this->getCharacterEm()->getConnection(); - $stmt = $conn->prepare($query); - $stmt->bindValue(1, $character); - $stmt->executeQuery(); - $res = $stmt->executeQuery(); - return $res->fetchAllAssociative(); + $stmt = $conn->prepare( + "SELECT 1 FROM `characters` WHERE `name` = ? AND `account` = ? AND `deleteDate` IS NULL" + ); + $stmt->bindValue(1, $name); + $stmt->bindValue(2, intval($accId)); + return (bool) $stmt->executeQuery()->fetchOne(); } /** From 3fb9994b731d3d0e7aad6a6c7a681f9c41c7e1ea Mon Sep 17 00:00:00 2001 From: FlyingArowana <16946913+TheSCREWEDSoftware@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:54:32 +0100 Subject: [PATCH 05/18] Misc menus: Unstuck nonce/data, Resurrection Scroll, RAF nonce + icon escaping --- .../ResurrectionScrollMenu.php | 13 ++++- .../ResurrectionScrollView.php | 8 +-- .../Components/UnstuckMenu/UnstuckView.php | 54 +++++++++---------- .../UserPanel/Pages/RafProgressPage.php | 23 ++++---- .../src/Manager/Soap/SmartstoneService.php | 2 +- 5 files changed, 56 insertions(+), 44 deletions(-) diff --git a/src/acore-wp-plugin/src/Components/ResurrectionScrollMenu/ResurrectionScrollMenu.php b/src/acore-wp-plugin/src/Components/ResurrectionScrollMenu/ResurrectionScrollMenu.php index 4f11e8eeb..45816b1e0 100644 --- a/src/acore-wp-plugin/src/Components/ResurrectionScrollMenu/ResurrectionScrollMenu.php +++ b/src/acore-wp-plugin/src/Components/ResurrectionScrollMenu/ResurrectionScrollMenu.php @@ -24,9 +24,19 @@ public static function I() return self::$instance; } + private function isAvailable(): bool + { + if (Opts::I()->acore_resurrection_scroll != '1') { + return false; + } + $csv = get_option('acore_modules_csv', ''); + $modules = $csv ? array_map('trim', explode(',', $csv)) : []; + return in_array('mod-resurrection-scroll', $modules); + } + function acore_resurrection_scroll_menu() { - if (Opts::I()->acore_resurrection_scroll == '1') { + if ($this->isAvailable()) { add_submenu_page('profile.php', 'Scroll of Resurrection', 'Scroll of Resurrection', 'read', ACORE_SLUG . '-resurrection-scroll', array($this, 'acore_resurrection_scroll_menu_page')); } } @@ -41,6 +51,5 @@ function acore_resurrection_scroll_menu_page() function resurrection_scroll_menu_init() { $menu = ResurrectionScrollMenu::I(); - add_action('admin_menu', array($menu, 'acore_resurrection_scroll_menu')); } diff --git a/src/acore-wp-plugin/src/Components/ResurrectionScrollMenu/ResurrectionScrollView.php b/src/acore-wp-plugin/src/Components/ResurrectionScrollMenu/ResurrectionScrollView.php index 345fa33bb..19a69adf4 100644 --- a/src/acore-wp-plugin/src/Components/ResurrectionScrollMenu/ResurrectionScrollView.php +++ b/src/acore-wp-plugin/src/Components/ResurrectionScrollMenu/ResurrectionScrollView.php @@ -16,7 +16,7 @@ public function getRender($data) ob_start(); wp_enqueue_style('bootstrap-css', '//cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css', array(), '5.1.3'); - wp_enqueue_style('acore-css', ACORE_URL_PLG . 'web/assets/css/main.css', array(), '0.1'); + wp_enqueue_style('acore-css', ACORE_URL_PLG . 'web/assets/css/main.css', array(), '0.5'); wp_enqueue_script('bootstrap-js', '//cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js', array(), '5.1.3'); // Determine account status @@ -42,7 +42,7 @@ public function getRender($data) ?> -
+

Scroll of Resurrection

@@ -66,10 +66,10 @@ public function getRender($data) Enrolled - Eligible + Eligible - Log in to the game server to activate - Not Eligible + Not Eligible rest_url(ACORE_SLUG . '/v1/unstuck'), + 'nonce' => wp_create_nonce('wp_rest'), + )); ?> @@ -31,7 +37,7 @@ public function getUnstuckmenuRender($chars)

Unstuck

Unstuck your characters and teleport them to the hearthstone location


-
    +
      $currentTime); @@ -39,28 +45,26 @@ public function getUnstuckmenuRender($chars) $tooltipText = $isDisabled ? 'Unstuck is on cooldown' : ''; // Tooltip text if disabled $remainingCDTime = $isDisabled ? $char["time"] - $currentTime : 0; $endTime = $isDisabled ? $char["time"] : 0; + $clsStyle = AcoreCharColors::rowStyle(intval($char["class"]), intval($char["race"])); ?>
    • -
    • @@ -70,12 +74,6 @@ class="unstuck-button"
- getAcoreAccountId(); ?> -
- " . __('Recruit a Friend', Opts::I()->page_alias) . ""; - ?> -

Recruit your friends, help them to level up and get very awesome unique prizes.

+
+

page_alias); ?>

+

page_alias); ?>

+ eluna_raf_config['end_raf_on_same_ip'] === '1') { ?> -

Recruiting from the same IP address will cause the RAF to be automatically removed and no new RAF can be applied again.

+
+

page_alias); ?>

+
-

Whilst you can recruit a friend who shares an IP address, you will only get the teleport & XP bonuses, no rewards will be given (these are provided only to those who recruit people not on their own IP address).

+
+

page_alias); ?>

+
-
-
+ +
+
= (new \DateTime())) { ?>

You still have until format('D, d M Y H:i'); ?> [server time] to be recruited by a friend, enter his username here:

+

diff --git a/src/acore-wp-plugin/src/Manager/Soap/SmartstoneService.php b/src/acore-wp-plugin/src/Manager/Soap/SmartstoneService.php index 2f8b26822..79b89e0a6 100644 --- a/src/acore-wp-plugin/src/Manager/Soap/SmartstoneService.php +++ b/src/acore-wp-plugin/src/Manager/Soap/SmartstoneService.php @@ -18,7 +18,7 @@ public function addVanity($charName, $category, $vanityID) { * other service types will be rejected by the mod's HandleSmartStoneUnlockAccountCommand. * * $accountName is expected to come from a WordPress user_login (sanitize_user - * restricts these to alphanumerics, dot, hyphen, underscore, at — no spaces), + * restricts these to alphanumerics, dot, hyphen, underscore, at - no spaces), * but we still cast the numeric args at the boundary as belt-and-braces. */ public function addAccountVanity($accountName, $category, $vanityID) { From c4263c720a8d88d3632d1f8047f8d1e5808f1b6b Mon Sep 17 00:00:00 2001 From: FlyingArowana <16946913+TheSCREWEDSoftware@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:54:32 +0100 Subject: [PATCH 06/18] Security tab: password change, 2FA (website/in-game/admin/email), connection logging + GeoIP --- .../src/Components/AdminPanel/Pages/Tools.php | 583 +++++++++++++-- .../Components/ServerInfo/ServerInfoApi.php | 593 ++++++++++++++- .../UserPanel/Pages/SecurityPage.php | 678 ++++++++++++++++++ .../Components/UserPanel/UserController.php | 399 ++++++----- .../src/Components/UserPanel/UserMenu.php | 11 +- .../src/Components/UserPanel/UserView.php | 64 +- .../src/Hooks/User/ConnectionLogger.php | 362 ++++++++++ .../src/Hooks/User/Include.php | 2 + .../src/Hooks/User/PasswordHandler.php | 129 ++++ src/acore-wp-plugin/src/Hooks/User/User.php | 286 +++++++- .../src/Hooks/Various/tgmplugin_activator.php | 8 +- src/acore-wp-plugin/src/Manager/Opts.php | 3 + 12 files changed, 2831 insertions(+), 287 deletions(-) create mode 100644 src/acore-wp-plugin/src/Components/UserPanel/Pages/SecurityPage.php create mode 100644 src/acore-wp-plugin/src/Hooks/User/ConnectionLogger.php create mode 100644 src/acore-wp-plugin/src/Hooks/User/PasswordHandler.php diff --git a/src/acore-wp-plugin/src/Components/AdminPanel/Pages/Tools.php b/src/acore-wp-plugin/src/Components/AdminPanel/Pages/Tools.php index 86058a205..104c20a69 100644 --- a/src/acore-wp-plugin/src/Components/AdminPanel/Pages/Tools.php +++ b/src/acore-wp-plugin/src/Components/AdminPanel/Pages/Tools.php @@ -1,16 +1,83 @@ acore_resurrection_scroll == '1'; ?> @@ -21,20 +88,19 @@

Tools


+
-
+ + +
-
- Worldserver integration -
+
World Server Integration

- + - acore_resurrection_scroll != '1') echo 'style="display:none;"'?>> - + + @@ -67,91 +157,448 @@ -
+ +
+
+
+
Web Integration
+
+
+ + + + + + + + + + + + + + + + +
+ + +

Remove 2FA

+

+ Remove Website or In-game 2FA for any account. A warning is shown to the user until they re-enable it. +

+ + +

Website

+
+ + + +
+

+ + +
+

Backup Codes

+ Check a Website account above to view backup codes. + +
+ + +

In-Game

+
+ + + +
+

+ +
+
+
+ + +
-
- Name Unlock Settings -
+
Name Unlock Settings

Allowed banned names table (characters database): -
-
+

Inactivity Thresholds per Level: - + -
Add
+
+
+ Add +
+
+ Reset +
+
+
+ +
+ + +
+
+
User Login History
+
+

+ Look up the recorded login IP history for any account (the same list the user sees on their Security page). +

+
+ + +
+

+ + + + + + +

+ +

-
- -
+

+ +

+ + + diff --git a/src/acore-wp-plugin/src/Components/ServerInfo/ServerInfoApi.php b/src/acore-wp-plugin/src/Components/ServerInfo/ServerInfoApi.php index b6a325647..306ad46ef 100644 --- a/src/acore-wp-plugin/src/Components/ServerInfo/ServerInfoApi.php +++ b/src/acore-wp-plugin/src/Components/ServerInfo/ServerInfoApi.php @@ -15,12 +15,601 @@ public static function AccountCount() { } +/** + * Pure-PHP TOTP validator (RFC 6238 / HOTP-SHA1). + * Accepts the Base32-encoded secret stored by WP2FA and a 6-digit token string. + * Validates against the current 30-second window +-1 step. + */ +function acore_totp_validate(string $base32Secret, string $rawToken): bool { + $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + $input = strtoupper(rtrim($base32Secret, '=')); + $bits = ''; + foreach (str_split($input) as $char) { + $pos = strpos($alphabet, $char); + if ($pos === false) continue; + $bits .= str_pad(decbin($pos), 5, '0', STR_PAD_LEFT); + } + $secret = ''; + foreach (str_split($bits, 8) as $chunk) { + if (strlen($chunk) < 8) break; + $secret .= chr(bindec($chunk)); + } + if ($secret === '') return false; + + $token = (int) $rawToken; + $timestamp = (int) floor(time() / 30); + + for ($step = -1; $step <= 1; $step++) { + $t = $timestamp + $step; + $msg = "\x00\x00\x00\x00" . pack('N', $t); + $hash = hash_hmac('sha1', $msg, $secret, true); + $off = ord($hash[19]) & 0x0f; + $otp = ( + ((ord($hash[$off]) & 0x7f) << 24) | + ((ord($hash[$off+1]) & 0xff) << 16) | + ((ord($hash[$off+2]) & 0xff) << 8) | + (ord($hash[$off+3]) & 0xff) + ); + if (($otp % 1000000) === $token) return true; + } + return false; +} + +/** + * Validate a 6-digit website 2FA (TOTP) code for a user. + * + * Uses the WP 2FA plugin's own authenticator + * (\WP2FA\Authenticator\Authentication::is_valid_authcode), which transparently + * handles the plugin's "lsc_" key prefix, OpenSSL decryption and the configured + * time-drift window. Falls back to a manual decrypt + RFC 6238 check for older + * plugin builds that lack that method. + */ +function acore_wp2fa_code_is_valid(int $userId, string $token): bool { + $rawKey = (string) get_user_meta($userId, 'wp_2fa_totp_key', true); + if ($rawKey === '') return false; + + // Preferred path: let the plugin validate the code itself. + $auth = '\WP2FA\Authenticator\Authentication'; + if (is_callable([$auth, 'is_valid_authcode'])) { + if (is_callable([$auth, 'clear_decrypted_key'])) { + $auth::clear_decrypted_key(); + } + try { + return (bool) $auth::is_valid_authcode($rawKey, $token); + } catch (\Throwable $e) { + // fall through to the manual path below + } + } + + // Fallback: decrypt the secret ourselves, then validate (RFC 6238). + $secret = acore_wp2fa_decrypt_secret($rawKey); + if ($secret === '') return false; + return acore_totp_validate($secret, $token); +} + +/** + * Decrypt a WP 2FA stored TOTP key into its raw Base32 secret. + * The plugin stores the value as "lsc_" + base64(iv . ciphertext) when OpenSSL is + * available, or as a plain Base32 secret otherwise. + */ +function acore_wp2fa_decrypt_secret(string $rawKey): string { + if ($rawKey === '') return ''; + + $prefix = 'lsc_'; + $hasPrefix = strpos($rawKey, $prefix) === 0; + $cipherText = $hasPrefix ? substr($rawKey, strlen($prefix)) : $rawKey; + + // No prefix and already Base32? Encryption was disabled - use as-is. + if (!$hasPrefix) { + $clean = strtoupper(rtrim(trim($cipherText), '=')); + if ($clean !== '' && preg_match('/^[A-Z2-7]+$/', $clean)) { + return $cipherText; + } + } + + foreach (['\WP2FA\Authenticator\Open_SSL', '\WP2FA\Utils\Open_SSL'] as $cls) { + if (is_callable([$cls, 'decrypt'])) { + try { $dec = $cls::decrypt($cipherText); } + catch (\Throwable $e) { $dec = ''; } + if (is_string($dec) && $dec !== '') return $dec; + } + } + + return ''; +} + +/** + * Whether the user currently has Website 2FA (TOTP) active. + */ +function acore_website_totp_enabled(int $userId): bool { + $primary = get_user_meta($userId, 'wp_2fa_enabled_methods', true); + $totpKey = get_user_meta($userId, 'wp_2fa_totp_key', true); + return !empty($totpKey) && ( + (is_array($primary) && in_array('totp', $primary, true)) || + $primary === 'totp' + ); +} + +function acore_website_2fa_enabled(int $userId): bool { + $primary = get_user_meta($userId, 'wp_2fa_enabled_methods', true); + $methods = is_array($primary) ? $primary : ($primary !== '' ? [$primary] : []); + return acore_website_totp_enabled($userId) || in_array('email', $methods, true); +} + +function acore_2fa_unlock_key(int $userId): string { + $token = function_exists('wp_get_session_token') ? (string) wp_get_session_token() : ''; + return 'acore_2fa_panel_unlock_' . $userId . '_' . hash('sha256', $token); +} + +function acore_2fa_attempt_key(int $userId): string { + $token = function_exists('wp_get_session_token') ? (string) wp_get_session_token() : ''; + return 'acore_2fa_verify_attempts_' . $userId . '_' . hash('sha256', $token); +} + +/** + * Whether the current user has In-game 2FA active (account.totp_secret set). + */ +function acore_ingame_2fa_enabled(int $userId): bool { + try { + $services = \ACore\Manager\ACoreServices::I(); + $accId = $services->getAcoreAccountId(); + if (!$accId) return false; + $conn = $services->getAccountEm()->getConnection(); + $row = $conn->executeQuery('SELECT totp_secret FROM account WHERE id = ?', [$accId])->fetchAssociative(); + return $row && $row['totp_secret'] !== null; + } catch (\Throwable $e) { + return false; + } +} + +/** + * Detect a user-initiated Website 2FA removal (done through the WP 2FA plugin UI) + * and log it with the user's IP. Admin removals pre-set the "last seen" flag to + * '0', so they are never misattributed here. Runs in the user's own session, so + * the only IP ever recorded is the user's - never an administrator's. + */ +function acore_2fa_sync_self_removals(int $userId): void { + $enabled = acore_website_2fa_enabled($userId); + $seen = get_user_meta($userId, 'acore_2fa_ws_seen_enabled', true); + + if ($seen === '') { + update_user_meta($userId, 'acore_2fa_ws_seen_enabled', $enabled ? '1' : '0'); + return; + } + + if ($seen === '1' && !$enabled) { + $log = get_user_meta($userId, 'acore_2fa_admin_log', true); + $log = is_array($log) ? $log : []; + $log[] = [ + 'type' => 'website', + 'by' => 'self', + 'timestamp' => time(), + 'ip' => \ACore\Hooks\User\acore_resolve_client_ip(), + ]; + update_user_meta($userId, 'acore_2fa_admin_log', $log); + } + + update_user_meta($userId, 'acore_2fa_ws_seen_enabled', $enabled ? '1' : '0'); +} + add_action( 'rest_api_init', function () { register_rest_route( ACORE_SLUG . '/v1', 'server-info', array( 'methods' => 'GET', + 'permission_callback' => function() { return current_user_can('manage_options'); }, 'callback' => function( $request ) { - $data = ['message' => ServerInfoApi::serverInfo()]; - return $data; + $result = ServerInfoApi::serverInfo(); + $errorPatterns = [ + 'could not connect', 'connection refused', 'operation timed out', + 'error fetching', 'failed to enable', 'soap fault', 'not configured', + 'unable to connect', 'network unreachable', + ]; + foreach ($errorPatterns as $pattern) { + if (stripos($result, $pattern) !== false) { + error_log('[acore] server-info SOAP error: ' . (is_scalar($result) ? (string) $result : 'non-scalar response')); + return new \WP_Error('soap_error', __('Server information is temporarily unavailable.', 'acore-wp-plugin'), ['status' => 503]); + } + } + return ['message' => $result]; + } + ) ); + + $defaultRequirements = [['Scroll of Resurrection', 'mod-resurrection-scroll']]; + + register_rest_route( ACORE_SLUG . '/v1', 'server-module-requirements', array( + 'methods' => 'GET', + 'permission_callback' => function() { return current_user_can('manage_options'); }, + 'callback' => function( $request ) use ($defaultRequirements) { + return ['requirements' => get_option('acore_module_requirements', $defaultRequirements)]; + } + )); + + register_rest_route( ACORE_SLUG . '/v1', 'server-module-requirements', array( + 'methods' => 'POST', + 'permission_callback' => function() { return current_user_can('manage_options'); }, + 'callback' => function( $request ) { + $data = $request->get_json_params(); + $reqs = isset($data['requirements']) ? $data['requirements'] : []; + $clean = []; + foreach ($reqs as $row) { + if (is_array($row) && count($row) === 2) { + $clean[] = [sanitize_text_field($row[0]), sanitize_text_field($row[1])]; + } + } + update_option('acore_module_requirements', $clean); + return ['success' => true, 'requirements' => $clean]; + } + )); + + register_rest_route( ACORE_SLUG . '/v1', 'server-modules', array( + 'methods' => 'POST', + 'permission_callback' => function() { return current_user_can('manage_options'); }, + 'callback' => function( $request ) { + try { + $raw = ACoreServices::I()->getServerSoap()->executeCommand('.server debug'); + } catch (\Throwable $e) { + return new \WP_Error('soap_error', $e->getMessage(), ['status' => 503]); + } + if (!is_string($raw)) { + return new \WP_Error('soap_error', __('Unexpected module response from the server.', 'acore-wp-plugin'), ['status' => 503]); + } + $modules = []; + $capturing = false; + foreach (explode("\n", $raw) as $line) { + $line = trim($line); + if (!$capturing) { + if (stripos($line, 'List of enabled modules:') !== false) $capturing = true; + continue; + } + if (preg_match('/\b(mod-[a-zA-Z0-9_-]+)/', $line, $m)) $modules[] = $m[1]; + } + $csv = implode(',', $modules); + $timestamp = time(); + update_option('acore_modules_csv', $csv); + update_option('acore_modules_refreshed', $timestamp); + return ['modules' => $modules, 'csv' => $csv, 'refreshed' => $timestamp]; + } + ) ); + + // Admin: check 2FA status for any user + register_rest_route( ACORE_SLUG . '/v1', 'admin/2fa-check', array( + 'methods' => 'POST', + 'permission_callback' => function() { return current_user_can('manage_options'); }, + 'callback' => function( \WP_REST_Request $request ) { + $data = $request->get_json_params(); + $type = isset($data['type']) ? sanitize_text_field($data['type']) : ''; + $username = isset($data['username']) ? sanitize_text_field($data['username']) : ''; + if (!in_array($type, ['website', 'ingame'], true)) + return new \WP_Error('invalid_type', 'Invalid type.', ['status' => 400]); + if ($username === '') + return new \WP_Error('missing_username', 'Username is required.', ['status' => 400]); + $user = get_user_by('login', $username); + if (!$user) + return new \WP_Error('user_not_found', 'No WordPress account found with that username.', ['status' => 404]); + + $log = get_user_meta($user->ID, 'acore_2fa_admin_log', true); + $log = is_array($log) ? $log : []; + $lastRemoval = null; + foreach (array_reverse($log) as $entry) { + if ($entry['type'] === $type) { $lastRemoval = $entry; break; } + } + + if ($type === 'website') { + $active = acore_website_2fa_enabled($user->ID); + $backupCodes = get_user_meta($user->ID, 'wp_2fa_backup_codes', true); + $resp = [ + 'found' => true, + 'username' => $user->user_login, + 'active' => $active, + 'backup_codes' => is_array($backupCodes) ? count($backupCodes) : 0, + ]; + if ($lastRemoval) $resp['last_removal'] = ['date' => wp_date('jS \o\f F, Y \a\t H:i', $lastRemoval['timestamp']), 'by' => $lastRemoval['by'] ?? 'admin', 'staff' => $lastRemoval['staff'] ?? null, 'ip' => $lastRemoval['ip'] ?? null]; + return $resp; + } + + try { + $conn = ACoreServices::I()->getAccountEm()->getConnection(); + $result = $conn->executeQuery('SELECT totp_secret FROM account WHERE username = ?', [strtoupper($username)]); + $row = $result->fetchAssociative(); + if (!$row) return new \WP_Error('no_game_account', 'No game account found for this user.', ['status' => 404]); + $resp = ['found' => true, 'username' => $user->user_login, 'active' => $row['totp_secret'] !== null]; + if ($lastRemoval) $resp['last_removal'] = ['date' => wp_date('jS \o\f F, Y \a\t H:i', $lastRemoval['timestamp']), 'by' => $lastRemoval['by'] ?? 'admin', 'staff' => $lastRemoval['staff'] ?? null, 'ip' => $lastRemoval['ip'] ?? null]; + return $resp; + } catch (\Exception $e) { + return new \WP_Error('db_error', 'Database error.', ['status' => 500]); + } + } + )); + + // Admin: remove 2FA for any user and log the action + register_rest_route( ACORE_SLUG . '/v1', 'admin/2fa-remove', array( + 'methods' => 'POST', + 'permission_callback' => function() { return current_user_can('manage_options'); }, + 'callback' => function( \WP_REST_Request $request ) { + $data = $request->get_json_params(); + $type = isset($data['type']) ? sanitize_text_field($data['type']) : ''; + $username = isset($data['username']) ? sanitize_text_field($data['username']) : ''; + if (!in_array($type, ['website', 'ingame'], true)) + return new \WP_Error('invalid_type', 'Invalid type.', ['status' => 400]); + if ($username === '') + return new \WP_Error('missing_username', 'Username is required.', ['status' => 400]); + $user = get_user_by('login', $username); + if (!$user) + return new \WP_Error('user_not_found', 'No WordPress account found with that username.', ['status' => 404]); + + if ($type === 'website') { + delete_user_meta($user->ID, 'wp_2fa_totp_key'); + delete_user_meta($user->ID, 'wp_2fa_enabled_methods'); + delete_user_meta($user->ID, 'wp_2fa_backup_methods_enabled'); + delete_user_meta($user->ID, 'wp_2fa_grace_period_expiry'); + delete_user_meta($user->ID, 'wp_2fa_user_setup_started_at'); + delete_user_meta($user->ID, 'wp_2fa_user_authenticated_methods'); + // Pre-set the user's last-seen flag so the self-removal detector + // does not misattribute this admin action to the user. + update_user_meta($user->ID, 'acore_2fa_ws_seen_enabled', '0'); + } else { + try { + $conn = ACoreServices::I()->getAccountEm()->getConnection(); + $rows = $conn->executeStatement('UPDATE account SET totp_secret = NULL WHERE username = ?', [strtoupper($username)]); + if ($rows === 0) return new \WP_Error('no_game_account', 'No game account found for this user.', ['status' => 404]); + } catch (\Exception $e) { + return new \WP_Error('db_error', 'Database error.', ['status' => 500]); + } + } + + $staff = wp_get_current_user(); + $now = time(); + $log = get_user_meta($user->ID, 'acore_2fa_admin_log', true); + $log = is_array($log) ? $log : []; + $log[] = ['type' => $type, 'by' => 'admin', 'timestamp' => $now, 'staff' => $staff->user_login, 'staff_id' => $staff->ID]; + update_user_meta($user->ID, 'acore_2fa_admin_log', $log); + return ['success' => true, 'date' => wp_date('jS \o\f F, Y \a\t H:i', $now), 'staff' => $staff->user_login]; + } + )); + + // Admin: remove a user's WP 2FA backup codes and notify them + register_rest_route( ACORE_SLUG . '/v1', 'admin/backup-codes-remove', array( + 'methods' => 'POST', + 'permission_callback' => function() { return current_user_can('manage_options'); }, + 'callback' => function( \WP_REST_Request $request ) { + $data = $request->get_json_params(); + $username = isset($data['username']) ? sanitize_text_field($data['username']) : ''; + if ($username === '') + return new \WP_Error('missing_username', 'Username is required.', ['status' => 400]); + $user = get_user_by('login', $username); + if (!$user) + return new \WP_Error('user_not_found', 'No WordPress account found with that username.', ['status' => 404]); + + delete_user_meta($user->ID, 'wp_2fa_backup_codes'); + + $staff = wp_get_current_user(); + $now = time(); + $log = get_user_meta($user->ID, 'acore_2fa_admin_log', true); + $log = is_array($log) ? $log : []; + $log[] = ['type' => 'backup', 'by' => 'admin', 'timestamp' => $now, 'staff' => $staff->user_login, 'staff_id' => $staff->ID]; + update_user_meta($user->ID, 'acore_2fa_admin_log', $log); + + return ['success' => true, 'date' => wp_date('jS \o\f F, Y \a\t H:i', $now), 'staff' => $staff->user_login]; + } + )); + + // Admin: look up a user's recorded login IP history (same data the user sees) + register_rest_route( ACORE_SLUG . '/v1', 'admin/login-history', array( + 'methods' => 'POST', + 'permission_callback' => function() { return current_user_can('manage_options'); }, + 'callback' => function( \WP_REST_Request $request ) { + $data = $request->get_json_params(); + $username = isset($data['username']) ? sanitize_text_field($data['username']) : ''; + $page = max(1, (int) ($data['page'] ?? 1)); + $perPage = 50; + if ($username === '') + return new \WP_Error('missing_username', 'Username is required.', ['status' => 400]); + + $user = get_user_by('login', $username); + if (!$user) + return new \WP_Error('user_not_found', 'No WordPress account found with that username.', ['status' => 404]); + $all = \ACore\Hooks\User\acore_get_login_history($user->ID, 500); + + $all = is_array($all) ? $all : []; + $total = count($all); + $offset = ($page - 1) * $perPage; + $slice = array_slice($all, $offset, $perPage); + + $history = []; + foreach ($slice as $r) { + $history[] = [ + 'ip' => $r['ip_address'] ?? '', + 'country' => $r['country'] ?? 'Unknown', + 'date' => isset($r['login_at']) ? \ACore\Hooks\User\acore_format_connection_date($r['login_at']) : '', + 'where' => (($r['source'] ?? 'website') === 'ingame') ? 'In-game' : 'Website', + ]; + } + return [ + 'found' => true, + 'username' => $username, + 'history' => $history, + 'total' => $total, + 'from' => $total ? $offset + 1 : 0, + 'to' => $offset + count($slice), + 'page' => $page, + 'has_more' => ($offset + count($slice)) < $total, + ]; + } + )); + + register_rest_route( ACORE_SLUG . '/v1', 'remove-ingame-2fa', array( + 'methods' => 'POST', + 'permission_callback' => function() { return is_user_logged_in(); }, + 'callback' => function( \WP_REST_Request $request ) { + $user = wp_get_current_user(); + if (!acore_website_2fa_enabled($user->ID)) + return new \WP_Error('no_website_2fa', __('You must have website 2FA enabled to remove in-game 2FA from here.'), ['status' => 403]); + + // Authorise via a recent panel unlock (TOTP or email) OR a fresh TOTP code. + if (!get_transient(acore_2fa_unlock_key($user->ID))) { + if (!acore_website_totp_enabled($user->ID)) + return new \WP_Error('verify_required', __('Please verify your 2FA above before removing in-game 2FA.'), ['status' => 403]); + $data = $request->get_json_params(); + $token = isset($data['token']) ? trim((string) $data['token']) : ''; + if (!preg_match('/^\d{6}$/', $token)) + return new \WP_Error('invalid_token', __('Please enter a valid 6-digit code.'), ['status' => 400]); + if (!\ACore\Components\ServerInfo\acore_wp2fa_code_is_valid($user->ID, $token)) + return new \WP_Error('wrong_token', __('Incorrect code. Please try again.'), ['status' => 401]); + } + + try { + $accId = ACoreServices::I()->getAcoreAccountId(); + if (!$accId) return new \WP_Error('no_account', __('Could not find your game account.'), ['status' => 404]); + $conn = ACoreServices::I()->getAccountEm()->getConnection(); + $conn->executeStatement('UPDATE account SET totp_secret = NULL WHERE id = ?', [$accId]); + return ['success' => true]; + } catch (\Exception $e) { + return new \WP_Error('db_error', __('Database error. Please try again.'), ['status' => 500]); + } + } + ) ); + + // User: verify own website 2FA (TOTP) code - used to gate sensitive panels (e.g. backup codes) + register_rest_route( ACORE_SLUG . '/v1', 'verify-website-2fa', array( + 'methods' => 'POST', + 'permission_callback' => function() { return is_user_logged_in(); }, + 'callback' => function( \WP_REST_Request $request ) { + $user = wp_get_current_user(); + if (!acore_website_totp_enabled($user->ID)) + return new \WP_Error('no_website_2fa', __('Website 2FA is not enabled on your account.'), ['status' => 400]); + + $attemptKey = acore_2fa_attempt_key($user->ID); + if ((int) get_transient($attemptKey) >= 5) + return new \WP_Error('rate_limited', __('Too many incorrect codes. Please wait a few minutes.', 'acore-wp-plugin'), ['status' => 429]); + + $data = $request->get_json_params(); + $token = isset($data['token']) ? trim((string) $data['token']) : ''; + if (!preg_match('/^\d{6}$/', $token)) + return new \WP_Error('invalid_token', __('Please enter a valid 6-digit code.'), ['status' => 400]); + if (!\ACore\Components\ServerInfo\acore_wp2fa_code_is_valid($user->ID, $token)) { + set_transient($attemptKey, ((int) get_transient($attemptKey)) + 1, 10 * MINUTE_IN_SECONDS); + return new \WP_Error('wrong_token', __('Incorrect code. Please try again.'), ['status' => 401]); + } + + delete_transient($attemptKey); + // Remember this unlock briefly so page refreshes don't re-prompt for the code. + set_transient(acore_2fa_unlock_key($user->ID), time(), 30 * MINUTE_IN_SECONDS); + + return ['success' => true]; + } + ) ); + + // User: email an unlock code (for accounts using email-based website 2FA) + register_rest_route( ACORE_SLUG . '/v1', 'request-email-2fa', array( + 'methods' => 'POST', + 'permission_callback' => function() { return is_user_logged_in(); }, + 'callback' => function() { + $user = wp_get_current_user(); + $rateKey = 'acore_2fa_email_rate_' . $user->ID; + if (get_transient($rateKey)) + return new \WP_Error('rate_limited', __('Please wait before requesting another code.'), ['status' => 429]); + + $primary = get_user_meta($user->ID, 'wp_2fa_enabled_methods', true); + $methods = is_array($primary) ? $primary : ($primary !== '' ? [$primary] : []); + if (!in_array('email', $methods, true)) + return new \WP_Error('no_email_2fa', __('Email 2FA is not enabled on your account.'), ['status' => 400]); + + $code = (string) wp_rand(100000, 999999); + set_transient('acore_2fa_email_code_' . acore_2fa_unlock_key($user->ID), wp_hash($code), 10 * MINUTE_IN_SECONDS); + set_transient($rateKey, true, 60); + + $sent = wp_mail( + $user->user_email, + __('Your security verification code', 'acore-wp-plugin'), + sprintf(__('Your verification code is: %s (valid for 10 minutes).', 'acore-wp-plugin'), $code) + ); + if (!$sent) + return new \WP_Error('email_failed', __('Could not send the email. Please try again later.'), ['status' => 500]); + + return ['success' => true]; + } + ) ); + + // User: verify an emailed code to unlock sensitive panels (same unlock as TOTP) + register_rest_route( ACORE_SLUG . '/v1', 'verify-email-2fa', array( + 'methods' => 'POST', + 'permission_callback' => function() { return is_user_logged_in(); }, + 'callback' => function( \WP_REST_Request $request ) { + $user = wp_get_current_user(); + $key = 'acore_2fa_email_code_' . acore_2fa_unlock_key($user->ID); + $attemptKey = acore_2fa_attempt_key($user->ID); + if ((int) get_transient($attemptKey) >= 5) + return new \WP_Error('rate_limited', __('Too many incorrect codes. Please wait a few minutes.', 'acore-wp-plugin'), ['status' => 429]); + + $data = $request->get_json_params(); + $code = isset($data['code']) ? trim((string) $data['code']) : ''; + if (!preg_match('/^\d{6}$/', $code)) + return new \WP_Error('invalid_token', __('Please enter a valid 6-digit code.'), ['status' => 400]); + + $stored = get_transient($key); + if (!$stored || !hash_equals((string) $stored, wp_hash($code))) { + set_transient($attemptKey, ((int) get_transient($attemptKey)) + 1, 10 * MINUTE_IN_SECONDS); + return new \WP_Error('wrong_token', __('Incorrect or expired code. Please try again.'), ['status' => 401]); + } + + delete_transient($attemptKey); + delete_transient($key); + set_transient(acore_2fa_unlock_key($user->ID), time(), 30 * MINUTE_IN_SECONDS); + + return ['success' => true]; + } + ) ); + + // User: lightweight 2FA status for real-time removal detection on the Security page + register_rest_route( ACORE_SLUG . '/v1', '2fa-status', array( + 'methods' => 'GET', + 'permission_callback' => function() { return is_user_logged_in(); }, + 'callback' => function() { + $user = wp_get_current_user(); + $log = get_user_meta($user->ID, 'acore_2fa_admin_log', true); + $log = is_array($log) ? $log : []; + return [ + 'website_enabled' => acore_website_2fa_enabled($user->ID), + 'ingame_enabled' => acore_ingame_2fa_enabled($user->ID), + 'removal_count' => count($log), + ]; + } + ) ); + + // User: paginated connection history (for "see more" without a page reload) + register_rest_route( ACORE_SLUG . '/v1', 'connections', array( + 'methods' => 'GET', + 'permission_callback' => function() { return is_user_logged_in(); }, + 'callback' => function( \WP_REST_Request $request ) { + $user = wp_get_current_user(); + $perPage = 50; + $page = max(1, (int) $request->get_param('page')); + + $all = \ACore\Hooks\User\acore_get_login_history($user->ID, 500); + $all = is_array($all) ? $all : []; + $total = count($all); + $offset = ($page - 1) * $perPage; + $slice = array_slice($all, $offset, $perPage); + $myIp = \ACore\Hooks\User\acore_resolve_client_ip(); + + $rows = array_map(function ($r) use ($myIp) { + $ip = $r['ip_address'] ?? ($r['ip'] ?? ''); + return [ + 'ip' => $ip, + 'country' => ($r['country'] ?? '') !== '' ? $r['country'] : 'Unknown', + 'date' => ($r['login_at'] ?? '') !== '' ? \ACore\Hooks\User\acore_format_connection_date($r['login_at']) : '', + 'where' => (($r['source'] ?? 'website') === 'ingame') ? 'In-game' : 'Website', + 'current' => ($ip !== '' && $ip === $myIp), + ]; + }, $slice); + + return [ + 'rows' => array_values($rows), + 'page' => $page, + 'total' => $total, + 'from' => $total ? $offset + 1 : 0, + 'to' => $offset + count($slice), + 'has_more' => ($offset + count($slice)) < $total, + ]; } ) ); }); diff --git a/src/acore-wp-plugin/src/Components/UserPanel/Pages/SecurityPage.php b/src/acore-wp-plugin/src/Components/UserPanel/Pages/SecurityPage.php new file mode 100644 index 000000000..3d30d1be0 --- /dev/null +++ b/src/acore-wp-plugin/src/Components/UserPanel/Pages/SecurityPage.php @@ -0,0 +1,678 @@ + + +
+

+ +
+
+

+
+
+ + +
+

+
+ + +

+ + +

+ + + +
+
+ + + + + + + + + + + + + + + +

+ + +

+
+
+ +
+
+ + ID)); + $restBase = rest_url(ACORE_SLUG . '/v1/remove-ingame-2fa'); + $verifyBase = rest_url(ACORE_SLUG . '/v1/verify-website-2fa'); + $emailRequestBase = rest_url(ACORE_SLUG . '/v1/request-email-2fa'); + $emailVerifyBase = rest_url(ACORE_SLUG . '/v1/verify-email-2fa'); + $statusBase = rest_url(ACORE_SLUG . '/v1/2fa-status'); + $restNonce = wp_create_nonce('wp_rest'); + + // Admin-removal log: find last entry per type + $adminLog = get_user_meta($user->ID, 'acore_2fa_admin_log', true); + $adminLog = is_array($adminLog) ? $adminLog : []; + $lastWebRemoval = null; + $lastGameRemoval = null; + $lastBackupRemoval = null; + foreach ($adminLog as $entry) { + if ($entry['type'] === 'website') $lastWebRemoval = $entry; + if ($entry['type'] === 'ingame') $lastGameRemoval = $entry; + if ($entry['type'] === 'backup') $lastBackupRemoval = $entry; + } + $backupCodesMeta = get_user_meta($user->ID, 'wp_2fa_backup_codes', true); + $backupCodesLeft = is_array($backupCodesMeta) ? count($backupCodesMeta) : 0; + // Only warn if 2FA is not currently active (user hasn't re-enabled yet) + $showWebWarning = $lastWebRemoval && !$websiteAnyEnabled; + $showGameWarning = $lastGameRemoval && !$ingame2faActive; + $showBackupWarning = $lastBackupRemoval && $backupCodesLeft === 0; + ?> + +
+
+

+
+
+ + +
+

+ + +

+ +

+ - ' . esc_html(wp_date('jS \o\f F, Y \a\t H:i', $lastWebRemoval['timestamp'])) . ''; + if (($lastWebRemoval['by'] ?? 'admin') === 'self') { + printf( + __('Website 2FA was manually removed by you on %1$s (last IP: %2$s). Please re-enable it for account security.', 'acore-wp-plugin'), + $webDate, + '' . esc_html($lastWebRemoval['ip'] ?? __('unknown', 'acore-wp-plugin')) . '' + ); + } else { + printf( + __('Website 2FA was manually removed by an administrator on %1$s. Please re-enable it for account security.', 'acore-wp-plugin'), + $webDate + ); + } + ?> +

+ + +

+ - ' . esc_html(wp_date('jS \o\f F, Y \a\t H:i', $lastGameRemoval['timestamp'])) . '' + ); ?> +

+ + +

+ - ' . esc_html(wp_date('jS \o\f F, Y \a\t H:i', $lastBackupRemoval['timestamp'])) . '' + ); ?> +

+ +
+ + +

+ Time-based (TOTP) key.', 'acore-wp-plugin'); ?> + two separate setups: one exclusively for logging into the website, and another for logging into the game server. Each has its own independent code.', 'acore-wp-plugin'); ?> +

+ +
+ + +

+ + + callbacks as $callbacks) { + foreach ($callbacks as $cb) { + $func = $cb['function']; + $id = ''; + if (is_array($func) && is_object($func[0])) $id = get_class($func[0]); + elseif (is_array($func) && is_string($func[0])) $id = $func[0]; + elseif (is_string($func)) $id = $func; + if ($id && ( + stripos($id, 'WP2FA') !== false || + stripos($id, 'wp_2fa') !== false || + stripos($id, 'Two_Factor') !== false + )) { + call_user_func($func, $user); + } + } + } + } + $wp2faHtml = ob_get_clean(); + // Strip the WP2FA section heading + subtitle (redundant with our own "Website" label) + $wp2faHtml = preg_replace( + '/]*>\s*Two-factor authentication settings\s*<\/h[1-4]>\s*(]*>[^<]*<\/p>)?/i', + '', + $wp2faHtml + ); + ?> + + + +
+ +
+ + +
+

+ +

+
+ + +
+
+
+ + + +
+

+ +

+

+ +

+
+ + +
+
+
+ + + + + + + +

+ + +
+ + +

+ + + + + + + + + + +

+ + + +
+ + + + +

+ +

+
    +
  1. + + .account 2fa setup 1 +
  2. +
  3. + 2FA Key, for example:', 'acore-wp-plugin'); ?> + K6NXC763GDQTZJG3CTH4WIOGAW6MZYOO +
  4. +
  5. + +
    +
  6. +
  7. + Time based (TOTP).', 'acore-wp-plugin'); ?> +
  8. +
  9. + +
  10. +
  11. + + .account 2fa setup <6-digit-code> + without the < > brackets.', 'acore-wp-plugin'); ?> +
  12. +
  13. + +
  14. +
+ + +

+ +

+ +
+
+ + +
+
+
+ + + +
+

+ + +

+ + +
+
+ + +
+
+

+
+
+ + + + +

+ +

+ - + $perPage): ?> + + +

+ + + + + + + + + + + + + > + + + + + + + +
+ +

+ +

+ + + +
+
+ +
+ + diff --git a/src/acore-wp-plugin/src/Components/UserPanel/UserController.php b/src/acore-wp-plugin/src/Components/UserPanel/UserController.php index 1199fb418..9424cfaa9 100644 --- a/src/acore-wp-plugin/src/Components/UserPanel/UserController.php +++ b/src/acore-wp-plugin/src/Components/UserPanel/UserController.php @@ -1,175 +1,224 @@ -view = new UserView($this); - } - - public function showRafProgress() { - try { - $acServices = ACoreServices::I(); - } catch (\Exception $e) { - wp_die(__($e->getMessage())); - } - $errorMessages = []; - $user = wp_get_current_user(); - - if ($_SERVER['REQUEST_METHOD'] == 'POST') { - $maxRecruitDatetime = (new \DateTime($user->get("user_registered")))->modify('+7days'); - - if ($maxRecruitDatetime < (new \DateTime())) { - $errorMessages[] = "You can't be recruited by a friend, the 7 days limit has passed."; - } else { - if (!isset($_POST["recruited"])) { - $errorMessages[] = "No recruiter value sent."; - } else { - $recruiterCode = $_POST["recruited"]; - $existingRecruiterId = $acServices->getUserNameByUserId($recruiterCode); - $newRecruitId = $acServices->getAcoreAccountId(); - - if (!$newRecruitId || !$existingRecruiterId) { - $errorMessages[] = "Recruiter id or user id not found, please try again or contact a staff member."; - } - - if ($recruiterCode == $newRecruitId) { - $errorMessages[] = "You can't recruit yourself."; - } - - if (Opts::I()->eluna_raf_config['check_ip'] === '1') { - $userIp = $acServices->getAcoreAccountLastIp(); - $activeUserIp = ""; - if ( ! empty( $_SERVER['HTTP_CLIENT_IP'] ) ) { - // check ip from share internet - $activeUserIp = $_SERVER['HTTP_CLIENT_IP']; - } elseif ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) { - // to check ip is pass from proxy - $activeUserIp = $_SERVER['HTTP_X_FORWARDED_FOR']; - } else { - // use default remote ip - $activeUserIp = $_SERVER['REMOTE_ADDR']; - } - - $activeUserIp = apply_filters( 'wpb_get_ip', $activeUserIp ); - $recruiterIp = $acServices->getAcoreAccountLastIpById($recruiterCode); - if (isset($userIp) && isset($recruiterIp)) { - if ($userIp != '127.0.0.1' && $recruiterIp != '127.0.0.1' && ($userIp == $recruiterIp || $activeUserIp == $recruiterIp)) { - $errorMessages[] = "You can't be recruited by a player with your same IP."; - } - } - } - - if (count($errorMessages) == 0) { - $soap = ACoreServices::I()->getServerSoap(); - $res = $soap->serverInfo(); - - if ($res instanceof \Exception) { - $errorMessages[] = "The server seems to be offline, try again later!"; - } else { - $res = $soap->executeCommand("bindraf $newRecruitId $recruiterCode"); - if ($res instanceof \Exception) { - $errorMessages[] = "An error ocurred while binding accounts. Please try again later."; - } - } - - } - } - - } - if (count($errorMessages) > 0) { - ?>

getAcoreAccountId(); - if (!isset($accId)) { - wp_die("

An error ocurred while loading your account information, please try again later. If this errors continues, please ask for support.

"); - } - - try { - $conn = $acServices->getElunaMgr()->getConnection(); - - $query = "SELECT `account_id`, `recruiter_account`, `time_stamp`, `ip_abuse_counter`, `kick_counter` - FROM `recruit_a_friend_links` - WHERE `account_id` = $accId - "; - $queryResult = $conn->executeQuery($query); - $rafPersonalInfo = $queryResult->fetchAssociative(); - - $query = "SELECT COALESCE(`reward_level`, 0) as reward_level - FROM `recruit_a_friend_rewards` - WHERE `recruiter_account` = $accId - "; - $queryResult = $conn->executeQuery($query); - $rafPersonalProgress = $queryResult->fetchAssociative(); - - if (!isset($rafPersonalProgress['reward_level'])) { - $rafPersonalProgress = ['reward_level' => 0]; - } - - $query = "SELECT `account_id`, `recruiter_account`, `time_stamp`, `ip_abuse_counter`, `kick_counter` - FROM `recruit_a_friend_links` - WHERE `recruiter_account` = $accId - "; - $queryResult = $conn->executeQuery($query); - $rafRecruitedInfo = $queryResult->fetchAllAssociative(); - } catch (\Exception $e) { - echo '

Recruit a Friend

' - . '

No Recruit-A-Friend installation was found. ' - . 'Please ensure the Eluna database is properly configured and the RAF module is installed on your server.

'; - return; - } - - echo $this->getView()->getRafProgressRender($rafPersonalInfo, $rafPersonalProgress, $rafRecruitedInfo); - } - - public function showItemRestorationPage() { - if ($_SERVER["REQUEST_METHOD"] == "POST") { - $this->saveCharacterOrder(); - ?> -

Character settings succesfully saved.

- getAcoreAccountId(); - $conn = ACoreServices::I()->getCharacterEm()->getConnection(); - $queryResult = $conn->executeQuery( - " SELECT `guid`, `name`, `order` - FROM `characters` - WHERE `characters`.`deleteDate` IS NULL AND `account` = $account - ORDER BY `order`, `guid` - " - ); - - echo $this->getView()->getItemRestorationRender($queryResult->fetchAllAssociative()); - } - - /** - * - * @return UserView - */ - public function getView() { - return $this->view; - } - - /** - * - * @return UserModel - */ - public function getModel() { - return $this->model; - } - -} +view = new UserView(); + } + + public function showRafProgress() { + try { + $acServices = ACoreServices::I(); + } catch (\Exception $e) { + wp_die(__($e->getMessage())); + } + $errorMessages = []; + $user = wp_get_current_user(); + + if ($_SERVER['REQUEST_METHOD'] == 'POST') { + check_admin_referer('acore_raf_recruit', 'acore_raf_nonce'); + $maxRecruitDatetime = (new \DateTime($user->get("user_registered")))->modify('+7days'); + + if ($maxRecruitDatetime < (new \DateTime())) { + $errorMessages[] = "You can't be recruited by a friend, the 7 days limit has passed."; + } else { + if (!isset($_POST["recruited"])) { + $errorMessages[] = "No recruiter value sent."; + } else { + $recruiterCode = absint($_POST["recruited"]); + if ($recruiterCode <= 0) { + $errorMessages[] = "Invalid recruiter value sent."; + } + $existingRecruiterId = $acServices->getUserNameByUserId($recruiterCode); + $newRecruitId = $acServices->getAcoreAccountId(); + + if (!$newRecruitId || !$existingRecruiterId) { + $errorMessages[] = "Recruiter id or user id not found, please try again or contact a staff member."; + } + + if ($recruiterCode == $newRecruitId) { + $errorMessages[] = "You can't recruit yourself."; + } + + if (Opts::I()->eluna_raf_config['check_ip'] === '1') { + $userIp = $acServices->getAcoreAccountLastIp(); + $activeUserIp = ""; + if ( ! empty( $_SERVER['HTTP_CLIENT_IP'] ) ) { + $activeUserIp = $_SERVER['HTTP_CLIENT_IP']; + } elseif ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) { + $activeUserIp = $_SERVER['HTTP_X_FORWARDED_FOR']; + } else { + $activeUserIp = $_SERVER['REMOTE_ADDR']; + } + + $activeUserIp = apply_filters( 'wpb_get_ip', $activeUserIp ); + $recruiterIp = $acServices->getAcoreAccountLastIpById($recruiterCode); + if (isset($userIp) && isset($recruiterIp)) { + if ($userIp != '127.0.0.1' && $recruiterIp != '127.0.0.1' && ($userIp == $recruiterIp || $activeUserIp == $recruiterIp)) { + $errorMessages[] = "You can't be recruited by a player with your same IP."; + } + } + } + + if (count($errorMessages) == 0) { + $soap = ACoreServices::I()->getServerSoap(); + $res = $soap->serverInfo(); + + if ($res instanceof \Exception) { + $errorMessages[] = "The server seems to be offline, try again later!"; + } else { + $res = $soap->executeCommand("bindraf $newRecruitId $recruiterCode"); + if ($res instanceof \Exception) { + $errorMessages[] = "An error ocurred while binding accounts. Please try again later."; + } + } + + } + } + + } + if (count($errorMessages) > 0) { + ?>

getAcoreAccountId(); + if (!isset($accId)) { + wp_die("

An error ocurred while loading your account information, please try again later. If this errors continues, please ask for support.

"); + } + + try { + $conn = $acServices->getElunaMgr()->getConnection(); + + $query = "SELECT `account_id`, `recruiter_account`, `time_stamp`, `ip_abuse_counter`, `kick_counter` + FROM `recruit_a_friend_links` + WHERE `account_id` = $accId + "; + $queryResult = $conn->executeQuery($query); + $rafPersonalInfo = $queryResult->fetchAssociative(); + + $query = "SELECT COALESCE(`reward_level`, 0) as reward_level + FROM `recruit_a_friend_rewards` + WHERE `recruiter_account` = $accId + "; + $queryResult = $conn->executeQuery($query); + $rafPersonalProgress = $queryResult->fetchAssociative(); + + if (!isset($rafPersonalProgress['reward_level'])) { + $rafPersonalProgress = ['reward_level' => 0]; + } + + $query = "SELECT `account_id`, `recruiter_account`, `time_stamp`, `ip_abuse_counter`, `kick_counter` + FROM `recruit_a_friend_links` + WHERE `recruiter_account` = $accId + "; + $queryResult = $conn->executeQuery($query); + $rafRecruitedInfo = $queryResult->fetchAllAssociative(); + } catch (\Exception $e) { + echo '

Recruit a Friend

' + . '

No Recruit-A-Friend installation was found. ' + . 'Please ensure the Eluna database is properly configured and the RAF module is installed on your server.

'; + return; + } + + echo $this->getView()->getRafProgressRender($rafPersonalInfo, $rafPersonalProgress, $rafRecruitedInfo); + } + + public function showItemRestorationPage() { + $account = ACoreServices::I()->getAcoreAccountId(); + $conn = ACoreServices::I()->getCharacterEm()->getConnection(); + $queryResult = $conn->executeQuery( + "SELECT `guid`, `name`, `order`, `race`, `class`, `gender`, `level` + FROM `characters` + WHERE `deleteDate` IS NULL AND `account` = $account + ORDER BY `order`, `guid`" + ); + + echo $this->getView()->getItemRestorationRender($queryResult->fetchAllAssociative()); + } + + public function showSecurityPage() { + $user = wp_get_current_user(); + + // Detect & log a user-initiated Website 2FA removal (records the user's IP). + if (function_exists('ACore\\Components\\ServerInfo\\acore_2fa_sync_self_removals')) { + \ACore\Components\ServerInfo\acore_2fa_sync_self_removals($user->ID); + } + + $passwordMessage = \ACore\Hooks\User\acore_pw_get_message($user->ID); + $passwordChangedAt = get_user_meta($user->ID, 'acore_password_changed_at', true); + $twoFaData = $this->getTwoFaData($user); + $ingame2faActive = $this->getIngame2faStatus(); + $connections = \ACore\Hooks\User\acore_get_login_history($user->ID, 500); + + echo $this->getView()->getSecurityRender($connections, $passwordChangedAt, $twoFaData, $ingame2faActive, $passwordMessage); + } + + private function getIngame2faStatus(): bool { + try { + $accId = ACoreServices::I()->getAcoreAccountId(); + if (!$accId) return false; + $conn = ACoreServices::I()->getAccountEm()->getConnection(); + $result = $conn->executeQuery('SELECT totp_secret FROM account WHERE id = ?', [$accId]); + $row = $result->fetchAssociative(); + return $row && $row['totp_secret'] !== null; + } catch (\Exception $e) { + return false; + } + } + + private function getTwoFaData(\WP_User $user) { + $active = class_exists('\WP2FA\WP2FA') || function_exists('wp2fa_get_enabled_providers_for_user'); + + if (!$active) { + return ['plugin_active' => false]; + } + + $primaryMethods = get_user_meta($user->ID, 'wp_2fa_enabled_methods', true); + $backupMethods = get_user_meta($user->ID, 'wp_2fa_backup_methods_enabled', true); + $totpKey = get_user_meta($user->ID, 'wp_2fa_totp_key', true); + // TOTP is only considered enabled when the key exists AND it is actively + // listed as a primary method - WP2FA may leave the key in meta after removal. + $totpEnabled = !empty($totpKey) && ( + (is_array($primaryMethods) && in_array('totp', $primaryMethods)) || + $primaryMethods === 'totp' + ); + $emailEnabled = (is_array($primaryMethods) && in_array('email', $primaryMethods)) + || $primaryMethods === 'email'; + + return [ + 'plugin_active' => true, + 'primary_methods' => $primaryMethods ?: [], + 'backup_methods' => $backupMethods ?: [], + 'totp_enabled' => $totpEnabled, + 'email_enabled' => $emailEnabled, + 'setup_url' => admin_url('profile.php?page=wp-2fa-setup'), + ]; + } + + /** + * @return UserView + */ + public function getView() { + return $this->view; + } + + /** + * @return UserModel + */ + public function getModel() { + return $this; + } + +} diff --git a/src/acore-wp-plugin/src/Components/UserPanel/UserMenu.php b/src/acore-wp-plugin/src/Components/UserPanel/UserMenu.php index 8d70c09b8..3bd8b7366 100644 --- a/src/acore-wp-plugin/src/Components/UserPanel/UserMenu.php +++ b/src/acore-wp-plugin/src/Components/UserPanel/UserMenu.php @@ -26,9 +26,10 @@ public static function I() return self::$instance; } - // action function for above hook function acore_user_menu() { + add_submenu_page('profile.php', 'Security', 'Security', 'read', ACORE_SLUG . '-security', array($this, 'security_page')); + if (Opts::I()->eluna_recruit_a_friend == '1') { add_submenu_page('profile.php', 'Recruit a Friend', 'Recruit a Friend', 'read', ACORE_SLUG . '-eluna-raf-progress', array($this, 'eluna_raf_progress_page')); } @@ -38,6 +39,13 @@ function acore_user_menu() } } + // action function for above hook + function security_page() + { + $ctrl = new UserController(); + $ctrl->showSecurityPage(); + } + // action function for above hook function eluna_raf_progress_page() { @@ -48,6 +56,7 @@ function eluna_raf_progress_page() } } + // action function for above hook function item_restoration_page() { $SettingsCtrl = new UserController(); diff --git a/src/acore-wp-plugin/src/Components/UserPanel/UserView.php b/src/acore-wp-plugin/src/Components/UserPanel/UserView.php index 97b1b8528..8fb260b8b 100644 --- a/src/acore-wp-plugin/src/Components/UserPanel/UserView.php +++ b/src/acore-wp-plugin/src/Components/UserPanel/UserView.php @@ -1,28 +1,36 @@ -prefix . 'acore_login_history'; + $collate = $wpdb->get_charset_collate(); + $sql = "CREATE TABLE $table ( + id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT, + user_id bigint(20) UNSIGNED NOT NULL, + ip_address varchar(45) NOT NULL, + country varchar(10) NOT NULL DEFAULT 'Unknown', + source varchar(20) NOT NULL DEFAULT 'website', + login_at datetime NOT NULL, + PRIMARY KEY (id), + KEY user_id (user_id), + KEY login_at (login_at) + ) $collate;"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta($sql); + update_option('acore_login_history_db_version', '2'); +} + +add_action('wp_login', __NAMESPACE__ . '\\acore_log_login', 10, 2); + +function acore_log_login($user_login, $user) { + if (get_option('acore_security_logging', '0') !== '1') { + return; + } + + global $wpdb; + + $ip = acore_resolve_client_ip(); + $country = acore_lookup_country($ip); + + $wpdb->insert( + $wpdb->prefix . 'acore_login_history', + [ + 'user_id' => $user->ID, + 'ip_address' => $ip, + 'country' => $country, + 'source' => 'website', + 'login_at' => current_time('mysql'), + ], + ['%d', '%s', '%s', '%s', '%s'] + ); + + // Also capture the account's most recent in-game login IP. + acore_log_ingame_last_ip($user); +} + +/** + * Record the player's last in-game login IP (from the game account table) as an + * "ingame" connection entry. Skipped if it already matches the latest one. + */ +function acore_log_ingame_last_ip($user) { + try { + $conn = \ACore\Manager\ACoreServices::I()->getAccountEm()->getConnection(); + $row = $conn->executeQuery( + 'SELECT last_ip, last_login FROM account WHERE username = ?', + [strtoupper($user->user_login)] + )->fetchAssociative(); + } catch (\Throwable $e) { + return; + } + + if (!$row || empty($row['last_ip']) || $row['last_ip'] === '0.0.0.0') { + return; + } + + $ip = sanitize_text_field($row['last_ip']); + $loginAt = !empty($row['last_login']) ? $row['last_login'] : current_time('mysql'); + + global $wpdb; + $table = $wpdb->prefix . 'acore_login_history'; + + // De-dup: skip if the latest ingame row already matches this IP + time. + $last = $wpdb->get_row($wpdb->prepare( + "SELECT ip_address, login_at FROM {$table} WHERE user_id = %d AND source = 'ingame' ORDER BY login_at DESC, id DESC LIMIT 1", + $user->ID + ), ARRAY_A); + if ($last && $last['ip_address'] === $ip && (string) $last['login_at'] === (string) $loginAt) { + return; + } + + $wpdb->insert( + $table, + [ + 'user_id' => $user->ID, + 'ip_address' => $ip, + 'country' => acore_lookup_country($ip), + 'source' => 'ingame', + 'login_at' => $loginAt, + ], + ['%d', '%s', '%s', '%s', '%s'] + ); +} + +function acore_resolve_client_ip() { + $remote = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'; + $ip = $remote; + + // Honour forwarded headers only when REMOTE_ADDR is a configured trusted proxy. + if (get_option('acore_trust_proxy_headers', '0') === '1' && acore_ip_is_trusted_proxy($remote)) { + if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { + $parts = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']); + $candidate = trim($parts[0]); + if (filter_var($candidate, FILTER_VALIDATE_IP)) { + $ip = $candidate; + } + } elseif (!empty($_SERVER['HTTP_CLIENT_IP']) && filter_var($_SERVER['HTTP_CLIENT_IP'], FILTER_VALIDATE_IP)) { + $ip = $_SERVER['HTTP_CLIENT_IP']; + } + } + + $ip = trim((string) $ip); + if (!filter_var($ip, FILTER_VALIDATE_IP)) { + $ip = $remote; + } + return $ip; +} + +function acore_ip_is_trusted_proxy($ip): bool { + $list = get_option('acore_trusted_proxies', ''); + if (!is_string($list) || trim($list) === '') { + return false; + } + foreach (preg_split('/[\s,]+/', trim($list)) as $entry) { + if ($entry !== '' && acore_ip_in_cidr($ip, $entry)) { + return true; + } + } + return false; +} + +function acore_ip_in_cidr($ip, $cidr): bool { + if (strpos($cidr, '/') === false) { + return $ip === $cidr; + } + list($subnet, $bits) = explode('/', $cidr, 2); + if (!ctype_digit((string) $bits)) { + return false; + } + $bits = (int) $bits; + $ipBin = @inet_pton($ip); + $suBin = @inet_pton($subnet); + if ($ipBin === false || $suBin === false || strlen($ipBin) !== strlen($suBin)) { + return false; + } + if ($bits > strlen($ipBin) * 8) { + return false; + } + $bytes = intdiv($bits, 8); + $rem = $bits % 8; + if ($bytes > 0 && substr($ipBin, 0, $bytes) !== substr($suBin, 0, $bytes)) { + return false; + } + if ($rem > 0) { + $mask = chr((0xff << (8 - $rem)) & 0xff); + if ((ord($ipBin[$bytes]) & ord($mask)) !== (ord($suBin[$bytes]) & ord($mask))) { + return false; + } + } + return true; +} + +function acore_geoip_is_public_ip($ip): bool { + if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + return false; + } + $long = ip2long($ip); + // RFC 6598 shared address space (100.64.0.0/10) is not covered by the flags. + if ($long !== false && ($long & 0xFFC00000) === (ip2long('100.64.0.0') & 0xFFC00000)) { + return false; + } + return true; +} + +// Reuse a country already resolved for this IP in the history table (oldest wins). +function acore_geoip_known_country($ip) { + global $wpdb; + $table = $wpdb->prefix . 'acore_login_history'; + $c = $wpdb->get_var($wpdb->prepare( + "SELECT country FROM {$table} WHERE ip_address = %s AND country NOT IN ('Unknown', 'Local', '') ORDER BY login_at ASC, id ASC LIMIT 1", + $ip + )); + return $c ?: null; +} + +function acore_geoip_is_blocked(): bool { + return (bool) get_transient('acore_geoip_blocked'); +} + +// Honour ip-api rate-limit headers: if X-Rl hits 0, stop calling for X-Ttl seconds. +function acore_geoip_note_headers($response): void { + $rl = wp_remote_retrieve_header($response, 'x-rl'); + $ttl = wp_remote_retrieve_header($response, 'x-ttl'); + if ($rl !== '' && (int) $rl <= 0) { + set_transient('acore_geoip_blocked', 1, max(1, (int) $ttl)); + } +} + +function acore_lookup_country($ip) { + if (!acore_geoip_is_public_ip($ip)) { + return 'Local'; + } + if (get_option('acore_geoip_lookup', '0') !== '1') { + return 'Unknown'; + } + // Already resolved this IP before? Reuse it - no API call. + $known = acore_geoip_known_country($ip); + if ($known) { + return $known; + } + // Inside an ip-api cooldown - defer to the backfill cron. + if (acore_geoip_is_blocked()) { + return 'Unknown'; + } + + $response = wp_remote_get("http://ip-api.com/json/{$ip}?fields=status,countryCode", [ + 'timeout' => 3, + ]); + if (is_wp_error($response)) { + return 'Unknown'; + } + acore_geoip_note_headers($response); + + $data = json_decode(wp_remote_retrieve_body($response), true); + if (isset($data['status'], $data['countryCode']) && $data['status'] === 'success') { + return $data['countryCode']; + } + return 'Unknown'; +} + +/* GeoIP backfill: resolve Unknown countries in the background, oldest first, + reusing known IPs and using the batch endpoint, within ip-api's limits. */ +add_filter('cron_schedules', function ($s) { + if (!isset($s['acore_5min'])) { + $s['acore_5min'] = ['interval' => 300, 'display' => 'Every 5 minutes (ACore)']; + } + return $s; +}); + +add_action('init', __NAMESPACE__ . '\\acore_geoip_schedule_backfill'); +function acore_geoip_schedule_backfill() { + if (!wp_next_scheduled('acore_geoip_backfill_event')) { + wp_schedule_event(time() + 300, 'acore_5min', 'acore_geoip_backfill_event'); + } +} + +add_action('acore_geoip_backfill_event', __NAMESPACE__ . '\\acore_geoip_backfill'); +function acore_geoip_backfill() { + if (get_option('acore_security_logging', '0') !== '1' + || get_option('acore_geoip_lookup', '0') !== '1' + || acore_geoip_is_blocked()) { + return; + } + + global $wpdb; + $table = $wpdb->prefix . 'acore_login_history'; + + $rows = $wpdb->get_results( + "SELECT ip_address, MIN(login_at) AS oldest + FROM {$table} + WHERE country = 'Unknown' + GROUP BY ip_address + ORDER BY oldest ASC + LIMIT 100", + ARRAY_A + ); + if (!$rows) { + return; + } + + $toQuery = []; + foreach ($rows as $r) { + $ip = $r['ip_address']; + if (!acore_geoip_is_public_ip($ip)) { + $wpdb->query($wpdb->prepare("UPDATE {$table} SET country = 'Local' WHERE ip_address = %s AND country = 'Unknown'", $ip)); + continue; + } + $known = acore_geoip_known_country($ip); + if ($known) { + $wpdb->query($wpdb->prepare("UPDATE {$table} SET country = %s WHERE ip_address = %s AND country = 'Unknown'", $known, $ip)); + continue; + } + $toQuery[] = $ip; + } + if (empty($toQuery)) { + return; + } + + $toQuery = array_slice(array_values(array_unique($toQuery)), 0, 100); + $response = wp_remote_post('http://ip-api.com/batch?fields=status,countryCode,query', [ + 'timeout' => 5, + 'headers' => ['Content-Type' => 'application/json'], + 'body' => wp_json_encode(array_map(function ($ip) { return ['query' => $ip]; }, $toQuery)), + ]); + if (is_wp_error($response)) { + return; + } + acore_geoip_note_headers($response); + + $results = json_decode(wp_remote_retrieve_body($response), true); + if (!is_array($results)) { + return; + } + foreach ($results as $res) { + if (empty($res['query'])) { + continue; + } + if (isset($res['status'], $res['countryCode']) && $res['status'] === 'success') { + $wpdb->query($wpdb->prepare( + "UPDATE {$table} SET country = %s WHERE ip_address = %s AND country = 'Unknown'", + $res['countryCode'], + $res['query'] + )); + } + } +} + +function acore_get_login_history($user_id, $limit = 50) { + global $wpdb; + $table = $wpdb->prefix . 'acore_login_history'; + return $wpdb->get_results($wpdb->prepare( + "SELECT ip_address, country, login_at, source FROM $table WHERE user_id = %d ORDER BY login_at DESC LIMIT %d", + $user_id, + $limit + ), ARRAY_A); +} + +function acore_format_connection_date($datetime_str) { + $dt = new \DateTime($datetime_str); + $day = (int) $dt->format('j'); + + if (in_array($day % 100, [11, 12, 13])) { + $suffix = 'th'; + } else { + switch ($day % 10) { + case 1: $suffix = 'st'; break; + case 2: $suffix = 'nd'; break; + case 3: $suffix = 'rd'; break; + default: $suffix = 'th'; + } + } + + return sprintf('%d%s of %s, %s at %s', + $day, + $suffix, + $dt->format('F'), + $dt->format('Y'), + $dt->format('H:i') + ); +} diff --git a/src/acore-wp-plugin/src/Hooks/User/Include.php b/src/acore-wp-plugin/src/Hooks/User/Include.php index 17c75d439..06f7143dc 100644 --- a/src/acore-wp-plugin/src/Hooks/User/Include.php +++ b/src/acore-wp-plugin/src/Hooks/User/Include.php @@ -2,4 +2,6 @@ namespace ACore\Hooks\User; +require_once __DIR__ . "/ConnectionLogger.php"; +require_once __DIR__ . "/PasswordHandler.php"; require_once __DIR__ . "/User.php"; diff --git a/src/acore-wp-plugin/src/Hooks/User/PasswordHandler.php b/src/acore-wp-plugin/src/Hooks/User/PasswordHandler.php new file mode 100644 index 000000000..ad61abe7b --- /dev/null +++ b/src/acore-wp-plugin/src/Hooks/User/PasswordHandler.php @@ -0,0 +1,129 @@ +ID, 'error', __('Security check failed.', 'acore-wp-plugin')); + wp_redirect($security_url); + exit; + } + + if (\ACore\Components\ServerInfo\acore_website_2fa_enabled($user->ID) + && !get_transient(\ACore\Components\ServerInfo\acore_2fa_unlock_key($user->ID))) { + acore_pw_set_message($user->ID, 'error', __('Please verify your 2FA code before changing your password.', 'acore-wp-plugin')); + wp_redirect($security_url); + exit; + } + + $oldPass = isset($_POST['acore_old_pass']) ? (string) wp_unslash($_POST['acore_old_pass']) : ''; + $newPass = isset($_POST['acore_new_pass']) ? (string) wp_unslash($_POST['acore_new_pass']) : ''; + $confirmPass = isset($_POST['acore_confirm_pass']) ? (string) wp_unslash($_POST['acore_confirm_pass']) : ''; + + if (!wp_check_password($oldPass, $user->user_pass, $user->ID)) { + acore_pw_set_message($user->ID, 'error', __('Current password is incorrect.', 'acore-wp-plugin')); + wp_redirect($security_url); + exit; + } + + if ($newPass !== $confirmPass) { + acore_pw_set_message($user->ID, 'error', __('New passwords do not match.', 'acore-wp-plugin')); + wp_redirect($security_url); + exit; + } + + if (wp_check_password($newPass, $user->user_pass, $user->ID)) { + acore_pw_set_message($user->ID, 'error', __('New password must be different from your current password.', 'acore-wp-plugin')); + wp_redirect($security_url); + exit; + } + + $validation = UserValidator::validatePassword($newPass); + if ($validation !== true) { + acore_pw_set_message($user->ID, 'error', $validation); + wp_redirect($security_url); + exit; + } + + if (acore_password_is_reused($user->ID, $newPass)) { + acore_pw_set_message($user->ID, 'error', __('This password has been used before. Please choose a different one.', 'acore-wp-plugin')); + wp_redirect($security_url); + exit; + } + + $soapError = null; + try { + $soap = ACoreServices::I()->getAccountSoap(); + $result = $soap->setAccountPassword($user->user_login, $newPass); + if ($result instanceof \Exception) { + $soapError = $result->getMessage(); + } elseif (!is_string($result) || stripos($result, 'changed') === false) { + $raw = is_string($result) ? trim($result) : ''; + $soapError = $raw !== '' ? $raw : __('the game server rejected the change', 'acore-wp-plugin'); + } + } catch (\Exception $e) { + $soapError = $e->getMessage(); + } + + if ($soapError !== null) { + error_log('[acore] in-game password sync failed (user ID ' . $user->ID . '): ' . $soapError); + acore_pw_set_message($user->ID, 'error', __('The in-game password could not be updated, so your website password was left unchanged. Please try again later.', 'acore-wp-plugin')); + wp_redirect($security_url); + exit; + } + + acore_password_record_history($user->ID, $user->user_pass); + + wp_set_password($newPass, $user->ID); + update_user_meta($user->ID, 'acore_password_changed_at', current_time('mysql')); + + wp_set_current_user($user->ID); + wp_set_auth_cookie($user->ID, false); + + acore_pw_set_message($user->ID, 'success', __('Password updated successfully.', 'acore-wp-plugin')); + wp_redirect($security_url); + exit; +} + +function acore_pw_set_message($user_id, $type, $text) { + set_transient('acore_pw_msg_' . $user_id, ['type' => $type, 'text' => $text], 60); +} + +function acore_pw_get_message($user_id) { + $msg = get_transient('acore_pw_msg_' . $user_id); + if ($msg) { + delete_transient('acore_pw_msg_' . $user_id); + } + return $msg ?: null; +} + +function acore_password_is_reused($user_id, string $newPass): bool { + if (get_option('acore_allow_old_passwords', '0') === '1') { + return false; + } + $history = get_user_meta($user_id, 'acore_password_history', true) ?: []; + foreach ($history as $oldHash) { + if (wp_check_password($newPass, $oldHash, $user_id)) { + return true; + } + } + return false; +} + +function acore_password_record_history($user_id, string $currentHash): void { + $history = get_user_meta($user_id, 'acore_password_history', true) ?: []; + array_unshift($history, $currentHash); + update_user_meta($user_id, 'acore_password_history', array_slice($history, 0, 10)); +} diff --git a/src/acore-wp-plugin/src/Hooks/User/User.php b/src/acore-wp-plugin/src/Hooks/User/User.php index 8148212b2..4e8d09e86 100644 --- a/src/acore-wp-plugin/src/Hooks/User/User.php +++ b/src/acore-wp-plugin/src/Hooks/User/User.php @@ -108,6 +108,7 @@ function user_profile_update($user_id, $old_user_data) if ($result instanceof \Exception) { die(sprintf(__("#2 ACore Error: Game server error: %s", 'acore-wp-plugin'), $result->getMessage())); } + update_user_meta($user_id, 'acore_password_changed_at', current_time('mysql')); } } @@ -132,6 +133,7 @@ function user_password_reset($user, $new_pass) if ($result instanceof \Exception) { die(sprintf(__("#1 ACore Error: Game server error: %s", 'acore-wp-plugin'), $result->getMessage())); } + update_user_meta($user->ID, 'acore_password_changed_at', current_time('mysql')); } add_action('password_reset', __NAMESPACE__ . '\user_password_reset', 10, 2); @@ -369,17 +371,63 @@ function extra_user_profile_fields($user) - + getExpansion()) { @@ -564,3 +616,213 @@ function validPasswordChars(password) { } add_action('login_enqueue_scripts', __NAMESPACE__ . '\login_checks'); + +add_action('show_user_profile', __NAMESPACE__ . '\acore_profile_2fa_removal_warning'); +add_action('edit_user_profile', __NAMESPACE__ . '\acore_profile_2fa_removal_warning'); + +function acore_profile_2fa_removal_warning($user) { + // admin_notices already shows this at the top of plain profile.php — avoid duplicate + global $pagenow; + if ($pagenow === 'profile.php' && !isset($_GET['page'])) return; + + $adminLog = get_user_meta($user->ID, 'acore_2fa_admin_log', true); + $adminLog = is_array($adminLog) ? $adminLog : []; + $lastWebRemoval = null; + $lastGameRemoval = null; + foreach ($adminLog as $entry) { + if ($entry['type'] === 'website') $lastWebRemoval = $entry; + if ($entry['type'] === 'ingame') $lastGameRemoval = $entry; + } + + // Check current 2FA state - website + $webActive = \ACore\Components\ServerInfo\acore_website_2fa_enabled($user->ID); + + // Check current 2FA state - ingame + $gameActive = false; + try { + $conn = \ACore\Manager\ACoreServices::I()->getAccountEm()->getConnection(); + $result = $conn->executeQuery('SELECT totp_secret FROM account WHERE username = ?', [strtoupper($user->user_login)]); + $row = $result->fetchAssociative(); + $gameActive = $row && $row['totp_secret'] !== null; + } catch (\Exception $e) { + // DB unavailable - skip + } + + $showWebWarning = $lastWebRemoval && !$webActive; + $showGameWarning = $lastGameRemoval && !$gameActive; + + if (!$showWebWarning && !$showGameWarning) return; + + $security_url = admin_url('profile.php?page=' . ACORE_SLUG . '-security'); + ?> +
+

+ +

+ +

+ - ' . esc_html(wp_date('jS \o\f F, Y \a\t H:i', $lastWebRemoval['timestamp'])) . '', + (($lastWebRemoval['by'] ?? 'admin') === 'self' + ? '' . esc_html__('you', 'acore-wp-plugin') . '' + : '' . esc_html($lastWebRemoval['staff'] ?? __('a staff member', 'acore-wp-plugin')) . '') + ); ?> +

+ + +

+ - ' . esc_html(wp_date('jS \o\f F, Y \a\t H:i', $lastGameRemoval['timestamp'])) . '', + '' . esc_html($lastGameRemoval['staff']) . '' + ); ?> +

+ +

+ +

+
+ callbacks as $priority => $callbacks) { + foreach ($callbacks as $key => $cb) { + $func = $cb['function']; + $id = ''; + if (is_array($func) && is_object($func[0])) $id = get_class($func[0]); + elseif (is_array($func) && is_string($func[0])) $id = $func[0]; + elseif (is_string($func)) $id = $func; + if ($id && ( + stripos($id, 'WP2FA') !== false || + stripos($id, 'wp_2fa') !== false || + stripos($id, 'Two_Factor') !== false + )) { + unset($wp_filter[$hook]->callbacks[$priority][$key]); + } + } + } + } +}, 99); + +// Hide "New Password" fields on the standard profile page +add_filter('show_password_fields', function ($show) { + global $pagenow; + if ($pagenow === 'profile.php') return false; + return $show; +}); + +// 2FA removal warning at the TOP of Profile > Profile (admin_notices) +add_action('admin_notices', function () { + global $pagenow; + if ($pagenow !== 'profile.php') return; + if (isset($_GET['page'])) return; // sub-pages (Security, etc.) — skip + + $user = wp_get_current_user(); + $adminLog = get_user_meta($user->ID, 'acore_2fa_admin_log', true); + $adminLog = is_array($adminLog) ? $adminLog : []; + $lastWebRemoval = null; + $lastGameRemoval = null; + foreach ($adminLog as $entry) { + if ($entry['type'] === 'website') $lastWebRemoval = $entry; + if ($entry['type'] === 'ingame') $lastGameRemoval = $entry; + } + + $webActive = \ACore\Components\ServerInfo\acore_website_2fa_enabled($user->ID); + $gameActive = false; + try { + $conn = \ACore\Manager\ACoreServices::I()->getAccountEm()->getConnection(); + $result = $conn->executeQuery('SELECT totp_secret FROM account WHERE username = ?', [strtoupper($user->user_login)]); + $row = $result->fetchAssociative(); + $gameActive = $row && $row['totp_secret'] !== null; + } catch (\Exception $e) {} + + $showWebWarning = $lastWebRemoval && !$webActive; + $showGameWarning = $lastGameRemoval && !$gameActive; + if (!$showWebWarning && !$showGameWarning) return; + + $security_url = admin_url('profile.php?page=' . ACORE_SLUG . '-security'); + ?> +
+

+ +

- ' . esc_html(wp_date('jS \o\f F, Y \a\t H:i', $lastWebRemoval['timestamp'])) . '', + '' . esc_html($lastWebRemoval['staff']) . '' + ); ?>

+ + +

- ' . esc_html(wp_date('jS \o\f F, Y \a\t H:i', $lastGameRemoval['timestamp'])) . '', + '' . esc_html($lastGameRemoval['staff']) . '' + ); ?>

+ +

+
+ ID, 10); + $rows = array_slice($rows, 0, 10); + ?> +

+ +

+ +

+ + +

+
- > + + + + + + +

+ ⚠ + +

+ +
+ + + + + + + + + + + > + + + + + + + +
+

+ +

+ + 'mycred', 'required' => true, ), - + array( + 'name' => 'WP 2FA', + 'slug' => 'wp-2fa', + 'required' => true, + 'force_activation' => true, + ), + // ); diff --git a/src/acore-wp-plugin/src/Manager/Opts.php b/src/acore-wp-plugin/src/Manager/Opts.php index d94880216..a57257ad4 100644 --- a/src/acore-wp-plugin/src/Manager/Opts.php +++ b/src/acore-wp-plugin/src/Manager/Opts.php @@ -47,6 +47,9 @@ class Opts { [81, 360], // else, 360 days ]; public $acore_name_unlock_allowed_banned_names_table=""; + public $acore_security_logging="0"; + public $acore_allow_old_passwords="0"; + public $acore_geoip_lookup="0"; public function __get($property) { if (property_exists($this, $property)) { From 80e07dc8a437eb4077f39638d17ce215374f73bf Mon Sep 17 00:00:00 2001 From: FlyingArowana <16946913+TheSCREWEDSoftware@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:54:32 +0100 Subject: [PATCH 07/18] Admin: Realm Settings revamp (modules panel) + CSRF nonces on all settings forms --- .../AdminPanel/Pages/ElunaSettings.php | 1 + .../AdminPanel/Pages/PVPRewards.php | 1 + .../AdminPanel/Pages/RealmSettings.php | 614 +++++++++++------- .../AdminPanel/SettingsController.php | 7 + 4 files changed, 385 insertions(+), 238 deletions(-) diff --git a/src/acore-wp-plugin/src/Components/AdminPanel/Pages/ElunaSettings.php b/src/acore-wp-plugin/src/Components/AdminPanel/Pages/ElunaSettings.php index 6ba0396cd..1cfaa810f 100644 --- a/src/acore-wp-plugin/src/Components/AdminPanel/Pages/ElunaSettings.php +++ b/src/acore-wp-plugin/src/Components/AdminPanel/Pages/ElunaSettings.php @@ -6,6 +6,7 @@

page_alias) ?>

Configure database connection for Eluna script that need use of the CMS.

+
diff --git a/src/acore-wp-plugin/src/Components/AdminPanel/Pages/PVPRewards.php b/src/acore-wp-plugin/src/Components/AdminPanel/Pages/PVPRewards.php index 101ce4a6b..ef1beea80 100644 --- a/src/acore-wp-plugin/src/Components/AdminPanel/Pages/PVPRewards.php +++ b/src/acore-wp-plugin/src/Components/AdminPanel/Pages/PVPRewards.php @@ -14,6 +14,7 @@
Give rewards

+ diff --git a/src/acore-wp-plugin/src/Components/AdminPanel/Pages/RealmSettings.php b/src/acore-wp-plugin/src/Components/AdminPanel/Pages/RealmSettings.php index d99ff9f7e..5ac88f32a 100644 --- a/src/acore-wp-plugin/src/Components/AdminPanel/Pages/RealmSettings.php +++ b/src/acore-wp-plugin/src/Components/AdminPanel/Pages/RealmSettings.php @@ -1,254 +1,392 @@

page_alias) ?>

Configure realm name and database connection.

- -
-
-
- General Settings -
-
-
- - - - - - - - -

- First time using SOAP? Click me! + +

+ + +
+ + +
+
+
General Settings
+
+ + + + + + + + +

First time using SOAP? Click me!

+
+
+ SOAP Settings + acore_soap_host && Opts::I()->acore_soap_user): ?> + Checking… + +
+ + + + + + + + + + + + + + + + + + + + +
+
Database: Auth
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
Database: Characters
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
Database: World
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +

+ +

-
- -
- SOAP Settings -
- - - - - - - - - - - - - - - - - - - - - -
- - -
- Database: Auth -
- - - - - - - - - - - - - - - - - - - - - - - - - -
- - -
- Database: Characters -
- - - - - - - - - - - - - - - - - - - - - - - - - -
- - -
- Database: World -
- - - - - - - - - - - - - - - - - - - - - - - - -
+
You will need to "Save Changes" above before checking your SOAP Configuration!
+
-
+ + - -
+ + + +
+
diff --git a/src/acore-wp-plugin/src/Components/AdminPanel/SettingsController.php b/src/acore-wp-plugin/src/Components/AdminPanel/SettingsController.php index b14b73694..e069943a7 100644 --- a/src/acore-wp-plugin/src/Components/AdminPanel/SettingsController.php +++ b/src/acore-wp-plugin/src/Components/AdminPanel/SettingsController.php @@ -44,6 +44,8 @@ public function loadSettings() { // If they did, this hidden field will be set to 'Y' if ($_SERVER['REQUEST_METHOD'] == 'POST') { + check_admin_referer('acore_realm_settings_save', 'acore_realm_settings_nonce'); + foreach (Opts::I()->getConfs() as $key => $value) { if (isset($_POST[$key])) { $this->storeConf($key, $_POST[$key]); @@ -84,6 +86,8 @@ public function loadElunaSettings() { // If they did, this hidden field will be set to 'Y' if ($_SERVER['REQUEST_METHOD'] == 'POST') { + check_admin_referer('acore_eluna_settings_save', 'acore_eluna_settings_nonce'); + foreach (Opts::I()->getConfs() as $key => $value) { if (isset($_POST[$key])) { $this->storeConf($key, $_POST[$key]); @@ -131,6 +135,7 @@ public function loadPvpRewards() { //! DEV NOTE: Put the rest of stuff within try { ... } check to handle every exception properly try { if ($_SERVER['REQUEST_METHOD'] == 'POST') { + check_admin_referer('acore_pvp_rewards_save', 'acore_pvp_rewards_nonce'); global $wpdb; $tableResult = $wpdb->query("CREATE TEMPORARY TABLE temp_pvp_rewards ( `account` VARCHAR(255) COLLATE utf8_unicode_ci, @@ -385,6 +390,8 @@ public function loadTools() { //! DEV NOTE: Put the rest of stuff within try { ... } check to handle every exception properly try { if ($_SERVER['REQUEST_METHOD'] == 'POST') { + check_admin_referer('acore_tools_save', 'acore_tools_nonce'); + foreach (Opts::I()->getConfs() as $key => $value) { if (isset($_POST[$key])) { if ($key == 'acore_name_unlock_allowed_banned_names_table') { From 1e4dffe71a4da85e0008d3514ec8d92e1dfc4143 Mon Sep 17 00:00:00 2001 From: FlyingArowana <16946913+TheSCREWEDSoftware@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:14:20 +0100 Subject: [PATCH 08/18] 3 Columns --- .../CharactersMenu/CharactersView.php | 92 +++++++++++++------ src/acore-wp-plugin/web/assets/css/theme.css | 23 +++++ 2 files changed, 85 insertions(+), 30 deletions(-) diff --git a/src/acore-wp-plugin/src/Components/CharactersMenu/CharactersView.php b/src/acore-wp-plugin/src/Components/CharactersMenu/CharactersView.php index 3ca170abc..2701dbc11 100644 --- a/src/acore-wp-plugin/src/Components/CharactersMenu/CharactersView.php +++ b/src/acore-wp-plugin/src/Components/CharactersMenu/CharactersView.php @@ -24,43 +24,50 @@ public function getHomeRender($characters) { ?>
-
+

Order Character Screen

Change the order in which the characters appear in your in-game character selection screen, by dragging them, matches in-game position.


-
- +
+
+ + -
    - -
  • -
    - - - - " title="">Level - " src=""> - " src=""> - -
    - "> -
  • - -
+
    + +
  • +
    + + + + " title="">Level + " src=""> + " src=""> + +
    + "> +
  • + +
- -
- - -
- - + +
+ + +
+ + +
+
+
+
+
@@ -69,6 +76,31 @@ public function getHomeRender($characters) { + + emChar; } + /** + * + * @return \Doctrine\ORM\EntityManager + */ + public function getWorldEm() + { + return $this->emWorld; + } + /** * * @return string diff --git a/src/acore-wp-plugin/src/Manager/Opts.php b/src/acore-wp-plugin/src/Manager/Opts.php index a57257ad4..abcc3d3bb 100644 --- a/src/acore-wp-plugin/src/Manager/Opts.php +++ b/src/acore-wp-plugin/src/Manager/Opts.php @@ -47,6 +47,8 @@ class Opts { [81, 360], // else, 360 days ]; public $acore_name_unlock_allowed_banned_names_table=""; + public $acore_pdump_enabled="0"; + public $acore_bug_report_url="https://github.com/azerothcore/acore-cms/issues/new"; public $acore_security_logging="0"; public $acore_allow_old_passwords="0"; public $acore_geoip_lookup="0"; diff --git a/src/acore-wp-plugin/src/boot.php b/src/acore-wp-plugin/src/boot.php index a7b832cf3..244ae049b 100644 --- a/src/acore-wp-plugin/src/boot.php +++ b/src/acore-wp-plugin/src/boot.php @@ -9,6 +9,8 @@ require_once ACORE_PATH_PLG . 'src/Components/AdminPanel/AdminPanel.php'; require_once ACORE_PATH_PLG . 'src/Components/CharactersMenu/CharactersMenu.php'; +require_once ACORE_PATH_PLG . 'src/Components/CharactersMenu/CharacterDumpWriter.php'; +require_once ACORE_PATH_PLG . 'src/Components/CharactersMenu/CharacterDumpApi.php'; require_once ACORE_PATH_PLG . 'src/Components/UnstuckMenu/UnstuckMenu.php'; require_once ACORE_PATH_PLG . 'src/Components/MailReturnMenu/MailReturnMenu.php'; require_once ACORE_PATH_PLG . 'src/Components/ResurrectionScrollMenu/ResurrectionScrollMenu.php'; From 7d2c4373727a06dc83bd89d711e56d33d9127bd2 Mon Sep 17 00:00:00 2001 From: FlyingArowana <16946913+TheSCREWEDSoftware@users.noreply.github.com> Date: Thu, 25 Jun 2026 01:06:21 +0100 Subject: [PATCH 17/18] Format the single nad all pdump file names --- .../CharactersMenu/CharacterDumpApi.php | 98 +++++++++++++++++ .../CharactersMenu/CharactersView.php | 103 +++++++++++++++--- 2 files changed, 183 insertions(+), 18 deletions(-) diff --git a/src/acore-wp-plugin/src/Components/CharactersMenu/CharacterDumpApi.php b/src/acore-wp-plugin/src/Components/CharactersMenu/CharacterDumpApi.php index 41610f375..a88a2642f 100644 --- a/src/acore-wp-plugin/src/Components/CharactersMenu/CharacterDumpApi.php +++ b/src/acore-wp-plugin/src/Components/CharactersMenu/CharacterDumpApi.php @@ -11,6 +11,11 @@ 'callback' => __NAMESPACE__ . '\handlePdump', 'permission_callback' => '__return_true', ]); + register_rest_route(ACORE_SLUG . '/v1', 'pdump/all', [ + 'methods' => 'POST', + 'callback' => __NAMESPACE__ . '\handlePdumpAll', + 'permission_callback' => '__return_true', + ]); }); function handlePdump(\WP_REST_Request $request): void @@ -87,3 +92,96 @@ function handlePdump(\WP_REST_Request $request): void echo $dump; exit; } + +function handlePdumpAll(\WP_REST_Request $request): void +{ + $accId = ACoreServices::I()->getAcoreAccountId(); + + if (Opts::I()->acore_pdump_enabled != '1') { + wp_send_json_error(['message' => 'PDUMP export is not enabled on this server.'], 403); + exit; + } + + if (!$accId) { + wp_send_json_error(['message' => 'Forbidden.'], 403); + exit; + } + + $body = $request->get_json_params(); + $characters = $body['characters'] ?? []; + if (empty($characters) || !is_array($characters)) { + wp_send_json_error(['message' => 'No characters provided.'], 400); + exit; + } + + $conn = ACoreServices::I()->getCharacterEm()->getConnection(); + $now = date('d_m_Y_H_i_s'); + + $phpWarnings = []; + set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline) use (&$phpWarnings): bool { + $phpWarnings[] = "[{$errno}] {$errstr} in {$errfile}:{$errline}"; + return true; + }); + + ob_start(); + + $files = []; + foreach ($characters as $char) { + $guid = isset($char['guid']) ? (int) $char['guid'] : 0; + if ($guid < 1) continue; + + $row = $conn->executeQuery( + "SELECT `name` FROM `characters` + WHERE `guid` = ? AND `account` = ? AND `deleteDate` IS NULL LIMIT 1", + [$guid, $accId] + )->fetchAssociative(); + + if (!$row) continue; + + $writer = new CharacterDumpWriter($conn); + $dump = $writer->getDump($guid); + if ($dump === null) continue; + + $order = preg_replace('/[^0-9]/', '', (string)($char['order'] ?? '0')); + $level = preg_replace('/[^0-9]/', '', (string)($char['level'] ?? '0')); + $race = preg_replace('/[^a-zA-Z]/', '', (string)($char['race'] ?? 'Unknown')); + $class = preg_replace('/[^a-zA-Z]/', '', (string)($char['class'] ?? 'Unknown')); + + $files["{$order}_{$level}_{$race}_{$class}_{$now}.dump"] = $dump; + } + + ob_end_clean(); + restore_error_handler(); + + if (!empty($phpWarnings)) { + wp_send_json_error([ + 'message' => 'An internal error occurred while generating the dump.', + 'detail' => implode("\n", $phpWarnings), + ], 500); + exit; + } + + if (empty($files)) { + wp_send_json_error(['message' => 'No valid characters could be exported.'], 400); + exit; + } + + $tmp = tempnam(sys_get_temp_dir(), 'pdump_'); + $zip = new \ZipArchive(); + $zip->open($tmp, \ZipArchive::OVERWRITE); + foreach ($files as $filename => $content) { + $zip->addFromString($filename, $content); + } + $zip->close(); + + if (ob_get_level()) ob_end_clean(); + + header('Content-Type: application/zip'); + header('Content-Disposition: attachment; filename="dump_all_' . $now . '.zip"'); + header('Content-Length: ' . filesize($tmp)); + header('Cache-Control: no-cache, no-store, must-revalidate'); + header('Pragma: no-cache'); + readfile($tmp); + unlink($tmp); + exit; +} diff --git a/src/acore-wp-plugin/src/Components/CharactersMenu/CharactersView.php b/src/acore-wp-plugin/src/Components/CharactersMenu/CharactersView.php index af82559a6..bdfad6a3c 100644 --- a/src/acore-wp-plugin/src/Components/CharactersMenu/CharactersView.php +++ b/src/acore-wp-plugin/src/Components/CharactersMenu/CharactersView.php @@ -122,7 +122,13 @@ public function getHomeRender($characters, $mutetime = 0, $accBanRow = null, $se
- +
@@ -343,12 +349,28 @@ public function getHomeRender($characters, $mutetime = 0, $accBanRow = null, $se var acorePdumpRevisionUrl = ; var acorePdumpBugReportUrl = ; var acorePdumpRestBase = ; + var acorePdumpAllUrl = ; var acorePdumpNonce = ; var acorePdumpModal = document.getElementById('acore-pdump-modal'); var acorePdumpBody = document.getElementById('acore-pdump-modal-body'); var acorePdumpConfirm = document.getElementById('acore-pdump-confirm'); var acorePdumpOnConfirm = null; + function acorePdumpFilename(order, level, race, cls) { + var now = new Date(); + var pad = function(n) { return String(n).padStart(2, '0'); }; + return order + '_' + level + '_' + race + '_' + cls + '_' + + pad(now.getDate()) + '_' + pad(now.getMonth() + 1) + '_' + now.getFullYear() + '_' + + pad(now.getHours()) + '_' + pad(now.getMinutes()) + '_' + pad(now.getSeconds()) + '.dump'; + } + + function acorePdumpZipFilename() { + var now = new Date(); + var pad = function(n) { return String(n).padStart(2, '0'); }; + return 'dump_all_' + pad(now.getDate()) + '_' + pad(now.getMonth() + 1) + '_' + now.getFullYear() + '_' + + pad(now.getHours()) + '_' + pad(now.getMinutes()) + '_' + pad(now.getSeconds()) + '.zip'; + } + function acorePdumpRevisionLink() { if (!acorePdumpRevision) return '(unknown revision)'; if (acorePdumpRevisionUrl) { @@ -402,11 +424,7 @@ function acoreClosePdumpModal() { acorePdumpOnConfirm = null; } - /** - * Fetch the dump for one character GUID and trigger a browser download. - * Uses fetch() + Blob so errors are surfaced without a page navigation. - */ - function acoreDownloadDump(guid, charName) { + function acoreDownloadDump(guid, order, level, race, cls) { fetch(acorePdumpRestBase + guid, { headers: { 'X-WP-Nonce': acorePdumpNonce } }).then(function(resp) { @@ -422,7 +440,45 @@ function acoreDownloadDump(guid, charName) { } return resp.blob(); }).then(function(blob) { - var filename = charName.toUpperCase() + '_' + new Date().toISOString().slice(0,10) + '.dump'; + var filename = acorePdumpFilename(order, level, race, cls); + var url = URL.createObjectURL(blob); + var link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + setTimeout(function() { URL.revokeObjectURL(url); }, 10000); + }).catch(function(err) { + acoreShowPdumpError( + err && err.message ? err.message : String(err), + err && err.detail ? err.detail : null + ); + }); + } + + function acoreDownloadAll(chars) { + fetch(acorePdumpAllUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': acorePdumpNonce + }, + body: JSON.stringify({ characters: chars }) + }).then(function(resp) { + if (!resp.ok) { + return resp.json().then(function(body) { + var data = (body && body.data) ? body.data : body; + var msg = (data && data.message) ? data.message : 'Export failed (HTTP ' + resp.status + ').'; + var detail = (data && data.detail) ? data.detail : null; + var err = new Error(msg); + err.detail = detail; + throw err; + }); + } + return resp.blob(); + }).then(function(blob) { + var filename = acorePdumpZipFilename(); var url = URL.createObjectURL(blob); var link = document.createElement('a'); link.href = url; @@ -449,8 +505,12 @@ function acoreDownloadDump(guid, charName) { }); $(document).on('click', '.acore-export-btn', function() { - var guid = $(this).data('char-guid'); - var name = $(this).data('char-name'); + var guid = $(this).data('char-guid'); + var name = $(this).data('char-name'); + var order = $(this).data('char-order'); + var level = $(this).data('char-level'); + var race = $(this).data('char-race'); + var cls = $(this).data('char-class'); var upper = name.toUpperCase(); var msg = 'You\'re about to PDUMP a.k.a make a copy of your character ' + upper + ' that can be used in AzerothCore. ' + 'This will NOT COPY any custom contents, for example modules like Transmog or Custom Items like Physical Costumes. ' @@ -463,28 +523,35 @@ function acoreDownloadDump(guid, charName) { + 'EQUIPMENT SET NAMESCUSTOM CHAT CHANNELS • ' + 'CHARACTER & ACCOUNT IDs (replaced with random values).'; acoreShowPdumpModal(msg, function() { - acoreDownloadDump(guid, name); + acoreDownloadDump(guid, order, level, race, cls); }); }); $(document).on('click', '.acore-export-all-btn', function() { var chars = []; $('.acore-export-btn').each(function() { - chars.push({ guid: $(this).data('char-guid'), name: $(this).data('char-name') }); + chars.push({ + guid: $(this).data('char-guid'), + name: $(this).data('char-name'), + order: $(this).data('char-order'), + level: $(this).data('char-level'), + race: $(this).data('char-race'), + 'class': $(this).data('char-class') + }); }); var nameList = chars.map(function(c) { return '' + c.name.toUpperCase() + ''; }).join(', '); var msg = 'You\'re about to PDUMP a.k.a make a copy of ALL your characters: ' + nameList + '. ' + 'This will NOT COPY any custom contents, for example modules like Transmog or Custom Items like Physical Costumes. ' + '

This Character Dump was extracted from this version:
' + acorePdumpRevisionLink() + '

The following information will be anonymised or omitted from the dump: ' - + 'character name, position, hearthstone location, gold, timestamps, online status, ' - + 'achievement dates, mail contents and sender, item creator/gifter, custom chat channels, ' - + 'and all character & account IDs (replaced with random values).'; + + 'CHARACTER NAMEPOSITIONHEARTHSTONE LOCATION • ' + + 'GOLDTIMESTAMPSONLINE STATUS • ' + + 'ACHIEVEMENT DATESMAIL CONTENTS & SENDER • ' + + 'ITEM CREATOR/GIFTERAURA CASTER • ' + + 'EQUIPMENT SET NAMESCUSTOM CHAT CHANNELS • ' + + 'CHARACTER & ACCOUNT IDs (replaced with random values).'; acoreShowPdumpModal(msg, function() { - // Download sequentially with a small delay so the browser doesn't block multiple downloads - chars.forEach(function(c, i) { - setTimeout(function() { acoreDownloadDump(c.guid, c.name); }, i * 800); - }); + acoreDownloadAll(chars); }); }); }); From f581aba14886a788c56974289ef20761b4b77348 Mon Sep 17 00:00:00 2001 From: FlyingArowana <16946913+TheSCREWEDSoftware@users.noreply.github.com> Date: Thu, 25 Jun 2026 01:19:40 +0100 Subject: [PATCH 18/18] Toggle On and off showing the ban / mute info and choose what to appear. Forgot to add the toggle setting in https://github.com/azerothcore/acore-cms/pull/205 --- .../src/Components/AdminPanel/Pages/Tools.php | 36 +++++++++++++- .../CharactersMenu/CharactersController.php | 11 ++++- .../CharactersMenu/CharactersMenu.php | 48 +++++++++++-------- .../CharactersMenu/CharactersView.php | 12 ++--- src/acore-wp-plugin/src/Hooks/User/User.php | 30 ++++++++---- src/acore-wp-plugin/src/Manager/Opts.php | 4 ++ 6 files changed, 105 insertions(+), 36 deletions(-) diff --git a/src/acore-wp-plugin/src/Components/AdminPanel/Pages/Tools.php b/src/acore-wp-plugin/src/Components/AdminPanel/Pages/Tools.php index 10824e8b3..c1ca7b2a0 100644 --- a/src/acore-wp-plugin/src/Components/AdminPanel/Pages/Tools.php +++ b/src/acore-wp-plugin/src/Components/AdminPanel/Pages/Tools.php @@ -189,7 +189,33 @@ class="acore_security_logging != '1' ? 'acore-days-inactive-disab - + + + +
+ + + +
+ + + +