PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.5.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.5.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 3.0.60 3.0.61 3.0.62 3.0.70 3.0.71 3.0.72 3.1.0 All 34 releases
double-opt-in / src / Admin / AdminPageController.php

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

445 lines 14.4 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 );
268
269 /**
270 * Filter the admin SPA configuration data.
271 *
272 * Allows Pro and other extensions to inject additional data
273 * into the frontend configuration object.
274 *
275 * @param array $config The configuration array.
276 *
277 * @since 4.2.0
278 */
279 $config = apply_filters( 'f12_doi_admin_localize_data', $config );
280
281 wp_localize_script( 'doi-admin-ui', 'doiAdmin', $config );
282 }
283
284 /**
285 * Product-site link for the SPA.
286 *
287 * The URL builders live in core/feedback.php, a plain-function file in the
288 * legacy namespace rather than an autoloaded class. The guard is not
289 * ceremony: this controller is also exercised by tests that do not load that
290 * file, and a fatal there would be a poor trade for a marketing link.
291 *
292 * @param string $from Entry point recorded on the link.
293 * @param string $fallback Used when core/feedback.php is not loaded.
294 */
295 private function productUrl( string $from, string $fallback ): string {
296 $fn = '\\forge12\\contactform7\\CF7DoubleOptIn\\get_product_url';
297
298 return function_exists( $fn ) ? esc_url_raw( $fn( $from ) ) : $fallback;
299 }
300
301 /**
302 * Support link for the SPA. Empty when unavailable, so the UI can hide it.
303 */
304 private function supportUrl(): string {
305 $fn = '\\forge12\\contactform7\\CF7DoubleOptIn\\get_support_url';
306
307 return function_exists( $fn ) ? esc_url_raw( $fn( 'admin-spa' ) ) : '';
308 }
309
310 /**
311 * Feedback link for the SPA. Empty when unavailable, so the UI can hide it.
312 */
313 private function feedbackUrl(): string {
314 $fn = '\\forge12\\contactform7\\CF7DoubleOptIn\\get_feedback_url';
315
316 return function_exists( $fn ) ? esc_url_raw( $fn( 'admin-spa' ) ) : '';
317 }
318
319 /**
320 * Feed the active locale's "double-opt-in" translations to wp.i18n so the
321 * React admin renders in the WP-admin language.
322 *
323 * WordPress's own wp_set_script_translations() keys its JSON by the md5 of
324 * each source JS file's path, which doesn't fit a single bundled IIFE. So
325 * instead we build a Jed-format locale-data object by reading the plugin's
326 * .mo directly and hand it to wp.i18n.setLocaleData() via an inline script
327 * that runs *before* the bundle (hence the 'before' position + the wp-i18n
328 * dependency).
329 *
330 * @param string $handle Enqueued script handle to attach the inline script to.
331 *
332 * @return void
333 */
334 private function injectSpaTranslations( string $handle ): void {
335 $locale = determine_locale();
336
337 // English is the source language — nothing to translate.
338 if ( $locale === 'en_US' || $locale === 'en' ) {
339 return;
340 }
341
342 // Read the .mo directly with POMO rather than
343 // get_translations_for_domain(): under WP 6.5+'s new
344 // WP_Translation_Controller that call returns a proxy whose ->entries
345 // is EMPTY even when __() resolves, so iterating it injects nothing.
346 $moFile = plugin_dir_path( dirname( __DIR__ ) ) . 'languages/double-opt-in-' . $locale . '.mo';
347 if ( ! is_readable( $moFile ) ) {
348 return;
349 }
350
351 if ( ! class_exists( '\\MO' ) ) {
352 require_once ABSPATH . WPINC . '/pomo/mo.php';
353 }
354
355 $mo = new \MO();
356 if ( ! $mo->import_from_file( $moFile ) || empty( $mo->entries ) ) {
357 return;
358 }
359
360 $locale_data = array(
361 '' => array(
362 'domain' => 'double-opt-in',
363 'lang' => $locale,
364 'plural-forms' => $mo->headers['Plural-Forms'] ?? 'nplurals=2; plural=(n != 1);',
365 ),
366 );
367
368 foreach ( $mo->entries as $entry ) {
369 // Jed prefixes context entries with "<context><msgid>".
370 $key = ( isset( $entry->context ) && $entry->context !== '' )
371 ? $entry->context . "\4" . $entry->singular
372 : $entry->singular;
373
374 $locale_data[ $key ] = $entry->translations;
375 }
376
377 wp_add_inline_script(
378 $handle,
379 'wp.i18n.setLocaleData( ' . wp_json_encode( $locale_data ) . ', "double-opt-in" );',
380 'before'
381 );
382 }
383
384 /**
385 * Render the React SPA mount point.
386 *
387 * @return void
388 */
389 public function renderApp(): void {
390 echo '<div id="doi-admin-root"></div>';
391 }
392
393 /**
394 * IDs of every addon currently loaded AND available on this site.
395 *
396 * Used by the React SPA to gate per-feature settings cards
397 * (Reminder, MX Validation, Domain Blocklist, etc.) — drift to
398 * gating on `isProActive` alone re-introduces the user-reported
399 * bug where the Pro Bundle license shows feature settings for
400 * addons that aren`t even installed on the site.
401 *
402 * Returns an empty list when AddonRegistry isn`t loaded yet (very
403 * early boot or unit tests without it). The frontend treats an
404 * empty list as "no addons available", which is the safe default.
405 *
406 * @return list<string>
407 */
408 private function getLoadedAddonIds(): array {
409 if ( ! class_exists( '\\Forge12\\DoubleOptIn\\Addon\\AddonRegistry' ) ) {
410 return array();
411 }
412
413 $registry = \Forge12\DoubleOptIn\Addon\AddonRegistry::getInstance();
414 $available = $registry->available();
415
416 return array_values( array_keys( $available ) );
417 }
418
419 /**
420 * IDs of every addon plugin that is loaded/registered, regardless of
421 * whether its license is currently active.
422 *
423 * Distinguishes "addon plugin missing" from "addon plugin installed
424 * but not unlocked". The frontend ProGate component uses both lists
425 * to render the right upsell. Under bundle-only licensing a covered
426 * addon is unlocked whenever the Pro bundle is active, so the middle
427 * state means the bundle isn't active — not a per-module license:
428 *
429 * - id in loadedAddons → fully active, no gate
430 * - id in registeredAddons but NOT in loadedAddons
431 * → "Pro Bundle Required" (plugin is here,
432 * the bundle isn't active)
433 * - id in neither → "Addon Required" (plugin not installed)
434 *
435 * @return list<string>
436 */
437 private function getRegisteredAddonIds(): array {
438 if ( ! class_exists( '\\Forge12\\DoubleOptIn\\Addon\\AddonRegistry' ) ) {
439 return array();
440 }
441 $registry = \Forge12\DoubleOptIn\Addon\AddonRegistry::getInstance();
442 return array_values( array_keys( $registry->all() ) );
443 }
444 }
445