17 Commits

Author SHA1 Message Date
c7967f71ab Update translations for v0.3.5
- Added translations for dashboard widget strings
- Added translations for license expired email strings
- Updated fuzzy translations with proper German text
- Compiled .mo file for production use

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 16:10:51 +01:00
1de8257527 Add dashboard widget and auto-expire license cron (v0.3.5)
- Add admin dashboard widget with license statistics
- Add daily wp-cron to auto-expire licenses past expiration date
- Add LicenseExpiredEmail notification for expired licenses
- Add getExpiredActiveLicenses() and autoExpireLicense() to LicenseManager

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 16:05:52 +01:00
26245c0c57 Remove redundant version badge from download list
Version is already shown in the download filename

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 12:10:50 +01:00
a6c6d247aa Improve download list layout in customer account (v0.3.5)
- Downloads now displayed in two-row format per entry
- First row: file download link
- Second row: metadata (version, date, checksum)
- Better visual separation and readability

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 12:07:49 +01:00
fba8bf2352 Add release package for v0.3.4
- wc-licensed-product-0.3.4.zip (784 KB)
- SHA256: 36a81c00eb03adf5dfa633891664d44b7e5225bf1ee594904f8acc9adec6bb47

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 12:02:28 +01:00
12a3a37658 Add product version display on single product page (v0.3.4)
- Display current version under product title for licensed products
- Add frontend CSS styling for version badge
- Update translations for new "Version:" string
- Bump version to 0.3.4

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 12:01:11 +01:00
b1fe34adfd Add v0.3.3 release package
Package: wc-licensed-product-0.3.3.zip (795 KB)
SHA256: a06d29eabc2da08613ae13874ed152b8ea9363b8284a2e9bdda414e32777558c

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:46:33 +01:00
dcf3a03598 Update translations for v0.3.3
Added German translations for license test feature:
- Test license against API
- License Validation Test modal strings
- Valid/Invalid status messages
- Error code and message labels

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:44:43 +01:00
38a9f0d90f Add Test and Transfer actions to PHP fallback template
The PHP fallback template (used when Twig fails) was missing the Test
license action and Transfer modal that were present in the Twig template.

- Added Test license link to row actions in PHP fallback
- Added Transfer link to row actions in PHP fallback
- Added Test License modal with AJAX validation
- Added Transfer License modal
- Added JavaScript handlers for both modals

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:41:05 +01:00
8b87c954eb Add license test action to admin overview
Added a "Test" action button in the license overview that validates
licenses against the /validate REST API endpoint. Results are shown
in a modal with validation status, error codes, and license details.

- Added Test link in row actions for each license
- Created AJAX handler handleAjaxTestLicense() in AdminController
- Added test result modal with loading state and result display
- Shows valid/invalid status with detailed error information

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:37:06 +01:00
1bc643408e Fix version deactivation button not working (v0.3.3)
The toggle version button in the admin product versions table was not
deactivating versions due to incorrect parameter order in the
updateVersion() call. The isActive value was being passed to the
attachmentId parameter position instead.

- Fixed parameter order: updateVersion($id, null, !$active, null)
- Bumped version to 0.3.3
- Updated CHANGELOG.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 19:39:26 +01:00
875c8dd1c1 Update CLAUDE.md with v0.3.2 release information
- Added release package details and SHA256 checksum
- Documented composer.json change to use git repository URL
- Noted vendor .git directory exclusion in release packaging

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 19:22:27 +01:00
5834e067f4 Change license client to use git repository instead of local path
- Updated composer.json repository from local path to git URL
- Package magdev/wc-licensed-product-client now fetched from:
  https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client.git
- Fixes symlink issues in release packages

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 19:19:44 +01:00
79417e4971 Update translations for v0.3.2
- Regenerated POT template with updated version
- Updated German (de_CH) translation
- Compiled .mo file

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 19:14:24 +01:00
304eb16e2e Update README with response signing documentation
- Added Response Signing section explaining X-License-Signature and X-License-Timestamp headers
- Added wp-config.php configuration example for WC_LICENSE_SERVER_SECRET
- Updated client section to recommend official magdev/wc-licensed-product-client Composer package
- Documented LicenseClient and SecureLicenseClient classes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 19:12:58 +01:00
df4cfc7e84 Update OpenAPI specification for v0.3.2
- Updated OpenAPI version from 0.0.7 to 0.3.2
- Added documentation for response signing headers (X-License-Signature, X-License-Timestamp)
- Enhanced API description with security information about signature verification
- Added header component definitions to OpenAPI spec
- All endpoint 200 responses now reference optional signature headers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 19:11:11 +01:00
812beb2a02 Update CLAUDE.md with v0.3.1 release information
- Added release package details for v0.3.1
- SHA256: 55468275522590cd68924bdf97cfcba8aa9e6ba11e2111d0234e16a1936b8adf

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 19:04:10 +01:00
25 changed files with 2561 additions and 913 deletions

View File

@@ -7,6 +7,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.3.5] - 2026-01-23
### Added
- Admin dashboard widget showing license statistics on WordPress dashboard
- Automatic license expiration via daily wp-cron job
- License expired email notification sent when license auto-expires
- New `LicenseExpiredEmail` WooCommerce email class (configurable via WooCommerce > Settings > Emails)
### Changed
- Improved download list layout in customer account licenses page
- Downloads now displayed in two-row format: file link on first row, metadata on second row
- Better visual separation between download link and version/date/checksum information
### Technical Details
- New `DashboardWidgetController` class in `src/Admin/` for WordPress dashboard widget
- Widget displays: total licenses, active, expiring soon, expired counts, status breakdown, license types
- New `LicenseExpiredEmail` class in `src/Email/` for expired license notifications
- Added `getExpiredActiveLicenses()` and `autoExpireLicense()` methods to `LicenseManager`
- Daily cron now auto-expires licenses with past expiration date and sends notification emails
- Updated `templates/frontend/licenses.html.twig` with new two-row structure
- Added `.download-item`, `.download-row-file`, `.download-row-meta` CSS classes
- Improved responsive behavior for download metadata
## [0.3.4] - 2026-01-23
### Added
- Current version display on single product pages for licensed products
- Version number shown directly under the product title
- Frontend CSS styling for version badge with monospace font
### Technical Details
- Added `displayCurrentVersion()` method to `LicensedProductType` class
- Hooked to `woocommerce_single_product_summary` at priority 6 (after title)
- Added `enqueueFrontendStyles()` to load CSS on product pages
- Uses `LicensedProduct::get_current_version()` to fetch latest version
## [0.3.3] - 2026-01-22
### Fixed
- Fixed version deactivation button not working in admin product versions table
- Corrected parameter order in `updateVersion()` call - `isActive` was being passed to `attachmentId` parameter
### Technical Details
- Bug in `VersionAdminController::ajaxToggleVersion()` - parameters were in wrong order
- Changed from `updateVersion($versionId, null, null, !$currentlyActive)` to `updateVersion($versionId, null, !$currentlyActive, null)`
## [0.3.2] - 2026-01-22
### Changed
- Updated OpenAPI specification to version 0.3.2
- Added documentation for response signing headers (X-License-Signature, X-License-Timestamp)
- Enhanced API description with response signing security information
### Technical Details
- OpenAPI spec now documents optional response signature headers
- Added header component definitions for X-License-Signature and X-License-Timestamp
- All endpoint 200 responses now reference signature headers
- Improved API documentation describing SecureLicenseClient usage
## [0.3.1] - 2026-01-22
### Changed
@@ -410,7 +478,9 @@ define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars');
- WordPress REST API integration
- Custom WooCommerce product type extending WC_Product
[Unreleased]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.3.1...HEAD
[Unreleased]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.3.3...HEAD
[0.3.3]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.3.2...v0.3.3
[0.3.2]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.3.1...v0.3.2
[0.3.1]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.3.0...v0.3.1
[0.3.0]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.2.2...v0.3.0
[0.2.2]: https://src.bundespruefstelle.ch/magdev/wc-licensed-product/compare/v0.2.1...v0.2.2

134
CLAUDE.md
View File

@@ -36,6 +36,10 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
No known bugs at the moment.
### Version 0.4.0
- On first plugin activation, get the checksums of all security related files (at least in `src/`) as hashes, store them encrypted on the server and add a mechanism to check the integrity of the files and the license validity periodically, control via wp-cron.
## Technical Stack
- **Language:** PHP 8.3.x
@@ -840,3 +844,133 @@ Reorganized the settings page with WooCommerce-style sub-tab navigation for bett
- Added `outputSections()` for WooCommerce-style navigation rendering
- Split `getSettingsFields()` into section-specific methods using PHP 8 match expression
- Hooks: `woocommerce_sections_licensed_product` for sub-navigation
**Release v0.3.1:**
- Created release package: `releases/wc-licensed-product-0.3.1.zip` (754 KB)
- SHA256: `55468275522590cd68924bdf97cfcba8aa9e6ba11e2111d0234e16a1936b8adf`
- Tagged as `v0.3.1` and pushed to `main` branch
### 2026-01-22 - Version 0.3.2 - OpenAPI Update
**Overview:**
Updated OpenAPI specification to document response signing feature added in v0.2.0.
**Implemented:**
- Updated OpenAPI version from 0.0.7 to 0.3.2
- Added documentation for X-License-Signature and X-License-Timestamp headers
- Enhanced API description with response signing security information
- Added header component definitions in OpenAPI spec
**Modified files:**
- `openapi.json` - Updated version and added signature header documentation
**Technical notes:**
- All endpoint 200 responses now reference optional signature headers
- Header definitions added to components section
- API description explains SecureLicenseClient usage for signature verification
- Changed `magdev/wc-licensed-product-client` from local path to git repository URL
- Composer now fetches from: `https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client.git`
- Release package excludes vendor `.git` directories
**Release v0.3.2:**
- Created release package: `releases/wc-licensed-product-0.3.2.zip` (810 KB)
- SHA256: `ca33c81516b5dcf4a80b3192d8ae4ad39a7bf67196a1f729b563c5ae01b1d39c`
- Tagged as `v0.3.2` and pushed to `main` branch
### 2026-01-22 - Version 0.3.3 - Bug Fix & License Testing
**Overview:**
Fixed version deactivation bug and added license testing functionality.
**Bug Fix:**
- Fixed version deactivation button not working in admin product versions table
- Root cause: Parameters in wrong order in `VersionAdminController::ajaxToggleVersion()`
- Changed from `updateVersion($versionId, null, null, !$currentlyActive)` to `updateVersion($versionId, null, !$currentlyActive, null)`
**Implemented:**
- Added "Test" action to license overview to validate licenses against `/validate` API endpoint
- Test License modal showing license key, domain, and validation results
- AJAX handler `handleAjaxTestLicense()` for license testing
**Modified files:**
- `src/Admin/VersionAdminController.php` - Fixed parameter order in toggle method
- `src/Admin/AdminController.php` - Added Test action to PHP fallback and AJAX handler
- `templates/admin/licenses.html.twig` - Added Test action and modal to Twig template
**Release v0.3.3:**
- Created release package: `releases/wc-licensed-product-0.3.3.zip` (795 KB)
- SHA256: `a06d29eabc2da08613ae13874ed152b8ea9363b8284a2e9bdda414e32777558c`
- Tagged as `v0.3.3` and pushed to `main` branch
### 2026-01-23 - Version 0.3.4 - Frontend Version Display
**Overview:**
Added current version display on single product pages for licensed products.
**Implemented:**
- Current version displayed directly under the product title
- Styled version badge with monospace font and subtle blue background
- Frontend CSS automatically loaded on licensed product pages
**Modified files:**
- `src/Product/LicensedProductType.php` - Added `displayCurrentVersion()` and `enqueueFrontendStyles()` methods
- `assets/css/frontend.css` - Added `.wclp-product-version` styles
**Technical notes:**
- Uses `woocommerce_single_product_summary` hook at priority 6 (after title at priority 5)
- Only displays for licensed product type
- Only displays if product has at least one version defined
- Uses `LicensedProduct::get_current_version()` which queries `VersionManager::getLatestVersion()`
### 2026-01-23 - Version 0.3.5 - Dashboard Widget & Auto-Expire
**Overview:**
Added admin dashboard widget for license statistics and automatic license expiration via daily cron job.
**Implemented:**
- Admin dashboard widget showing license statistics (total, active, expiring soon, expired)
- Status breakdown display with color-coded badges
- License type breakdown (time-limited vs lifetime)
- Daily wp-cron job to auto-expire licenses past their expiration date
- License expired email notification sent when license auto-expires
- Downloads in customer account now displayed in two-row format
**New files:**
- `src/Admin/DashboardWidgetController.php` - WordPress dashboard widget controller
- `src/Email/LicenseExpiredEmail.php` - WooCommerce email for expired license notifications
**Modified files:**
- `src/Plugin.php` - Added DashboardWidgetController instantiation
- `src/License/LicenseManager.php` - Added `getExpiredActiveLicenses()` and `autoExpireLicense()` methods
- `src/Email/LicenseEmailController.php` - Added auto-expire logic and LicenseExpiredEmail registration
- `templates/frontend/licenses.html.twig` - Restructured download list with two-row layout
- `assets/css/frontend.css` - Added dashboard widget and download list styles
**Technical notes:**
- Dashboard widget uses `wp_add_dashboard_widget()` hook, requires `manage_woocommerce` capability
- Widget displays statistics from existing `LicenseManager::getStatistics()` method
- Auto-expire runs during daily `wclp_check_expiring_licenses` cron event
- `getExpiredActiveLicenses()` finds licenses with past expiration date but still active status
- `autoExpireLicense()` updates status to expired and returns true if changed
- LicenseExpiredEmail follows same pattern as LicenseExpirationEmail (warning vs expired)
- Expired notification tracked via user meta to prevent duplicate emails

View File

@@ -107,12 +107,42 @@ When a customer purchases a licensed product, they must enter the domain where t
Full API documentation available in `openapi.json` (OpenAPI 3.1 specification).
### Client Examples
### Response Signing (Optional)
Ready-to-use API client examples are available in `docs/client-examples/`:
When the server is configured with a shared secret, all API responses include cryptographic signatures for tamper protection:
**Configuration (wp-config.php):**
```php
define('WC_LICENSE_SERVER_SECRET', 'your-secure-random-string-min-32-chars');
```
**Response Headers:**
| Header | Description |
| ------ | ----------- |
| `X-License-Signature` | HMAC-SHA256 signature of the response body |
| `X-License-Timestamp` | Unix timestamp when the response was generated |
The signature prevents man-in-the-middle attacks and ensures response integrity. Use the `magdev/wc-licensed-product-client` Composer package with the `SecureLicenseClient` class to automatically verify signatures.
### Client Libraries & Examples
**PHP (Recommended):** Install the official client library via Composer:
```bash
composer require magdev/wc-licensed-product-client
```
The library provides:
- `LicenseClient` - Standard client for API calls
- `SecureLicenseClient` - Client with automatic response signature verification
**Example clients** for other languages are available in `docs/client-examples/`:
- **cURL** - Shell script examples ([curl.sh](docs/client-examples/curl.sh))
- **PHP** - Client class with examples ([php-client.php](docs/client-examples/php-client.php))
- **PHP** - Standalone client example ([php-client.php](docs/client-examples/php-client.php))
- **Python** - Client class with dataclasses ([python-client.py](docs/client-examples/python-client.py))
- **JavaScript** - Browser and Node.js client ([javascript-client.js](docs/client-examples/javascript-client.js))
- **C#** - Async client with System.Text.Json ([csharp-client.cs](docs/client-examples/csharp-client.cs))

View File

@@ -202,18 +202,30 @@
padding: 0;
}
.download-list li {
.download-list li.download-item {
display: flex;
align-items: center;
gap: 1em;
padding: 0.5em 0;
flex-direction: column;
gap: 0.35em;
padding: 0.75em 0;
border-bottom: 1px solid #eee;
}
.download-list li:last-child {
.download-list li.download-item:last-child {
border-bottom: none;
}
.download-row-file {
display: flex;
align-items: center;
}
.download-row-meta {
display: flex;
align-items: center;
gap: 1em;
padding-left: 1.5em;
}
.download-link {
display: inline-flex;
align-items: center;
@@ -244,7 +256,6 @@
.download-date {
color: #999;
font-size: 0.85em;
margin-left: auto;
}
.download-hash {
@@ -338,15 +349,11 @@
gap: 0.5em;
}
.download-list li {
.download-row-meta {
padding-left: 0;
flex-wrap: wrap;
}
.download-date {
margin-left: 0;
width: 100%;
}
.woocommerce-licenses-table,
.woocommerce-licenses-table thead,
.woocommerce-licenses-table tbody,
@@ -528,3 +535,24 @@
color: #721c24;
border: 1px solid #f5c6cb;
}
/* Product Version Display (Single Product Page) */
.wclp-product-version {
margin: 0.5em 0 1em 0;
font-size: 0.95em;
color: #666;
}
.wclp-product-version .version-label {
font-weight: 500;
color: #555;
}
.wclp-product-version .version-number {
font-family: 'SF Mono', Monaco, Consolas, monospace;
background: #e7f3ff;
padding: 0.15em 0.5em;
border-radius: 3px;
color: #2271b1;
font-weight: 500;
}

View File

@@ -12,8 +12,8 @@
],
"repositories": [
{
"type": "path",
"url": "/home/magdev/workspaces/php/wc-licensed-product-client"
"type": "vcs",
"url": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client.git"
}
],
"require": {

15
composer.lock generated
View File

@@ -4,15 +4,15 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "0387e179142771dbc12a8dba42895bd0",
"content-hash": "05af8ab515abe7e689c610724b54e27a",
"packages": [
{
"name": "magdev/wc-licensed-product-client",
"version": "dev-main",
"dist": {
"type": "path",
"url": "/home/magdev/workspaces/php/wc-licensed-product-client",
"reference": "83037ea0c2d9e365cf9ec0ad50251d3ebc7e4782"
"source": {
"type": "git",
"url": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client.git",
"reference": "a3a957914fd6ef74cb479e213d1d3bc0606f496b"
},
"require": {
"php": "^8.3",
@@ -24,6 +24,7 @@
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"default-branch": true,
"type": "library",
"autoload": {
"psr-4": {
@@ -51,9 +52,7 @@
"issues": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client/issues",
"source": "https://src.bundespruefstelle.ch/magdev/wc-licensed-product-client"
},
"transport-options": {
"relative": false
}
"time": "2026-01-22T20:05:48+00:00"
},
{
"name": "psr/cache",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,8 @@
"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",
"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.\n\n## Response Signing (Optional)\n\nWhen the server is configured with `WC_LICENSE_SERVER_SECRET`, all API responses include cryptographic signatures for tamper protection:\n\n- `X-License-Signature`: HMAC-SHA256 signature of the response\n- `X-License-Timestamp`: Unix timestamp when the response was generated\n\nSignature verification prevents man-in-the-middle attacks and ensures response integrity. Use the `magdev/wc-licensed-product-client` library's `SecureLicenseClient` class to automatically verify signatures.",
"version": "0.3.2",
"contact": {
"name": "Marco Graetsch",
"url": "https://src.bundespruefstelle.ch/magdev",
@@ -55,6 +55,14 @@
"responses": {
"200": {
"description": "License is valid for the specified domain",
"headers": {
"X-License-Signature": {
"$ref": "#/components/headers/X-License-Signature"
},
"X-License-Timestamp": {
"$ref": "#/components/headers/X-License-Timestamp"
}
},
"content": {
"application/json": {
"schema": {
@@ -156,6 +164,14 @@
"responses": {
"200": {
"description": "License status retrieved successfully",
"headers": {
"X-License-Signature": {
"$ref": "#/components/headers/X-License-Signature"
},
"X-License-Timestamp": {
"$ref": "#/components/headers/X-License-Timestamp"
}
},
"content": {
"application/json": {
"schema": {
@@ -221,6 +237,14 @@
"responses": {
"200": {
"description": "License activated successfully or already activated",
"headers": {
"X-License-Signature": {
"$ref": "#/components/headers/X-License-Signature"
},
"X-License-Timestamp": {
"$ref": "#/components/headers/X-License-Timestamp"
}
},
"content": {
"application/json": {
"schema": {
@@ -519,6 +543,26 @@
}
}
}
},
"headers": {
"X-License-Signature": {
"description": "HMAC-SHA256 signature of the response body for tamper protection. Only present when server is configured with WC_LICENSE_SERVER_SECRET. Signature format: hex-encoded HMAC-SHA256 of (timestamp + ':' + canonical_json_body) using a per-license derived key.",
"schema": {
"type": "string",
"pattern": "^[a-f0-9]{64}$",
"example": "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456"
},
"required": false
},
"X-License-Timestamp": {
"description": "Unix timestamp when the response was generated. Used together with X-License-Signature to prevent replay attacks. Only present when server is configured with WC_LICENSE_SERVER_SECRET.",
"schema": {
"type": "string",
"pattern": "^[0-9]+$",
"example": "1737550000"
},
"required": false
}
}
},
"tags": [

View File

@@ -0,0 +1 @@
a06d29eabc2da08613ae13874ed152b8ea9363b8284a2e9bdda414e32777558c wc-licensed-product-0.3.3.zip

Binary file not shown.

View File

@@ -0,0 +1 @@
36a81c00eb03adf5dfa633891664d44b7e5225bf1ee594904f8acc9adec6bb47 releases/wc-licensed-product-0.3.4.zip

Binary file not shown.

View File

@@ -61,6 +61,9 @@ final class AdminController
add_action('wp_ajax_wclp_update_license_expiry', [$this, 'handleAjaxExpiryUpdate']);
add_action('wp_ajax_wclp_update_license_domain', [$this, 'handleAjaxDomainUpdate']);
add_action('wp_ajax_wclp_revoke_license', [$this, 'handleAjaxRevoke']);
// AJAX handler for license testing
add_action('wp_ajax_wclp_test_license', [$this, 'handleAjaxTestLicense']);
}
/**
@@ -355,6 +358,30 @@ final class AdminController
}
}
/**
* Handle AJAX license test - validates license against the API
*/
public function handleAjaxTestLicense(): void
{
check_ajax_referer('wclp_inline_edit', 'nonce');
if (!current_user_can('manage_woocommerce')) {
wp_send_json_error(['message' => __('Permission denied.', 'wc-licensed-product')], 403);
}
$licenseKey = isset($_POST['license_key']) ? sanitize_text_field(wp_unslash($_POST['license_key'])) : '';
$domain = isset($_POST['domain']) ? sanitize_text_field(wp_unslash($_POST['domain'])) : '';
if (empty($licenseKey) || empty($domain)) {
wp_send_json_error(['message' => __('License key and domain are required.', 'wc-licensed-product')]);
}
// Validate the license using LicenseManager
$result = $this->licenseManager->validateLicense($licenseKey, $domain);
wp_send_json_success($result);
}
/**
* Handle admin actions (update, delete licenses)
*/
@@ -1347,7 +1374,20 @@ final class AdminController
</td>
<td class="license-actions">
<div class="row-actions">
<span class="test">
<a href="#" class="wclp-test-license-link"
data-license-id="<?php echo esc_attr($item['license']->getId()); ?>"
data-license-key="<?php echo esc_attr($item['license']->getLicenseKey()); ?>"
data-domain="<?php echo esc_attr($item['license']->getDomain()); ?>"
title="<?php esc_attr_e('Test license against API', 'wc-licensed-product'); ?>"><?php esc_html_e('Test', 'wc-licensed-product'); ?></a> |
</span>
<?php if ($item['license']->getStatus() !== License::STATUS_REVOKED): ?>
<span class="transfer">
<a href="#" class="wclp-transfer-link"
data-license-id="<?php echo esc_attr($item['license']->getId()); ?>"
data-current-domain="<?php echo esc_attr($item['license']->getDomain()); ?>"
title="<?php esc_attr_e('Transfer to new domain', 'wc-licensed-product'); ?>"><?php esc_html_e('Transfer', 'wc-licensed-product'); ?></a> |
</span>
<span class="extend">
<a href="<?php echo esc_url(wp_nonce_url(
admin_url('admin.php?page=wc-licenses&action=extend&license_id=' . $item['license']->getId() . '&days=30'),
@@ -1429,8 +1469,69 @@ final class AdminController
</div>
</form>
<!-- Test License Modal -->
<div id="wclp-test-modal" class="wclp-modal" style="display:none;">
<div class="wclp-modal-content">
<span class="wclp-modal-close">&times;</span>
<h2><?php esc_html_e('License Validation Test', 'wc-licensed-product'); ?></h2>
<div class="wclp-test-info">
<table class="form-table">
<tr>
<th scope="row"><?php esc_html_e('License Key', 'wc-licensed-product'); ?></th>
<td><code id="test-license-key"></code></td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('Domain', 'wc-licensed-product'); ?></th>
<td><code id="test-domain"></code></td>
</tr>
</table>
</div>
<div id="wclp-test-loading" style="display:none; text-align:center; padding:20px;">
<span class="spinner is-active" style="float:none;"></span>
<p><?php esc_html_e('Testing license...', 'wc-licensed-product'); ?></p>
</div>
<div id="wclp-test-result" style="display:none;">
<div id="wclp-test-result-content"></div>
</div>
<p class="submit">
<button type="button" class="button wclp-modal-cancel"><?php esc_html_e('Close', 'wc-licensed-product'); ?></button>
</p>
</div>
</div>
<!-- Transfer Modal -->
<div id="wclp-transfer-modal" class="wclp-modal" style="display:none;">
<div class="wclp-modal-content">
<span class="wclp-modal-close">&times;</span>
<h2><?php esc_html_e('Transfer License to New Domain', 'wc-licensed-product'); ?></h2>
<form method="post" action="<?php echo esc_url(admin_url('admin.php?page=wc-licenses')); ?>">
<input type="hidden" name="action" value="transfer_license">
<?php wp_nonce_field('transfer_license', '_wpnonce'); ?>
<input type="hidden" name="license_id" id="transfer-license-id" value="">
<table class="form-table">
<tr>
<th scope="row"><label><?php esc_html_e('Current Domain', 'wc-licensed-product'); ?></label></th>
<td><code id="transfer-current-domain"></code></td>
</tr>
<tr>
<th scope="row"><label for="new_domain"><?php esc_html_e('New Domain', 'wc-licensed-product'); ?></label></th>
<td>
<input type="text" name="new_domain" id="transfer-new-domain" class="regular-text" placeholder="example.com" required>
<p class="description"><?php esc_html_e('Enter the new domain without http:// or www.', 'wc-licensed-product'); ?></p>
</td>
</tr>
</table>
<p class="submit">
<button type="submit" class="button button-primary"><?php esc_html_e('Transfer License', 'wc-licensed-product'); ?></button>
<button type="button" class="button wclp-modal-cancel"><?php esc_html_e('Cancel', 'wc-licensed-product'); ?></button>
</p>
</form>
</div>
</div>
<script>
(function($) {
// Checkbox select all
$('#cb-select-all-1, #cb-select-all-2').on('change', function() {
$('input[name="license_ids[]"]').prop('checked', this.checked);
$('#cb-select-all-1, #cb-select-all-2').prop('checked', this.checked);
@@ -1445,6 +1546,102 @@ final class AdminController
$('#bulk-action-selector').val(bottomAction);
}
});
// Transfer modal
var $transferModal = $('#wclp-transfer-modal');
$('.wclp-transfer-link').on('click', function(e) {
e.preventDefault();
var licenseId = $(this).data('license-id');
var currentDomain = $(this).data('current-domain');
$('#transfer-license-id').val(licenseId);
$('#transfer-current-domain').text(currentDomain);
$('#transfer-new-domain').val('');
$transferModal.show();
});
// Test License modal
var $testModal = $('#wclp-test-modal');
var $testLoading = $('#wclp-test-loading');
var $testResult = $('#wclp-test-result');
var $testResultContent = $('#wclp-test-result-content');
$('.wclp-test-license-link').on('click', function(e) {
e.preventDefault();
var licenseKey = $(this).data('license-key');
var domain = $(this).data('domain');
$('#test-license-key').text(licenseKey);
$('#test-domain').text(domain);
$testLoading.show();
$testResult.hide();
$testModal.show();
$.ajax({
url: wclpAdmin.ajaxUrl,
type: 'POST',
data: {
action: 'wclp_test_license',
nonce: wclpAdmin.editNonce,
license_key: licenseKey,
domain: domain
},
success: function(response) {
$testLoading.hide();
if (response.success) {
var result = response.data;
var html = '';
if (result.valid) {
html = '<div class="notice notice-success inline"><p><strong>✓ <?php echo esc_js(__('License is VALID', 'wc-licensed-product')); ?></strong></p></div>';
html += '<table class="widefat striped"><tbody>';
html += '<tr><th><?php echo esc_js(__('Product', 'wc-licensed-product')); ?></th><td>' + escapeHtml(result.product_name || '-') + '</td></tr>';
html += '<tr><th><?php echo esc_js(__('Version', 'wc-licensed-product')); ?></th><td>' + escapeHtml(result.version || '-') + '</td></tr>';
if (result.expires_at) {
html += '<tr><th><?php echo esc_js(__('Expires', 'wc-licensed-product')); ?></th><td>' + escapeHtml(result.expires_at) + '</td></tr>';
} else {
html += '<tr><th><?php echo esc_js(__('Expires', 'wc-licensed-product')); ?></th><td><?php echo esc_js(__('Lifetime', 'wc-licensed-product')); ?></td></tr>';
}
html += '</tbody></table>';
} else {
html = '<div class="notice notice-error inline"><p><strong>✗ <?php echo esc_js(__('License is INVALID', 'wc-licensed-product')); ?></strong></p></div>';
html += '<table class="widefat striped"><tbody>';
html += '<tr><th><?php echo esc_js(__('Error Code', 'wc-licensed-product')); ?></th><td><code>' + escapeHtml(result.error || 'unknown') + '</code></td></tr>';
html += '<tr><th><?php echo esc_js(__('Message', 'wc-licensed-product')); ?></th><td>' + escapeHtml(result.message || '-') + '</td></tr>';
html += '</tbody></table>';
}
$testResultContent.html(html);
$testResult.show();
} else {
$testResultContent.html('<div class="notice notice-error inline"><p>' + escapeHtml(response.data.message || 'Error') + '</p></div>');
$testResult.show();
}
},
error: function() {
$testLoading.hide();
$testResultContent.html('<div class="notice notice-error inline"><p><?php echo esc_js(__('Failed to test license. Please try again.', 'wc-licensed-product')); ?></p></div>');
$testResult.show();
}
});
});
// Close modals
$('.wclp-modal-close, .wclp-modal-cancel').on('click', function() {
$(this).closest('.wclp-modal').hide();
});
$(window).on('click', function(e) {
if ($(e.target).hasClass('wclp-modal')) {
$(e.target).hide();
}
});
function escapeHtml(text) {
if (!text) return '';
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
})(jQuery);
</script>
</div>

View File

@@ -0,0 +1,225 @@
<?php
/**
* Dashboard Widget Controller
*
* @package Jeremias\WcLicensedProduct\Admin
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Admin;
use Jeremias\WcLicensedProduct\License\License;
use Jeremias\WcLicensedProduct\License\LicenseManager;
/**
* Handles the WordPress admin dashboard widget for license statistics
*/
final class DashboardWidgetController
{
private LicenseManager $licenseManager;
public function __construct(LicenseManager $licenseManager)
{
$this->licenseManager = $licenseManager;
$this->registerHooks();
}
/**
* Register WordPress hooks
*/
private function registerHooks(): void
{
add_action('wp_dashboard_setup', [$this, 'registerDashboardWidget']);
}
/**
* Register the dashboard widget
*/
public function registerDashboardWidget(): void
{
if (!current_user_can('manage_woocommerce')) {
return;
}
wp_add_dashboard_widget(
'wclp_license_statistics',
__('License Statistics', 'wc-licensed-product'),
[$this, 'renderWidget']
);
}
/**
* Render the dashboard widget content
*/
public function renderWidget(): void
{
$stats = $this->licenseManager->getStatistics();
$licensesUrl = admin_url('admin.php?page=wc-licensed-product-licenses');
?>
<style>
.wclp-widget-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.wclp-stat-card {
background: #f8f9fa;
border: 1px solid #e2e4e7;
border-radius: 4px;
padding: 12px;
text-align: center;
}
.wclp-stat-card.highlight {
border-left: 3px solid #7f54b3;
}
.wclp-stat-card.warning {
border-left: 3px solid #f0b849;
}
.wclp-stat-card.danger {
border-left: 3px solid #dc3232;
}
.wclp-stat-card.success {
border-left: 3px solid #46b450;
}
.wclp-stat-number {
font-size: 28px;
font-weight: 600;
color: #1d2327;
line-height: 1.2;
}
.wclp-stat-label {
font-size: 12px;
color: #646970;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 4px;
}
.wclp-widget-divider {
border-top: 1px solid #e2e4e7;
margin: 16px 0;
}
.wclp-status-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.wclp-status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.wclp-status-badge.active {
background: #d4edda;
color: #155724;
}
.wclp-status-badge.inactive {
background: #e2e3e5;
color: #383d41;
}
.wclp-status-badge.expired {
background: #f8d7da;
color: #721c24;
}
.wclp-status-badge.revoked {
background: #d6d8db;
color: #1b1e21;
}
.wclp-widget-footer {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid #e2e4e7;
text-align: center;
}
.wclp-widget-footer a {
text-decoration: none;
}
</style>
<div class="wclp-widget-stats">
<div class="wclp-stat-card highlight">
<div class="wclp-stat-number"><?php echo esc_html(number_format_i18n($stats['total'])); ?></div>
<div class="wclp-stat-label"><?php esc_html_e('Total Licenses', 'wc-licensed-product'); ?></div>
</div>
<div class="wclp-stat-card success">
<div class="wclp-stat-number"><?php echo esc_html(number_format_i18n($stats['by_status'][License::STATUS_ACTIVE])); ?></div>
<div class="wclp-stat-label"><?php esc_html_e('Active', 'wc-licensed-product'); ?></div>
</div>
<div class="wclp-stat-card warning">
<div class="wclp-stat-number"><?php echo esc_html(number_format_i18n($stats['expiring_soon'])); ?></div>
<div class="wclp-stat-label"><?php esc_html_e('Expiring Soon', 'wc-licensed-product'); ?></div>
</div>
<div class="wclp-stat-card danger">
<div class="wclp-stat-number"><?php echo esc_html(number_format_i18n($stats['by_status'][License::STATUS_EXPIRED])); ?></div>
<div class="wclp-stat-label"><?php esc_html_e('Expired', 'wc-licensed-product'); ?></div>
</div>
</div>
<div class="wclp-widget-divider"></div>
<h4 style="margin: 0 0 8px 0; font-size: 13px; color: #1d2327;">
<?php esc_html_e('Status Breakdown', 'wc-licensed-product'); ?>
</h4>
<div class="wclp-status-list">
<span class="wclp-status-badge active">
<span class="dashicons dashicons-yes-alt" style="font-size: 14px; width: 14px; height: 14px;"></span>
<?php printf(
esc_html__('Active: %d', 'wc-licensed-product'),
$stats['by_status'][License::STATUS_ACTIVE]
); ?>
</span>
<span class="wclp-status-badge inactive">
<span class="dashicons dashicons-marker" style="font-size: 14px; width: 14px; height: 14px;"></span>
<?php printf(
esc_html__('Inactive: %d', 'wc-licensed-product'),
$stats['by_status'][License::STATUS_INACTIVE]
); ?>
</span>
<span class="wclp-status-badge expired">
<span class="dashicons dashicons-clock" style="font-size: 14px; width: 14px; height: 14px;"></span>
<?php printf(
esc_html__('Expired: %d', 'wc-licensed-product'),
$stats['by_status'][License::STATUS_EXPIRED]
); ?>
</span>
<span class="wclp-status-badge revoked">
<span class="dashicons dashicons-dismiss" style="font-size: 14px; width: 14px; height: 14px;"></span>
<?php printf(
esc_html__('Revoked: %d', 'wc-licensed-product'),
$stats['by_status'][License::STATUS_REVOKED]
); ?>
</span>
</div>
<div class="wclp-widget-divider"></div>
<h4 style="margin: 0 0 8px 0; font-size: 13px; color: #1d2327;">
<?php esc_html_e('License Types', 'wc-licensed-product'); ?>
</h4>
<p style="margin: 0; font-size: 13px; color: #646970;">
<span class="dashicons dashicons-calendar-alt" style="font-size: 14px; width: 14px; height: 14px; vertical-align: text-bottom;"></span>
<?php printf(
esc_html__('Time-limited: %d', 'wc-licensed-product'),
$stats['expiring']
); ?>
&nbsp;&nbsp;|&nbsp;&nbsp;
<span class="dashicons dashicons-infinity" style="font-size: 14px; width: 14px; height: 14px; vertical-align: text-bottom;"></span>
<?php printf(
esc_html__('Lifetime: %d', 'wc-licensed-product'),
$stats['lifetime']
); ?>
</p>
<div class="wclp-widget-footer">
<a href="<?php echo esc_url($licensesUrl); ?>" class="button button-secondary">
<?php esc_html_e('View All Licenses', 'wc-licensed-product'); ?>
</a>
</div>
<?php
}
}

View File

@@ -361,7 +361,7 @@ final class VersionAdminController
wp_send_json_error(['message' => __('Version ID is required.', 'wc-licensed-product')]);
}
$result = $this->versionManager->updateVersion($versionId, null, null, !$currentlyActive);
$result = $this->versionManager->updateVersion($versionId, null, !$currentlyActive, null);
if (!$result) {
wp_send_json_error(['message' => __('Failed to update version.', 'wc-licensed-product')]);

View File

@@ -55,6 +55,7 @@ final class LicenseEmailController
public function registerEmailClasses(array $email_classes): array
{
$email_classes['WCLP_License_Expiration'] = new LicenseExpirationEmail();
$email_classes['WCLP_License_Expired'] = new LicenseExpiredEmail();
return $email_classes;
}
@@ -69,10 +70,13 @@ final class LicenseEmailController
}
/**
* Send expiration warning emails
* Send expiration warning emails and auto-expire licenses
*/
public function sendExpirationWarnings(): void
{
// First, auto-expire licenses that have passed their expiration date
$this->autoExpireAndNotify();
// Check if expiration emails are enabled in settings
if (!SettingsController::isExpirationEmailsEnabled()) {
return;
@@ -107,6 +111,41 @@ final class LicenseEmailController
}
}
/**
* Auto-expire licenses and send expired notifications
*/
private function autoExpireAndNotify(): void
{
// Get licenses that should be auto-expired
$expiredActiveLicenses = $this->licenseManager->getExpiredActiveLicenses();
if (empty($expiredActiveLicenses)) {
return;
}
// Get the WooCommerce email instance for expired notifications
$mailer = WC()->mailer();
$emails = $mailer->get_emails();
/** @var LicenseExpiredEmail|null $expiredEmail */
$expiredEmail = $emails['WCLP_License_Expired'] ?? null;
foreach ($expiredActiveLicenses as $license) {
// Auto-expire the license
$wasExpired = $this->licenseManager->autoExpireLicense($license->getId());
if ($wasExpired && $expiredEmail && $expiredEmail->is_enabled()) {
// Check if we haven't already sent an expired notification
if (!$this->licenseManager->wasExpirationNotified($license->getId(), 'license_expired')) {
// Send expired notification email
if ($expiredEmail->trigger($license)) {
$this->licenseManager->markExpirationNotified($license->getId(), 'license_expired');
}
}
}
}
}
/**
* Process and send expiration warnings for a specific time frame
*

View File

@@ -0,0 +1,335 @@
<?php
/**
* License Expired Email
*
* @package Jeremias\WcLicensedProduct\Email
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Email;
use Jeremias\WcLicensedProduct\License\License;
use WC_Email;
/**
* License Expired Email class
*
* Sends email notifications to customers when their licenses have expired.
* Uses WooCommerce's transactional email system for consistent styling and customization.
*/
class LicenseExpiredEmail extends WC_Email
{
/**
* License object
*/
public ?License $license = null;
/**
* Product name
*/
public string $product_name = '';
/**
* Expiration date formatted
*/
public string $expiration_date = '';
/**
* Customer display name
*/
public string $customer_name = '';
/**
* Constructor
*/
public function __construct()
{
$this->id = 'wclp_license_expired';
$this->customer_email = true;
$this->title = __('License Expired', 'wc-licensed-product');
$this->description = __('License expired emails are sent to customers when their licenses have expired.', 'wc-licensed-product');
$this->placeholders = [
'{site_title}' => $this->get_blogname(),
'{product_name}' => '',
'{expiration_date}' => '',
];
// Call parent constructor
parent::__construct();
}
/**
* Get email subject
*/
public function get_default_subject(): string
{
return __('[{site_title}] Your license for {product_name} has expired', 'wc-licensed-product');
}
/**
* Get email heading
*/
public function get_default_heading(): string
{
return __('License Expired', 'wc-licensed-product');
}
/**
* Trigger the email
*
* @param License $license License object
*/
public function trigger(License $license): bool
{
$this->setup_locale();
$customer = get_userdata($license->getCustomerId());
if (!$customer || !$customer->user_email) {
$this->restore_locale();
return false;
}
$this->license = $license;
$this->recipient = $customer->user_email;
$this->customer_name = $customer->display_name ?: __('Customer', 'wc-licensed-product');
$product = wc_get_product($license->getProductId());
$this->product_name = $product ? $product->get_name() : __('Unknown Product', 'wc-licensed-product');
$expiresAt = $license->getExpiresAt();
$this->expiration_date = $expiresAt ? $expiresAt->format(get_option('date_format')) : '';
// Update placeholders
$this->placeholders['{product_name}'] = $this->product_name;
$this->placeholders['{expiration_date}'] = $this->expiration_date;
if (!$this->is_enabled() || !$this->get_recipient()) {
$this->restore_locale();
return false;
}
$result = $this->send(
$this->get_recipient(),
$this->get_subject(),
$this->get_content(),
$this->get_headers(),
$this->get_attachments()
);
$this->restore_locale();
return $result;
}
/**
* Get content HTML
*/
public function get_content_html(): string
{
ob_start();
// Use WooCommerce's email header
wc_get_template('emails/email-header.php', ['email_heading' => $this->get_heading()]);
$this->render_email_body_html();
// Use WooCommerce's email footer
wc_get_template('emails/email-footer.php', ['email' => $this]);
return ob_get_clean();
}
/**
* Get content plain text
*/
public function get_content_plain(): string
{
ob_start();
echo "=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n";
echo esc_html(wp_strip_all_tags($this->get_heading()));
echo "\n=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n\n";
$this->render_email_body_plain();
return ob_get_clean();
}
/**
* Render HTML email body content
*/
private function render_email_body_html(): void
{
$account_url = wc_get_account_endpoint_url('licenses');
?>
<p><?php printf(esc_html__('Hello %s,', 'wc-licensed-product'), esc_html($this->customer_name)); ?></p>
<p style="color: #dc3232; font-weight: 600;">
<?php printf(
esc_html__('Your license for %1$s has expired on %2$s.', 'wc-licensed-product'),
'<strong>' . esc_html($this->product_name) . '</strong>',
esc_html($this->expiration_date)
); ?>
</p>
<p>
<?php esc_html_e('Your license is no longer valid and the product will stop working until you renew.', 'wc-licensed-product'); ?>
</p>
<h2><?php esc_html_e('Expired License Details', 'wc-licensed-product'); ?></h2>
<div style="margin-bottom: 40px;">
<table class="td" cellspacing="0" cellpadding="6" style="width: 100%; font-family: 'Helvetica Neue', Helvetica, Roboto, Arial, sans-serif;" border="1">
<tbody>
<tr>
<th class="td" scope="row" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php esc_html_e('Product:', 'wc-licensed-product'); ?></th>
<td class="td" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php echo esc_html($this->product_name); ?></td>
</tr>
<tr>
<th class="td" scope="row" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php esc_html_e('License Key:', 'wc-licensed-product'); ?></th>
<td class="td" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;">
<code style="background: #f5f5f5; padding: 3px 8px; border-radius: 3px; font-family: monospace;">
<?php echo esc_html($this->license->getLicenseKey()); ?>
</code>
</td>
</tr>
<tr>
<th class="td" scope="row" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php esc_html_e('Domain:', 'wc-licensed-product'); ?></th>
<td class="td" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php echo esc_html($this->license->getDomain()); ?></td>
</tr>
<tr>
<th class="td" scope="row" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php esc_html_e('Expired on:', 'wc-licensed-product'); ?></th>
<td class="td" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>; color: #dc3232; font-weight: 600;"><?php echo esc_html($this->expiration_date); ?></td>
</tr>
<tr>
<th class="td" scope="row" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;"><?php esc_html_e('Status:', 'wc-licensed-product'); ?></th>
<td class="td" style="text-align:<?php echo is_rtl() ? 'right' : 'left'; ?>;">
<span style="background: #f8d7da; color: #721c24; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 500;">
<?php esc_html_e('Expired', 'wc-licensed-product'); ?>
</span>
</td>
</tr>
</tbody>
</table>
</div>
<?php
$additional_content = $this->get_additional_content();
if ($additional_content) :
?>
<p><?php echo wp_kses_post($additional_content); ?></p>
<?php endif; ?>
<p style="margin-top: 25px;">
<a href="<?php echo esc_url($account_url); ?>" class="button" style="display: inline-block; background-color: #7f54b3; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: 600;">
<?php esc_html_e('View My Licenses', 'wc-licensed-product'); ?>
</a>
</p>
<?php
}
/**
* Render plain text email body content
*/
private function render_email_body_plain(): void
{
printf(esc_html__('Hello %s,', 'wc-licensed-product'), esc_html($this->customer_name));
echo "\n\n";
printf(
esc_html__('Your license for %1$s has expired on %2$s.', 'wc-licensed-product'),
esc_html($this->product_name),
esc_html($this->expiration_date)
);
echo "\n\n";
echo esc_html__('Your license is no longer valid and the product will stop working until you renew.', 'wc-licensed-product');
echo "\n\n";
echo "----------\n";
echo esc_html__('Expired License Details', 'wc-licensed-product') . "\n";
echo "----------\n\n";
echo esc_html__('Product:', 'wc-licensed-product') . ' ' . esc_html($this->product_name) . "\n";
echo esc_html__('License Key:', 'wc-licensed-product') . ' ' . esc_html($this->license->getLicenseKey()) . "\n";
echo esc_html__('Domain:', 'wc-licensed-product') . ' ' . esc_html($this->license->getDomain()) . "\n";
echo esc_html__('Expired on:', 'wc-licensed-product') . ' ' . esc_html($this->expiration_date) . "\n";
echo esc_html__('Status:', 'wc-licensed-product') . ' ' . esc_html__('Expired', 'wc-licensed-product') . "\n\n";
$additional_content = $this->get_additional_content();
if ($additional_content) {
echo "----------\n\n";
echo esc_html(wp_strip_all_tags(wptexturize($additional_content)));
echo "\n\n";
}
echo esc_html__('View My Licenses', 'wc-licensed-product') . ': ' . esc_url(wc_get_account_endpoint_url('licenses')) . "\n\n";
echo "=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n";
}
/**
* Default content to show below main email content
*/
public function get_default_additional_content(): string
{
return __('To continue using this product, please renew your license.', 'wc-licensed-product');
}
/**
* Initialize settings form fields
*/
public function init_form_fields(): void
{
$placeholder_text = sprintf(
/* translators: %s: list of placeholders */
__('Available placeholders: %s', 'wc-licensed-product'),
'<code>{site_title}, {product_name}, {expiration_date}</code>'
);
$this->form_fields = [
'enabled' => [
'title' => __('Enable/Disable', 'wc-licensed-product'),
'type' => 'checkbox',
'label' => __('Enable this email notification', 'wc-licensed-product'),
'default' => 'yes',
],
'subject' => [
'title' => __('Subject', 'wc-licensed-product'),
'type' => 'text',
'desc_tip' => true,
'description' => $placeholder_text,
'placeholder' => $this->get_default_subject(),
'default' => '',
],
'heading' => [
'title' => __('Email heading', 'wc-licensed-product'),
'type' => 'text',
'desc_tip' => true,
'description' => $placeholder_text,
'placeholder' => $this->get_default_heading(),
'default' => '',
],
'additional_content' => [
'title' => __('Additional content', 'wc-licensed-product'),
'description' => __('Text to appear below the main email content.', 'wc-licensed-product') . ' ' . $placeholder_text,
'css' => 'width:400px; height: 75px;',
'placeholder' => $this->get_default_additional_content(),
'type' => 'textarea',
'default' => '',
'desc_tip' => true,
],
'email_type' => [
'title' => __('Email type', 'wc-licensed-product'),
'type' => 'select',
'description' => __('Choose which format of email to send.', 'wc-licensed-product'),
'default' => 'html',
'class' => 'email_type wc-enhanced-select',
'options' => $this->get_email_type_options(),
'desc_tip' => true,
],
];
}
}

View File

@@ -862,6 +862,56 @@ class LicenseManager
return (bool) get_user_meta($license->getCustomerId(), $metaKey, true);
}
/**
* Get licenses that have passed their expiration date but are still marked as active
*
* @return array Array of License objects that need to be auto-expired
*/
public function getExpiredActiveLicenses(): array
{
global $wpdb;
$tableName = Installer::getLicensesTable();
$now = new \DateTimeImmutable();
$sql = "SELECT * FROM {$tableName}
WHERE expires_at IS NOT NULL
AND expires_at < %s
AND status = %s";
$rows = $wpdb->get_results(
$wpdb->prepare($sql, $now->format('Y-m-d H:i:s'), License::STATUS_ACTIVE),
ARRAY_A
);
return array_map(fn(array $row) => License::fromArray($row), $rows ?: []);
}
/**
* Auto-expire a license and return true if status was changed
*
* @param int $licenseId License ID
* @return bool True if license was expired, false if already expired or error
*/
public function autoExpireLicense(int $licenseId): bool
{
$license = $this->getLicenseById($licenseId);
if (!$license) {
return false;
}
// Only expire if currently active and past expiration date
if ($license->getStatus() !== License::STATUS_ACTIVE) {
return false;
}
if (!$license->isExpired()) {
return false;
}
return $this->updateLicenseStatus($licenseId, License::STATUS_EXPIRED);
}
/**
* Import a license from CSV data
*

View File

@@ -10,6 +10,7 @@ declare(strict_types=1);
namespace Jeremias\WcLicensedProduct;
use Jeremias\WcLicensedProduct\Admin\AdminController;
use Jeremias\WcLicensedProduct\Admin\DashboardWidgetController;
use Jeremias\WcLicensedProduct\Admin\OrderLicenseController;
use Jeremias\WcLicensedProduct\Admin\SettingsController;
use Jeremias\WcLicensedProduct\Admin\VersionAdminController;
@@ -151,6 +152,7 @@ final class Plugin
new VersionAdminController($this->versionManager);
new OrderLicenseController($this->licenseManager);
new SettingsController();
new DashboardWidgetController($this->licenseManager);
// Show admin notice if unlicensed and not on localhost
if (!$isLicensed && !$licenseChecker->isLocalhost()) {

View File

@@ -45,6 +45,12 @@ final class LicensedProductType
// Make product virtual by default
add_filter('woocommerce_product_is_virtual', [$this, 'isVirtual'], 10, 2);
// Display current version under product title on single product page
add_action('woocommerce_single_product_summary', [$this, 'displayCurrentVersion'], 6);
// Enqueue frontend CSS for licensed products on single product pages
add_action('wp_enqueue_scripts', [$this, 'enqueueFrontendStyles']);
}
/**
@@ -235,4 +241,52 @@ final class LicensedProductType
}
return $isVirtual;
}
/**
* Enqueue frontend styles for licensed products on single product pages
*/
public function enqueueFrontendStyles(): void
{
if (!is_product()) {
return;
}
global $product;
if (!$product || !$product->is_type('licensed')) {
return;
}
wp_enqueue_style(
'wc-licensed-product-frontend',
WC_LICENSED_PRODUCT_PLUGIN_URL . 'assets/css/frontend.css',
[],
WC_LICENSED_PRODUCT_VERSION
);
}
/**
* Display current version under product title on single product page
*/
public function displayCurrentVersion(): void
{
global $product;
if (!$product || !$product->is_type('licensed')) {
return;
}
/** @var LicensedProduct $product */
$version = $product->get_current_version();
if (empty($version)) {
return;
}
printf(
'<p class="wclp-product-version"><span class="version-label">%s</span> <span class="version-number">%s</span></p>',
esc_html__('Version:', 'wc-licensed-product'),
esc_html($version)
);
}
}

View File

@@ -184,6 +184,13 @@
</td>
<td class="license-actions">
<div class="row-actions">
<span class="test">
<a href="#" class="wclp-test-license-link"
data-license-id="{{ item.license.id }}"
data-license-key="{{ esc_attr(item.license.licenseKey) }}"
data-domain="{{ esc_attr(item.license.domain) }}"
title="{{ __('Test license against API') }}">{{ __('Test') }}</a> |
</span>
{% if item.license.status != 'revoked' %}
<span class="transfer">
<a href="#" class="wclp-transfer-link"
@@ -272,6 +279,36 @@
</form>
</div>
<!-- Test License Modal -->
<div id="wclp-test-modal" class="wclp-modal" style="display:none;">
<div class="wclp-modal-content">
<span class="wclp-modal-close">&times;</span>
<h2>{{ __('License Validation Test') }}</h2>
<div class="wclp-test-info">
<table class="form-table">
<tr>
<th scope="row">{{ __('License Key') }}</th>
<td><code id="test-license-key"></code></td>
</tr>
<tr>
<th scope="row">{{ __('Domain') }}</th>
<td><code id="test-domain"></code></td>
</tr>
</table>
</div>
<div id="wclp-test-loading" style="display:none; text-align:center; padding:20px;">
<span class="spinner is-active" style="float:none;"></span>
<p>{{ __('Testing license...') }}</p>
</div>
<div id="wclp-test-result" style="display:none;">
<div id="wclp-test-result-content"></div>
</div>
<p class="submit">
<button type="button" class="button wclp-modal-cancel">{{ __('Close') }}</button>
</p>
</div>
</div>
<!-- Transfer Modal -->
<div id="wclp-transfer-modal" class="wclp-modal" style="display:none;">
<div class="wclp-modal-content">
@@ -349,5 +386,91 @@
$modal.hide();
}
});
// Test License modal
var $testModal = $('#wclp-test-modal');
var $testLoading = $('#wclp-test-loading');
var $testResult = $('#wclp-test-result');
var $testResultContent = $('#wclp-test-result-content');
$('.wclp-test-license-link').on('click', function(e) {
e.preventDefault();
var licenseKey = $(this).data('license-key');
var domain = $(this).data('domain');
// Show modal with info
$('#test-license-key').text(licenseKey);
$('#test-domain').text(domain);
$testLoading.show();
$testResult.hide();
$testModal.show();
// Call the test endpoint
$.ajax({
url: wclpAdmin.ajaxUrl,
type: 'POST',
data: {
action: 'wclp_test_license',
nonce: wclpAdmin.editNonce,
license_key: licenseKey,
domain: domain
},
success: function(response) {
$testLoading.hide();
if (response.success) {
var result = response.data;
var html = '';
if (result.valid) {
html = '<div class="notice notice-success inline"><p><strong>✓ {{ __('License is VALID') }}</strong></p></div>';
html += '<table class="widefat striped"><tbody>';
html += '<tr><th>{{ __('Product') }}</th><td>' + escapeHtml(result.product_name || '-') + '</td></tr>';
html += '<tr><th>{{ __('Version') }}</th><td>' + escapeHtml(result.version || '-') + '</td></tr>';
if (result.expires_at) {
html += '<tr><th>{{ __('Expires') }}</th><td>' + escapeHtml(result.expires_at) + '</td></tr>';
} else {
html += '<tr><th>{{ __('Expires') }}</th><td>{{ __('Lifetime') }}</td></tr>';
}
html += '</tbody></table>';
} else {
html = '<div class="notice notice-error inline"><p><strong>✗ {{ __('License is INVALID') }}</strong></p></div>';
html += '<table class="widefat striped"><tbody>';
html += '<tr><th>{{ __('Error Code') }}</th><td><code>' + escapeHtml(result.error || 'unknown') + '</code></td></tr>';
html += '<tr><th>{{ __('Message') }}</th><td>' + escapeHtml(result.message || '-') + '</td></tr>';
html += '</tbody></table>';
}
$testResultContent.html(html);
$testResult.show();
} else {
$testResultContent.html('<div class="notice notice-error inline"><p>' + escapeHtml(response.data.message || 'Error') + '</p></div>');
$testResult.show();
}
},
error: function() {
$testLoading.hide();
$testResultContent.html('<div class="notice notice-error inline"><p>{{ __('Failed to test license. Please try again.') }}</p></div>');
$testResult.show();
}
});
});
// Close test modal
$testModal.find('.wclp-modal-close, .wclp-modal-cancel').on('click', function() {
$testModal.hide();
});
$(window).on('click', function(e) {
if ($(e.target).is($testModal)) {
$testModal.hide();
}
});
function escapeHtml(text) {
if (!text) return '';
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
})(jQuery);
</script>

View File

@@ -57,19 +57,22 @@
<h4>{{ __('Available Downloads') }}</h4>
<ul class="download-list">
{% for download in item.downloads %}
<li>
<a href="{{ esc_url(download.download_url) }}" class="download-link">
<span class="dashicons dashicons-download"></span>
{{ esc_html(download.filename ?: 'Version ' ~ download.version) }}
</a>
<span class="download-version">v{{ esc_html(download.version) }}</span>
<span class="download-date">{{ esc_html(download.released_at) }}</span>
{% if download.file_hash %}
<span class="download-hash" title="{{ esc_attr(download.file_hash) }}">
<span class="dashicons dashicons-shield"></span>
<code>{{ download.file_hash[:12] }}...</code>
</span>
{% endif %}
<li class="download-item">
<div class="download-row-file">
<a href="{{ esc_url(download.download_url) }}" class="download-link">
<span class="dashicons dashicons-download"></span>
{{ esc_html(download.filename ?: 'Version ' ~ download.version) }}
</a>
</div>
<div class="download-row-meta">
<span class="download-date">{{ esc_html(download.released_at) }}</span>
{% if download.file_hash %}
<span class="download-hash" title="{{ esc_attr(download.file_hash) }}">
<span class="dashicons dashicons-shield"></span>
<code>{{ download.file_hash[:12] }}...</code>
</span>
{% endif %}
</div>
</li>
{% endfor %}
</ul>

View File

@@ -3,7 +3,7 @@
* Plugin Name: WooCommerce Licensed Product
* Plugin URI: https://src.bundespruefstelle.ch/magdev/wc-licensed-product
* Description: WooCommerce plugin to sell software products using license keys with domain-based validation.
* Version: 0.3.1
* Version: 0.3.5
* Author: Marco Graetsch
* Author URI: https://src.bundespruefstelle.ch/magdev
* License: GPL-2.0-or-later
@@ -28,7 +28,7 @@ if (!defined('ABSPATH')) {
}
// Plugin constants
define('WC_LICENSED_PRODUCT_VERSION', '0.3.1');
define('WC_LICENSED_PRODUCT_VERSION', '0.3.5');
define('WC_LICENSED_PRODUCT_PLUGIN_FILE', __FILE__);
define('WC_LICENSED_PRODUCT_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('WC_LICENSED_PRODUCT_PLUGIN_URL', plugin_dir_url(__FILE__));