From a0b5f8496db0ad559c9fb1925f58201e28667200 Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Sat, 31 Jan 2026 22:22:22 +0700 Subject: [PATCH] feat: Implement OAuth license activation flow - Add LicenseConnect.tsx focused OAuth confirmation page in customer SPA - Add /licenses/oauth/validate and /licenses/oauth/confirm API endpoints - Update App.tsx to render license-connect outside BaseLayout (no header/footer) - Add license_activation_method field to product settings in Admin SPA - Create LICENSING_MODULE.md with comprehensive OAuth flow documentation - Update API_ROUTES.md with license module endpoints --- .../email-notification-audit-2026-01-29.md | 228 ++++++ .../license-activation-research-2026-01-31.md | 391 +++++++++++ .../reports/product-flow-audit-2026-01-29.md | 212 ++++++ API_ROUTES.md | 25 + LICENSING_MODULE.md | 284 ++++++++ .../Pages/store/usePageEditorStore.ts | 1 + .../Products/partials/ProductFormTabbed.tsx | 6 + .../Products/partials/tabs/GeneralTab.tsx | 25 + customer-spa/src/App.tsx | 83 ++- customer-spa/src/hooks/useWishlist.ts | 162 ++--- .../src/pages/Account/LicenseConnect.tsx | 289 ++++++++ customer-spa/src/pages/Account/index.tsx | 9 +- customer-spa/src/pages/Product/index.tsx | 91 ++- debug-variation.php | 69 ++ includes/Api/Controllers/CartController.php | 215 ++++-- includes/Api/LicensesController.php | 270 ++++++-- includes/Api/ProductsController.php | 653 ++++++++++-------- includes/Core/Notifications/EmailRenderer.php | 81 ++- includes/Frontend/CartController.php | 22 +- includes/Frontend/ShopController.php | 144 ++-- includes/Modules/Licensing/LicenseManager.php | 429 ++++++++---- .../Modules/Licensing/LicensingModule.php | 303 +++++++- includes/Modules/LicensingSettings.php | 32 +- 23 files changed, 3218 insertions(+), 806 deletions(-) create mode 100644 .agent/reports/email-notification-audit-2026-01-29.md create mode 100644 .agent/reports/license-activation-research-2026-01-31.md create mode 100644 .agent/reports/product-flow-audit-2026-01-29.md create mode 100644 LICENSING_MODULE.md create mode 100644 customer-spa/src/pages/Account/LicenseConnect.tsx create mode 100644 debug-variation.php diff --git a/.agent/reports/email-notification-audit-2026-01-29.md b/.agent/reports/email-notification-audit-2026-01-29.md new file mode 100644 index 0000000..f86fefb --- /dev/null +++ b/.agent/reports/email-notification-audit-2026-01-29.md @@ -0,0 +1,228 @@ +# Email Notification System Audit + +**Date:** January 29, 2026 +**Status:** ✅ System Architecture Sound, Minor Issues Identified + +--- + +## Executive Summary + +The WooNooW email notification system is **well-architected** with proper async handling, template rendering, and event management. The main components work together correctly. However, some potential gaps and improvements were identified. + +--- + +## System Architecture + +```mermaid +flowchart TD + A[WooCommerce Hooks] --> B[EmailManager] + B --> C{Is WooNooW Mode?} + C -->|Yes| D[EmailRenderer] + C -->|No| E[WC Default Emails] + D --> F[TemplateProvider] + F --> G[Get Template] + G --> H[Replace Variables] + H --> I[Parse Markdown/Cards] + I --> J[wp_mail] + J --> K[WooEmailOverride Intercepts] + K --> L[MailQueue::enqueue] + L --> M[Action Scheduler] + M --> N[MailQueue::sendNow] + N --> O[Actual wp_mail] +``` + +--- + +## Core Components + +| File | Purpose | +|------|---------| +| [EmailManager.php](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/EmailManager.php) | Hooks WC order events, disables WC emails, routes to renderer | +| [EmailRenderer.php](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/EmailRenderer.php) | Renders templates, replaces variables, parses markdown | +| [TemplateProvider.php](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/TemplateProvider.php) | Manages templates, defaults, variable definitions | +| [EventRegistry.php](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/EventRegistry.php) | Central registry of all notification events | +| [NotificationManager.php](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/NotificationManager.php) | Validates settings, dispatches to channels | +| [WooEmailOverride.php](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Mail/WooEmailOverride.php) | Intercepts wp_mail via `pre_wp_mail` filter | +| [MailQueue.php](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Mail/MailQueue.php) | Async queue via Action Scheduler | + +--- + +## Email Flow Trace + +### 1. Event Trigger +- WooCommerce fires hooks like `woocommerce_order_status_pending_to_processing` +- `EmailManager::init_hooks()` registers callbacks for these hooks + +### 2. EmailManager Processing +```php +// In EmailManager.php +add_action('woocommerce_order_status_pending_to_processing', [$this, 'send_order_processing_email']); +``` +- Checks if WooNooW mode enabled: `is_enabled()` +- Checks if event enabled: `is_event_enabled()` +- Calls `send_email($event_id, $recipient_type, $order)` + +### 3. Email Rendering +- `EmailRenderer::render()` called +- Gets template from `TemplateProvider::get_template()` +- Gets variables from `get_variables()` (order, customer, product data) +- Replaces `{variable}` placeholders +- Parses `[card]` markdown syntax +- Wraps in HTML template from `templates/emails/base.html` + +### 4. wp_mail Interception +- `wp_mail()` is called with rendered HTML +- `WooEmailOverride::interceptMail()` catches via `pre_wp_mail` filter +- Returns `true` to short-circuit synchronous send + +### 5. Queue & Async Send +- `MailQueue::enqueue()` stores payload in `wp_options` (temp) +- Schedules `woonoow/mail/send` action via Action Scheduler +- `MailQueue::sendNow()` runs asynchronously: + - Retrieves payload from options + - Disables `WooEmailOverride` to prevent loop + - Calls actual `wp_mail()` + - Deletes temp option + +--- + +## Findings + +### ✅ Working Correctly + +1. **Async Email Queue**: Properly prevents timeout issues +2. **Template System**: Variables replaced correctly +3. **Event Registry**: Single source of truth +4. **Subscription Events**: Registered via `woonoow_notification_events_registry` filter +5. **Global Toggle**: WooNooW vs WooCommerce mode works +6. **WC Email Disable**: Default emails properly disabled when WooNooW active + +### ⚠️ Potential Issues + +#### 1. Missing Subscription Variable Population in EmailRenderer +**Location:** [EmailRenderer.php:147-299](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/EmailRenderer.php#L147-L299) + +**Issue:** `get_variables()` handles `WC_Order`, `WC_Product`, `WC_Customer` but NOT subscription objects. Subscription notifications pass data like: +```php +$data = [ + 'subscription' => $subscription, // Custom subscription object + 'customer' => $user, + 'product' => $product, + ... +] +``` + +**Impact:** Subscription email variables like `{subscription_id}`, `{billing_period}`, `{next_payment_date}` may not be replaced. + +**Recommendation:** Add subscription variable population in `EmailRenderer::get_variables()`. + +--- + +#### 2. EmailRenderer Type Check for Subscription +**Location:** [EmailRenderer.php:121-137](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/EmailRenderer.php#L121-L137) + +**Issue:** `get_recipient_email()` only checks for `WC_Order` and `WC_Customer`. For subscriptions, `$data` is an array, so recipient email extraction fails. + +**Impact:** Subscription emails may not find recipient email. + +**Recommendation:** Handle array data or subscription object in `get_recipient_email()`. + +--- + +#### 3. SubscriptionModule Sends to NotificationManager, Not EmailManager +**Location:** [SubscriptionModule.php:529-531](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Modules/Subscription/SubscriptionModule.php#L529-L531) + +**Code:** +```php +\WooNooW\Core\Notifications\NotificationManager::send($event_id, 'email', $data); +``` + +**Issue:** This goes through `NotificationManager`, which calls its own `send_email()` that uses `EmailRenderer::render()`. The `EmailRenderer::render()` method receives `$data['subscription']` but doesn't know how to handle it. + +**Impact:** Subscription email rendering may fail silently. + +--- + +#### 4. No Error Logging in Email Rendering Failures +**Location:** [EmailRenderer.php:48-57](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Core/Notifications/EmailRenderer.php#L48-L57) + +**Issue:** When `get_template_settings()` returns null or `get_recipient_email()` returns null, the function returns null silently with only an empty debug log statement. + +**Recommendation:** Add proper `error_log()` calls for debugging. + +--- + +#### 5. Duplicate wp_mail Calls +**Location:** Multiple places call `wp_mail()` directly: +- `EmailManager::send_email()` (line 521) +- `EmailManager::send_password_reset_email()` (line 406) +- `NotificationManager::send_email()` (line 170) +- `NotificationsController` test endpoint (line 1013) +- `CampaignManager` (lines 275, 329) +- `NewsletterController` (line 203) + +**Issue:** All these are intercepted by `WooEmailOverride`, which is correct. However, if `WooEmailOverride` is disabled (testing mode), all send synchronously. + +**Status:** Working as designed. + +--- + +## Subscription Email Gap Analysis + +The subscription module has these events defined but needs variable population: + +| Event | Variables Needed | +|-------|-----------------| +| `subscription_pending_cancellation` | subscription_id, product_name, end_date | +| `subscription_cancelled` | subscription_id, cancel_reason | +| `subscription_expired` | subscription_id, product_name | +| `subscription_paused` | subscription_id, product_name | +| `subscription_resumed` | subscription_id, product_name | +| `subscription_renewal_failed` | subscription_id, failed_count, payment_link | +| `subscription_renewal_payment_due` | subscription_id, payment_link | +| `subscription_renewal_reminder` | subscription_id, next_payment_date | + +**Required Fix:** Add subscription data handling to `EmailRenderer::get_variables()`. + +--- + +## Recommendations + +### High Priority + +1. **Fix `EmailRenderer::get_variables()`** - Add handling for subscription data arrays +2. **Fix `EmailRenderer::get_recipient_email()`** - Handle array data with customer key + +### Medium Priority + +3. **Add error logging** - Replace empty debug conditions with actual logging +4. **Clean up debug conditions** - Many `if (defined('WP_DEBUG') && WP_DEBUG) {}` are empty + +### Low Priority + +5. **Consolidate email sending paths** - Consider routing all through one method +6. **Add email send failure tracking** - Log failed sends for troubleshooting + +--- + +## Test Scripts Available + +| Script | Purpose | +|--------|---------| +| `check-settings.php` | Diagnose notification settings | +| `test-email-flow.php` | Interactive email testing dashboard | +| `test-email-direct.php` | Direct wp_mail testing | + +--- + +## Documentation + +Comprehensive docs exist: +- [NOTIFICATION_SYSTEM.md](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/NOTIFICATION_SYSTEM.md) +- [EMAIL_DEBUGGING_GUIDE.md](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/EMAIL_DEBUGGING_GUIDE.md) + +--- + +## Conclusion + +The email notification system is **production-ready** for order-related notifications. The main gap is **subscription email variable population**, which requires updates to `EmailRenderer.php` to properly handle subscription data and extract variables. diff --git a/.agent/reports/license-activation-research-2026-01-31.md b/.agent/reports/license-activation-research-2026-01-31.md new file mode 100644 index 0000000..275232d --- /dev/null +++ b/.agent/reports/license-activation-research-2026-01-31.md @@ -0,0 +1,391 @@ +# OAuth-Style License Activation Research Report + +**Date:** January 31, 2026 +**Objective:** Design a strict license activation system requiring vendor site authentication + +--- + +## Executive Summary + +After researching Elementor Pro, Tutor LMS, EDD Software Licensing, and industry standards, the **redirect-based OAuth-like activation flow** is the most secure and user-friendly approach. This pattern: +- Prevents license key sharing by tying activation to user accounts +- Provides better UX than manual key entry +- Enables flexible license management +- Creates an anti-piracy layer beyond just key validation + +--- + +## Industry Analysis + +### 1. Elementor Pro + +| Aspect | Implementation | +|--------|----------------| +| **Flow** | "Connect & Activate" button → redirect to Elementor.com → login required → authorize connection → return to WP Admin | +| **Why Listed** | Market leader with 5M+ users; sets the standard for premium plugin activation | +| **Anti-Piracy** | Account-tied activation; no ability to share just a license key | +| **Fallback** | Manual key entry via hidden URL parameter `?mode=manually` | + +**Key Pattern:** Elementor never shows the license key in the normal flow—users authenticate with their account, not a key. + +--- + +### 2. Tutor LMS (Themeum) + +| Aspect | Implementation | +|--------|----------------| +| **Flow** | License settings → Enter key → "Connect" button → redirect to Themeum → login → confirm connection | +| **Why Listed** | Popular LMS plugin; hybrid approach (key + account verification) | +| **Anti-Piracy** | License keys tied to specific domains registered in user account | +| **License Display** | Keys visible in account dashboard for copy-paste | + +**Key Pattern:** Requires domain registration in vendor account before activation works. + +--- + +### 3. Easy Digital Downloads (EDD) Software Licensing + +| Aspect | Implementation | +|--------|----------------| +| **Flow** | API-based: plugin sends key + site URL to vendor → server validates → returns activation status | +| **Why Listed** | Powers many WordPress plugin vendors (WPForms, MonsterInsights, etc.) | +| **Anti-Piracy** | Activation limits (e.g., 1 site, 5 sites, unlimited); site URL tracking | +| **Management** | Customer can manage activations in their EDD account | + +**Key Pattern:** Traditional key-based but with strict activation limits and site tracking. + +--- + +### 4. WooCommerce Software License Manager + +| Aspect | Implementation | +|--------|----------------| +| **Flow** | REST API with key + secret authentication | +| **Why Listed** | Common for WooCommerce-based vendors | +| **Anti-Piracy** | API-key authentication; activation records | + +**Key Pattern:** Programmatic API access, less user-facing UX focus. + +--- + +## Best Practices Identified + +### Anti-Piracy Measures + +| Measure | Effectiveness | UX Impact | +|---------|---------------|-----------| +| **Account authentication required** | ★★★★★ | Minor inconvenience | +| **Activation limits per license** | ★★★★☆ | None | +| **Domain/URL binding** | ★★★★☆ | None | +| **Tying updates/support to valid license** | ★★★★★ | Incentivizes purchase | +| **Periodic license re-validation** | ★★★☆☆ | Can cause issues | +| **Encrypted API communication (HTTPS)** | ★★★★★ | None | + +### UX Considerations + +| Consideration | Priority | +|---------------|----------| +| One-click activation (minimal friction) | High | +| Clear error messages | High | +| License status visibility in WP Admin | Medium | +| Easy deactivation for site migrations | High | +| Fallback manual activation | Medium | + +--- + +## Security Comparison + +| Method | Piracy Resistance | Implementation Complexity | +|--------|-------------------|---------------------------| +| **Simple key validation** | Low | Simple | +| **Key + site URL binding** | Medium | Medium | +| **Key + activation limits** | Medium-High | Medium | +| **OAuth redirect + account tie** | High | Complex | +| **OAuth + key + activation limits** | Very High | Complex | + +--- + +## Your Proposed Flow Analysis + +### Original Flow Points + +1. User navigates to license page → clicks [ACTIVATE] +2. Redirect to vendor site (licensing.woonoow.com or similar) +3. Vendor site: login required +4. Vendor shows licenses for user's account, filtered by product +5. User selects license to connect +6. Click "Connect This Site" +7. Return to `return_url` after short delay + +### Identified Gaps + +| Gap | Risk | Solution | +|-----|------|----------| +| No state parameter | CSRF attack possible | Add signed `state` token | +| No nonce verification | Replay attacks | Include one-time nonce | +| Return URL manipulation | Redirect hijacking | Validate return URL on server | +| No deactivation flow | User can't migrate | Add disconnect button | + +--- + +## Perfected Implementation Plan + +### Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ WP ADMIN (Client Site) │ +├─────────────────────────────────────────────────────────────────┤ +│ Settings → License │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Status: Not Connected │ │ +│ │ [🔗 Connect & Activate] │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ + │ + ▼ Redirect with signed params +┌──────────────────────────────────────────────────────────────────┐ +│ VENDOR SITE (License Server) │ +├──────────────────────────────────────────────────────────────────┤ +│ /license/connect? │ +│ product_id=woonoow-pro& │ +│ site_url=https://customer-site.com& │ +│ return_url=https://customer-site.com/wp-admin/...& │ +│ state=& │ +│ nonce= │ +├──────────────────────────────────────────────────────────────────┤ +│ 1. Force login if not authenticated │ +│ 2. Show licenses owned by user for this product │ +│ 3. User selects: "Pro License (3/5 sites used)" │ +│ 4. Click [Connect This Site] │ +│ 5. Server records activation │ +│ 6. Redirect back with activation token │ +└──────────────────────────────────────────────────────────────────┘ + │ + ▼ Callback with activation token +┌──────────────────────────────────────────────────────────────────┐ +│ WP ADMIN (Client Site) │ +├──────────────────────────────────────────────────────────────────┤ +│ Callback handler: │ +│ 1. Verify state matches stored value │ +│ 2. Exchange activation_token for license_key via API │ +│ 3. Store license_key securely │ +│ 4. Show success: "License activated successfully!" │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Status: ✅ Active │ │ +│ │ License: Pro (expires Dec 31, 2026) │ │ +│ │ Sites: 4/5 activated │ │ +│ │ [Disconnect] │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +--- + +### Detailed Flow + +#### Phase 1: Initiation (Client Plugin) + +```php +// User clicks "Connect & Activate" +$params = [ + 'product_id' => 'woonoow-pro', + 'site_url' => home_url(), + 'return_url' => admin_url('admin.php?page=woonoow-license&action=callback'), + 'nonce' => wp_create_nonce('woonoow_license_connect'), + 'state' => $this->generate_state_token(), // Signed, stored in transient + 'timestamp' => time(), +]; + +$redirect_url = 'https://licensing.woonoow.com/connect?' . http_build_query($params); +wp_redirect($redirect_url); +``` + +#### Phase 2: Authentication (Vendor Server) + +1. **Login Gate**: If user not logged in → redirect to login with `?redirect=/connect?...` +2. **Validate Request**: Check `state`, `nonce`, `timestamp` (reject if >10 min old) +3. **Fetch User Licenses**: Query licenses owned by authenticated user for `product_id` +4. **Display License Selector**: + ``` + ┌─────────────────────────────────────────────────┐ + │ Connect site-name.com to your license │ + ├─────────────────────────────────────────────────┤ + │ ○ WooNooW Pro - Agency (Unlimited sites) │ + │ ● WooNooW Pro - Business (3/5 sites) ←selected │ + │ ○ WooNooW Pro - Personal (1/1 sites) [FULL] │ + ├─────────────────────────────────────────────────┤ + │ [Cancel] [Connect This Site] │ + └─────────────────────────────────────────────────┘ + ``` +5. **Record Activation**: Insert into `license_activations` table +6. **Generate Callback**: Redirect to `return_url` with: + - `activation_token`: Short-lived token (5 min expiry) + - `state`: Original state for verification + +#### Phase 3: Callback (Client Plugin) + +```php +// Handle callback +$activation_token = sanitize_text_field($_GET['activation_token']); +$state = sanitize_text_field($_GET['state']); + +// 1. Verify state matches stored transient +if (!$this->verify_state_token($state)) { + wp_die('Invalid state. Possible CSRF attack.'); +} + +// 2. Exchange token for license details via secure API +$response = wp_remote_post('https://licensing.woonoow.com/api/v1/token/exchange', [ + 'body' => [ + 'activation_token' => $activation_token, + 'site_url' => home_url(), + ], +]); + +// 3. Store license data +$license_data = json_decode(wp_remote_retrieve_body($response), true); +update_option('woonoow_license', [ + 'key' => $license_data['license_key'], + 'status' => 'active', + 'expires' => $license_data['expires_at'], + 'tier' => $license_data['tier'], + 'sites_used' => $license_data['sites_used'], + 'sites_max' => $license_data['sites_max'], +]); + +// 4. Redirect with success +wp_redirect(admin_url('admin.php?page=woonoow-license&activated=1')); +``` + +--- + +### Security Parameters + +| Parameter | Purpose | Implementation | +|-----------|---------|----------------| +| `state` | CSRF protection | HMAC-signed, stored in transient, expires 10 min | +| `nonce` | Replay prevention | One-time use, verified on server | +| `timestamp` | Request freshness | Reject requests >10 min old | +| `activation_token` | Secure exchange | Short-lived (5 min), single-use | +| `site_url` | Domain binding | Stored with activation record | + +--- + +### Database Schema (Vendor Server) + +```sql +CREATE TABLE license_activations ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + license_id BIGINT NOT NULL, + site_url VARCHAR(255) NOT NULL, + activation_token VARCHAR(64), + token_expires_at DATETIME, + activated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_check DATETIME, + status ENUM('active', 'deactivated') DEFAULT 'active', + metadata JSON, + UNIQUE KEY unique_license_site (license_id, site_url), + FOREIGN KEY (license_id) REFERENCES licenses(id) +); +``` + +--- + +### Deactivation Flow + +``` +Client: [Disconnect] button clicked + → POST /api/v1/license/deactivate + → Body: { license_key, site_url } + → Server removes activation record + → Client clears stored license + → Show "Disconnected" status +``` + +--- + +### Periodic Validation + +```php +// Cron check every 24 hours +add_action('woonoow_daily_license_check', function() { + $license = get_option('woonoow_license'); + if (!$license) return; + + $response = wp_remote_post('https://licensing.woonoow.com/api/v1/license/validate', [ + 'body' => [ + 'license_key' => $license['key'], + 'site_url' => home_url(), + ], + ]); + + $data = json_decode(wp_remote_retrieve_body($response), true); + + if ($data['status'] !== 'active') { + update_option('woonoow_license', ['status' => 'invalid']); + // Optionally disable premium features + } +}); +``` + +--- + +## API Endpoints (Vendor Server) + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/connect` | GET | OAuth-like authorization page | +| `/api/v1/token/exchange` | POST | Exchange activation token for license | +| `/api/v1/license/validate` | POST | Validate license status | +| `/api/v1/license/deactivate` | POST | Remove site activation | +| `/api/v1/license/info` | GET | Get license details | + +--- + +## Comparison: Your Flow vs. Perfected + +| Aspect | Your Original | Perfected | +|--------|---------------|-----------| +| CSRF Protection | ❌ None | ✅ State token | +| Replay Prevention | ❌ None | ✅ Nonce + timestamp | +| Token Exchange | ❌ Direct return | ✅ Secure exchange | +| Return URL Security | ❌ Unvalidated | ✅ Server whitelist | +| Deactivation | ❌ Not mentioned | ✅ Full flow | +| Periodic Validation | ❌ Not mentioned | ✅ Daily cron | +| Fallback | ❌ None | ✅ Manual key entry | + +--- + +## Implementation Phases + +### Phase 1: Server-Side (Licensing Portal) +1. Create `/connect` authorization page +2. Build license selection UI +3. Implement activation recording +4. Create token exchange API + +### Phase 2: Client-Side (WooNooW Plugin) +1. Create Settings → License admin page +2. Implement connect redirect +3. Handle callback and token exchange +4. Store license securely +5. Add disconnect functionality + +### Phase 3: Validation & Updates +1. Implement periodic license checks +2. Gate premium features behind valid license +3. Integrate with plugin update checker + +--- + +## References + +| Source | Relevance | +|--------|-----------| +| Elementor Pro Activation | Primary reference for UX flow | +| Tutor LMS / Themeum | Hybrid key+account approach | +| OAuth 2.0 Authorization Code Flow | Security pattern basis | +| EDD Software Licensing | Activation limits pattern | +| OWASP API Security | State/nonce implementation | diff --git a/.agent/reports/product-flow-audit-2026-01-29.md b/.agent/reports/product-flow-audit-2026-01-29.md new file mode 100644 index 0000000..85bdb0d --- /dev/null +++ b/.agent/reports/product-flow-audit-2026-01-29.md @@ -0,0 +1,212 @@ +# Product Create/Update Flow Audit Report + +**Date:** 2026-01-29 +**Scope:** Full trace of product creation, update, SKU validation, variation handling, virtual product setting, and customer-facing add-to-cart + +--- + +## Executive Summary + +**Total Issues Found: 4** +- **CRITICAL:** 2 +- **WARNING:** 1 +- **INFO:** 1 + +--- + +## Critical Issues + +### 🔴 Issue #1: SKU Validation Blocks Variation Updates + +**Severity:** CRITICAL +**Location:** [ProductsController.php#L1009](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/ProductsController.php#L1009) + +**Problem:** +When updating a variable product, the `save_product_variations` method sets SKU unconditionally: +```php +if (isset($var_data['sku'])) $variation->set_sku($var_data['sku']); +``` + +WooCommerce validates that SKU must be unique across all products. When updating a variation that already has that SKU, WooCommerce throws an exception because it sees the SKU as a duplicate. + +**Root Cause:** +WooCommerce's `set_sku()` method checks for uniqueness but doesn't know the variation already owns that SKU during the update. + +**Fix Required:** +Before setting SKU, check if the new SKU is the same as the current SKU: +```php +if (isset($var_data['sku'])) { + $current_sku = $variation->get_sku(); + $new_sku = $var_data['sku']; + // Only set if different (to avoid WC duplicate check issue) + if ($current_sku !== $new_sku) { + $variation->set_sku($new_sku); + } +} +``` + +--- + +### 🔴 Issue #2: Variation Selection Fails (Attribute Format Mismatch) + +**Severity:** CRITICAL +**Location:** +- Backend: [ShopController.php#L363-365](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Frontend/ShopController.php#L363) +- Frontend: [Product/index.tsx#L97-127](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/customer-spa/src/pages/Product/index.tsx#L97) + +**Problem:** +"Please select all product options" error appears even when a variation is selected. + +**Root Cause:** +Format mismatch between backend API and frontend matching logic: + +| Source | Format | Example | +|--------|--------|---------| +| API `variations.attributes` | `attribute_pa_color: "red"` | Lowercase, prefixed | +| API `attributes` | `name: "Color"` | Human-readable | +| Frontend `selectedAttributes` | `Color: "Red"` | Human-readable, case preserved | + +The matching logic at lines 100-120 has complex normalization but may fail at edge cases: +- Taxonomy attributes use `pa_` prefix (e.g., `attribute_pa_color`) +- Custom attributes use direct prefix (e.g., `attribute_size`) +- The comparison normalizes both sides but attribute names in `selectedAttributes` are human-readable labels + +**Fix Required:** +Improve variation matching by normalizing attribute names consistently: + +```typescript +// In find matching variation logic: +const variation = (product.variations as any[]).find(v => { + return Object.entries(selectedAttributes).every(([attrName, attrValue]) => { + const normalizedAttrName = attrName.toLowerCase(); + const normalizedValue = attrValue.toLowerCase(); + + // Try all possible attribute key formats + const possibleKeys = [ + `attribute_${normalizedAttrName}`, + `attribute_pa_${normalizedAttrName}`, + normalizedAttrName + ]; + + for (const key of possibleKeys) { + if (key in v.attributes) { + return v.attributes[key].toLowerCase() === normalizedValue; + } + } + return false; + }); +}); +``` + +--- + +## Warning Issues + +### 🟡 Issue #3: Virtual Product Setting May Not Persist for Variable Products + +**Severity:** WARNING +**Location:** [ProductsController.php#L496-498](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/ProductsController.php#L496) + +**Problem:** +User reports cannot change product to virtual. Investigation shows: +- Admin-SPA correctly sends `virtual: true` in payload +- Backend `update_product` correctly calls `$product->set_virtual()` +- However, for variable products, virtual status may need to be set on each variation + +**Observation:** +The backend code at lines 496-498 handles virtual correctly: +```php +if (isset($data['virtual'])) { + $product->set_virtual((bool) $data['virtual']); +} +``` + +**Potential Issue:** +WooCommerce may ignore parent product's virtual flag for variable products. Each variation may need to be set as virtual individually. + +**Fix Required:** +When saving variations, also propagate virtual flag: +```php +// In save_product_variations, after setting other fields: +if ($product->is_virtual()) { + $variation->set_virtual(true); +} +``` + +--- + +## Info Issues + +### ℹ️ Issue #4: Missing Error Handling in Add-to-Cart Backend + +**Severity:** INFO +**Location:** [CartController.php#L202-203](file:///Users/dwindown/Local%20Sites/woonoow/app/public/wp-content/plugins/woonoow/includes/Api/Controllers/CartController.php#L202) + +**Observation:** +When `add_to_cart()` returns false, the error message is generic: +```php +if (!$cart_item_key) { + return new WP_Error('add_to_cart_failed', 'Failed to add product to cart', ['status' => 400]); +} +``` + +WooCommerce may have more specific notices in `wc_notice` stack that could provide better error messages. + +**Enhancement:** +```php +if (!$cart_item_key) { + $notices = wc_get_notices('error'); + $message = !empty($notices) ? $notices[0]['notice'] : 'Failed to add product to cart'; + wc_clear_notices(); + return new WP_Error('add_to_cart_failed', $message, ['status' => 400]); +} +``` + +--- + +## Code Flow Summary + +### Product Update Flow +```mermaid +sequenceDiagram + Admin SPA->>ProductsController: PUT /products/{id} + ProductsController->>WC_Product: set_name, set_sku, etc. + ProductsController->>WC_Product: set_virtual, set_downloadable + ProductsController->>ProductsController: save_product_variations() + ProductsController->>WC_Product_Variation: set_sku (BUG: no duplicate check) + WC_Product_Variation-->>WooCommerce: validate_sku() + WooCommerce-->>ProductsController: Exception (duplicate SKU) +``` + +### Add-to-Cart Flow +```mermaid +sequenceDiagram + Customer SPA->>Product Page: Select variation + Product Page->>useState: selectedAttributes = {Color: "Red"} + Product Page->>useEffect: Find matching variation + Note right of Product Page: Mismatch: API has attribute_pa_color + Product Page-->>useState: selectedVariation = null + Customer->>Product Page: Click Add to Cart + Product Page->>Customer: "Please select all product options" +``` + +--- + +## Files to Modify + +| File | Change | +|------|--------| +| `ProductsController.php` | Fix SKU check in `save_product_variations` | +| `Product/index.tsx` | Fix variation matching logic | +| `ProductsController.php` | Propagate virtual to variations | +| `CartController.php` | (Optional) Improve error messages | + +--- + +## Verification Plan + +After fixes: +1. Create a variable product with SKU on variations +2. Edit the product without changing SKU → should save successfully +3. Add products to cart → verify variation selection works +4. Test virtual product setting on simple and variable products diff --git a/API_ROUTES.md b/API_ROUTES.md index 1c7edb6..f6683c7 100644 --- a/API_ROUTES.md +++ b/API_ROUTES.md @@ -109,6 +109,31 @@ GET /analytics/orders # Order analytics GET /analytics/customers # Customer analytics ``` +### Licensing Module (`LicensesController.php`) +``` +# Admin Endpoints (admin auth required) +GET /licenses # List licenses (with pagination, search) +GET /licenses/{id} # Get single license +POST /licenses # Create license +PUT /licenses/{id} # Update license +DELETE /licenses/{id} # Delete license + +# Public Endpoints (for client software validation) +POST /licenses/validate # Validate license key +POST /licenses/activate # Activate license on domain +POST /licenses/deactivate # Deactivate license from domain + +# OAuth Endpoints (user auth required) +GET /licenses/oauth/validate # Validate OAuth state and license ownership +POST /licenses/oauth/confirm # Confirm activation and generate token +``` + +**Implementation Details:** +- **List:** Supports pagination (`page`, `per_page`), search by key/email +- **activate:** Supports Simple API and OAuth modes +- **OAuth flow:** `oauth/validate` + `oauth/confirm` for secure user verification +- See `LICENSING_MODULE.md` for full OAuth flow documentation + --- ## Conflict Prevention Rules diff --git a/LICENSING_MODULE.md b/LICENSING_MODULE.md new file mode 100644 index 0000000..acab4b9 --- /dev/null +++ b/LICENSING_MODULE.md @@ -0,0 +1,284 @@ +# Licensing Module Documentation + +## Overview + +WooNooW's Licensing Module provides software license management for digital products. It supports two activation methods: + +1. **Simple API** - Direct license key validation via API +2. **Secure OAuth** - User verification via vendor portal before activation + +--- + +## API Endpoints + +### Admin Endpoints (Authenticated Admin) + +``` +GET /licenses # List all licenses (with pagination, search) +GET /licenses/{id} # Get single license +POST /licenses # Create license +PUT /licenses/{id} # Update license +DELETE /licenses/{id} # Delete license +``` + +### Public Endpoints (For Client Software) + +``` +POST /licenses/validate # Validate license key +POST /licenses/activate # Activate license on domain +POST /licenses/deactivate # Deactivate license from domain +``` + +### OAuth Endpoints (Authenticated User) + +``` +GET /licenses/oauth/validate # Validate OAuth state and license ownership +POST /licenses/oauth/confirm # Confirm activation and get token +``` + +--- + +## Activation Flows + +### 1. Simple API Flow + +Direct license activation without user verification. Suitable for trusted environments. + +``` +Client Vendor API + | | + |-- POST /licenses/activate -| + | {license_key, domain} | + | | + |<-- {success, activation_id}| +``` + +**Example Request:** +```bash +curl -X POST "https://vendor.com/wp-json/woonoow/v1/licenses/activate" \ + -H "Content-Type: application/json" \ + -d '{ + "license_key": "XXXX-YYYY-ZZZZ-WWWW", + "domain": "https://customer-site.com", + "machine_id": "optional-unique-id" + }' +``` + +**Example Response:** +```json +{ + "success": true, + "activation_id": 123, + "license_key": "XXXX-YYYY-ZZZZ-WWWW", + "status": "active", + "expires_at": "2025-01-31T00:00:00Z", + "activation_limit": 3, + "activation_count": 1 +} +``` + +--- + +### 2. Secure OAuth Flow (Recommended) + +User must verify ownership on vendor portal before activation. More secure. + +``` +Client Vendor Portal Vendor API + | | | + |-- POST /licenses/activate -| | + | {license_key, domain} | | + | | | + |<-- {oauth_redirect, state}-| | + | | | + |== User redirects browser ==| | + | | | + |-------- BROWSER ---------->| | + | /my-account/license-connect?license_key=...&state=...| + | | | + | [User logs in if needed] | + | [User sees confirmation page] | + | [User clicks "Authorize"] | + | | | + | |-- POST /oauth/confirm -->| + | |<-- {token} --------------| + | | | + |<------- REDIRECT ----------| | + | {return_url}?activation_token=xxx | + | | | + |-- POST /licenses/activate -----------------------> | + | {license_key, activation_token} | + | | | + |<-- {success, activation_id} --------------------------| +``` + +--- + +## OAuth Flow Step by Step + +### Step 1: Client Requests Activation (OAuth Mode) + +```bash +curl -X POST "https://vendor.com/wp-json/woonoow/v1/licenses/activate" \ + -H "Content-Type: application/json" \ + -d '{ + "license_key": "XXXX-YYYY-ZZZZ-WWWW", + "domain": "https://customer-site.com", + "return_url": "https://customer-site.com/activation-callback", + "activation_mode": "oauth" + }' +``` + +**Response (OAuth Required):** +```json +{ + "success": false, + "oauth_required": true, + "oauth_redirect": "https://vendor.com/my-account/license-connect/?license_key=XXXX-YYYY-ZZZZ-WWWW&site_url=https://customer-site.com&return_url=https://customer-site.com/activation-callback&state=abc123&nonce=xyz789", + "state": "abc123" +} +``` + +### Step 2: User Opens Browser to OAuth URL + +Client opens the `oauth_redirect` URL in user's browser. The user: +1. Logs into vendor portal (if not already) +2. Sees license activation confirmation page +3. Reviews license key and requesting site +4. Clicks "Authorize" to confirm + +### Step 3: User Gets Redirected Back + +After authorization, user is redirected to `return_url` with token: + +``` +https://customer-site.com/activation-callback?activation_token=xyz123&license_key=XXXX-YYYY-ZZZZ-WWWW&nonce=xyz789 +``` + +### Step 4: Client Exchanges Token for Activation + +```bash +curl -X POST "https://vendor.com/wp-json/woonoow/v1/licenses/activate" \ + -H "Content-Type: application/json" \ + -d '{ + "license_key": "XXXX-YYYY-ZZZZ-WWWW", + "domain": "https://customer-site.com", + "activation_token": "xyz123" + }' +``` + +**Response (Success):** +```json +{ + "success": true, + "activation_id": 456, + "license_key": "XXXX-YYYY-ZZZZ-WWWW", + "status": "active" +} +``` + +--- + +## Configuration + +### Site-Level Settings + +In Admin SPA: **Settings > Licensing** + +| Setting | Description | +|---------|-------------| +| Default Activation Method | `api` or `oauth` - Default for all products | +| License Key Format | Format pattern for generated keys | +| Default Validity Period | Days until license expires | +| Default Activation Limit | Max activations per license | + +### Per-Product Settings + +In Admin SPA: **Products > Edit Product > General Tab** + +| Setting | Description | +|---------|-------------| +| Enable Licensing | Toggle to enable license generation | +| Activation Method | `Use Site Default`, `Simple API`, or `Secure OAuth` | + +--- + +## Database Schema + +### Licenses Table (`wp_woonoow_licenses`) + +| Column | Type | Description | +|--------|------|-------------| +| id | BIGINT | Primary key | +| license_key | VARCHAR(255) | Unique license key | +| product_id | BIGINT | WooCommerce product ID | +| order_id | BIGINT | WooCommerce order ID | +| user_id | BIGINT | Customer user ID | +| status | VARCHAR(50) | active, inactive, expired, revoked | +| activation_limit | INT | Max allowed activations | +| activation_count | INT | Current activation count | +| expires_at | DATETIME | Expiration date | +| created_at | DATETIME | Created timestamp | +| updated_at | DATETIME | Updated timestamp | + +### Activations Table (`wp_woonoow_license_activations`) + +| Column | Type | Description | +|--------|------|-------------| +| id | BIGINT | Primary key | +| license_id | BIGINT | Foreign key to licenses | +| domain | VARCHAR(255) | Activated domain | +| machine_id | VARCHAR(255) | Optional machine identifier | +| status | VARCHAR(50) | active, deactivated, pending | +| user_agent | TEXT | Client user agent | +| activated_at | DATETIME | Activation timestamp | + +--- + +## Customer SPA: License Connect Page + +The OAuth confirmation page is available at: +``` +/my-account/license-connect/ +``` + +### Query Parameters + +| Parameter | Required | Description | +|-----------|----------|-------------| +| license_key | Yes | License key to activate | +| site_url | Yes | Requesting site URL | +| return_url | Yes | Callback URL after authorization | +| state | Yes | CSRF protection token | +| nonce | No | Additional security nonce | + +### UI Features + +- **Focused Layout** - No header/sidebar/footer, just the authorization card +- **Brand Display** - Shows vendor site name +- **License Details** - Displays license key, site URL, product name +- **Security Warning** - Warns user to only authorize trusted sites +- **Authorize/Deny Buttons** - Clear actions for user + +--- + +## Security Considerations + +1. **State Token** - Prevents CSRF attacks, expires after 5 minutes +2. **Activation Token** - Single-use, expires after 5 minutes +3. **User Verification** - OAuth ensures license owner authorizes activation +4. **Domain Validation** - Tracks activated domains for audit +5. **Rate Limiting** - Consider implementing on activation endpoints + +--- + +## Files Reference + +| File | Purpose | +|------|---------| +| `includes/Modules/Licensing/LicensingModule.php` | Module registration, endpoint handlers | +| `includes/Modules/Licensing/LicenseManager.php` | Core license operations | +| `includes/Api/LicensesController.php` | REST API endpoints | +| `customer-spa/src/pages/Account/LicenseConnect.tsx` | OAuth confirmation UI | +| `customer-spa/src/pages/Account/index.tsx` | Routing for license pages | +| `customer-spa/src/App.tsx` | Top-level routing (license-connect outside BaseLayout) | diff --git a/admin-spa/src/routes/Appearance/Pages/store/usePageEditorStore.ts b/admin-spa/src/routes/Appearance/Pages/store/usePageEditorStore.ts index 302b3b3..3f5f638 100644 --- a/admin-spa/src/routes/Appearance/Pages/store/usePageEditorStore.ts +++ b/admin-spa/src/routes/Appearance/Pages/store/usePageEditorStore.ts @@ -60,6 +60,7 @@ export interface PageItem { slug?: string; title: string; url?: string; + isFrontPage?: boolean; isSpaLanding?: boolean; } diff --git a/admin-spa/src/routes/Products/partials/ProductFormTabbed.tsx b/admin-spa/src/routes/Products/partials/ProductFormTabbed.tsx index 5bb90df..267adf2 100644 --- a/admin-spa/src/routes/Products/partials/ProductFormTabbed.tsx +++ b/admin-spa/src/routes/Products/partials/ProductFormTabbed.tsx @@ -40,6 +40,7 @@ export type ProductFormData = { licensing_enabled?: boolean; license_activation_limit?: string; license_duration_days?: string; + license_activation_method?: '' | 'api' | 'oauth'; // Subscription subscription_enabled?: boolean; subscription_period?: 'day' | 'week' | 'month' | 'year'; @@ -95,6 +96,7 @@ export function ProductFormTabbed({ const [licensingEnabled, setLicensingEnabled] = useState(initial?.licensing_enabled || false); const [licenseActivationLimit, setLicenseActivationLimit] = useState(initial?.license_activation_limit || ''); const [licenseDurationDays, setLicenseDurationDays] = useState(initial?.license_duration_days || ''); + const [licenseActivationMethod, setLicenseActivationMethod] = useState<'' | 'api' | 'oauth'>(initial?.license_activation_method || ''); // Subscription state const [subscriptionEnabled, setSubscriptionEnabled] = useState(initial?.subscription_enabled || false); const [subscriptionPeriod, setSubscriptionPeriod] = useState<'day' | 'week' | 'month' | 'year'>(initial?.subscription_period || 'month'); @@ -131,6 +133,7 @@ export function ProductFormTabbed({ setLicensingEnabled(initial.licensing_enabled || false); setLicenseActivationLimit(initial.license_activation_limit || ''); setLicenseDurationDays(initial.license_duration_days || ''); + setLicenseActivationMethod(initial.license_activation_method || ''); // Subscription setSubscriptionEnabled(initial.subscription_enabled || false); setSubscriptionPeriod(initial.subscription_period || 'month'); @@ -199,6 +202,7 @@ export function ProductFormTabbed({ licensing_enabled: licensingEnabled, license_activation_limit: licensingEnabled ? licenseActivationLimit : undefined, license_duration_days: licensingEnabled ? licenseDurationDays : undefined, + license_activation_method: licensingEnabled ? licenseActivationMethod : undefined, // Subscription subscription_enabled: subscriptionEnabled, subscription_period: subscriptionEnabled ? subscriptionPeriod : undefined, @@ -261,6 +265,8 @@ export function ProductFormTabbed({ setLicenseActivationLimit={setLicenseActivationLimit} licenseDurationDays={licenseDurationDays} setLicenseDurationDays={setLicenseDurationDays} + licenseActivationMethod={licenseActivationMethod} + setLicenseActivationMethod={setLicenseActivationMethod} subscriptionEnabled={subscriptionEnabled} setSubscriptionEnabled={setSubscriptionEnabled} subscriptionPeriod={subscriptionPeriod} diff --git a/admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx b/admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx index c5183a0..284ecb6 100644 --- a/admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx +++ b/admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx @@ -50,6 +50,8 @@ type GeneralTabProps = { setLicenseActivationLimit?: (value: string) => void; licenseDurationDays?: string; setLicenseDurationDays?: (value: string) => void; + licenseActivationMethod?: '' | 'api' | 'oauth'; + setLicenseActivationMethod?: (value: '' | 'api' | 'oauth') => void; // Subscription subscriptionEnabled?: boolean; setSubscriptionEnabled?: (value: boolean) => void; @@ -95,6 +97,8 @@ export function GeneralTab({ setLicenseActivationLimit, licenseDurationDays, setLicenseDurationDays, + licenseActivationMethod, + setLicenseActivationMethod, subscriptionEnabled, setSubscriptionEnabled, subscriptionPeriod, @@ -498,6 +502,27 @@ export function GeneralTab({

+ {setLicenseActivationMethod && ( +
+ + +

+ {__('Override site-level activation method for this product')} +

+
+ )} )} diff --git a/customer-spa/src/App.tsx b/customer-spa/src/App.tsx index d347893..fe7e7bb 100644 --- a/customer-spa/src/App.tsx +++ b/customer-spa/src/App.tsx @@ -80,49 +80,60 @@ function AppRoutes() { const frontPageSlug = getFrontPageSlug(); return ( - - - {/* Root route: If frontPageSlug exists, render it. Else redirect to initialRoute (e.g. /shop) */} - - ) : ( - - ) - } - /> + + {/* License Connect - Standalone focused page without layout */} + } /> - {/* Shop Routes */} - } /> - } /> + {/* All other routes wrapped in BaseLayout */} + + + {/* Root route: If frontPageSlug exists, render it. Else redirect to initialRoute (e.g. /shop) */} + + ) : ( + + ) + } + /> - {/* Cart & Checkout */} - } /> - } /> - } /> - } /> - } /> + {/* Shop Routes */} + } /> + } /> - {/* Wishlist - Public route accessible to guests */} - } /> + {/* Cart & Checkout */} + } /> + } /> + } /> + } /> + } /> - {/* Login & Auth */} - } /> - } /> - } /> + {/* Wishlist - Public route accessible to guests */} + } /> - {/* My Account */} - } /> + {/* Login & Auth */} + } /> + } /> + } /> - {/* Dynamic Pages - CPT content with path base (e.g., /blog/:slug) */} - } /> + {/* My Account */} + } /> - {/* Dynamic Pages - Structural pages (e.g., /about, /contact) */} - } /> - - + {/* Dynamic Pages - CPT content with path base (e.g., /blog/:slug) */} + } /> + + {/* Dynamic Pages - Structural pages (e.g., /about, /contact) */} + } /> + + + } + /> + ); } diff --git a/customer-spa/src/hooks/useWishlist.ts b/customer-spa/src/hooks/useWishlist.ts index c5db5bc..4570163 100644 --- a/customer-spa/src/hooks/useWishlist.ts +++ b/customer-spa/src/hooks/useWishlist.ts @@ -1,4 +1,5 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from '@/lib/api/client'; import { toast } from 'sonner'; @@ -19,114 +20,115 @@ interface WishlistItem { const GUEST_WISHLIST_KEY = 'woonoow_guest_wishlist'; export function useWishlist() { - const [items, setItems] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [productIds, setProductIds] = useState>(new Set()); - + const queryClient = useQueryClient(); + const [guestIds, setGuestIds] = useState>(new Set()); + // Check if wishlist is enabled (default true if not explicitly set to false) const settings = (window as any).woonoowCustomer?.settings; const isEnabled = settings?.wishlist_enabled !== false; const isLoggedIn = (window as any).woonoowCustomer?.user?.isLoggedIn; - // Load guest wishlist from localStorage - const loadGuestWishlist = useCallback(() => { - try { - const stored = localStorage.getItem(GUEST_WISHLIST_KEY); - if (stored) { - const guestIds = JSON.parse(stored) as number[]; - setProductIds(new Set(guestIds)); + // Load guest wishlist on mount + useEffect(() => { + if (!isLoggedIn) { + try { + const stored = localStorage.getItem(GUEST_WISHLIST_KEY); + if (stored) { + const ids = JSON.parse(stored) as number[]; + setGuestIds(new Set(ids)); + } + } catch (error) { + console.error('Failed to load guest wishlist:', error); } - } catch (error) { - console.error('Failed to load guest wishlist:', error); } - }, []); + }, [isLoggedIn]); - // Save guest wishlist to localStorage + // Save guest wishlist helper const saveGuestWishlist = useCallback((ids: Set) => { try { localStorage.setItem(GUEST_WISHLIST_KEY, JSON.stringify(Array.from(ids))); + setGuestIds(ids); } catch (error) { console.error('Failed to save guest wishlist:', error); } }, []); - // Load wishlist on mount - useEffect(() => { - if (isEnabled) { - if (isLoggedIn) { - loadWishlist(); - } else { - loadGuestWishlist(); - } - } - }, [isEnabled, isLoggedIn]); + // Fetch wishlist items (Server) + const { data: serverItems = [], isLoading: isServerLoading } = useQuery({ + queryKey: ['wishlist'], + queryFn: async () => { + return await api.get('/account/wishlist'); + }, + enabled: isEnabled && isLoggedIn, + staleTime: 60 * 1000, // 1 minute cache + retry: 1, + }); - const loadWishlist = useCallback(async () => { - if (!isLoggedIn) return; - - try { - setIsLoading(true); - const data = await api.get('/account/wishlist'); - setItems(data); - setProductIds(new Set(data.map(item => item.product_id))); - } catch (error) { - console.error('Failed to load wishlist:', error); - } finally { - setIsLoading(false); - } - }, [isLoggedIn]); + // Calculate merged state + const items = isLoggedIn ? serverItems : []; // Guest items not stored as full objects here, usually handled by fetching products by ID elsewhere + const isLoading = isLoggedIn ? isServerLoading : false; - const addToWishlist = useCallback(async (productId: number) => { - // Guest mode: store in localStorage only - if (!isLoggedIn) { - const newIds = new Set(productIds); - newIds.add(productId); - setProductIds(newIds); - saveGuestWishlist(newIds); + // Compute set of IDs for O(1) lookup + const productIds = useMemo(() => { + if (isLoggedIn) { + return new Set(serverItems.map(item => item.product_id)); + } + return guestIds; + }, [isLoggedIn, serverItems, guestIds]); + + // Mutations + const addMutation = useMutation({ + mutationFn: async (productId: number) => { + return await api.post('/account/wishlist', { product_id: productId }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['wishlist'] }); toast.success('Added to wishlist'); - return true; - } - - // Logged in: use API - try { - await api.post('/account/wishlist', { product_id: productId }); - await loadWishlist(); // Reload to get full product details - toast.success('Added to wishlist'); - return true; - } catch (error: any) { + }, + onError: (error: any) => { const message = error?.message || 'Failed to add to wishlist'; toast.error(message); - return false; } - }, [isLoggedIn, productIds, loadWishlist, saveGuestWishlist]); + }); + + const removeMutation = useMutation({ + mutationFn: async (productId: number) => { + return await api.delete(`/account/wishlist/${productId}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['wishlist'] }); + toast.success('Removed from wishlist'); + }, + onError: () => { + toast.error('Failed to remove from wishlist'); + } + }); + + const addToWishlist = useCallback(async (productId: number) => { + if (!isLoggedIn) { + const newIds = new Set(guestIds); + newIds.add(productId); + saveGuestWishlist(newIds); + toast.success('Added to wishlist'); + return true; + } + + await addMutation.mutateAsync(productId); + return true; + }, [isLoggedIn, guestIds, saveGuestWishlist, addMutation]); const removeFromWishlist = useCallback(async (productId: number) => { - // Guest mode: remove from localStorage only if (!isLoggedIn) { - const newIds = new Set(productIds); + const newIds = new Set(guestIds); newIds.delete(productId); - setProductIds(newIds); saveGuestWishlist(newIds); toast.success('Removed from wishlist'); return true; } - // Logged in: use API - try { - await api.delete(`/account/wishlist/${productId}`); - setItems(items.filter(item => item.product_id !== productId)); - setProductIds(prev => { - const newSet = new Set(prev); - newSet.delete(productId); - return newSet; - }); - toast.success('Removed from wishlist'); - return true; - } catch (error) { - toast.error('Failed to remove from wishlist'); - return false; - } - }, [isLoggedIn, productIds, items, saveGuestWishlist]); + await removeMutation.mutateAsync(productId); + return true; + }, [isLoggedIn, guestIds, saveGuestWishlist, removeMutation]); const toggleWishlist = useCallback(async (productId: number) => { if (productIds.has(productId)) { @@ -145,12 +147,12 @@ export function useWishlist() { isLoading, isEnabled, isLoggedIn, - count: items.length, + count: productIds.size, productIds, addToWishlist, removeFromWishlist, toggleWishlist, isInWishlist, - refresh: loadWishlist, + refresh: () => queryClient.invalidateQueries({ queryKey: ['wishlist'] }), }; } diff --git a/customer-spa/src/pages/Account/LicenseConnect.tsx b/customer-spa/src/pages/Account/LicenseConnect.tsx new file mode 100644 index 0000000..937f310 --- /dev/null +++ b/customer-spa/src/pages/Account/LicenseConnect.tsx @@ -0,0 +1,289 @@ +import React, { useState, useEffect } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { api } from '@/lib/api/client'; +import { Button } from '@/components/ui/button'; +import { Shield, Globe, Key, Check, X, Loader2, AlertTriangle } from 'lucide-react'; + +export default function LicenseConnect() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + + const [loading, setLoading] = useState(false); + const [confirming, setConfirming] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [licenseInfo, setLicenseInfo] = useState(null); + + // Get params from URL + const licenseKey = searchParams.get('license_key') || ''; + const siteUrl = searchParams.get('site_url') || ''; + const returnUrl = searchParams.get('return_url') || ''; + const state = searchParams.get('state') || ''; + const nonce = searchParams.get('nonce') || ''; + + // Get site name from window + const siteName = (window as any).woonoowCustomer?.siteName || 'WooNooW'; + + // Validate and load license info + useEffect(() => { + if (!licenseKey || !siteUrl || !state) { + setError('Invalid license connection request. Missing required parameters.'); + return; + } + + const loadLicenseInfo = async () => { + setLoading(true); + try { + const response = await api.get(`/licenses/oauth/validate?license_key=${encodeURIComponent(licenseKey)}&state=${encodeURIComponent(state)}`); + setLicenseInfo(response); + } catch (err: any) { + setError(err.message || 'Failed to validate license connection request.'); + } finally { + setLoading(false); + } + }; + + loadLicenseInfo(); + }, [licenseKey, siteUrl, state]); + + // Handle confirmation + const handleConfirm = async () => { + setConfirming(true); + setError(null); + + try { + const response = await api.post<{ success?: boolean; redirect_url?: string }>('/licenses/oauth/confirm', { + license_key: licenseKey, + site_url: siteUrl, + state: state, + nonce: nonce, + }); + + if (response.success && response.redirect_url) { + // Redirect to return URL with activation token + window.location.href = response.redirect_url; + } else { + setSuccess(true); + } + } catch (err: any) { + setError(err.message || 'Failed to confirm license activation.'); + } finally { + setConfirming(false); + } + }; + + // Handle cancel + const handleCancel = () => { + if (returnUrl) { + window.location.href = `${returnUrl}?error=cancelled&message=User%20cancelled%20the%20license%20activation`; + } else { + navigate('/my-account/licenses'); + } + }; + + // Full-page focused container + const PageWrapper = ({ children }: { children: React.ReactNode }) => ( +
+ {/* Minimal header with brand */} +
+
{siteName}
+
+ + {/* Centered content */} +
+ {children} +
+ + {/* Minimal footer */} +
+ Secure License Activation +
+
+ ); + + // Render error state (when no license info is loaded) + if (error && !licenseInfo) { + return ( + +
+
+
+
+
+ +
+
+

+ Connection Error +

+
+ {error} +
+
+
+ +
+
+
+
+ ); + } + + // Render loading state + if (loading) { + return ( + +
+
+ +

Validating license request...

+

Please wait

+
+
+
+ ); + } + + // Render success state + if (success) { + return ( + +
+
+
+
+
+ +
+
+

+ License Activated! +

+

+ Your license has been successfully activated for the specified site. +

+
+
+ +
+
+
+
+ ); + } + + // Render confirmation page + return ( + +
+
+ {/* Header */} +
+
+
+ +
+
+

Activate Your License

+

+ A site is requesting to activate your license +

+
+ + {/* Content */} +
+ {error && ( +
+ {error} +
+ )} + + {/* License Info Cards */} +
+
+
+ +
+
+

License Key

+

{licenseKey}

+
+
+ +
+
+ +
+
+

Requesting Site

+

{siteUrl}

+
+
+ + {licenseInfo?.product_name && ( +
+
+ +
+
+

Product

+

{licenseInfo.product_name}

+
+
+ )} +
+ + {/* Warning */} +
+ +

+ By confirming, you authorize this site to use your license. + Only confirm if you trust the requesting site. +

+
+
+ + {/* Footer Actions */} +
+ + +
+
+
+
+ ); +} diff --git a/customer-spa/src/pages/Account/index.tsx b/customer-spa/src/pages/Account/index.tsx index 5073b83..fd1d2e3 100644 --- a/customer-spa/src/pages/Account/index.tsx +++ b/customer-spa/src/pages/Account/index.tsx @@ -10,6 +10,7 @@ import Addresses from './Addresses'; import Wishlist from './Wishlist'; import AccountDetails from './AccountDetails'; import Licenses from './Licenses'; +import LicenseConnect from './LicenseConnect'; import Subscriptions from './Subscriptions'; import SubscriptionDetail from './SubscriptionDetail'; @@ -19,10 +20,15 @@ export default function Account() { // Redirect to login if not authenticated if (!user?.isLoggedIn) { - const currentPath = location.pathname; + const currentPath = location.pathname + location.search; return ; } + // Check if this is the license-connect route (render without AccountLayout) + if (location.pathname.includes('/license-connect')) { + return ; + } + return ( @@ -43,4 +49,3 @@ export default function Account() { ); } - diff --git a/customer-spa/src/pages/Product/index.tsx b/customer-spa/src/pages/Product/index.tsx index b4ac1ef..64badfd 100644 --- a/customer-spa/src/pages/Product/index.tsx +++ b/customer-spa/src/pages/Product/index.tsx @@ -98,25 +98,47 @@ export default function Product() { if (!v.attributes) return false; return Object.entries(selectedAttributes).every(([attrName, attrValue]) => { - const normalizedValue = attrValue.toLowerCase().trim(); + const normalizedSelectedValue = attrValue.toLowerCase().trim(); + const attrNameLower = attrName.toLowerCase(); - // Check all attribute keys in variation (case-insensitive) - for (const [vKey, vValue] of Object.entries(v.attributes)) { - const vKeyLower = vKey.toLowerCase(); - const attrNameLower = attrName.toLowerCase(); + // Find the attribute definition to get the slug + const attrDef = product.attributes?.find((a: any) => a.name === attrName); + const attrSlug = attrDef?.slug || attrNameLower.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); - if (vKeyLower === `attribute_${attrNameLower}` || - vKeyLower === `attribute_pa_${attrNameLower}` || - vKeyLower === attrNameLower) { + // Try to find a matching key in the variation attributes + let variationValue: string | undefined = undefined; - const varValueNormalized = String(vValue).toLowerCase().trim(); - if (varValueNormalized === normalizedValue) { - return true; - } - } + // Check for common WooCommerce attribute key formats + // 1. Check strict slug format (attribute_7-days-...) + if (`attribute_${attrSlug}` in v.attributes) { + variationValue = v.attributes[`attribute_${attrSlug}`]; + } + // 2. Check pa_ format (attribute_pa_color) + else if (`attribute_pa_${attrSlug}` in v.attributes) { + variationValue = v.attributes[`attribute_pa_${attrSlug}`]; + } + // 3. Fallback to name-based checks (legacy) + else if (`attribute_${attrNameLower}` in v.attributes) { + variationValue = v.attributes[`attribute_${attrNameLower}`]; + } else if (`attribute_pa_${attrNameLower}` in v.attributes) { + variationValue = v.attributes[`attribute_pa_${attrNameLower}`]; + } else if (attrNameLower in v.attributes) { + variationValue = v.attributes[attrNameLower]; } - return false; + // If key is undefined/missing in variation, it means "Any" -> Match + if (variationValue === undefined || variationValue === null) { + return true; + } + + // If empty string, it also means "Any" -> Match + const normalizedVarValue = String(variationValue).toLowerCase().trim(); + if (normalizedVarValue === '') { + return true; + } + + // Otherwise, values must match + return normalizedVarValue === normalizedSelectedValue; }); }); @@ -181,11 +203,36 @@ export default function Product() { } } + // Construct variation params using keys from the matched variation + // but filling in values from user selection (handles "Any" variations with empty values) + let variation_params: Record = {}; + if (product.type === 'variable' && selectedVariation?.attributes) { + // Get keys from the variation's attributes (these are the correct WooCommerce keys) + Object.keys(selectedVariation.attributes).forEach(key => { + // Key format is like "attribute_7-days-auto-closing-variation-plan" + // Extract the slug part after "attribute_" + const slug = key.replace(/^attribute_/, ''); + + // Find the matching user-selected value by attribute name + const attrDef = product.attributes?.find((a: any) => + a.slug === slug || a.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') === slug + ); + + if (attrDef && selectedAttributes[attrDef.name]) { + variation_params[key] = selectedAttributes[attrDef.name]; + } else { + // Fallback to stored value if no user selection + variation_params[key] = selectedVariation.attributes[key]; + } + }); + } + try { await apiClient.post(apiClient.endpoints.cart.add, { product_id: product.id, quantity, variation_id: selectedVariation?.id || 0, + variation: variation_params, }); addItem({ @@ -320,8 +367,8 @@ export default function Product() { key={index} onClick={() => setSelectedImage(img)} className={`w-2 h-2 rounded-full transition-all ${selectedImage === img - ? 'bg-primary w-6' - : 'bg-gray-300 hover:bg-gray-400' + ? 'bg-primary w-6' + : 'bg-gray-300 hover:bg-gray-400' }`} aria-label={`View image ${index + 1}`} /> @@ -354,8 +401,8 @@ export default function Product() { key={index} onClick={() => setSelectedImage(img)} className={`flex-shrink-0 w-24 h-24 md:w-28 md:h-28 rounded-lg overflow-hidden border-2 transition-all shadow-md hover:shadow-lg ${selectedImage === img - ? 'border-primary ring-4 ring-primary ring-offset-2' - : 'border-gray-300 hover:border-gray-400' + ? 'border-primary ring-4 ring-primary ring-offset-2' + : 'border-gray-300 hover:border-gray-400' }`} > handleAttributeChange(attr.name, option)} className={`min-w-[48px] min-h-[48px] px-5 py-3 rounded-xl border-2 font-medium transition-all ${isSelected - ? 'bg-gray-900 text-white border-gray-900 shadow-lg' - : 'bg-white text-gray-700 border-gray-200 hover:border-gray-400 hover:shadow-md' + ? 'bg-gray-900 text-white border-gray-900 shadow-lg' + : 'bg-white text-gray-700 border-gray-200 hover:border-gray-400 hover:shadow-md' }`} > {option} @@ -503,8 +550,8 @@ export default function Product() { '; + echo ''; + echo esc_html__('Cancel', 'woonoow'); + echo ''; + echo ''; + echo ''; + } + + echo ''; + + get_footer('woonoow'); + exit; + } + /** * Add licensing fields to product edit page */ - public static function add_product_licensing_fields() { + public static function add_product_licensing_fields() + { global $post; - + if (!ModuleRegistry::is_enabled('licensing')) { return; } - + echo '
'; - + woocommerce_wp_checkbox([ 'id' => '_woonoow_licensing_enabled', 'label' => __('Enable Licensing', 'woonoow'), 'description' => __('Generate license keys for this product on purchase', 'woonoow'), ]); - + woocommerce_wp_text_input([ 'id' => '_woonoow_license_activation_limit', 'label' => __('Activation Limit', 'woonoow'), @@ -95,7 +323,7 @@ class LicensingModule { 'step' => '1', ], ]); - + woocommerce_wp_text_input([ 'id' => '_woonoow_license_expiry_days', 'label' => __('License Expiry (Days)', 'woonoow'), @@ -106,23 +334,48 @@ class LicensingModule { 'step' => '1', ], ]); - + + // Only show activation method if per-product override is enabled + $licensing_settings = get_option('woonoow_module_licensing_settings', []); + $allow_override = $licensing_settings['allow_product_override'] ?? false; + if ($allow_override) { + woocommerce_wp_select([ + 'id' => '_woonoow_license_activation_method', + 'label' => __('Activation Method', 'woonoow'), + 'description' => __('Override site-level setting for this product', 'woonoow'), + 'options' => [ + '' => __('Use Site Default', 'woonoow'), + 'api' => __('Simple API (license key only)', 'woonoow'), + 'oauth' => __('Secure OAuth (requires account login)', 'woonoow'), + ], + ]); + } + echo '
'; } - + /** * Save licensing fields */ - public static function save_product_licensing_fields($post_id) { + public static function save_product_licensing_fields($post_id) + { $licensing_enabled = isset($_POST['_woonoow_licensing_enabled']) ? 'yes' : 'no'; update_post_meta($post_id, '_woonoow_licensing_enabled', $licensing_enabled); - + if (isset($_POST['_woonoow_license_activation_limit'])) { update_post_meta($post_id, '_woonoow_license_activation_limit', absint($_POST['_woonoow_license_activation_limit'])); } - + if (isset($_POST['_woonoow_license_expiry_days'])) { update_post_meta($post_id, '_woonoow_license_expiry_days', absint($_POST['_woonoow_license_expiry_days'])); } + + if (isset($_POST['_woonoow_license_activation_method'])) { + $method = $_POST['_woonoow_license_activation_method']; + // Accept empty (site default), api, or oauth + if ($method === '' || in_array($method, ['api', 'oauth'])) { + update_post_meta($post_id, '_woonoow_license_activation_method', sanitize_key($method)); + } + } } } diff --git a/includes/Modules/LicensingSettings.php b/includes/Modules/LicensingSettings.php index 46b2b68..77b7fc6 100644 --- a/includes/Modules/LicensingSettings.php +++ b/includes/Modules/LicensingSettings.php @@ -1,4 +1,5 @@ [ 'type' => 'select', @@ -88,8 +92,24 @@ class LicensingSettings { 'min' => 1, 'max' => 30, ], + 'activation_method' => [ + 'type' => 'select', + 'label' => __('Activation Method', 'woonoow'), + 'description' => __('How licenses are activated. OAuth requires user login on your site (anti-piracy).', 'woonoow'), + 'options' => [ + 'api' => __('Simple API (license key only)', 'woonoow'), + 'oauth' => __('Secure OAuth (requires account login)', 'woonoow'), + ], + 'default' => 'api', + ], + 'allow_product_override' => [ + 'type' => 'toggle', + 'label' => __('Allow Per-Product Override', 'woonoow'), + 'description' => __('Show activation method field on each product for individual customization', 'woonoow'), + 'default' => false, + ], ]; - + return $schemas; } }