Skip to content

Commit e187330

Browse files
Jmosier69Jim Mosier
andauthored
feat: onboarding wizard scan, industry suggestions, activation tab redesign (#3)
* feat: onboarding wizard scan, industry suggestions, activation tab redesign Phase 1: Fix portal auth — separate portal_auth_headers() (Bearer sessionToken) from auth_headers() (X-License-Key). Add /complete call to onboarding submit. Harden: POST-only AJAX, verify_compact_token rename, sodium_unavailable reason, stale cache 24h TTL with _cached_at, 60s clock skew leeway. Phase 2: Website scan integration — real scan flow replacing fake animation. POST /api/portal/onboarding/website-scan triggers scan, GET /api/portal/ onboarding/state polled with backoff (3s→10s, max 20 attempts, 60s timeout). SSRF guard: HTTPS-only, host match with IDN normalization, userinfo rejection. Rate limit: 1 scan/minute/user via transient. Phase 3: Industry suggestion — get_site_hints() reads tagline + active plugin slugs (woocommerce→ecommerce, booking→booking). Suggested industries promoted to top of SearchableSelect with "Suggested for you" group header. Phase 4: Activation tab redesign — 4-card layout: Account Status (tier, email), Connected Services (GA4/Ads/GSC/GBP grid with status badges), Dashboard Status (progress bar/ready/error), Account Actions (disconnect/reconnect/re-run wizard). New endpoints: GET /api/portal/integrations, DELETE /api/portal/integrations/google. Security: stale_or_invalid() fails fast on sodium_unavailable, wp_parse_url return validation, error logging in handle_license_validate. * fix: address PR review — generic error message, null body guard, load failure state - Remove raw exception message from handle_license_validate JSON response; keep detailed logging server-side, return generic message to browser - Normalize $result['body'] and $complete['body'] to array before appending failed_step fields — parse_response can return null from json_decode on empty/non-JSON backend responses - Add integrationsLoadFailed state to ActivatedState so a transient API error shows "Could not load service status" with retry button instead of silently rendering everything as "Not configured" --------- Co-authored-by: Jim Mosier <jmosier69@Jims-Mac-mini.local>
1 parent 2d6228d commit e187330

10 files changed

Lines changed: 1004 additions & 156 deletions

File tree

build/index.tsx.asset.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime'), 'version' => '36653695eb65c41030f7');
1+
<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime'), 'version' => '5e9fe35256e010106e99');

build/index.tsx.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

includes/class-api.php

Lines changed: 201 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,12 @@ public function register_hooks(): void {
4747
'roi_insights_tracking_save' => 'handle_tracking_save',
4848
'roi_insights_settings_load' => 'handle_settings_load',
4949
'roi_insights_settings_save' => 'handle_settings_save',
50-
'roi_insights_onboarding_submit' => 'handle_onboarding_submit',
50+
'roi_insights_onboarding_submit' => 'handle_onboarding_submit',
51+
'roi_insights_onboarding_complete' => 'handle_onboarding_complete',
52+
'roi_insights_onboarding_scan' => 'handle_onboarding_scan',
53+
'roi_insights_onboarding_state' => 'handle_onboarding_state',
54+
'roi_insights_integrations_get' => 'handle_integrations_get',
55+
'roi_insights_integrations_disconnect' => 'handle_integrations_disconnect',
5156
);
5257

5358
foreach ( $actions as $action => $method ) {
@@ -67,7 +72,7 @@ private function verify_request(): void {
6772
/** Decode the JSON-encoded 'data' field sent by api.post(). */
6873
private function get_body(): array {
6974
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- already verified in verify_request().
70-
$raw = isset( $_REQUEST['data'] ) ? wp_unslash( $_REQUEST['data'] ) : '{}';
75+
$raw = isset( $_POST['data'] ) ? wp_unslash( $_POST['data'] ) : '{}';
7176
$decoded = json_decode( $raw, true );
7277
return is_array( $decoded ) ? $decoded : array();
7378
}
@@ -83,8 +88,8 @@ public function handle_license_status(): void {
8388
/** Return the OAuth popup URL — no backend call needed. */
8489
public function handle_license_sso(): void {
8590
$this->verify_request();
86-
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
87-
$src = isset( $_REQUEST['src'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['src'] ) ) : 'default';
91+
$body = $this->get_body();
92+
$src = sanitize_text_field( $body['src'] ?? 'default' );
8893
$provider = ( 'default' === $src ) ? 'google' : $src;
8994
$domain = wp_parse_url( home_url(), PHP_URL_HOST );
9095
$url = self::BACKEND . '/api/roi/plugin/auth/' . rawurlencode( $provider ) . '/redirect?domain=' . rawurlencode( $domain );
@@ -123,11 +128,16 @@ public function handle_license_notify(): void {
123128
$this->send_backend_result( $result );
124129
}
125130

126-
/** Poll magic-link verification status. */
131+
/**
132+
* Poll magic-link verification status.
133+
* The poll_token arrives via POST body (not URL) from the frontend to avoid
134+
* browser history / CDN log exposure. The server-to-server call to the backend
135+
* uses a query param since that's the endpoint's contract.
136+
*/
127137
public function handle_license_pending(): void {
128138
$this->verify_request();
129-
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
130-
$poll_token = isset( $_REQUEST['poll_token'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['poll_token'] ) ) : '';
139+
$body = $this->get_body();
140+
$poll_token = sanitize_text_field( $body['poll_token'] ?? '' );
131141
$result = $this->backend_get( '/api/roi/plugin/auth/magic-link/status?poll_token=' . rawurlencode( $poll_token ) );
132142
$this->send_backend_result( $result );
133143
}
@@ -136,7 +146,13 @@ public function handle_license_pending(): void {
136146
public function handle_license_validate(): void {
137147
$this->verify_request();
138148
$this->license->clear_cache();
139-
wp_send_json_success( $this->license->get_license_data( true ) );
149+
try {
150+
wp_send_json_success( $this->license->get_license_data( true ) );
151+
} catch ( \Throwable $e ) {
152+
// phpcs:ignore WordPress.PHP.DevelopmentFunctions
153+
error_log( 'roi-insights: license validation failed: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine() );
154+
wp_send_json_error( array( 'error' => 'Validation failed. Please try again or contact support if the issue persists.' ), 503 );
155+
}
140156
}
141157

142158
// ─── Tracking Handlers ──────────────────────────────────────────────────────
@@ -176,9 +192,15 @@ public function handle_settings_save(): void {
176192
wp_send_json_success( array( 'ok' => true ) );
177193
}
178194

179-
// ─── Onboarding Handler ────────────────────────────────────────────────────
195+
// ─── Onboarding Handlers ───────────────────────────────────────────────────
180196

181-
/** Submit onboarding wizard data (business context, objectives, channels, budget). */
197+
/**
198+
* Submit onboarding wizard data (business context, objectives, channels, budget)
199+
* and signal completion to trigger the dashboard backfill.
200+
*
201+
* Portal endpoints require the sessionToken (looker_api_key), not the plugin
202+
* license key. See portal_auth_headers().
203+
*/
182204
public function handle_onboarding_submit(): void {
183205
$this->verify_request();
184206
$body = $this->get_body();
@@ -214,17 +236,105 @@ public function handle_onboarding_submit(): void {
214236
),
215237
);
216238

217-
foreach ( $steps as $step ) {
218-
$result = $this->backend_post( $step['path'], $step['body'] );
239+
foreach ( $steps as $idx => $step ) {
240+
$result = $this->backend_portal_post( $step['path'], $step['body'] );
219241
if ( $result['status'] < 200 || $result['status'] >= 300 ) {
242+
$result['body'] = is_array( $result['body'] ) ? $result['body'] : array();
243+
$result['body']['failed_step'] = basename( $step['path'] );
244+
$result['body']['failed_step_index'] = $idx;
220245
$this->send_backend_result( $result );
221-
return; // wp_send_json_error exits, but return for clarity.
246+
return;
222247
}
223248
}
224249

250+
// Step 5: Signal onboarding completion — triggers the dashboard backfill.
251+
// The /complete endpoint is idempotent on the backend (sets state to
252+
// ONBOARDING_COMPLETE; duplicate calls are no-ops).
253+
$complete = $this->backend_portal_post( '/api/portal/onboarding/complete', new \stdClass() );
254+
if ( $complete['status'] < 200 || $complete['status'] >= 300 ) {
255+
$complete['body'] = is_array( $complete['body'] ) ? $complete['body'] : array();
256+
$complete['body']['failed_step'] = 'complete';
257+
$complete['body']['failed_step_index'] = count( $steps );
258+
$this->send_backend_result( $complete );
259+
return;
260+
}
261+
225262
wp_send_json_success( array( 'ok' => true ) );
226263
}
227264

265+
/** Trigger a website scan for onboarding Step 1. */
266+
public function handle_onboarding_scan(): void {
267+
$this->verify_request();
268+
269+
// Rate limit: 1 scan per minute per user.
270+
$throttle_key = 'roi_scan_' . get_current_blog_id() . '_' . get_current_user_id();
271+
if ( get_transient( $throttle_key ) ) {
272+
wp_send_json_error( array( 'message' => 'Please wait before scanning again.' ), 429 );
273+
}
274+
set_transient( $throttle_key, 1, 60 );
275+
276+
$body = $this->get_body();
277+
$url = esc_url_raw( $body['url'] ?? '' );
278+
279+
// SSRF guard: only allow scanning the site's own domain over HTTPS.
280+
// Normalize IDN/punycode to ASCII for consistent comparison, and reject
281+
// URLs containing userinfo (user:pass@host) which could bypass host checks.
282+
$parsed = wp_parse_url( $url );
283+
if ( ! is_array( $parsed ) ) {
284+
wp_send_json_error( array( 'message' => 'Invalid scan URL.' ), 400 );
285+
}
286+
if ( ! empty( $parsed['user'] ) || ! empty( $parsed['pass'] ) ) {
287+
wp_send_json_error( array( 'message' => 'Invalid scan URL.' ), 400 );
288+
}
289+
290+
$scan_scheme = $parsed['scheme'] ?? '';
291+
$scan_host = strtolower( $parsed['host'] ?? '' );
292+
$site_host = strtolower( wp_parse_url( home_url(), PHP_URL_HOST ) );
293+
294+
// Normalize IDN domains to ASCII (punycode) for safe comparison.
295+
if ( function_exists( 'idn_to_ascii' ) ) {
296+
$scan_host = idn_to_ascii( $scan_host, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46 ) ?: $scan_host;
297+
$site_host = idn_to_ascii( $site_host, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46 ) ?: $site_host;
298+
}
299+
300+
if ( 'https' !== $scan_scheme || $scan_host !== $site_host ) {
301+
wp_send_json_error( array( 'message' => 'Scan URL must match the site domain over HTTPS.' ), 400 );
302+
}
303+
304+
$result = $this->backend_portal_post( '/api/portal/onboarding/website-scan', array( 'url' => $url ) );
305+
$this->send_backend_result( $result );
306+
}
307+
308+
/** Poll onboarding state (includes scan status/results). */
309+
public function handle_onboarding_state(): void {
310+
$this->verify_request();
311+
$result = $this->backend_portal_get( '/api/portal/onboarding/state' );
312+
$this->send_backend_result( $result );
313+
}
314+
315+
/** Standalone completion endpoint — allows retry if the /complete call fails independently. */
316+
public function handle_onboarding_complete(): void {
317+
$this->verify_request();
318+
$result = $this->backend_portal_post( '/api/portal/onboarding/complete', new \stdClass() );
319+
$this->send_backend_result( $result );
320+
}
321+
322+
// ─── Integrations Handlers ─────────────────────────────────────────────────
323+
324+
/** Fetch connected service statuses for the Activation tab. */
325+
public function handle_integrations_get(): void {
326+
$this->verify_request();
327+
$result = $this->backend_portal_get( '/api/portal/integrations' );
328+
$this->send_backend_result( $result );
329+
}
330+
331+
/** Disconnect the Google account (keeps license key valid). */
332+
public function handle_integrations_disconnect(): void {
333+
$this->verify_request();
334+
$result = $this->backend_portal_delete( '/api/portal/integrations/google' );
335+
$this->send_backend_result( $result );
336+
}
337+
228338
// ─── Response Helpers ───────────────────────────────────────────────────────
229339

230340
/**
@@ -271,20 +381,93 @@ private function backend_post( string $path, array $body ): array {
271381
return $this->parse_response( $response );
272382
}
273383

384+
/**
385+
* Auth headers for plugin-specific endpoints (/api/roi/plugin/*).
386+
* Uses the qdsh_… license key stored in wp_options.
387+
*
388+
* Only sends X-License-Key — the single header plugin endpoints expect.
389+
* Portal endpoints use portal_auth_headers() with a Bearer token instead.
390+
*/
274391
private function auth_headers(): array {
275392
$key = $this->settings->get_license_key();
276393
if ( ! $key ) {
277394
return array();
278395
}
279-
// Send the key in all formats the backend may expect.
280-
// Plugin endpoints use X-License-Key; portal endpoints use Authorization or X-API-Key.
281396
return array(
282-
'X-License-Key' => $key,
283-
'X-API-Key' => $key,
284-
'Authorization' => 'Bearer ' . $key,
397+
'Accept' => 'application/json',
398+
'X-License-Key' => $key,
285399
);
286400
}
287401

402+
/**
403+
* Auth headers for portal endpoints (/api/portal/*).
404+
*
405+
* Portal endpoints authenticate via the looker_api_key (sessionToken),
406+
* NOT the qdsh_… plugin license key. The sessionToken is returned by the
407+
* /api/roi/plugin/validate endpoint and cached in the license transient.
408+
*/
409+
private function portal_auth_headers(): array {
410+
$data = $this->license->get_license_data();
411+
$token = $data['sessionToken'] ?? '';
412+
if ( empty( $token ) ) {
413+
// No sessionToken available — return Accept only so the request fails
414+
// with a clear 401 rather than sending a mismatched X-License-Key
415+
// that portal endpoints won't recognize.
416+
return array( 'Accept' => 'application/json' );
417+
}
418+
return array(
419+
'Accept' => 'application/json',
420+
'Authorization' => 'Bearer ' . $token,
421+
);
422+
}
423+
424+
// ─── Portal Backend Proxy Helpers ───────────────────────────────────────────
425+
426+
/**
427+
* @return array{status:int,body:mixed}
428+
*/
429+
private function backend_portal_get( string $path ): array {
430+
$response = wp_remote_get(
431+
self::BACKEND . $path,
432+
array(
433+
'timeout' => 10,
434+
'headers' => $this->portal_auth_headers(),
435+
)
436+
);
437+
return $this->parse_response( $response );
438+
}
439+
440+
/**
441+
* @param object|array $body Use stdClass for empty {} payloads.
442+
* @return array{status:int,body:mixed}
443+
*/
444+
private function backend_portal_post( string $path, $body ): array {
445+
$response = wp_remote_post(
446+
self::BACKEND . $path,
447+
array(
448+
'timeout' => 10,
449+
'headers' => array_merge( $this->portal_auth_headers(), array( 'Content-Type' => 'application/json' ) ),
450+
'body' => wp_json_encode( $body ),
451+
)
452+
);
453+
return $this->parse_response( $response );
454+
}
455+
456+
/**
457+
* @return array{status:int,body:mixed}
458+
*/
459+
private function backend_portal_delete( string $path ): array {
460+
$response = wp_remote_request(
461+
self::BACKEND . $path,
462+
array(
463+
'method' => 'DELETE',
464+
'timeout' => 10,
465+
'headers' => $this->portal_auth_headers(),
466+
)
467+
);
468+
return $this->parse_response( $response );
469+
}
470+
288471
/**
289472
* @param WP_Error|array $response
290473
* @return array{status:int,body:mixed}

0 commit comments

Comments
 (0)