diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c8620e..ddcea90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,110 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.7] - 2026-01-21 + +### Added + +- License Dashboard moved to WooCommerce Reports section (Reports > Licenses) +- License search and filtering in admin (by license key, domain, status, product) +- Customer-facing license transfer request with AJAX-based modal +- Email notifications for license expiration warnings (7 days and 1 day before) +- Bulk import licenses from CSV functionality +- Import page with detailed format instructions +- Scheduled cron job for daily expiration checks + +### Changed + +- Dashboard now accessible via WooCommerce > Reports > Licenses tab +- License list page includes search box and filter dropdowns +- Pagination preserves filter state +- Import CSV button added to licenses page header + +### Technical Details + +- AccountController extended with customer transfer AJAX handler and domain normalization +- LicenseEmailController extended with expiration warning scheduling and email templates +- LicenseManager extended with `getLicensesExpiringSoon()`, `markExpirationNotified()`, `wasExpirationNotified()`, `importLicense()` methods +- AdminController extended with CSV import handling and import page rendering +- Installer clears cron events on plugin deactivation +- Frontend JavaScript extended for transfer modal handling +- Frontend CSS extended with modal and transfer button styles + +## [0.0.6] - 2026-01-21 + +### Added + +- License usage statistics/analytics dashboard (WooCommerce > License Dashboard) +- License transfer functionality to change domain from admin +- Export licenses to CSV functionality +- OpenAPI 3.1 specification for REST API documentation (`openapi.json`) +- Monthly license creation chart on dashboard +- Top products and top domains statistics +- Expiring soon alerts on dashboard + +### Removed + +- API endpoint `/deactivate` removed (license deactivation is now admin-only) + +### Changed + +- Licenses admin page header now includes Export CSV button +- Actions column widened to accommodate Transfer link +- Dashboard link added to licenses page + +### Technical Details + +- New admin page: License Dashboard with statistics overview +- LicenseManager extended with `transferLicense()`, `getStatistics()`, `exportLicensesForCsv()` methods +- AdminController extended with dashboard rendering, CSV export, and transfer handling +- Transfer modal with form for domain change +- REST API now only has three endpoints: `/validate`, `/status`, `/activate` +- OpenAPI 3.1 specification documents all API endpoints with examples + +## [0.0.5] - 2026-01-21 + +### Added + +- Bulk license operations in admin (activate, deactivate, revoke, extend, delete) +- License renewal/extension functionality (extend by 30/90/365 days) +- Set license to lifetime (remove expiration) +- Quick action buttons per license row (+30d, ∞, Revoke, Delete) +- Checkbox selection for bulk operations with select-all functionality +- Improved admin notices for bulk operation results + +### Changed + +- Licenses admin page redesigned with WordPress list table styling +- License actions use row-actions pattern for cleaner UI +- Bulk action dropdowns at top and bottom of table (synchronized) + +### Technical Details + +- LicenseManager extended with `extendLicense()`, `setLicenseLifetime()`, `bulkUpdateStatus()`, `bulkDelete()`, `bulkExtend()` methods +- AdminController extended with bulk action handling and extend/lifetime actions +- Twig template functions for generating action URLs with nonces +- JavaScript for checkbox sync and bulk action handling + +## [0.0.4] - 2026-01-21 + +### Added + +- WooCommerce settings tab "Licensed Products" for default license settings +- Default settings for Max Activations, License Validity, and Bind to Major Version +- Per-product settings now override global defaults (empty = use default) +- Settings link in product edit page pointing to WooCommerce settings + +### Changed + +- Product license settings now show placeholder with default values +- LicensedProduct class now falls back to global defaults when product settings are empty + +### Technical Details + +- New class: SettingsController for WooCommerce settings integration +- LicensedProduct model extended with `has_custom_*` methods for checking overrides +- Settings stored using WooCommerce options API + ## [0.0.3] - 2026-01-21 ### Added @@ -67,7 +171,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `POST /wp-json/wc-licensed-product/v1/validate` - Validate license for domain - `POST /wp-json/wc-licensed-product/v1/status` - Check license status - `POST /wp-json/wc-licensed-product/v1/activate` - Activate license on domain - - `POST /wp-json/wc-licensed-product/v1/deactivate` - Deactivate license - Checkout domain field for licensed products - Customer account page "Licenses" to view purchased licenses - Admin interface for license management (WooCommerce > Licenses) @@ -92,7 +195,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - WordPress REST API integration - Custom WooCommerce product type extending WC_Product -[Unreleased]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.3...HEAD +[Unreleased]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.7...HEAD +[0.0.7]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.6...v0.0.7 +[0.0.6]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.5...v0.0.6 +[0.0.5]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.4...v0.0.5 +[0.0.4]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.3...v0.0.4 [0.0.3]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.2...v0.0.3 [0.0.2]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.0.1...v0.0.2 [0.0.1]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/releases/tag/v0.0.1 diff --git a/CLAUDE.md b/CLAUDE.md index e18d47b..247f876 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,11 +36,9 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w _No known bugs at this time._ -### Version 0.0.4 (Next) +### Version 0.0.8 (Next) -- Consider adding license usage statistics/analytics -- Consider adding bulk license operations in admin -- Consider adding license renewal/extension functionality +_No planned features yet. Add items as needed._ ## Technical Stack @@ -225,12 +223,13 @@ Created on plugin activation via `Installer::createTables()`: Base: `/wp-json/wc-licensed-product/v1/` -| Endpoint | Method | Description | -| -------------- | ------ | -------------------------------- | -| `/validate` | POST | Validate license key for domain | -| `/status` | POST | Get license status | -| `/activate` | POST | Activate license on domain | -| `/deactivate` | POST | Deactivate license | +| Endpoint | Method | Description | +| ----------- | ------ | ------------------------------- | +| `/validate` | POST | Validate license key for domain | +| `/status` | POST | Get license status | +| `/activate` | POST | Activate license on domain | + +Full API documentation available in `openapi.json` (OpenAPI 3.1 specification). ### Key Classes @@ -316,3 +315,117 @@ Base: `/wp-json/wc-licensed-product/v1/` **Bug fixes:** - Fixed product versions meta box visibility for non-licensed product types (targets `#wc_licensed_product_versions` container) + +### 2026-01-21 - Version 0.0.4 Features + +**Implemented:** + +- WooCommerce settings tab "Licensed Products" for default license settings +- Default settings for Max Activations, License Validity, and Bind to Major Version +- Per-product settings override global defaults (empty = use default) +- Settings link in product edit page pointing to WooCommerce settings + +**New classes:** + +- `SettingsController` - WooCommerce settings tab integration + +**Technical notes:** + +- Settings stored using WooCommerce options API +- LicensedProduct model falls back to global defaults when product settings are empty +- Added `has_custom_*` methods to check if product has overridden defaults +- Product edit panel shows placeholders with default values + +### 2026-01-21 - Version 0.0.5 Features + +**Implemented:** + +- Bulk license operations in admin (activate, deactivate, revoke, extend, delete) +- License renewal/extension functionality (extend by 30/90/365 days) +- Set license to lifetime (remove expiration) +- Quick action buttons per license row (+30d, ∞, Revoke, Delete) +- Checkbox selection with select-all for bulk operations +- Improved admin notices for bulk operation results + +**New methods in LicenseManager:** + +- `extendLicense()` - Extend license by specified days +- `setLicenseLifetime()` - Remove expiration (set to lifetime) +- `bulkUpdateStatus()` - Bulk update license status +- `bulkDelete()` - Bulk delete licenses +- `bulkExtend()` - Bulk extend licenses + +**Technical notes:** + +- Admin page redesigned with WordPress list table styling +- Twig template functions for generating action URLs with nonces +- JavaScript for checkbox synchronization and bulk action handling +- Row-actions pattern for cleaner per-license action UI + +### 2026-01-21 - Version 0.0.6 Features + +**Implemented:** + +- License usage statistics/analytics dashboard (WooCommerce > License Dashboard) +- License transfer functionality (change domain from admin) +- Export licenses to CSV with all related data +- OpenAPI 3.1 specification for REST API (`openapi.json`) +- Removed `/deactivate` API endpoint (admin-only operation now) + +**New methods in LicenseManager:** + +- `transferLicense()` - Transfer license to new domain +- `getStatistics()` - Get comprehensive license statistics +- `exportLicensesForCsv()` - Export all licenses for CSV download + +**New files:** + +- `templates/admin/dashboard.html.twig` - Dashboard template +- `openapi.json` - OpenAPI 3.1 specification + +**Technical notes:** + +- Dashboard shows status cards, monthly chart, top products/domains +- Transfer uses modal dialog with form submission +- CSV export includes BOM for UTF-8 Excel compatibility +- OpenAPI spec documents all endpoints with examples and error responses +- Statistics query uses SQL aggregation for performance + +### 2026-01-21 - Version 0.0.7 Features + +**Implemented:** + +- License Dashboard moved to WooCommerce Reports section (Reports > Licenses tab) +- License search and filtering in admin (by key, domain, status, product) +- Customer-facing license transfer request with AJAX modal +- Email notifications for license expiration warnings (7 days and 1 day before) +- Bulk import licenses from CSV with validation and options + +**New methods in LicenseManager:** + +- `getLicensesExpiringSoon()` - Get licenses expiring within specified days +- `markExpirationNotified()` - Track expiration notification state +- `wasExpirationNotified()` - Check if notification already sent +- `importLicense()` - Create license from imported data + +**Updated controllers:** + +- `AdminController` - Added Reports integration, search/filter handling, CSV import +- `AccountController` - Added customer transfer AJAX handler with domain validation +- `LicenseEmailController` - Added cron scheduling and expiration warning emails +- `Installer` - Clears cron events on deactivation + +**New UI features:** + +- Search box and filter dropdowns on licenses page +- Transfer button and modal on customer licenses page +- Import CSV page with format documentation +- Pagination preserves filter state + +**Technical notes:** + +- WooCommerce Reports integration uses `woocommerce_admin_reports` filter +- Customer transfer uses AJAX with nonce verification +- Expiration warnings use WordPress cron with daily schedule +- CSV import supports both exported format and simplified format +- User meta tracks expiration notifications to prevent duplicates diff --git a/README.md b/README.md index 3e72684..8b0b0b2 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,34 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e ## Features +### Core Features + - **Licensed Product Type**: New WooCommerce product type for software sales -- **Automatic License Generation**: License keys generated on order completion +- **Automatic License Generation**: License keys generated on order completion (format: XXXX-XXXX-XXXX-XXXX) - **Domain Binding**: Licenses are bound to customer-specified domains - **REST API**: Public endpoints for license validation and management -- **Customer Account**: Customers can view their licenses in My Account -- **Admin Management**: Full CRUD interface for license management - **Version Binding**: Optional binding to major software versions - **Expiration Support**: Set license validity periods or lifetime licenses +- **Rate Limiting**: API endpoints protected with rate limiting (30 requests/minute) + +### Customer Features + +- **My Account Licenses**: Customers can view their licenses in My Account +- **License Transfers**: Customers can transfer licenses to new domains +- **Secure Downloads**: Download purchased software versions with license verification +- **Copy to Clipboard**: Easy license key copying + +### Admin Features + +- **License Management**: Full CRUD interface for license management +- **License Dashboard**: Statistics and analytics (WooCommerce > Reports > Licenses) +- **Search & Filtering**: Search by license key, domain, status, or product +- **Bulk Operations**: Activate, deactivate, revoke, extend, or delete multiple licenses +- **License Transfer**: Transfer licenses to new domains +- **CSV Export/Import**: Export and import licenses via CSV +- **Expiration Warnings**: Automatic email notifications before license expiration +- **Version Management**: Manage multiple versions per product with file attachments +- **Global Settings**: Default license settings via WooCommerce settings tab ## Requirements @@ -42,6 +62,19 @@ WC Licensed Product adds a new product type "Licensed Product" to WooCommerce, e - **Bind to Major Version**: Lock license to current major version - **Current Version**: Your software's current version +### Managing Product Versions + +1. Edit a Licensed Product +2. Use the "Product Versions" meta box to add versions +3. Upload version files via WordPress Media Library +4. Version numbers are auto-detected from filenames (e.g., `plugin-v1.2.3.zip`) + +### Global Default Settings + +1. Go to WooCommerce > Settings > Licensed Products +2. Set default values for Max Activations, License Validity, and Version Binding +3. Per-product settings override these defaults + ### Customer Checkout When a customer purchases a licensed product, they must enter the domain where they will use the license during checkout. @@ -50,11 +83,30 @@ When a customer purchases a licensed product, they must enter the domain where t - **Customers**: My Account > Licenses - **Administrators**: WooCommerce > Licenses +- **Dashboard**: WooCommerce > Reports > Licenses (for statistics) + +### Exporting & Importing Licenses + +**Export:** + +1. Go to WooCommerce > Licenses +2. Click "Export CSV" to download all licenses + +**Import:** + +1. Go to WooCommerce > Licenses +2. Click "Import CSV" +3. Upload a CSV file (supports exported format or simplified format) +4. Choose options: skip header row, update existing licenses ## REST API +Full API documentation available in `openapi.json` (OpenAPI 3.1 specification). + ### Validate License +Validate a license key for a specific domain. + ```http POST /wp-json/wc-licensed-product/v1/validate Content-Type: application/json @@ -65,8 +117,33 @@ Content-Type: application/json } ``` +**Success Response (200):** + +```json +{ + "valid": true, + "license": { + "product_id": 123, + "expires_at": "2027-01-21", + "version_id": 5 + } +} +``` + +**Error Response (403):** + +```json +{ + "valid": false, + "error": "domain_mismatch", + "message": "This license is not valid for this domain." +} +``` + ### Check Status +Get detailed license status information. + ```http POST /wp-json/wc-licensed-product/v1/status Content-Type: application/json @@ -76,8 +153,23 @@ Content-Type: application/json } ``` +**Response (200):** + +```json +{ + "valid": true, + "status": "active", + "domain": "example.com", + "expires_at": "2027-01-21", + "activations_count": 1, + "max_activations": 3 +} +``` + ### Activate License +Activate a license on a domain. + ```http POST /wp-json/wc-licensed-product/v1/activate Content-Type: application/json @@ -88,18 +180,27 @@ Content-Type: application/json } ``` -### Deactivate License - -```http -POST /wp-json/wc-licensed-product/v1/deactivate -Content-Type: application/json +**Response (200):** +```json { - "license_key": "XXXX-XXXX-XXXX-XXXX", - "domain": "example.com" + "success": true, + "message": "License activated successfully." } ``` +### Error Codes + +| Code | Description | +| ---- | ----------- | +| `license_not_found` | License key does not exist | +| `license_revoked` | License has been revoked | +| `license_expired` | License has expired | +| `license_inactive` | License is inactive | +| `domain_mismatch` | License not valid for this domain | +| `max_activations_reached` | Maximum activations reached | +| `rate_limit_exceeded` | Too many requests (wait and retry) | + ## License Statuses - **Active**: License is valid and usable @@ -107,6 +208,18 @@ Content-Type: application/json - **Expired**: License validity period has ended - **Revoked**: License has been manually revoked by admin +## Email Notifications + +The plugin sends automatic email notifications: + +- **Order Completion**: License keys included in order confirmation emails +- **Expiration Warning (7 days)**: Reminder sent 7 days before expiration +- **Expiration Warning (1 day)**: Urgent reminder sent 1 day before expiration + +## Changelog + +See [CHANGELOG.md](CHANGELOG.md) for version history and changes. + ## Support For issues and feature requests, please visit: diff --git a/assets/css/admin.css b/assets/css/admin.css index ce60dbf..cca0628 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -165,3 +165,306 @@ vertical-align: middle; margin-left: 5px; } + +/* Licenses Table Bulk Actions */ +.licenses-table { + margin-top: 0; +} + +.licenses-table .check-column { + width: 2.2em; + padding: 0.5em; +} + +.licenses-table .license-actions { + width: 150px; +} + +.licenses-table .row-actions { + visibility: visible; + padding: 2px 0 0; +} + +.licenses-table .row-actions a { + text-decoration: none; +} + +.licenses-table .row-actions .submitdelete { + color: #b32d2e; +} + +.licenses-table .row-actions .submitdelete:hover { + color: #dc3232; +} + +/* Lifetime Badge */ +.license-lifetime { + display: inline-block; + padding: 0.2em 0.5em; + font-size: 0.85em; + font-weight: 500; + background-color: #e7f3ff; + color: #2271b1; + border-radius: 3px; +} + +/* Bulk Actions Styling */ +.tablenav .actions select { + margin-right: 6px; +} + +.tablenav .bulkactions { + padding: 0; +} + +/* Transfer Modal */ +.wclp-modal { + position: fixed; + z-index: 100000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); +} + +.wclp-modal-content { + background-color: #fff; + margin: 10% auto; + padding: 20px 30px; + border-radius: 4px; + width: 500px; + max-width: 90%; + position: relative; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); +} + +.wclp-modal-close { + position: absolute; + right: 15px; + top: 10px; + font-size: 28px; + font-weight: bold; + cursor: pointer; + color: #666; +} + +.wclp-modal-close:hover { + color: #000; +} + +.wclp-modal h2 { + margin-top: 0; + padding-bottom: 10px; + border-bottom: 1px solid #ddd; +} + +.wclp-modal .form-table th { + padding: 15px 10px 15px 0; + width: 120px; +} + +.wclp-modal .submit { + border-top: 1px solid #ddd; + padding-top: 15px; + margin-bottom: 0; +} + +/* Dashboard Styles */ +.wclp-dashboard-stats { + margin-top: 20px; +} + +.wclp-stat-cards { + display: flex; + flex-wrap: wrap; + gap: 15px; + margin-bottom: 20px; +} + +.wclp-stat-card { + flex: 1; + min-width: 150px; + max-width: 200px; + background: #fff; + border: 1px solid #c3c4c7; + border-radius: 4px; + padding: 15px; + display: flex; + align-items: center; + gap: 12px; +} + +.wclp-stat-icon { + font-size: 32px; + line-height: 1; +} + +.wclp-stat-icon .dashicons { + font-size: 32px; + width: 32px; + height: 32px; +} + +.wclp-stat-content { + display: flex; + flex-direction: column; +} + +.wclp-stat-number { + font-size: 24px; + font-weight: 600; + line-height: 1.2; +} + +.wclp-stat-label { + font-size: 12px; + color: #646970; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.wclp-stat-total .wclp-stat-icon .dashicons { + color: #2271b1; +} + +.wclp-stat-active .wclp-stat-icon .dashicons { + color: #00a32a; +} + +.wclp-stat-inactive .wclp-stat-icon .dashicons { + color: #dba617; +} + +.wclp-stat-expired .wclp-stat-icon .dashicons { + color: #d63638; +} + +.wclp-stat-revoked .wclp-stat-icon .dashicons { + color: #646970; +} + +.wclp-stat-row { + display: flex; + flex-wrap: wrap; + gap: 20px; + margin-bottom: 20px; +} + +.wclp-stat-box { + flex: 1; + min-width: 280px; + background: #fff; + border: 1px solid #c3c4c7; + border-radius: 4px; + padding: 15px 20px; +} + +.wclp-stat-box.wclp-stat-full { + flex: 100%; + min-width: 100%; +} + +.wclp-stat-box h3 { + margin-top: 0; + margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 1px solid #ddd; +} + +.wclp-stat-box .widefat { + border: none; +} + +.wclp-stat-value { + text-align: right; + font-weight: 600; +} + +.wclp-stat-value.wclp-warning { + color: #d63638; +} + +/* Dashboard Bar Chart */ +.wclp-chart-container { + overflow-x: auto; +} + +.wclp-bar-chart { + display: flex; + align-items: flex-end; + gap: 10px; + height: 200px; + padding: 20px 0; +} + +.wclp-bar-wrapper { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; + min-width: 50px; +} + +.wclp-bar { + width: 100%; + max-width: 40px; + background: linear-gradient(180deg, #2271b1, #135e96); + border-radius: 3px 3px 0 0; + min-height: 4px; + position: relative; + transition: background 0.2s; +} + +.wclp-bar:hover { + background: linear-gradient(180deg, #135e96, #0a4b78); +} + +.wclp-bar-value { + position: absolute; + top: -20px; + left: 50%; + transform: translateX(-50%); + font-size: 11px; + font-weight: 600; + color: #1d2327; +} + +.wclp-bar-label { + margin-top: 8px; + font-size: 10px; + color: #646970; + text-align: center; + white-space: nowrap; +} + +/* Dashboard Actions */ +.wclp-dashboard-actions { + background: #fff; + border: 1px solid #c3c4c7; + border-radius: 4px; + padding: 15px 20px; + margin-top: 20px; +} + +.wclp-dashboard-actions h2 { + margin-top: 0; + margin-bottom: 15px; +} + +.wclp-action-buttons { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.wclp-action-buttons .button .dashicons { + vertical-align: middle; + margin-right: 5px; + margin-top: -2px; +} + +/* Wider actions column for transfer link */ +.licenses-table .license-actions { + width: 220px; +} diff --git a/assets/css/frontend.css b/assets/css/frontend.css index 68b9261..860cf71 100644 --- a/assets/css/frontend.css +++ b/assets/css/frontend.css @@ -150,6 +150,39 @@ flex-wrap: wrap; } +.license-domain-display { + display: flex; + align-items: center; + gap: 0.5em; +} + +/* Transfer Button */ +.wclp-transfer-btn { + display: inline-flex; + align-items: center; + gap: 0.25em; + padding: 0.2em 0.6em; + background: #f0f0f0; + border: 1px solid #ddd; + border-radius: 3px; + font-size: 0.85em; + color: #555; + cursor: pointer; + transition: all 0.2s ease; +} + +.wclp-transfer-btn:hover { + background: #e5e5e5; + border-color: #ccc; + color: #333; +} + +.wclp-transfer-btn .dashicons { + font-size: 14px; + width: 14px; + height: 14px; +} + /* Download Section */ .license-downloads { padding: 1em 1.5em; @@ -323,4 +356,151 @@ width: 45%; font-weight: 600; } + + .license-domain-display { + flex-wrap: wrap; + } + + .wclp-transfer-btn { + margin-top: 0.5em; + } +} + +/* Transfer Modal */ +.wclp-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 100000; + display: flex; + align-items: center; + justify-content: center; +} + +.wclp-modal-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); +} + +.wclp-modal-content { + position: relative; + background: #fff; + padding: 2em; + border-radius: 8px; + max-width: 450px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); +} + +.wclp-modal-close { + position: absolute; + top: 0.5em; + right: 0.5em; + background: none; + border: none; + font-size: 1.5em; + cursor: pointer; + color: #666; + line-height: 1; + padding: 0.25em; +} + +.wclp-modal-close:hover { + color: #333; +} + +.wclp-modal-content h3 { + margin: 0 0 1.5em 0; + font-size: 1.25em; +} + +.wclp-form-row { + margin-bottom: 1.5em; +} + +.wclp-form-row label { + display: block; + font-weight: 600; + margin-bottom: 0.5em; + color: #333; +} + +.wclp-form-row input[type="text"] { + width: 100%; + padding: 0.75em; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1em; +} + +.wclp-form-row input[type="text"]:focus { + outline: none; + border-color: #2271b1; + box-shadow: 0 0 0 1px #2271b1; +} + +.wclp-current-domain { + margin: 0; +} + +.wclp-current-domain code { + background: #f5f5f5; + padding: 0.4em 0.8em; + border-radius: 4px; + font-size: 1em; +} + +.wclp-field-description { + margin: 0.5em 0 0 0; + font-size: 0.85em; + color: #666; +} + +.wclp-form-actions { + display: flex; + gap: 1em; + margin-top: 2em; +} + +.wclp-btn-primary { + background: #2271b1 !important; + border-color: #2271b1 !important; + color: #fff !important; +} + +.wclp-btn-primary:hover { + background: #135e96 !important; + border-color: #135e96 !important; +} + +.wclp-btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.wclp-message { + margin-top: 1em; + padding: 0.75em 1em; + border-radius: 4px; + font-size: 0.95em; +} + +.wclp-message.success { + background: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +.wclp-message.error { + background: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; } diff --git a/assets/js/frontend.js b/assets/js/frontend.js index 909e3f9..7380dc7 100644 --- a/assets/js/frontend.js +++ b/assets/js/frontend.js @@ -8,12 +8,29 @@ 'use strict'; var WCLicensedProductFrontend = { + $modal: null, + $form: null, + init: function() { + this.$modal = $('#wclp-transfer-modal'); + this.$form = $('#wclp-transfer-form'); this.bindEvents(); }, bindEvents: function() { $(document).on('click', '.copy-license-btn', this.copyLicenseKey); + + // Transfer modal events + $(document).on('click', '.wclp-transfer-btn', this.openTransferModal.bind(this)); + $(document).on('click', '.wclp-modal-close, .wclp-modal-cancel, .wclp-modal-overlay', this.closeTransferModal.bind(this)); + $(document).on('submit', '#wclp-transfer-form', this.submitTransfer.bind(this)); + + // Close modal on escape key + $(document).on('keyup', function(e) { + if (e.key === 'Escape') { + WCLicensedProductFrontend.closeTransferModal(); + } + }); }, /** @@ -80,6 +97,106 @@ $(this).remove(); }); }, 1500); + }, + + /** + * Open transfer modal + */ + openTransferModal: function(e) { + e.preventDefault(); + + var $btn = $(e.currentTarget); + var licenseId = $btn.data('license-id'); + var currentDomain = $btn.data('current-domain'); + + $('#transfer-license-id').val(licenseId); + $('#transfer-current-domain').text(currentDomain); + $('#transfer-new-domain').val(''); + $('#wclp-transfer-message').hide().removeClass('success error'); + $('#wclp-transfer-submit').prop('disabled', false); + + this.$modal.show(); + $('#transfer-new-domain').focus(); + }, + + /** + * Close transfer modal + */ + closeTransferModal: function(e) { + if (e && $(e.target).closest('.wclp-modal-content').length && !$(e.target).is('.wclp-modal-close, .wclp-modal-cancel')) { + return; + } + this.$modal.hide(); + }, + + /** + * Submit transfer request + */ + submitTransfer: function(e) { + e.preventDefault(); + + var self = this; + var licenseId = $('#transfer-license-id').val(); + var newDomain = $('#transfer-new-domain').val().trim(); + var $message = $('#wclp-transfer-message'); + var $submit = $('#wclp-transfer-submit'); + + // Basic validation + if (!newDomain) { + $message.text(wcLicensedProduct.strings.invalidDomain) + .removeClass('success').addClass('error').show(); + return; + } + + // Confirm transfer + if (!confirm(wcLicensedProduct.strings.transferConfirm)) { + return; + } + + // Disable submit button + $submit.prop('disabled', true); + $message.hide(); + + // Send AJAX request + $.ajax({ + url: wcLicensedProduct.ajaxUrl, + type: 'POST', + data: { + action: 'wclp_customer_transfer_license', + nonce: wcLicensedProduct.transferNonce, + license_id: licenseId, + new_domain: newDomain + }, + success: function(response) { + if (response.success) { + $message.text(response.data.message) + .removeClass('error').addClass('success').show(); + + // Update the domain display in the license card + var $domainDisplay = $('.license-domain-display[data-license-id="' + licenseId + '"]'); + $domainDisplay.find('.domain-value').text(response.data.new_domain); + $domainDisplay.find('.wclp-transfer-btn').data('current-domain', response.data.new_domain); + + // Close modal after a short delay + setTimeout(function() { + self.closeTransferModal(); + }, 1500); + } else { + $message.text(response.data.message || wcLicensedProduct.strings.transferError) + .removeClass('success').addClass('error').show(); + $submit.prop('disabled', false); + } + }, + error: function(xhr) { + var message = wcLicensedProduct.strings.transferError; + if (xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) { + message = xhr.responseJSON.data.message; + } + $message.text(message) + .removeClass('success').addClass('error').show(); + $submit.prop('disabled', false); + } + }); } }; diff --git a/openapi.json b/openapi.json new file mode 100644 index 0000000..6e98b39 --- /dev/null +++ b/openapi.json @@ -0,0 +1,538 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "WooCommerce Licensed Product API", + "description": "REST API for validating and managing software licenses bound to domains. This API allows external applications to validate license keys, check license status, and activate licenses on specific domains.", + "version": "0.0.7", + "contact": { + "name": "Marco Graetsch", + "url": "https://src.bundespruefstelle.ch/magdev", + "email": "magdev3.0@gmail.com" + }, + "license": { + "name": "GPL-2.0-or-later", + "url": "https://www.gnu.org/licenses/gpl-2.0.html" + } + }, + "servers": [ + { + "url": "{baseUrl}/wp-json/wc-licensed-product/v1", + "description": "WordPress REST API endpoint", + "variables": { + "baseUrl": { + "default": "https://example.com", + "description": "The base URL of your WordPress installation" + } + } + } + ], + "paths": { + "/validate": { + "post": { + "operationId": "validateLicense", + "summary": "Validate a license key for a domain", + "description": "Validates whether a license key is valid for a specific domain. Checks license status, expiration, and domain binding. This is the primary endpoint for software to verify license validity.", + "tags": ["License Validation"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidateRequest" + }, + "example": { + "license_key": "ABCD-1234-EFGH-5678", + "domain": "example.com" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ValidateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "License is valid for the specified domain", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidateSuccessResponse" + }, + "example": { + "valid": true, + "license": { + "product_id": 123, + "expires_at": "2027-01-21", + "version_id": 5 + } + } + } + } + }, + "403": { + "description": "License validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidateErrorResponse" + }, + "examples": { + "license_not_found": { + "summary": "License key not found", + "value": { + "valid": false, + "error": "license_not_found", + "message": "License key not found." + } + }, + "license_revoked": { + "summary": "License has been revoked", + "value": { + "valid": false, + "error": "license_revoked", + "message": "This license has been revoked." + } + }, + "license_expired": { + "summary": "License has expired", + "value": { + "valid": false, + "error": "license_expired", + "message": "This license has expired." + } + }, + "license_inactive": { + "summary": "License is inactive", + "value": { + "valid": false, + "error": "license_inactive", + "message": "This license is inactive." + } + }, + "domain_mismatch": { + "summary": "License not valid for this domain", + "value": { + "valid": false, + "error": "domain_mismatch", + "message": "This license is not valid for this domain." + } + } + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimitExceeded" + } + } + } + }, + "/status": { + "post": { + "operationId": "checkStatus", + "summary": "Get license status information", + "description": "Retrieves detailed status information for a license key, including validity, domain binding, expiration date, and activation counts.", + "tags": ["License Status"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusRequest" + }, + "example": { + "license_key": "ABCD-1234-EFGH-5678" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/StatusRequest" + } + } + } + }, + "responses": { + "200": { + "description": "License status retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusResponse" + }, + "example": { + "valid": true, + "status": "active", + "domain": "example.com", + "expires_at": "2027-01-21", + "activations_count": 1, + "max_activations": 3 + } + } + } + }, + "404": { + "description": "License key not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "valid": false, + "error": "license_not_found", + "message": "License key not found." + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimitExceeded" + } + } + } + }, + "/activate": { + "post": { + "operationId": "activateLicense", + "summary": "Activate a license on a domain", + "description": "Activates a license key on a specific domain. If the license is already activated on the same domain, returns success. If activating on a new domain, the old domain binding is replaced (single-domain licenses).", + "tags": ["License Activation"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivateRequest" + }, + "example": { + "license_key": "ABCD-1234-EFGH-5678", + "domain": "newdomain.com" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ActivateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "License activated successfully or already activated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivateSuccessResponse" + }, + "examples": { + "activated": { + "summary": "License activated on new domain", + "value": { + "success": true, + "message": "License activated successfully." + } + }, + "already_activated": { + "summary": "License already activated on this domain", + "value": { + "success": true, + "message": "License is already activated for this domain." + } + } + } + } + } + }, + "403": { + "description": "Activation failed due to license restrictions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "license_invalid": { + "summary": "License is not valid", + "value": { + "success": false, + "error": "license_invalid", + "message": "This license is not valid." + } + }, + "max_activations_reached": { + "summary": "Maximum activations reached", + "value": { + "success": false, + "error": "max_activations_reached", + "message": "Maximum number of activations reached." + } + } + } + } + } + }, + "404": { + "description": "License key not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "success": false, + "error": "license_not_found", + "message": "License key not found." + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimitExceeded" + }, + "500": { + "description": "Server error during activation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "success": false, + "error": "activation_failed", + "message": "Failed to activate license." + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ValidateRequest": { + "type": "object", + "required": ["license_key", "domain"], + "properties": { + "license_key": { + "type": "string", + "description": "The license key to validate (format: XXXX-XXXX-XXXX-XXXX)", + "maxLength": 64, + "example": "ABCD-1234-EFGH-5678" + }, + "domain": { + "type": "string", + "description": "The domain to validate the license against", + "maxLength": 255, + "example": "example.com" + } + } + }, + "ValidateSuccessResponse": { + "type": "object", + "properties": { + "valid": { + "type": "boolean", + "const": true, + "description": "Indicates the license is valid" + }, + "license": { + "type": "object", + "properties": { + "product_id": { + "type": "integer", + "description": "WooCommerce product ID associated with the license" + }, + "expires_at": { + "type": ["string", "null"], + "format": "date", + "description": "Expiration date (null for lifetime licenses)" + }, + "version_id": { + "type": ["integer", "null"], + "description": "Product version ID if license is bound to a version" + } + } + } + } + }, + "ValidateErrorResponse": { + "type": "object", + "properties": { + "valid": { + "type": "boolean", + "const": false, + "description": "Indicates validation failed" + }, + "error": { + "type": "string", + "enum": ["license_not_found", "license_revoked", "license_expired", "license_inactive", "domain_mismatch"], + "description": "Error code for programmatic handling" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + } + } + }, + "StatusRequest": { + "type": "object", + "required": ["license_key"], + "properties": { + "license_key": { + "type": "string", + "description": "The license key to check", + "example": "ABCD-1234-EFGH-5678" + } + } + }, + "StatusResponse": { + "type": "object", + "properties": { + "valid": { + "type": "boolean", + "description": "Whether the license is currently valid" + }, + "status": { + "type": "string", + "enum": ["active", "inactive", "expired", "revoked"], + "description": "Current license status" + }, + "domain": { + "type": "string", + "description": "Domain the license is bound to" + }, + "expires_at": { + "type": ["string", "null"], + "format": "date", + "description": "Expiration date (null for lifetime licenses)" + }, + "activations_count": { + "type": "integer", + "description": "Current number of activations" + }, + "max_activations": { + "type": "integer", + "description": "Maximum allowed activations" + } + } + }, + "ActivateRequest": { + "type": "object", + "required": ["license_key", "domain"], + "properties": { + "license_key": { + "type": "string", + "description": "The license key to activate", + "example": "ABCD-1234-EFGH-5678" + }, + "domain": { + "type": "string", + "description": "The domain to activate the license on", + "example": "newdomain.com" + } + } + }, + "ActivateSuccessResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "const": true, + "description": "Indicates activation was successful" + }, + "message": { + "type": "string", + "description": "Human-readable success message" + } + } + }, + "ErrorResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "const": false, + "description": "Indicates the operation failed" + }, + "valid": { + "type": "boolean", + "const": false, + "description": "Indicates validation failed (for validation endpoints)" + }, + "error": { + "type": "string", + "description": "Error code for programmatic handling" + }, + "message": { + "type": "string", + "description": "Human-readable error message" + } + } + }, + "RateLimitResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "const": false + }, + "error": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "example": "Too many requests. Please try again later." + }, + "retry_after": { + "type": "integer", + "description": "Seconds until rate limit resets" + } + } + } + }, + "responses": { + "RateLimitExceeded": { + "description": "Rate limit exceeded (30 requests per minute per IP)", + "headers": { + "Retry-After": { + "schema": { + "type": "integer" + }, + "description": "Seconds until the rate limit resets" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitResponse" + }, + "example": { + "success": false, + "error": "rate_limit_exceeded", + "message": "Too many requests. Please try again later.", + "retry_after": 45 + } + } + } + } + } + }, + "tags": [ + { + "name": "License Validation", + "description": "Validate license keys against domains" + }, + { + "name": "License Status", + "description": "Check license status and details" + }, + { + "name": "License Activation", + "description": "Activate licenses on domains" + } + ] +} diff --git a/src/Admin/AdminController.php b/src/Admin/AdminController.php index bb835b8..c05c151 100644 --- a/src/Admin/AdminController.php +++ b/src/Admin/AdminController.php @@ -49,6 +49,9 @@ final class AdminController // HPOS compatibility add_filter('woocommerce_shop_order_list_table_columns', [$this, 'addOrdersLicenseColumn']); add_action('woocommerce_shop_order_list_table_custom_column', [$this, 'displayOrdersLicenseColumnHpos'], 10, 2); + + // Add to WooCommerce Reports + add_filter('woocommerce_admin_reports', [$this, 'addLicenseReports']); } /** @@ -66,12 +69,36 @@ final class AdminController ); } + /** + * Add license reports to WooCommerce Reports + */ + public function addLicenseReports(array $reports): array + { + $reports['licenses'] = [ + 'title' => __('Licenses', 'wc-licensed-product'), + 'reports' => [ + 'overview' => [ + 'title' => __('Overview', 'wc-licensed-product'), + 'description' => '', + 'hide_title' => true, + 'callback' => [$this, 'renderDashboardPage'], + ], + ], + ]; + + return $reports; + } + /** * Enqueue admin styles and scripts */ public function enqueueStyles(string $hook): void { - if ($hook !== 'woocommerce_page_wc-licenses') { + // Check for our pages and WooCommerce Reports page with licenses tab + $isLicensePage = in_array($hook, ['woocommerce_page_wc-licenses', 'woocommerce_page_wc-license-dashboard'], true); + $isReportsPage = $hook === 'woocommerce_page_wc-reports' && isset($_GET['tab']) && $_GET['tab'] === 'licenses'; + + if (!$isLicensePage && !$isReportsPage) { return; } @@ -110,6 +137,41 @@ final class AdminController if (isset($_GET['action']) && $_GET['action'] === 'revoke' && isset($_GET['license_id'])) { $this->handleRevoke(); } + + // Handle extend + if (isset($_GET['action']) && $_GET['action'] === 'extend' && isset($_GET['license_id'])) { + $this->handleExtend(); + } + + // Handle set lifetime + if (isset($_GET['action']) && $_GET['action'] === 'lifetime' && isset($_GET['license_id'])) { + $this->handleSetLifetime(); + } + + // Handle bulk actions + if (isset($_POST['bulk_action']) && !empty($_POST['license_ids'])) { + $this->handleBulkAction(); + } + + // Handle transfer + if (isset($_POST['action']) && $_POST['action'] === 'transfer_license') { + $this->handleTransfer(); + } + + // Handle CSV export + if (isset($_GET['action']) && $_GET['action'] === 'export_csv') { + $this->handleCsvExport(); + } + + // Handle CSV import page + if (isset($_GET['action']) && $_GET['action'] === 'import_csv') { + // Show import form - handled in renderImportPage + } + + // Handle CSV import upload + if (isset($_POST['action']) && $_POST['action'] === 'process_import_csv') { + $this->handleCsvImport(); + } } /** @@ -168,17 +230,505 @@ final class AdminController } } + /** + * Handle license extension + */ + private function handleExtend(): void + { + if (!wp_verify_nonce($_GET['_wpnonce'] ?? '', 'extend_license')) { + wp_die(__('Security check failed.', 'wc-licensed-product')); + } + + $licenseId = absint($_GET['license_id'] ?? 0); + $days = absint($_GET['days'] ?? 30); + + if ($licenseId && $days > 0) { + $this->licenseManager->extendLicense($licenseId, $days); + + wp_redirect(admin_url('admin.php?page=wc-licenses&extended=1')); + exit; + } + } + + /** + * Handle set license to lifetime + */ + private function handleSetLifetime(): void + { + if (!wp_verify_nonce($_GET['_wpnonce'] ?? '', 'lifetime_license')) { + wp_die(__('Security check failed.', 'wc-licensed-product')); + } + + $licenseId = absint($_GET['license_id'] ?? 0); + if ($licenseId) { + $this->licenseManager->setLicenseLifetime($licenseId); + + wp_redirect(admin_url('admin.php?page=wc-licenses&lifetime=1')); + exit; + } + } + + /** + * Handle license transfer + */ + private function handleTransfer(): void + { + if (!wp_verify_nonce($_POST['_wpnonce'] ?? '', 'transfer_license')) { + wp_die(__('Security check failed.', 'wc-licensed-product')); + } + + $licenseId = absint($_POST['license_id'] ?? 0); + $newDomain = sanitize_text_field($_POST['new_domain'] ?? ''); + + if ($licenseId && !empty($newDomain)) { + $success = $this->licenseManager->transferLicense($licenseId, $newDomain); + + if ($success) { + wp_redirect(admin_url('admin.php?page=wc-licenses&transferred=1')); + } else { + wp_redirect(admin_url('admin.php?page=wc-licenses&transfer_failed=1')); + } + exit; + } + + wp_redirect(admin_url('admin.php?page=wc-licenses')); + exit; + } + + /** + * Handle CSV export + */ + private function handleCsvExport(): void + { + if (!current_user_can('manage_woocommerce')) { + wp_die(__('You do not have permission to export licenses.', 'wc-licensed-product')); + } + + $data = $this->licenseManager->exportLicensesForCsv(); + + if (empty($data)) { + wp_redirect(admin_url('admin.php?page=wc-licenses&export_empty=1')); + exit; + } + + // Set headers for CSV download + $filename = 'licenses-export-' . gmdate('Y-m-d-His') . '.csv'; + header('Content-Type: text/csv; charset=utf-8'); + header('Content-Disposition: attachment; filename=' . $filename); + header('Pragma: no-cache'); + header('Expires: 0'); + + $output = fopen('php://output', 'w'); + + // Write BOM for UTF-8 + fwrite($output, "\xEF\xBB\xBF"); + + // Write header row + fputcsv($output, array_keys($data[0])); + + // Write data rows + foreach ($data as $row) { + fputcsv($output, $row); + } + + fclose($output); + exit; + } + + /** + * Handle CSV import + */ + private function handleCsvImport(): void + { + if (!wp_verify_nonce($_POST['_wpnonce'] ?? '', 'import_licenses_csv')) { + wp_die(__('Security check failed.', 'wc-licensed-product')); + } + + if (!current_user_can('manage_woocommerce')) { + wp_die(__('You do not have permission to import licenses.', 'wc-licensed-product')); + } + + // Check if file was uploaded + if (!isset($_FILES['import_file']) || $_FILES['import_file']['error'] !== UPLOAD_ERR_OK) { + wp_redirect(admin_url('admin.php?page=wc-licenses&action=import_csv&import_error=upload')); + exit; + } + + $file = $_FILES['import_file']; + + // Validate file type + $fileType = wp_check_filetype($file['name']); + if ($fileType['ext'] !== 'csv') { + wp_redirect(admin_url('admin.php?page=wc-licenses&action=import_csv&import_error=filetype')); + exit; + } + + // Read the CSV file + $handle = fopen($file['tmp_name'], 'r'); + if (!$handle) { + wp_redirect(admin_url('admin.php?page=wc-licenses&action=import_csv&import_error=read')); + exit; + } + + // Get import options + $skipFirstRow = isset($_POST['skip_first_row']) && $_POST['skip_first_row'] === '1'; + $updateExisting = isset($_POST['update_existing']) && $_POST['update_existing'] === '1'; + + // Skip BOM if present + $bom = fread($handle, 3); + if ($bom !== "\xEF\xBB\xBF") { + rewind($handle); + } + + // Read header row if skipping + if ($skipFirstRow) { + fgetcsv($handle); + } + + $imported = 0; + $updated = 0; + $skipped = 0; + $errors = []; + + while (($row = fgetcsv($handle)) !== false) { + // Skip empty rows + if (empty($row) || (count($row) === 1 && empty($row[0]))) { + continue; + } + + // Map CSV columns (expected format from export): + // ID, License Key, Product, Product ID, Order ID, Order Number, Customer, Customer Email, Customer ID, Domain, Status, Activations, Max Activations, Expires At, Created At, Updated At + // For import we need: License Key (or generate), Product ID, Customer ID, Domain, Status, Max Activations, Expires At + $result = $this->processImportRow($row, $updateExisting); + + if ($result === 'imported') { + $imported++; + } elseif ($result === 'updated') { + $updated++; + } elseif ($result === 'skipped') { + $skipped++; + } else { + $errors[] = $result; + } + } + + fclose($handle); + + // Build redirect URL with results + $redirectUrl = add_query_arg([ + 'page' => 'wc-licenses', + 'imported' => $imported, + 'updated' => $updated, + 'skipped' => $skipped, + 'import_errors' => count($errors), + ], admin_url('admin.php')); + + wp_redirect($redirectUrl); + exit; + } + + /** + * Process a single import row + * + * @param array $row CSV row data + * @param bool $updateExisting Whether to update existing licenses + * @return string Result: 'imported', 'updated', 'skipped', or error message + */ + private function processImportRow(array $row, bool $updateExisting): string + { + // Determine if this is from our export format or simplified format + // Export format has 16 columns, simplified has fewer + + if (count($row) >= 10) { + // Full export format + $licenseKey = trim($row[1] ?? ''); + $productId = absint($row[3] ?? 0); + $orderId = absint($row[4] ?? 0); + $customerId = absint($row[8] ?? 0); + $domain = trim($row[9] ?? ''); + $status = strtolower(trim($row[10] ?? 'active')); + $activationsCount = absint($row[11] ?? 1); + $maxActivations = absint($row[12] ?? 1); + $expiresAt = trim($row[13] ?? ''); + } else { + // Simplified format: License Key, Product ID, Customer ID, Domain, Status, Max Activations, Expires At + $licenseKey = trim($row[0] ?? ''); + $productId = absint($row[1] ?? 0); + $customerId = absint($row[2] ?? 0); + $domain = trim($row[3] ?? ''); + $status = strtolower(trim($row[4] ?? 'active')); + $maxActivations = absint($row[5] ?? 1); + $expiresAt = trim($row[6] ?? ''); + $orderId = 0; + $activationsCount = 1; + } + + // Validate required fields + if (empty($domain)) { + return sprintf(__('Row missing domain', 'wc-licensed-product')); + } + + if ($productId <= 0) { + return sprintf(__('Row missing valid product ID', 'wc-licensed-product')); + } + + // Check if license key already exists + if (!empty($licenseKey)) { + $existing = $this->licenseManager->getLicenseByKey($licenseKey); + if ($existing) { + if ($updateExisting) { + // Update existing license + $this->licenseManager->updateLicenseDomain($existing->getId(), $domain); + if (in_array($status, [License::STATUS_ACTIVE, License::STATUS_INACTIVE, License::STATUS_REVOKED], true)) { + $this->licenseManager->updateLicenseStatus($existing->getId(), $status); + } + return 'updated'; + } + return 'skipped'; + } + } else { + // Generate new license key + $licenseKey = $this->licenseManager->generateLicenseKey(); + while ($this->licenseManager->getLicenseByKey($licenseKey)) { + $licenseKey = $this->licenseManager->generateLicenseKey(); + } + } + + // Normalize status + if (!in_array($status, [License::STATUS_ACTIVE, License::STATUS_INACTIVE, License::STATUS_EXPIRED, License::STATUS_REVOKED], true)) { + $status = License::STATUS_ACTIVE; + } + + // Parse expiration date + $expiresAtParsed = null; + if (!empty($expiresAt) && strtolower($expiresAt) !== 'lifetime') { + try { + $expiresAtParsed = new \DateTimeImmutable($expiresAt); + } catch (\Exception $e) { + // Invalid date, leave as null (lifetime) + } + } + + // Create the license + $result = $this->licenseManager->importLicense( + $licenseKey, + $productId, + $customerId, + $domain, + $orderId, + $status, + $maxActivations, + $activationsCount, + $expiresAtParsed + ); + + return $result ? 'imported' : sprintf(__('Failed to import license for domain %s', 'wc-licensed-product'), $domain); + } + + /** + * Handle bulk actions + */ + private function handleBulkAction(): void + { + if (!wp_verify_nonce($_POST['_wpnonce'] ?? '', 'bulk_license_action')) { + wp_die(__('Security check failed.', 'wc-licensed-product')); + } + + $action = sanitize_text_field($_POST['bulk_action'] ?? ''); + $licenseIds = array_map('absint', (array) ($_POST['license_ids'] ?? [])); + + if (empty($licenseIds)) { + wp_redirect(admin_url('admin.php?page=wc-licenses')); + exit; + } + + $count = 0; + + switch ($action) { + case 'activate': + $count = $this->licenseManager->bulkUpdateStatus($licenseIds, License::STATUS_ACTIVE); + wp_redirect(admin_url('admin.php?page=wc-licenses&bulk_activated=' . $count)); + break; + + case 'deactivate': + $count = $this->licenseManager->bulkUpdateStatus($licenseIds, License::STATUS_INACTIVE); + wp_redirect(admin_url('admin.php?page=wc-licenses&bulk_deactivated=' . $count)); + break; + + case 'revoke': + $count = $this->licenseManager->bulkUpdateStatus($licenseIds, License::STATUS_REVOKED); + wp_redirect(admin_url('admin.php?page=wc-licenses&bulk_revoked=' . $count)); + break; + + case 'delete': + $count = $this->licenseManager->bulkDelete($licenseIds); + wp_redirect(admin_url('admin.php?page=wc-licenses&bulk_deleted=' . $count)); + break; + + case 'extend_30': + $count = $this->licenseManager->bulkExtend($licenseIds, 30); + wp_redirect(admin_url('admin.php?page=wc-licenses&bulk_extended=' . $count)); + break; + + case 'extend_90': + $count = $this->licenseManager->bulkExtend($licenseIds, 90); + wp_redirect(admin_url('admin.php?page=wc-licenses&bulk_extended=' . $count)); + break; + + case 'extend_365': + $count = $this->licenseManager->bulkExtend($licenseIds, 365); + wp_redirect(admin_url('admin.php?page=wc-licenses&bulk_extended=' . $count)); + break; + + default: + wp_redirect(admin_url('admin.php?page=wc-licenses')); + } + + exit; + } + + /** + * Render license dashboard page + */ + public function renderDashboardPage(): void + { + $stats = $this->licenseManager->getStatistics(); + + try { + echo $this->twig->render('admin/dashboard.html.twig', [ + 'stats' => $stats, + 'admin_url' => admin_url('admin.php'), + ]); + } catch (\Exception $e) { + // Fallback to PHP template + $this->renderDashboardPageFallback($stats); + } + } + + /** + * Fallback render for dashboard page + */ + private function renderDashboardPageFallback(array $stats): void + { + ?> +
| - | - | - | - | - | - | - |
|---|---|---|---|---|---|---|
| - | ||||||
getLicenseKey()); ?> |
- - - - - - - - - | -
-
-
- - - |
- getDomain()); ?> | -- - getStatus())); ?> - - | -- getExpiresAt(); - echo $expiresAt - ? esc_html($expiresAt->format(get_option('date_format'))) - : esc_html__('Never', 'wc-licensed-product'); - ?> - | -- getStatus() !== License::STATUS_REVOKED): ?> - - - - - - - - | -
+ +
- 1): ?> -+ +
++ +
+ + ++ +
+ ++
ID, License Key, Product, Product ID, Order ID, Order Number, Customer, Customer Email, Customer ID, Domain, Status, Activations, Max Activations, Expires At, Created At, Updated At
+
+ +
License Key, Product ID, Customer ID, Domain, Status, Max Activations, Expires At
+
+
+ -
+ -
+ -
+
display_name)); ?>
+ + ++ +
+ ++ +
+ + +| + | + |
| + |
+
+ getLicenseKey()); ?>
+
+ |
+
| + | getDomain()); ?> | +
| + | + |
+ + + +
+ ++ ' . esc_html($siteName) . '' + ); ?> +
+