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
This commit is contained in:
Dwindi Ramadhana
2026-01-31 22:22:22 +07:00
parent d80f34c8b9
commit a0b5f8496d
23 changed files with 3218 additions and 806 deletions

View File

@@ -1,4 +1,5 @@
<?php
namespace WooNooW\Api\Controllers;
use WP_REST_Controller;
@@ -9,21 +10,23 @@ use WP_Error;
* Cart Controller
* Handles cart operations via REST API
*/
class CartController extends WP_REST_Controller {
class CartController extends WP_REST_Controller
{
/**
* Register routes
*/
public function register_routes() {
public function register_routes()
{
$namespace = 'woonoow/v1';
// Get cart
register_rest_route($namespace, '/cart', [
'methods' => 'GET',
'callback' => [$this, 'get_cart'],
'permission_callback' => '__return_true',
]);
// Add to cart
register_rest_route($namespace, '/cart/add', [
'methods' => 'POST',
@@ -48,7 +51,7 @@ class CartController extends WP_REST_Controller {
],
],
]);
// Update cart item
register_rest_route($namespace, '/cart/update', [
'methods' => 'POST',
@@ -66,7 +69,7 @@ class CartController extends WP_REST_Controller {
],
],
]);
// Remove from cart
register_rest_route($namespace, '/cart/remove', [
'methods' => 'POST',
@@ -79,14 +82,14 @@ class CartController extends WP_REST_Controller {
],
],
]);
// Clear cart
register_rest_route($namespace, '/cart/clear', [
'methods' => 'POST',
'callback' => [$this, 'clear_cart'],
'permission_callback' => '__return_true',
]);
// Apply coupon
register_rest_route($namespace, '/cart/apply-coupon', [
'methods' => 'POST',
@@ -100,7 +103,7 @@ class CartController extends WP_REST_Controller {
],
],
]);
// Remove coupon
register_rest_route($namespace, '/cart/remove-coupon', [
'methods' => 'POST',
@@ -115,25 +118,26 @@ class CartController extends WP_REST_Controller {
],
]);
}
/**
* Get cart contents
*/
public function get_cart($request) {
public function get_cart($request)
{
if (!function_exists('WC')) {
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
}
// Ensure cart is initialized
if (is_null(WC()->cart)) {
wc_load_cart();
}
$cart = WC()->cart;
// Calculate totals to ensure discounts are computed
$cart->calculate_totals();
// Format coupons with discount amounts
$coupons_with_discounts = [];
foreach ($cart->get_applied_coupons() as $coupon_code) {
@@ -145,7 +149,7 @@ class CartController extends WP_REST_Controller {
'type' => $coupon->get_discount_type(),
];
}
return new WP_REST_Response([
'items' => $this->format_cart_items($cart->get_cart()),
'totals' => [
@@ -167,42 +171,107 @@ class CartController extends WP_REST_Controller {
'item_count' => $cart->get_cart_contents_count(),
], 200);
}
/**
* Add product to cart
*/
public function add_to_cart($request) {
public function add_to_cart($request)
{
if (!function_exists('WC')) {
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
}
// Ensure cart is initialized
if (is_null(WC()->cart)) {
wc_load_cart();
}
$product_id = $request->get_param('product_id');
$quantity = $request->get_param('quantity') ?: 1;
$variation_id = $request->get_param('variation_id') ?: 0;
$variation = $request->get_param('variation') ?: [];
// TEMPORARY DEBUG: Return early to confirm this code is reached
if ($product_id == 512) {
return new WP_REST_Response([
'debug' => true,
'message' => 'CartController reached',
'product_id' => $product_id,
'variation_id' => $variation_id,
'variation' => $variation,
], 200);
}
// Validate product
$product = wc_get_product($product_id);
if (!$product) {
return new WP_Error('invalid_product', 'Product not found', ['status' => 404]);
}
// Check stock
if (!$product->is_in_stock()) {
return new WP_Error('out_of_stock', 'Product is out of stock', ['status' => 400]);
}
// Add to cart
$cart_item_key = WC()->cart->add_to_cart($product_id, $quantity, $variation_id);
if (!$cart_item_key) {
return new WP_Error('add_to_cart_failed', 'Failed to add product to cart', ['status' => 400]);
// For variable products with a variation_id, handle attributes properly
// This ensures correct attribute keys even if frontend sends wrong/empty data
if ($variation_id > 0) {
$variation_product = wc_get_product($variation_id);
if ($variation_product && $variation_product->is_type('variation')) {
// Get the actual attributes stored on the variation
$stored_attributes = $variation_product->get_variation_attributes();
// Merge: use stored attributes as base, but fill in empty values from frontend
// This handles variations created with "Any X" option (empty values)
$frontend_variation = $request->get_param('variation') ?: [];
foreach ($stored_attributes as $key => $value) {
if ($value === '' && isset($frontend_variation[$key]) && $frontend_variation[$key] !== '') {
// Stored value is empty ("Any"), use frontend value
$stored_attributes[$key] = sanitize_text_field($frontend_variation[$key]);
}
}
$variation = $stored_attributes;
}
}
// DEBUG: Log what we're passing to add_to_cart
error_log('[CartController] product_id: ' . $product_id);
error_log('[CartController] quantity: ' . $quantity);
error_log('[CartController] variation_id: ' . $variation_id);
error_log('[CartController] variation: ' . json_encode($variation));
error_log('[CartController] frontend_variation (raw): ' . json_encode($request->get_param('variation')));
// Add to cart
$cart_item_key = WC()->cart->add_to_cart($product_id, $quantity, $variation_id, $variation);
if (!$cart_item_key) {
// Get error notices from WooCommerce to provide a specific reason
$notices = wc_get_notices('error');
$message = 'Failed to add product to cart';
if (!empty($notices)) {
// Get the last error message
$last_notice = end($notices);
if (isset($last_notice['notice'])) {
// Strip HTML tags for clean error message
$message = wp_strip_all_tags($last_notice['notice']);
}
}
wc_clear_notices();
return new WP_Error('add_to_cart_failed', $message, [
'status' => 400,
'debug' => [
'product_id' => $product_id,
'variation_id' => $variation_id,
'variation_passed' => $variation,
'frontend_variation' => $request->get_param('variation'),
],
]);
}
return new WP_REST_Response([
'success' => true,
'cart_item_key' => $cart_item_key,
@@ -210,166 +279,172 @@ class CartController extends WP_REST_Controller {
'cart' => $this->get_cart($request)->data,
], 200);
}
/**
* Update cart item quantity
*/
public function update_cart_item($request) {
public function update_cart_item($request)
{
if (!function_exists('WC')) {
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
}
// Ensure cart is initialized
if (is_null(WC()->cart)) {
wc_load_cart();
}
$cart_item_key = $request->get_param('cart_item_key');
$quantity = $request->get_param('quantity');
// Validate cart item
$cart = WC()->cart->get_cart();
if (!isset($cart[$cart_item_key])) {
return new WP_Error('invalid_cart_item', 'Cart item not found', ['status' => 404]);
}
// Update quantity
$updated = WC()->cart->set_quantity($cart_item_key, $quantity);
if (!$updated) {
return new WP_Error('update_failed', 'Failed to update cart item', ['status' => 400]);
}
return new WP_REST_Response([
'success' => true,
'message' => 'Cart updated successfully',
'cart' => $this->get_cart($request)->data,
], 200);
}
/**
* Remove item from cart
*/
public function remove_from_cart($request) {
public function remove_from_cart($request)
{
if (!function_exists('WC')) {
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
}
// Ensure cart is initialized
if (is_null(WC()->cart)) {
wc_load_cart();
}
$cart_item_key = $request->get_param('cart_item_key');
// Validate cart item
$cart = WC()->cart->get_cart();
if (!isset($cart[$cart_item_key])) {
return new WP_Error('invalid_cart_item', 'Cart item not found', ['status' => 404]);
}
// Remove item
$removed = WC()->cart->remove_cart_item($cart_item_key);
if (!$removed) {
return new WP_Error('remove_failed', 'Failed to remove cart item', ['status' => 400]);
}
return new WP_REST_Response([
'success' => true,
'message' => 'Item removed from cart',
'cart' => $this->get_cart($request)->data,
], 200);
}
/**
* Clear cart
*/
public function clear_cart($request) {
public function clear_cart($request)
{
if (!function_exists('WC')) {
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
}
// Ensure cart is initialized
if (is_null(WC()->cart)) {
wc_load_cart();
}
WC()->cart->empty_cart();
return new WP_REST_Response([
'success' => true,
'message' => 'Cart cleared successfully',
], 200);
}
/**
* Apply coupon
*/
public function apply_coupon($request) {
public function apply_coupon($request)
{
if (!function_exists('WC')) {
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
}
// Ensure cart is initialized
if (is_null(WC()->cart)) {
wc_load_cart();
}
$coupon_code = $request->get_param('coupon_code');
// Apply coupon
$applied = WC()->cart->apply_coupon($coupon_code);
if (!$applied) {
return new WP_Error('coupon_failed', 'Failed to apply coupon', ['status' => 400]);
}
return new WP_REST_Response([
'success' => true,
'message' => 'Coupon applied successfully',
'cart' => $this->get_cart($request)->data,
], 200);
}
/**
* Remove coupon
*/
public function remove_coupon($request) {
public function remove_coupon($request)
{
if (!function_exists('WC')) {
return new WP_Error('woocommerce_not_active', 'WooCommerce is not active', ['status' => 500]);
}
// Ensure cart is initialized
if (is_null(WC()->cart)) {
wc_load_cart();
}
$coupon_code = $request->get_param('coupon_code');
// Remove coupon
$removed = WC()->cart->remove_coupon($coupon_code);
if (!$removed) {
return new WP_Error('coupon_remove_failed', 'Failed to remove coupon', ['status' => 400]);
}
return new WP_REST_Response([
'success' => true,
'message' => 'Coupon removed successfully',
'cart' => $this->get_cart($request)->data,
], 200);
}
/**
* Format cart items for response
*/
private function format_cart_items($cart_items) {
private function format_cart_items($cart_items)
{
$formatted = [];
foreach ($cart_items as $cart_item_key => $cart_item) {
$product = $cart_item['data'];
$formatted[] = [
'key' => $cart_item_key,
'product_id' => $cart_item['product_id'],
@@ -385,7 +460,7 @@ class CartController extends WP_REST_Controller {
'downloadable' => $product->is_downloadable(),
];
}
return $formatted;
}
}

View File

@@ -1,4 +1,5 @@
<?php
/**
* Licenses API Controller
*
@@ -17,91 +18,111 @@ use WP_Error;
use WooNooW\Core\ModuleRegistry;
use WooNooW\Modules\Licensing\LicenseManager;
class LicensesController {
class LicensesController
{
/**
* Register REST routes
*/
public static function register_routes() {
public static function register_routes()
{
// Check if module is enabled
if (!ModuleRegistry::is_enabled('licensing')) {
return;
}
// Admin routes
register_rest_route('woonoow/v1', '/licenses', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_licenses'],
'permission_callback' => function() {
'permission_callback' => function () {
return current_user_can('manage_woocommerce');
},
]);
register_rest_route('woonoow/v1', '/licenses/(?P<id>\d+)', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_license'],
'permission_callback' => function() {
'permission_callback' => function () {
return current_user_can('manage_woocommerce');
},
]);
register_rest_route('woonoow/v1', '/licenses/(?P<id>\d+)', [
'methods' => 'DELETE',
'callback' => [__CLASS__, 'revoke_license'],
'permission_callback' => function() {
'permission_callback' => function () {
return current_user_can('manage_woocommerce');
},
]);
register_rest_route('woonoow/v1', '/licenses/(?P<id>\d+)/activations', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_activations'],
'permission_callback' => function() {
'permission_callback' => function () {
return current_user_can('manage_woocommerce');
},
]);
// Customer routes
register_rest_route('woonoow/v1', '/account/licenses', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_customer_licenses'],
'permission_callback' => function() {
'permission_callback' => function () {
return is_user_logged_in();
},
]);
register_rest_route('woonoow/v1', '/account/licenses/(?P<id>\d+)/deactivate', [
'methods' => 'POST',
'callback' => [__CLASS__, 'customer_deactivate'],
'permission_callback' => function() {
'permission_callback' => function () {
return is_user_logged_in();
},
]);
// Public API routes (for software validation)
register_rest_route('woonoow/v1', '/licenses/validate', [
'methods' => 'POST',
'callback' => [__CLASS__, 'validate_license'],
'permission_callback' => '__return_true',
]);
register_rest_route('woonoow/v1', '/licenses/activate', [
'methods' => 'POST',
'callback' => [__CLASS__, 'activate_license'],
'permission_callback' => '__return_true',
]);
register_rest_route('woonoow/v1', '/licenses/deactivate', [
'methods' => 'POST',
'callback' => [__CLASS__, 'deactivate_license'],
'permission_callback' => '__return_true',
]);
// OAuth endpoints for license-connect
register_rest_route('woonoow/v1', '/licenses/oauth/validate', [
'methods' => 'GET',
'callback' => [__CLASS__, 'oauth_validate'],
'permission_callback' => function () {
return is_user_logged_in();
},
]);
register_rest_route('woonoow/v1', '/licenses/oauth/confirm', [
'methods' => 'POST',
'callback' => [__CLASS__, 'oauth_confirm'],
'permission_callback' => function () {
return is_user_logged_in();
},
]);
}
/**
* Get all licenses (admin)
*/
public static function get_licenses(WP_REST_Request $request) {
public static function get_licenses(WP_REST_Request $request)
{
$args = [
'search' => $request->get_param('search'),
'status' => $request->get_param('status'),
@@ -110,14 +131,14 @@ class LicensesController {
'limit' => $request->get_param('per_page') ?: 50,
'offset' => (($request->get_param('page') ?: 1) - 1) * ($request->get_param('per_page') ?: 50),
];
$result = LicenseManager::get_all_licenses($args);
// Enrich with product and user info
foreach ($result['licenses'] as &$license) {
$license = self::enrich_license($license);
}
return new WP_REST_Response([
'licenses' => $result['licenses'],
'total' => $result['total'],
@@ -125,167 +146,282 @@ class LicensesController {
'per_page' => $args['limit'],
]);
}
/**
* Get single license (admin)
*/
public static function get_license(WP_REST_Request $request) {
public static function get_license(WP_REST_Request $request)
{
$license = LicenseManager::get_license($request->get_param('id'));
if (!$license) {
return new WP_Error('not_found', __('License not found', 'woonoow'), ['status' => 404]);
}
$license = self::enrich_license($license);
$license['activations'] = LicenseManager::get_activations($license['id']);
return new WP_REST_Response($license);
}
/**
* Revoke license (admin)
*/
public static function revoke_license(WP_REST_Request $request) {
public static function revoke_license(WP_REST_Request $request)
{
$result = LicenseManager::revoke($request->get_param('id'));
if (!$result) {
return new WP_Error('revoke_failed', __('Failed to revoke license', 'woonoow'), ['status' => 500]);
}
return new WP_REST_Response(['success' => true]);
}
/**
* Get activations for license (admin)
*/
public static function get_activations(WP_REST_Request $request) {
public static function get_activations(WP_REST_Request $request)
{
$activations = LicenseManager::get_activations($request->get_param('id'));
return new WP_REST_Response($activations);
}
/**
* Get customer's licenses
*/
public static function get_customer_licenses(WP_REST_Request $request) {
public static function get_customer_licenses(WP_REST_Request $request)
{
$user_id = get_current_user_id();
$licenses = LicenseManager::get_user_licenses($user_id);
// Enrich each license
foreach ($licenses as &$license) {
$license = self::enrich_license($license);
$license['activations'] = LicenseManager::get_activations($license['id']);
}
return new WP_REST_Response($licenses);
}
/**
* Customer deactivate their own activation
*/
public static function customer_deactivate(WP_REST_Request $request) {
public static function customer_deactivate(WP_REST_Request $request)
{
$user_id = get_current_user_id();
$license = LicenseManager::get_license($request->get_param('id'));
if (!$license || $license['user_id'] != $user_id) {
return new WP_Error('not_found', __('License not found', 'woonoow'), ['status' => 404]);
}
$data = $request->get_json_params();
$result = LicenseManager::deactivate(
$license['license_key'],
$data['activation_id'] ?? null,
$data['machine_id'] ?? null
);
if (is_wp_error($result)) {
return $result;
}
return new WP_REST_Response($result);
}
/**
* Validate license (public API)
*/
public static function validate_license(WP_REST_Request $request) {
public static function validate_license(WP_REST_Request $request)
{
$data = $request->get_json_params();
if (empty($data['license_key'])) {
return new WP_Error('missing_key', __('License key is required', 'woonoow'), ['status' => 400]);
}
$result = LicenseManager::validate($data['license_key']);
return new WP_REST_Response($result);
}
/**
* Activate license (public API)
*/
public static function activate_license(WP_REST_Request $request) {
public static function activate_license(WP_REST_Request $request)
{
$data = $request->get_json_params();
if (empty($data['license_key'])) {
return new WP_Error('missing_key', __('License key is required', 'woonoow'), ['status' => 400]);
}
$activation_data = [
'domain' => $data['domain'] ?? null,
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null,
'machine_id' => $data['machine_id'] ?? null,
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null,
'return_url' => $data['return_url'] ?? null,
'activation_token' => $data['activation_token'] ?? null,
];
$result = LicenseManager::activate($data['license_key'], $activation_data);
if (is_wp_error($result)) {
return $result;
}
return new WP_REST_Response($result);
}
/**
* Deactivate license (public API)
*/
public static function deactivate_license(WP_REST_Request $request) {
public static function deactivate_license(WP_REST_Request $request)
{
$data = $request->get_json_params();
if (empty($data['license_key'])) {
return new WP_Error('missing_key', __('License key is required', 'woonoow'), ['status' => 400]);
}
$result = LicenseManager::deactivate(
$data['license_key'],
$data['activation_id'] ?? null,
$data['machine_id'] ?? null
);
if (is_wp_error($result)) {
return $result;
}
return new WP_REST_Response($result);
}
/**
* Enrich license with product and user info
*/
private static function enrich_license($license) {
private static function enrich_license($license)
{
// Add product info
$product = wc_get_product($license['product_id']);
$license['product_name'] = $product ? $product->get_name() : __('Unknown Product', 'woonoow');
// Add user info
$user = get_userdata($license['user_id']);
$license['user_email'] = $user ? $user->user_email : '';
$license['user_name'] = $user ? $user->display_name : __('Unknown User', 'woonoow');
// Add computed fields
$license['is_expired'] = $license['expires_at'] && strtotime($license['expires_at']) < time();
$license['activations_remaining'] = $license['activation_limit'] > 0
$license['activations_remaining'] = $license['activation_limit'] > 0
? max(0, $license['activation_limit'] - $license['activation_count'])
: -1;
return $license;
}
/**
* OAuth validate endpoint - validates license key and state for OAuth flow
*/
public static function oauth_validate(WP_REST_Request $request)
{
$license_key = sanitize_text_field($request->get_param('license_key'));
$state = sanitize_text_field($request->get_param('state'));
if (empty($license_key)) {
return new WP_Error('missing_license_key', __('License key is required.', 'woonoow'), ['status' => 400]);
}
// Get license
$license = LicenseManager::get_license_by_key($license_key);
if (!$license) {
return new WP_Error('license_not_found', __('License key not found.', 'woonoow'), ['status' => 404]);
}
// Verify license belongs to current user
$current_user_id = get_current_user_id();
if ($license['user_id'] != $current_user_id) {
return new WP_Error('unauthorized', __('This license does not belong to your account.', 'woonoow'), ['status' => 403]);
}
// Verify state token if provided
if (!empty($state)) {
$state_data = LicenseManager::verify_oauth_state($state);
if (!$state_data) {
return new WP_Error('invalid_state', __('Invalid or expired state token.', 'woonoow'), ['status' => 400]);
}
}
// Get product info
$product = wc_get_product($license['product_id']);
return new WP_REST_Response([
'license_key' => $license['license_key'],
'product_id' => $license['product_id'],
'product_name' => $product ? $product->get_name() : __('Unknown Product', 'woonoow'),
'status' => $license['status'],
'activation_limit' => $license['activation_limit'],
'activation_count' => $license['activation_count'],
'expires_at' => $license['expires_at'],
]);
}
/**
* OAuth confirm endpoint - confirms license activation and returns token
*/
public static function oauth_confirm(WP_REST_Request $request)
{
$data = $request->get_json_params();
$license_key = sanitize_text_field($data['license_key'] ?? '');
$site_url = esc_url_raw($data['site_url'] ?? '');
$state = sanitize_text_field($data['state'] ?? '');
$nonce = sanitize_text_field($data['nonce'] ?? '');
if (empty($license_key) || empty($site_url) || empty($state)) {
return new WP_Error('missing_params', __('Missing required parameters.', 'woonoow'), ['status' => 400]);
}
// Get license
$license = LicenseManager::get_license_by_key($license_key);
if (!$license) {
return new WP_Error('license_not_found', __('License key not found.', 'woonoow'), ['status' => 404]);
}
// Verify license belongs to current user
$current_user_id = get_current_user_id();
if ($license['user_id'] != $current_user_id) {
return new WP_Error('unauthorized', __('This license does not belong to your account.', 'woonoow'), ['status' => 403]);
}
// Verify state token
$state_data = LicenseManager::verify_oauth_state($state);
if (!$state_data) {
return new WP_Error('invalid_state', __('Invalid or expired state token.', 'woonoow'), ['status' => 400]);
}
// Generate activation token
$token_data = LicenseManager::generate_activation_token($license['id'], $site_url);
if (is_wp_error($token_data)) {
return $token_data;
}
$activation_token = $token_data['token'];
// Build return URL with token
$return_url = $state_data['return_url'] ?? $site_url;
$redirect_url = add_query_arg([
'activation_token' => $activation_token,
'license_key' => $license_key,
'nonce' => $nonce,
], $return_url);
return new WP_REST_Response([
'success' => true,
'redirect_url' => $redirect_url,
'activation_token' => $activation_token,
]);
}
}

File diff suppressed because it is too large Load Diff