PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.8.1
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.8.1
5.8.0 5.8.1 5.7.0 5.6.2 5.6.3 5.6.1 5.6.0 5.5.0 5.4.0 5.3.2 5.3.1 5.1.6 5.1.5 trunk 2.1.5 2.11 2.12 2.13 2.15 3.0.0 3.0.1 3.0.2 3.0.3 3.0.5 3.0.51 All 41 releases
double-opt-in / src / Admin / AdminPageController.php

AdminPageController.php in Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification 5.8.1, at src/Admin/AdminPageController.php

483 lines 15.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Admin Page Controller
4 *
5 * Registers the React SPA as a WordPress admin page and enqueues assets.
6 *
7 * @package Forge12\DoubleOptIn\Admin
8 * @since 4.2.0
9 */
10
11 namespace Forge12\DoubleOptIn\Admin;
12
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 /**
18 * Class AdminPageController
19 *
20 * Handles the React admin SPA page registration and asset loading.
21 */
22 class AdminPageController {
23
24 /**
25 * The admin page slug.
26 */
27 const PAGE_SLUG = 'f12-doi-admin';
28
29 /**
30 * The admin page hook suffix (set after registration).
31 *
32 * @var string
33 */
34 private string $hookSuffix = '';
35
36 /**
37 * Initialize the controller.
38 *
39 * @return void
40 */
41 public function init(): void {
42 add_action( 'admin_menu', array( $this, 'registerAdminPage' ) );
43 add_action( 'admin_enqueue_scripts', array( $this, 'enqueueAssets' ) );
44 }
45
46 /**
47 * Register the React SPA admin page.
48 *
49 * The page is added as a top-level menu item. The legacy PHP pages
50 * remain registered under their existing slugs for backward compatibility.
51 *
52 * @return void
53 */
54 public function registerAdminPage(): void {
55 $icon_url = plugins_url( 'assets/icon-double-opt-in-20x20.png', dirname( __DIR__ ) );
56
57 $this->hookSuffix = add_menu_page(
58 __( 'Double Opt-In', 'double-opt-in' ),
59 __( 'Double Opt-In', 'double-opt-in' ),
60 'manage_options',
61 self::PAGE_SLUG,
62 array( $this, 'renderApp' ),
63 $icon_url,
64 31
65 );
66 }
67
68 /**
69 * Enqueue React SPA assets only on our admin page.
70 *
71 * @param string $hook The current admin page hook suffix.
72 *
73 * @return void
74 */
75 public function enqueueAssets( string $hook ): void {
76 if ( $hook !== $this->hookSuffix ) {
77 return;
78 }
79
80 $pluginDir = plugin_dir_path( dirname( __DIR__ ) );
81 $pluginUrl = plugin_dir_url( dirname( __DIR__ ) );
82 $buildDir = $pluginDir . 'admin-ui/build/';
83
84 // Enqueue the main JS bundle (single IIFE file with CSS injected).
85 // Depends on `wp-i18n` so `window.wp.i18n` exists before the bundle
86 // runs — the SPA's __() calls resolve against it (vite externalises
87 // @wordpress/i18n to that global).
88 $jsFile = $buildDir . 'index.js';
89 if ( file_exists( $jsFile ) ) {
90 wp_enqueue_script(
91 'doi-admin-ui',
92 $pluginUrl . 'admin-ui/build/index.js',
93 array( 'wp-i18n' ),
94 filemtime( $jsFile ),
95 true
96 );
97
98 // Feed the active locale's translations to wp.i18n for the
99 // "double-opt-in" text domain, so the React admin renders in the
100 // WP-admin language instead of the hardcoded English source. We
101 // reuse the .mo already loaded by load_plugin_textdomain() rather
102 // than shipping a separate JS translation pipeline.
103 $this->injectSpaTranslations( 'doi-admin-ui' );
104 }
105
106 // NOTE: the legacy flat `email-editor/build/` enqueue was removed
107 // (2026-07). The editor bundle is enqueued solely by
108 // EmailEditorAddon::enqueueEditorOnSpa() now. Enqueuing it here as
109 // well caused the bundle to load twice on sites that still had the
110 // pre-Phase-2 flat build, which double-mounted the editor App and
111 // produced two "Save" buttons in the toolbar.
112
113 // Enqueue CSS if it exists as a separate file (fallback for alternate builds)
114 $assetsDir = $buildDir . 'assets/';
115 if ( is_dir( $assetsDir ) ) {
116 $cssFiles = glob( $assetsDir . 'index-*.css' );
117 if ( ! empty( $cssFiles ) ) {
118 $cssFile = $cssFiles[0];
119 $cssFilename = basename( $cssFile );
120 wp_enqueue_style(
121 'doi-admin-ui',
122 $pluginUrl . 'admin-ui/build/assets/' . $cssFilename,
123 array(),
124 filemtime( $cssFile )
125 );
126 }
127 }
128
129 // Adjust WP admin layout for clean React SPA
130 add_action(
131 'admin_notices',
132 function () {
133 echo '<style>
134 /* Hide WP clutter */
135 .notice, .updated, .error, .is-dismissible { display: none !important; }
136 #wpfooter { display: none; }
137
138 /* Remove default WP content padding */
139 #wpcontent { padding-left: 0; }
140 #wpbody-content { padding-bottom: 0; }
141
142 /* Offset React app below WP admin bar (32px) */
143 #doi-admin-root {
144 margin-left: 0;
145 margin-top: 0;
146 min-height: calc(100vh - 32px);
147 }
148
149 /*
150 * Sidebar offset for the WP admin bar. We target the
151 * sidebar by its data-attribute ONLY — the previous rule
152 * also targeted `.fixed`, which caught every Tailwind
153 * fixed-positioned element (Radix Dialog overlay + content
154 * primarily). The dialog content uses `top: 50%` +
155 * `translate-y(-50%)` centering, and `top: 32px !important`
156 * obliterated that math: the modal grew to nearly full
157 * viewport height with the body content squeezed off-
158 * screen above the buttons. Reported by user 2026-04-30.
159 */
160 #doi-admin-root [data-sidebar="sidebar"] {
161 top: 32px !important;
162 height: calc(100vh - 32px) !important;
163 }
164
165 /* Fix sticky header to sit below WP admin bar */
166 #doi-admin-root .sticky {
167 top: 32px !important;
168 }
169
170 /* Mobile WP admin bar is 46px */
171 @media screen and (max-width: 782px) {
172 #doi-admin-root [data-sidebar="sidebar"] {
173 top: 46px !important;
174 height: calc(100vh - 46px) !important;
175 }
176 #doi-admin-root .sticky {
177 top: 46px !important;
178 }
179 #doi-admin-root {
180 min-height: calc(100vh - 46px);
181 }
182 }
183
184 /* When admin bar is not shown (e.g. fullscreen) */
185 .no-adminbar #doi-admin-root [data-sidebar="sidebar"] {
186 top: 0 !important;
187 height: 100vh !important;
188 }
189 .no-adminbar #doi-admin-root .sticky {
190 top: 0 !important;
191 }
192
193 /* WP-admin\'s forms.css applies `button { border, background,
194 * box-shadow, cursor }` via tag selectors that bleed into
195 * shadcn components (most visibly the Tabs pill).
196 * Reset to neutral so Tailwind utilities can paint cleanly.
197 *
198 * Specificity: `#doi-admin-root button` is (1,0,1), beats
199 * WP\'s `.wp-core-ui .button` (0,2,0). Tailwind utilities
200 * are emitted as `#doi-admin-root .bg-muted` (1,1,0) via
201 * the `important: "#doi-admin-root"` config option, which
202 * beats this reset on class > tag. */
203 #doi-admin-root button {
204 /* `appearance: none` is the critical bit — without it
205 * browsers render the native button look (3D border,
206 * raised effect) on top of any Tailwind background, so
207 * the active tab gets a dark outline regardless of
208 * `bg-background`. WP-admin\'s forms.css drops this
209 * for native buttons but Tailwind\'s preflight is in
210 * the lower-priority @layer base; the unlayered WP CSS
211 * wins. We reset at #doi-admin-root scope to restore
212 * the neutral baseline.
213 *
214 * Border reset uses long-hand props (NOT shorthand
215 * `border: 0`) because the shorthand also sets
216 * `border-style: none`. Tailwind\'s `.border` utility
217 * only sets `border-width: 1px` — without `border-style:
218 * solid` from preflight (which we just clobbered),
219 * the border stays invisible despite the width.
220 * SelectTrigger reported as borderless 2026-04-30. */
221 -webkit-appearance: none;
222 appearance: none;
223 border-style: solid;
224 border-width: 0;
225 border-color: transparent;
226 background: transparent;
227 box-shadow: none;
228 color: inherit;
229 font: inherit;
230 cursor: pointer;
231 line-height: inherit;
232 padding: 0;
233 }
234 #doi-admin-root input,
235 #doi-admin-root select,
236 #doi-admin-root textarea {
237 font: inherit;
238 }
239 </style>';
240 },
241 999
242 );
243
244 // Inject configuration for the React app
245 $config = array(
246 'restUrl' => esc_url_raw( rest_url( 'f12-doi/v1/' ) ),
247 'nonce' => wp_create_nonce( 'wp_rest' ),
248 'isProActive' => (bool) apply_filters( 'f12_doi_is_pro_active', false ),
249 'isProInstalled' => defined( 'F12_DOI_PRO_VERSION' ),
250 'loadedAddons' => $this->getLoadedAddonIds(),
251 'registeredAddons' => $this->getRegisteredAddonIds(),
252 'version' => defined( 'FORGE12_OPTIN_VERSION' ) ? FORGE12_OPTIN_VERSION : '0.0.0',
253 'emailEditorUrl' => admin_url( 'admin.php?page=f12-doi-admin#/email-templates' ),
254 'upgradeUrl' => $this->productUrl( 'admin-upgrade', 'https://www.forge12.com' ),
255 'supportUrl' => $this->supportUrl(),
256 'feedbackUrl' => $this->feedbackUrl(),
257 'adminUrl' => admin_url(),
258 // Nonce for the legacy admin-ajax `doi_export_consent`
259 // handler. The handler hard-requires `_wpnonce` in
260 // `$_REQUEST` keyed on action `doi_consent_export`; without
261 // this the SPA's Export JSON/CSV links 403 with
262 // "Sicherheitsprüfung fehlgeschlagen" (user-reported
263 // 2026-05-13). REST routes use the separate `nonce` field
264 // above with action `wp_rest` — they live in different
265 // namespaces, so a single shared nonce won't work.
266 'consentExportNonce' => wp_create_nonce( 'doi_consent_export' ),
267 // Setup wizard state for the dashboard card. Null on sites that
268 // were installed before the wizard existed.
269 'setup' => $this->setupState(),
270 // Installed form plugins, so the forms page offers only add-ons
271 // for plugins this site actually runs.
272 'formPlugins' => $this->formPlugins(),
273 );
274
275 /**
276 * Filter the admin SPA configuration data.
277 *
278 * Allows Pro and other extensions to inject additional data
279 * into the frontend configuration object.
280 *
281 * @param array $config The configuration array.
282 *
283 * @since 4.2.0
284 */
285 $config = apply_filters( 'f12_doi_admin_localize_data', $config );
286
287 wp_localize_script( 'doi-admin-ui', 'doiAdmin', $config );
288 }
289
290 /**
291 * Product-site link for the SPA.
292 *
293 * The URL builders live in core/feedback.php, a plain-function file in the
294 * legacy namespace rather than an autoloaded class. The guard is not
295 * ceremony: this controller is also exercised by tests that do not load that
296 * file, and a fatal there would be a poor trade for a marketing link.
297 *
298 * @param string $from Entry point recorded on the link.
299 * @param string $fallback Used when core/feedback.php is not loaded.
300 */
301 /**
302 * @return array<int, array{id: string, name: string, addonLoaded: bool, productUrl: string}>
303 */
304 private function formPlugins(): array {
305 $plugins = array();
306 foreach ( ( new \Forge12\DoubleOptIn\Setup\FormPluginDetector() )->installed() as $plugin ) {
307 $plugins[] = array(
308 'id' => $plugin['id'],
309 'name' => $plugin['name'],
310 'addonLoaded' => $plugin['addonLoaded'],
311 'productUrl' => $this->productUrl( 'forms-detected-' . $plugin['id'], '' ),
312 );
313 }
314 return $plugins;
315 }
316
317 /**
318 * @return array{status: string, step: int, steps: int}|null
319 */
320 private function setupState(): ?array {
321 $state = new \Forge12\DoubleOptIn\Setup\SetupState();
322 if ( ! $state->exists() ) {
323 return null;
324 }
325 $current = $state->get();
326 return array(
327 'status' => $current['status'],
328 'step' => $current['step'],
329 'steps' => \Forge12\DoubleOptIn\Setup\SetupState::STEPS,
330 );
331 }
332
333 private function productUrl( string $from, string $fallback ): string {
334 $fn = '\\forge12\\contactform7\\CF7DoubleOptIn\\get_product_url';
335
336 return function_exists( $fn ) ? esc_url_raw( $fn( $from ) ) : $fallback;
337 }
338
339 /**
340 * Support link for the SPA. Empty when unavailable, so the UI can hide it.
341 */
342 private function supportUrl(): string {
343 $fn = '\\forge12\\contactform7\\CF7DoubleOptIn\\get_support_url';
344
345 return function_exists( $fn ) ? esc_url_raw( $fn( 'admin-spa' ) ) : '';
346 }
347
348 /**
349 * Feedback link for the SPA. Empty when unavailable, so the UI can hide it.
350 */
351 private function feedbackUrl(): string {
352 $fn = '\\forge12\\contactform7\\CF7DoubleOptIn\\get_feedback_url';
353
354 return function_exists( $fn ) ? esc_url_raw( $fn( 'admin-spa' ) ) : '';
355 }
356
357 /**
358 * Feed the active locale's "double-opt-in" translations to wp.i18n so the
359 * React admin renders in the WP-admin language.
360 *
361 * WordPress's own wp_set_script_translations() keys its JSON by the md5 of
362 * each source JS file's path, which doesn't fit a single bundled IIFE. So
363 * instead we build a Jed-format locale-data object by reading the plugin's
364 * .mo directly and hand it to wp.i18n.setLocaleData() via an inline script
365 * that runs *before* the bundle (hence the 'before' position + the wp-i18n
366 * dependency).
367 *
368 * @param string $handle Enqueued script handle to attach the inline script to.
369 *
370 * @return void
371 */
372 private function injectSpaTranslations( string $handle ): void {
373 $locale = determine_locale();
374
375 // English is the source language — nothing to translate.
376 if ( $locale === 'en_US' || $locale === 'en' ) {
377 return;
378 }
379
380 // Read the .mo directly with POMO rather than
381 // get_translations_for_domain(): under WP 6.5+'s new
382 // WP_Translation_Controller that call returns a proxy whose ->entries
383 // is EMPTY even when __() resolves, so iterating it injects nothing.
384 $moFile = plugin_dir_path( dirname( __DIR__ ) ) . 'languages/double-opt-in-' . $locale . '.mo';
385 if ( ! is_readable( $moFile ) ) {
386 return;
387 }
388
389 if ( ! class_exists( '\\MO' ) ) {
390 require_once ABSPATH . WPINC . '/pomo/mo.php';
391 }
392
393 $mo = new \MO();
394 if ( ! $mo->import_from_file( $moFile ) || empty( $mo->entries ) ) {
395 return;
396 }
397
398 $locale_data = array(
399 '' => array(
400 'domain' => 'double-opt-in',
401 'lang' => $locale,
402 'plural-forms' => $mo->headers['Plural-Forms'] ?? 'nplurals=2; plural=(n != 1);',
403 ),
404 );
405
406 foreach ( $mo->entries as $entry ) {
407 // Jed prefixes context entries with "<context><msgid>".
408 $key = ( isset( $entry->context ) && $entry->context !== '' )
409 ? $entry->context . "\4" . $entry->singular
410 : $entry->singular;
411
412 $locale_data[ $key ] = $entry->translations;
413 }
414
415 wp_add_inline_script(
416 $handle,
417 'wp.i18n.setLocaleData( ' . wp_json_encode( $locale_data ) . ', "double-opt-in" );',
418 'before'
419 );
420 }
421
422 /**
423 * Render the React SPA mount point.
424 *
425 * @return void
426 */
427 public function renderApp(): void {
428 echo '<div id="doi-admin-root"></div>';
429 }
430
431 /**
432 * IDs of every addon currently loaded AND available on this site.
433 *
434 * Used by the React SPA to gate per-feature settings cards
435 * (Reminder, MX Validation, Domain Blocklist, etc.) — drift to
436 * gating on `isProActive` alone re-introduces the user-reported
437 * bug where the Pro Bundle license shows feature settings for
438 * addons that aren`t even installed on the site.
439 *
440 * Returns an empty list when AddonRegistry isn`t loaded yet (very
441 * early boot or unit tests without it). The frontend treats an
442 * empty list as "no addons available", which is the safe default.
443 *
444 * @return list<string>
445 */
446 private function getLoadedAddonIds(): array {
447 if ( ! class_exists( '\\Forge12\\DoubleOptIn\\Addon\\AddonRegistry' ) ) {
448 return array();
449 }
450
451 $registry = \Forge12\DoubleOptIn\Addon\AddonRegistry::getInstance();
452 $available = $registry->available();
453
454 return array_values( array_keys( $available ) );
455 }
456
457 /**
458 * IDs of every addon plugin that is loaded/registered, regardless of
459 * whether its license is currently active.
460 *
461 * Distinguishes "addon plugin missing" from "addon plugin installed
462 * but not unlocked". The frontend ProGate component uses both lists
463 * to render the right upsell. Under bundle-only licensing a covered
464 * addon is unlocked whenever the Pro bundle is active, so the middle
465 * state means the bundle isn't active — not a per-module license:
466 *
467 * - id in loadedAddons → fully active, no gate
468 * - id in registeredAddons but NOT in loadedAddons
469 * → "Pro Bundle Required" (plugin is here,
470 * the bundle isn't active)
471 * - id in neither → "Addon Required" (plugin not installed)
472 *
473 * @return list<string>
474 */
475 private function getRegisteredAddonIds(): array {
476 if ( ! class_exists( '\\Forge12\\DoubleOptIn\\Addon\\AddonRegistry' ) ) {
477 return array();
478 }
479 $registry = \Forge12\DoubleOptIn\Addon\AddonRegistry::getInstance();
480 return array_values( array_keys( $registry->all() ) );
481 }
482 }
483