Add licensing system for ISP customers

- Add license.php with tiered licensing (Trial, Basic, Professional, Enterprise)
- Add license_info table to database schema
- Add license management UI to settings (License tab)
- Add license status, activation, and usage API endpoints
- Add entry and user limit enforcement based on license tier
- Add feature flags for webhooks, IP enrichment, whitelabel, PTR records
- Update README with licensing documentation and customer deployment guide

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Purple
2026-01-18 13:21:44 +00:00
parent a8e3be2e66
commit 753661a34c
5 changed files with 1074 additions and 2 deletions

View File

@@ -405,6 +405,81 @@ Ensure your IP prefixes are in valid CIDR notation (e.g., `192.168.1.0/24`)
- Click "Check for Updates" - Click "Check for Updates"
- Apply any missing schema changes - Apply any missing schema changes
## Licensing
ISP IP Manager uses a tiered licensing system. Each deployment requires a valid license key.
### License Tiers
| Feature | Trial | Basic | Professional | Enterprise |
|---------|-------|-------|--------------|------------|
| Duration | 14 days | 1 year | 1 year | Perpetual |
| Max Entries | 100 | 500 | 2,500 | Unlimited |
| Max Users | 2 | 5 | 15 | Unlimited |
| Basic CRUD | ✓ | ✓ | ✓ | ✓ |
| CSV Export | ✓ | ✓ | ✓ | ✓ |
| Webhooks | ✗ | ✓ | ✓ | ✓ |
| Audit Log | ✗ | ✓ | ✓ | ✓ |
| IP Enrichment | ✗ | ✗ | ✓ | ✓ |
| Whitelabel | ✗ | ✗ | ✓ | ✓ |
| PTR Records | ✗ | ✗ | ✓ | ✓ |
| API Access | ✗ | ✗ | ✗ | ✓ |
| Priority Support | ✗ | ✗ | ✗ | ✓ |
### License Key Format
License keys follow the format: `IPMAN-XXXX-XXXX-XXXX-XXXX`
The first character after `IPMAN-` determines the license tier:
- `B` - Basic license
- `P` - Professional license
- `E` - Enterprise license
- Any other character - Trial license
### Activating a License
1. Navigate to **Settings > License** tab
2. Enter your license key in the format `IPMAN-XXXX-XXXX-XXXX-XXXX`
3. Enter your company/licensee name
4. Enter your billing email address
5. Click **Activate License**
The license status will update immediately showing your tier and usage limits.
### Customer Deployment
Each ISP customer deploys their own dedicated instance:
1. **Provision Infrastructure**
- Deploy using Docker Compose or Dokploy
- Configure MariaDB database
- Set up Cloudflare Access for authentication (recommended)
2. **Configure Environment**
```env
DB_NAME=geofeed_manager
DB_USER=geofeed
DB_PASSWORD=secure_password
ADMIN_EMAILS=admin@customer-isp.com
```
3. **Activate License**
- Provide the customer with their license key
- Customer activates via Settings > License tab
- License determines available features and limits
4. **Optional Integrations**
- **Webhooks** (Basic+): Configure n8n for CDN automation
- **IP Enrichment** (Professional+): Add ipregistry.co API key
- **PTR Records** (Professional+): Configure AWS Route53 credentials
- **Whitelabel** (Professional+): Customize branding with customer logo
### Generating License Keys
Contact Purple Computing for license key generation:
- Email: info@purplecomputing.com
- Include: Customer name, email, desired tier
--- ---
**Copyright (c) 2025 Purple Computing Ltd. All rights reserved.** **Copyright (c) 2025 Purple Computing Ltd. All rights reserved.**

View File

@@ -214,3 +214,20 @@ ALTER TABLE geofeed_entries ADD COLUMN IF NOT EXISTS flag_threat TINYINT(1) DEFA
ALTER TABLE geofeed_entries ADD INDEX IF NOT EXISTS idx_sort_order (sort_order); ALTER TABLE geofeed_entries ADD INDEX IF NOT EXISTS idx_sort_order (sort_order);
ALTER TABLE geofeed_entries ADD INDEX IF NOT EXISTS idx_isp (ipr_isp); ALTER TABLE geofeed_entries ADD INDEX IF NOT EXISTS idx_isp (ipr_isp);
ALTER TABLE geofeed_entries ADD INDEX IF NOT EXISTS idx_asn (ipr_asn); ALTER TABLE geofeed_entries ADD INDEX IF NOT EXISTS idx_asn (ipr_asn);
-- License information table
CREATE TABLE IF NOT EXISTS license_info (
id INT AUTO_INCREMENT PRIMARY KEY,
license_key VARCHAR(64) NOT NULL,
licensee_name VARCHAR(255) NOT NULL,
licensee_email VARCHAR(255) NOT NULL,
license_type ENUM('trial', 'basic', 'professional', 'enterprise') DEFAULT 'trial',
max_entries INT DEFAULT 100,
max_users INT DEFAULT 3,
features JSON DEFAULT NULL,
issued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NULL,
last_validated_at TIMESTAMP NULL,
is_active TINYINT(1) DEFAULT 1,
UNIQUE KEY unique_license (license_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -5,6 +5,7 @@
*/ */
require_once __DIR__ . '/config.php'; require_once __DIR__ . '/config.php';
require_once __DIR__ . '/includes/license.php';
header('Content-Type: application/json'); header('Content-Type: application/json');
header('X-Content-Type-Options: nosniff'); header('X-Content-Type-Options: nosniff');
@@ -251,6 +252,23 @@ try {
handleAdminUserToggle($db); handleAdminUserToggle($db);
break; break;
// License management (admin only)
case 'license_status':
handleLicenseStatus($db);
break;
case 'license_activate':
handleLicenseActivate($db);
break;
case 'license_deactivate':
handleLicenseDeactivate($db);
break;
case 'license_usage':
handleLicenseUsage($db);
break;
default: default:
jsonResponse(['error' => 'Invalid action'], 400); jsonResponse(['error' => 'Invalid action'], 400);
} }
@@ -369,7 +387,20 @@ function handleCreate($db) {
if (!validateCSRFToken($input['csrf_token'] ?? '')) { if (!validateCSRFToken($input['csrf_token'] ?? '')) {
jsonResponse(['error' => 'Invalid CSRF token'], 403); jsonResponse(['error' => 'Invalid CSRF token'], 403);
} }
// Check license entry limit
$entryCheck = canAddEntry($db);
if (!$entryCheck['allowed']) {
$max = $entryCheck['max'];
$current = $entryCheck['current'];
jsonResponse([
'error' => "Entry limit reached. Your license allows $max entries (currently $current). Please upgrade your license to add more entries.",
'license_limit' => true,
'current' => $current,
'max' => $max
], 403);
}
// Validate required fields // Validate required fields
if (empty($input['ip_prefix'])) { if (empty($input['ip_prefix'])) {
jsonResponse(['error' => 'IP prefix is required'], 400); jsonResponse(['error' => 'IP prefix is required'], 400);
@@ -1191,6 +1222,11 @@ function handleWebhookSettingsSave($db) {
jsonResponse(['error' => 'Method not allowed'], 405); jsonResponse(['error' => 'Method not allowed'], 405);
} }
// Check license feature
if (!hasFeature('webhooks', $db)) {
jsonResponse(['error' => 'Webhook feature requires a Basic or higher license. Please upgrade to enable webhooks.', 'license_required' => 'webhooks'], 403);
}
$input = json_decode(file_get_contents('php://input'), true); $input = json_decode(file_get_contents('php://input'), true);
// Validate CSRF // Validate CSRF
@@ -1488,6 +1524,11 @@ function handleIpRegistrySettingsSave($db) {
jsonResponse(['error' => 'Method not allowed'], 405); jsonResponse(['error' => 'Method not allowed'], 405);
} }
// Check license feature
if (!hasFeature('ip_enrichment', $db)) {
jsonResponse(['error' => 'IP enrichment feature requires a Professional or higher license. Please upgrade to enable IP Registry integration.', 'license_required' => 'ip_enrichment'], 403);
}
$input = json_decode(file_get_contents('php://input'), true); $input = json_decode(file_get_contents('php://input'), true);
// Validate CSRF // Validate CSRF
@@ -2400,6 +2441,11 @@ function handleAwsSettingsSave($db) {
jsonResponse(['error' => 'Method not allowed'], 405); jsonResponse(['error' => 'Method not allowed'], 405);
} }
// Check license feature
if (!hasFeature('ptr_records', $db)) {
jsonResponse(['error' => 'PTR record management feature requires a Professional or higher license. Please upgrade to enable AWS Route53 integration.', 'license_required' => 'ptr_records'], 403);
}
$input = json_decode(file_get_contents('php://input'), true); $input = json_decode(file_get_contents('php://input'), true);
if (!validateCSRFToken($input['csrf_token'] ?? '')) { if (!validateCSRFToken($input['csrf_token'] ?? '')) {
@@ -3208,6 +3254,11 @@ function handleWhitelabelSave($db) {
jsonResponse(['error' => 'Method not allowed'], 405); jsonResponse(['error' => 'Method not allowed'], 405);
} }
// Check license feature
if (!hasFeature('whitelabel', $db)) {
jsonResponse(['error' => 'Whitelabel feature requires a Professional or higher license. Please upgrade to customize branding.', 'license_required' => 'whitelabel'], 403);
}
$input = json_decode(file_get_contents('php://input'), true); $input = json_decode(file_get_contents('php://input'), true);
// Verify CSRF token // Verify CSRF token
@@ -3406,6 +3457,27 @@ function handleAdminUserSave($db) {
$displayName = $input['display_name'] ?? null; $displayName = $input['display_name'] ?? null;
$createdBy = getAuditUser(); $createdBy = getAuditUser();
// Check if this is a new user (not an update)
$existingUser = $db->prepare("SELECT id FROM admin_users WHERE email = :email");
$existingUser->execute([':email' => $email]);
$isNewUser = !$existingUser->fetch();
// Check license user limit for new users
if ($isNewUser) {
$userCheck = canAddUser($db);
if (!$userCheck['allowed']) {
$max = $userCheck['max'];
$current = $userCheck['current'];
jsonResponse([
'error' => "User limit reached. Your license allows $max users (currently $current). Please upgrade your license to add more users.",
'license_limit' => true,
'current' => $current,
'max' => $max
], 403);
return;
}
}
$success = saveAdminUser($email, $role, $displayName, $createdBy); $success = saveAdminUser($email, $role, $displayName, $createdBy);
if ($success) { if ($success) {
@@ -3500,3 +3572,102 @@ function handleAdminUserToggle($db) {
jsonResponse(['error' => 'Failed to toggle user status'], 500); jsonResponse(['error' => 'Failed to toggle user status'], 500);
} }
} }
/**
* Get license status
*/
function handleLicenseStatus($db) {
$status = getLicenseStatus($db);
jsonResponse([
'success' => true,
'license' => $status
]);
}
/**
* Activate a license key (admin only)
*/
function handleLicenseActivate($db) {
require_once __DIR__ . '/includes/auth.php';
if (!isAdmin()) {
jsonResponse(['error' => 'Admin access required'], 403);
return;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['error' => 'POST method required'], 405);
return;
}
$input = json_decode(file_get_contents('php://input'), true);
if (empty($input['license_key'])) {
jsonResponse(['error' => 'License key is required'], 400);
return;
}
if (empty($input['licensee_name'])) {
jsonResponse(['error' => 'Licensee name is required'], 400);
return;
}
if (empty($input['licensee_email'])) {
jsonResponse(['error' => 'Licensee email is required'], 400);
return;
}
$result = activateLicense(
$db,
$input['license_key'],
$input['licensee_name'],
$input['licensee_email']
);
if ($result['success']) {
jsonResponse([
'success' => true,
'message' => $result['message'],
'type' => $result['type'],
'expires_at' => $result['expires_at']
]);
} else {
jsonResponse(['error' => $result['error']], 400);
}
}
/**
* Deactivate the current license (admin only)
*/
function handleLicenseDeactivate($db) {
require_once __DIR__ . '/includes/auth.php';
if (!isAdmin()) {
jsonResponse(['error' => 'Admin access required'], 403);
return;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['error' => 'POST method required'], 405);
return;
}
$result = deactivateLicense($db);
if ($result['success']) {
jsonResponse(['success' => true, 'message' => $result['message']]);
} else {
jsonResponse(['error' => $result['error']], 500);
}
}
/**
* Get license usage statistics
*/
function handleLicenseUsage($db) {
$usage = getLicenseUsage($db);
jsonResponse([
'success' => true,
'usage' => $usage
]);
}

405
webapp/includes/license.php Normal file
View File

@@ -0,0 +1,405 @@
<?php
/**
* License Management System
*
* Handles license validation, feature flags, and usage limits for ISP IP Manager
*
* Copyright (c) 2025 Purple Computing Ltd. All rights reserved.
*/
// License tier definitions
define('LICENSE_TRIAL', 'trial');
define('LICENSE_BASIC', 'basic');
define('LICENSE_PROFESSIONAL', 'professional');
define('LICENSE_ENTERPRISE', 'enterprise');
// License limits by tier
$LICENSE_LIMITS = [
LICENSE_TRIAL => [
'max_entries' => 100,
'max_users' => 2,
'features' => ['basic_crud', 'csv_export'],
'name' => 'Trial',
'duration_days' => 14
],
LICENSE_BASIC => [
'max_entries' => 500,
'max_users' => 5,
'features' => ['basic_crud', 'csv_export', 'webhooks', 'audit_log'],
'name' => 'Basic'
],
LICENSE_PROFESSIONAL => [
'max_entries' => 2500,
'max_users' => 15,
'features' => ['basic_crud', 'csv_export', 'webhooks', 'audit_log', 'ip_enrichment', 'whitelabel', 'ptr_records'],
'name' => 'Professional'
],
LICENSE_ENTERPRISE => [
'max_entries' => -1, // Unlimited
'max_users' => -1, // Unlimited
'features' => ['basic_crud', 'csv_export', 'webhooks', 'audit_log', 'ip_enrichment', 'whitelabel', 'ptr_records', 'api_access', 'priority_support'],
'name' => 'Enterprise'
]
];
/**
* Generate a new license key
* Format: IPMAN-XXXX-XXXX-XXXX-XXXX
*/
function generateLicenseKey() {
$segments = [];
for ($i = 0; $i < 4; $i++) {
$segments[] = strtoupper(bin2hex(random_bytes(2)));
}
return 'IPMAN-' . implode('-', $segments);
}
/**
* Create a signed license key with embedded data
*/
function createSignedLicense($licenseeEmail, $licenseType, $expiresAt = null) {
$data = [
'e' => $licenseeEmail,
't' => $licenseType,
'i' => time(),
'x' => $expiresAt ? strtotime($expiresAt) : null
];
$payload = base64_encode(json_encode($data));
$signature = hash_hmac('sha256', $payload, getLicenseSecret());
return 'IPMAN-' . substr($signature, 0, 8) . '-' . chunk_split(strtoupper(bin2hex(random_bytes(6))), 4, '-');
}
/**
* Get license secret (from environment or generate)
*/
function getLicenseSecret() {
$secret = getenv('LICENSE_SECRET');
if (empty($secret)) {
$secret = 'purple-computing-isp-ip-manager-2025';
}
return $secret;
}
/**
* Validate a license key format
*/
function isValidLicenseFormat($key) {
return preg_match('/^IPMAN-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}$/i', $key);
}
/**
* Get current license info from database
*/
function getLicenseInfo($db = null) {
if (!$db && function_exists('getDB')) {
$db = getDB();
}
if (!$db) {
return null;
}
try {
$stmt = $db->query("SELECT * FROM license_info WHERE is_active = 1 ORDER BY id DESC LIMIT 1");
return $stmt->fetch(PDO::FETCH_ASSOC);
} catch (Exception $e) {
return null;
}
}
/**
* Check if license is valid and not expired
*/
function isLicenseValid($db = null) {
$license = getLicenseInfo($db);
if (!$license) {
return false;
}
// Check if expired
if ($license['expires_at'] && strtotime($license['expires_at']) < time()) {
return false;
}
return $license['is_active'] == 1;
}
/**
* Get license status with details
*/
function getLicenseStatus($db = null) {
global $LICENSE_LIMITS;
$license = getLicenseInfo($db);
if (!$license) {
return [
'valid' => false,
'status' => 'unlicensed',
'message' => 'No license key configured',
'type' => null,
'limits' => $LICENSE_LIMITS[LICENSE_TRIAL]
];
}
$isExpired = $license['expires_at'] && strtotime($license['expires_at']) < time();
if ($isExpired) {
return [
'valid' => false,
'status' => 'expired',
'message' => 'License expired on ' . date('Y-m-d', strtotime($license['expires_at'])),
'type' => $license['license_type'],
'limits' => $LICENSE_LIMITS[LICENSE_TRIAL],
'license' => $license
];
}
if (!$license['is_active']) {
return [
'valid' => false,
'status' => 'inactive',
'message' => 'License has been deactivated',
'type' => $license['license_type'],
'limits' => $LICENSE_LIMITS[LICENSE_TRIAL],
'license' => $license
];
}
$type = $license['license_type'] ?: LICENSE_TRIAL;
$limits = $LICENSE_LIMITS[$type] ?? $LICENSE_LIMITS[LICENSE_TRIAL];
// Override with custom limits if set
if ($license['max_entries']) {
$limits['max_entries'] = $license['max_entries'];
}
if ($license['max_users']) {
$limits['max_users'] = $license['max_users'];
}
if ($license['features']) {
$customFeatures = json_decode($license['features'], true);
if (is_array($customFeatures)) {
$limits['features'] = $customFeatures;
}
}
$daysRemaining = null;
if ($license['expires_at']) {
$daysRemaining = max(0, floor((strtotime($license['expires_at']) - time()) / 86400));
}
return [
'valid' => true,
'status' => 'active',
'message' => $limits['name'] . ' License',
'type' => $type,
'limits' => $limits,
'license' => $license,
'days_remaining' => $daysRemaining,
'expires_at' => $license['expires_at']
];
}
/**
* Check if a specific feature is available
*/
function hasFeature($feature, $db = null) {
$status = getLicenseStatus($db);
// Always allow basic features for unlicensed/trial
$basicFeatures = ['basic_crud', 'csv_export'];
if (in_array($feature, $basicFeatures)) {
return true;
}
if (!$status['valid']) {
return false;
}
return in_array($feature, $status['limits']['features']);
}
/**
* Check if entry limit has been reached
*/
function canAddEntry($db = null) {
if (!$db && function_exists('getDB')) {
$db = getDB();
}
$status = getLicenseStatus($db);
$maxEntries = $status['limits']['max_entries'];
// -1 means unlimited
if ($maxEntries == -1) {
return ['allowed' => true, 'current' => 0, 'max' => -1];
}
try {
$stmt = $db->query("SELECT COUNT(*) FROM geofeed_entries");
$current = (int)$stmt->fetchColumn();
return [
'allowed' => $current < $maxEntries,
'current' => $current,
'max' => $maxEntries
];
} catch (Exception $e) {
return ['allowed' => true, 'current' => 0, 'max' => $maxEntries];
}
}
/**
* Check if user limit has been reached
*/
function canAddUser($db = null) {
if (!$db && function_exists('getDB')) {
$db = getDB();
}
$status = getLicenseStatus($db);
$maxUsers = $status['limits']['max_users'];
// -1 means unlimited
if ($maxUsers == -1) {
return ['allowed' => true, 'current' => 0, 'max' => -1];
}
try {
$stmt = $db->query("SELECT COUNT(*) FROM admin_users WHERE active = 1");
$current = (int)$stmt->fetchColumn();
return [
'allowed' => $current < $maxUsers,
'current' => $current,
'max' => $maxUsers
];
} catch (Exception $e) {
return ['allowed' => true, 'current' => 0, 'max' => $maxUsers];
}
}
/**
* Activate a license key
*/
function activateLicense($db, $licenseKey, $licenseeName, $licenseeEmail) {
if (!isValidLicenseFormat($licenseKey)) {
return ['success' => false, 'error' => 'Invalid license key format'];
}
// For now, accept any valid format key as a trial
// In production, you would validate against a license server
$licenseType = LICENSE_TRIAL;
$expiresAt = date('Y-m-d H:i:s', strtotime('+14 days'));
// Check if key starts with specific prefixes for different tiers
if (preg_match('/^IPMAN-[0-9A-F]{4}/', $licenseKey)) {
$prefix = substr($licenseKey, 6, 1);
switch ($prefix) {
case 'B':
$licenseType = LICENSE_BASIC;
$expiresAt = date('Y-m-d H:i:s', strtotime('+1 year'));
break;
case 'P':
$licenseType = LICENSE_PROFESSIONAL;
$expiresAt = date('Y-m-d H:i:s', strtotime('+1 year'));
break;
case 'E':
$licenseType = LICENSE_ENTERPRISE;
$expiresAt = null; // No expiry
break;
}
}
global $LICENSE_LIMITS;
$limits = $LICENSE_LIMITS[$licenseType];
try {
// Deactivate any existing licenses
$db->exec("UPDATE license_info SET is_active = 0");
// Insert new license
$stmt = $db->prepare("
INSERT INTO license_info (license_key, licensee_name, licensee_email, license_type, max_entries, max_users, features, expires_at, is_active)
VALUES (:key, :name, :email, :type, :max_entries, :max_users, :features, :expires, 1)
");
$stmt->execute([
':key' => $licenseKey,
':name' => $licenseeName,
':email' => $licenseeEmail,
':type' => $licenseType,
':max_entries' => $limits['max_entries'],
':max_users' => $limits['max_users'],
':features' => json_encode($limits['features']),
':expires' => $expiresAt
]);
return [
'success' => true,
'message' => 'License activated successfully',
'type' => $licenseType,
'expires_at' => $expiresAt
];
} catch (Exception $e) {
return ['success' => false, 'error' => 'Failed to activate license: ' . $e->getMessage()];
}
}
/**
* Deactivate current license
*/
function deactivateLicense($db) {
try {
$db->exec("UPDATE license_info SET is_active = 0");
return ['success' => true, 'message' => 'License deactivated'];
} catch (Exception $e) {
return ['success' => false, 'error' => 'Failed to deactivate license'];
}
}
/**
* Get usage statistics for license
*/
function getLicenseUsage($db = null) {
if (!$db && function_exists('getDB')) {
$db = getDB();
}
$status = getLicenseStatus($db);
$entryCount = 0;
$userCount = 0;
try {
$stmt = $db->query("SELECT COUNT(*) FROM geofeed_entries");
$entryCount = (int)$stmt->fetchColumn();
$stmt = $db->query("SELECT COUNT(*) FROM admin_users WHERE active = 1");
$userCount = (int)$stmt->fetchColumn();
} catch (Exception $e) {
// Ignore
}
$maxEntries = $status['limits']['max_entries'];
$maxUsers = $status['limits']['max_users'];
return [
'entries' => [
'current' => $entryCount,
'max' => $maxEntries,
'percentage' => $maxEntries > 0 ? round(($entryCount / $maxEntries) * 100) : 0,
'unlimited' => $maxEntries == -1
],
'users' => [
'current' => $userCount,
'max' => $maxUsers,
'percentage' => $maxUsers > 0 ? round(($userCount / $maxUsers) * 100) : 0,
'unlimited' => $maxUsers == -1
],
'license' => $status
];
}

View File

@@ -45,7 +45,7 @@ $showSettingsLink = false;
// Get current tab from URL // Get current tab from URL
$currentTab = $_GET['tab'] ?? 'integrations'; $currentTab = $_GET['tab'] ?? 'integrations';
$validTabs = ['integrations', 'users', 'audit', 'advanced', 'whitelabel', 'developer']; $validTabs = ['integrations', 'users', 'audit', 'advanced', 'whitelabel', 'license', 'developer'];
if (!in_array($currentTab, $validTabs)) { if (!in_array($currentTab, $validTabs)) {
$currentTab = 'integrations'; $currentTab = 'integrations';
} }
@@ -106,6 +106,13 @@ require_once __DIR__ . '/includes/header.php';
</svg> </svg>
Whitelabel Whitelabel
</a> </a>
<a href="?tab=license" class="settings-nav-item <?php echo $currentTab === 'license' ? 'active' : ''; ?>">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
License
</a>
<a href="?tab=developer" class="settings-nav-item <?php echo $currentTab === 'developer' ? 'active' : ''; ?>"> <a href="?tab=developer" class="settings-nav-item <?php echo $currentTab === 'developer' ? 'active' : ''; ?>">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="16 18 22 12 16 6"/> <polyline points="16 18 22 12 16 6"/>
@@ -527,6 +534,151 @@ require_once __DIR__ . '/includes/header.php';
</div> </div>
</div> </div>
<!-- License Section -->
<div class="settings-section <?php echo $currentTab === 'license' ? 'active' : ''; ?>" id="section-license">
<!-- Current License Status -->
<div class="advanced-section">
<h2 class="advanced-section-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
License Status
</h2>
<p class="advanced-section-desc">View and manage your ISP IP Manager license.</p>
<div id="licenseStatusContainer" style="margin-top: 16px;">
<div class="loading"><div class="spinner"></div></div>
</div>
</div>
<!-- Usage Statistics -->
<div class="advanced-section">
<h2 class="advanced-section-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/>
<path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/>
<path d="M18 12a2 2 0 0 0 0 4h4v-4h-4z"/>
</svg>
Usage Statistics
</h2>
<p class="advanced-section-desc">Current usage against your license limits.</p>
<div id="licenseUsageContainer" style="margin-top: 16px;">
<div class="loading"><div class="spinner"></div></div>
</div>
</div>
<!-- Activate License -->
<div class="advanced-section">
<h2 class="advanced-section-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
</svg>
Activate License
</h2>
<p class="advanced-section-desc">Enter your license key to activate or upgrade your plan.</p>
<div class="form-grid" style="margin-top: 16px;">
<div class="form-group">
<label class="form-label">License Key</label>
<input type="text" class="form-input" id="licenseKey" placeholder="IPMAN-XXXX-XXXX-XXXX-XXXX" style="font-family: monospace;">
</div>
<div class="form-group">
<label class="form-label">Licensee Name</label>
<input type="text" class="form-input" id="licenseeName" placeholder="Company or Individual Name">
</div>
</div>
<div class="form-group">
<label class="form-label">Licensee Email</label>
<input type="email" class="form-input" id="licenseeEmail" placeholder="billing@example.com" style="max-width: 400px;">
</div>
<div style="display: flex; gap: 12px; margin-top: 16px;">
<button class="btn btn-primary" onclick="activateLicense()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
Activate License
</button>
<button class="btn btn-danger" onclick="deactivateLicense()" id="deactivateLicenseBtn" style="display: none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
Deactivate License
</button>
</div>
</div>
<!-- License Tiers -->
<div class="advanced-section">
<h2 class="advanced-section-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
Available Plans
</h2>
<p class="advanced-section-desc">Compare features across different license tiers.</p>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-top: 16px;">
<!-- Trial -->
<div class="license-tier-card" style="padding: 20px; background: var(--bg-tertiary); border-radius: var(--radius-md); border: 1px solid var(--border-default);">
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 8px;">Trial</h3>
<p style="font-size: 12px; color: var(--text-tertiary); margin-bottom: 12px;">14 days free</p>
<ul style="font-size: 13px; color: var(--text-secondary); list-style: none; padding: 0; margin: 0;">
<li style="margin-bottom: 6px;">✓ 100 entries</li>
<li style="margin-bottom: 6px;">✓ 2 users</li>
<li style="margin-bottom: 6px;">✓ Basic CRUD</li>
<li style="margin-bottom: 6px;">✓ CSV export</li>
</ul>
</div>
<!-- Basic -->
<div class="license-tier-card" style="padding: 20px; background: var(--bg-tertiary); border-radius: var(--radius-md); border: 1px solid var(--border-default);">
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 8px;">Basic</h3>
<p style="font-size: 12px; color: var(--text-tertiary); margin-bottom: 12px;">Small ISPs</p>
<ul style="font-size: 13px; color: var(--text-secondary); list-style: none; padding: 0; margin: 0;">
<li style="margin-bottom: 6px;">✓ 500 entries</li>
<li style="margin-bottom: 6px;">✓ 5 users</li>
<li style="margin-bottom: 6px;">✓ Webhooks</li>
<li style="margin-bottom: 6px;">✓ Audit log</li>
</ul>
</div>
<!-- Professional -->
<div class="license-tier-card" style="padding: 20px; background: var(--bg-tertiary); border-radius: var(--radius-md); border: 1px solid var(--purple-primary); box-shadow: 0 0 0 1px var(--purple-primary);">
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 8px; color: var(--purple-primary);">Professional</h3>
<p style="font-size: 12px; color: var(--text-tertiary); margin-bottom: 12px;">Growing ISPs</p>
<ul style="font-size: 13px; color: var(--text-secondary); list-style: none; padding: 0; margin: 0;">
<li style="margin-bottom: 6px;">✓ 2,500 entries</li>
<li style="margin-bottom: 6px;">✓ 15 users</li>
<li style="margin-bottom: 6px;">✓ IP enrichment</li>
<li style="margin-bottom: 6px;">✓ Whitelabel</li>
<li style="margin-bottom: 6px;">✓ PTR records</li>
</ul>
</div>
<!-- Enterprise -->
<div class="license-tier-card" style="padding: 20px; background: var(--bg-tertiary); border-radius: var(--radius-md); border: 1px solid var(--border-default);">
<h3 style="font-size: 16px; font-weight: 600; margin-bottom: 8px;">Enterprise</h3>
<p style="font-size: 12px; color: var(--text-tertiary); margin-bottom: 12px;">Large ISPs</p>
<ul style="font-size: 13px; color: var(--text-secondary); list-style: none; padding: 0; margin: 0;">
<li style="margin-bottom: 6px;">✓ Unlimited entries</li>
<li style="margin-bottom: 6px;">✓ Unlimited users</li>
<li style="margin-bottom: 6px;">✓ API access</li>
<li style="margin-bottom: 6px;">✓ Priority support</li>
</ul>
</div>
</div>
<p style="margin-top: 16px; font-size: 13px; color: var(--text-tertiary);">
Contact <a href="mailto:info@purplecomputing.com" style="color: var(--purple-primary);">info@purplecomputing.com</a> for pricing and license keys.
</p>
</div>
</div>
<!-- Developer Section --> <!-- Developer Section -->
<div class="settings-section <?php echo $currentTab === 'developer' ? 'active' : ''; ?>" id="section-developer"> <div class="settings-section <?php echo $currentTab === 'developer' ? 'active' : ''; ?>" id="section-developer">
<!-- Import Section --> <!-- Import Section -->
@@ -643,6 +795,10 @@ document.addEventListener("DOMContentLoaded", function() {
case "whitelabel": case "whitelabel":
loadWhitelabelSettings(); loadWhitelabelSettings();
break; break;
case "license":
loadLicenseStatus();
loadLicenseUsage();
break;
case "developer": case "developer":
loadSystemInfo(); loadSystemInfo();
loadErrorLogs(); loadErrorLogs();
@@ -820,6 +976,254 @@ function formatDate(dateStr) {
minute: "2-digit" minute: "2-digit"
}); });
} }
// License Management Functions
async function loadLicenseStatus() {
try {
const response = await fetch("api.php?action=license_status", {
headers: { "X-CSRF-Token": CSRF_TOKEN }
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || "Failed to load license status");
}
const container = document.getElementById("licenseStatusContainer");
const license = data.license;
let statusBadge = "";
let statusClass = "";
switch (license.status) {
case "active":
statusBadge = "Active";
statusClass = "badge-success";
break;
case "expired":
statusBadge = "Expired";
statusClass = "badge-error";
break;
case "inactive":
statusBadge = "Inactive";
statusClass = "badge-warning";
break;
case "unlicensed":
statusBadge = "Unlicensed";
statusClass = "badge-gray";
break;
default:
statusBadge = license.status;
statusClass = "badge-gray";
}
let expiryInfo = "";
if (license.expires_at) {
const expiryDate = new Date(license.expires_at);
expiryInfo = `<p style="margin-top: 8px; font-size: 13px; color: var(--text-tertiary);">
Expires: ${expiryDate.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })}
${license.days_remaining !== null ? `(${license.days_remaining} days remaining)` : ""}
</p>`;
}
let licenseDetails = "";
if (license.license && license.license.licensee_name) {
licenseDetails = `
<div style="margin-top: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: var(--radius-md);">
<p style="font-size: 13px; margin-bottom: 4px;"><strong>Licensee:</strong> ${escapeHtml(license.license.licensee_name)}</p>
<p style="font-size: 13px; margin-bottom: 4px;"><strong>Email:</strong> ${escapeHtml(license.license.licensee_email)}</p>
<p style="font-size: 13px; margin-bottom: 0;"><strong>License Key:</strong> <code style="font-family: monospace; background: var(--bg-secondary); padding: 2px 6px; border-radius: 4px;">${maskLicenseKey(license.license.license_key)}</code></p>
</div>
`;
// Show deactivate button
document.getElementById("deactivateLicenseBtn").style.display = "inline-flex";
} else {
document.getElementById("deactivateLicenseBtn").style.display = "none";
}
container.innerHTML = `
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px;">
<span class="badge ${statusClass}" style="font-size: 14px; padding: 6px 12px;">${statusBadge}</span>
<span style="font-size: 16px; font-weight: 600;">${license.limits?.name || "Unknown"} License</span>
</div>
<p style="color: var(--text-secondary);">${license.message}</p>
${expiryInfo}
${licenseDetails}
`;
} catch (error) {
console.error("Error loading license status:", error);
document.getElementById("licenseStatusContainer").innerHTML = `
<div style="color: var(--error); padding: 12px; background: var(--error-bg); border-radius: var(--radius-md);">
Failed to load license status: ${escapeHtml(error.message)}
</div>
`;
}
}
function maskLicenseKey(key) {
if (!key || key.length < 10) return key;
return key.substring(0, 10) + "..." + key.substring(key.length - 4);
}
async function loadLicenseUsage() {
try {
const response = await fetch("api.php?action=license_usage", {
headers: { "X-CSRF-Token": CSRF_TOKEN }
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || "Failed to load license usage");
}
const container = document.getElementById("licenseUsageContainer");
const usage = data.usage;
const entriesBar = renderUsageBar(usage.entries);
const usersBar = renderUsageBar(usage.users);
container.innerHTML = `
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px;">
<div>
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
<span style="font-weight: 500;">Geofeed Entries</span>
<span style="color: var(--text-tertiary);">
${usage.entries.current} / ${usage.entries.unlimited ? "Unlimited" : usage.entries.max}
</span>
</div>
${entriesBar}
</div>
<div>
<div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
<span style="font-weight: 500;">Users</span>
<span style="color: var(--text-tertiary);">
${usage.users.current} / ${usage.users.unlimited ? "Unlimited" : usage.users.max}
</span>
</div>
${usersBar}
</div>
</div>
`;
} catch (error) {
console.error("Error loading license usage:", error);
document.getElementById("licenseUsageContainer").innerHTML = `
<div style="color: var(--error); padding: 12px; background: var(--error-bg); border-radius: var(--radius-md);">
Failed to load usage data: ${escapeHtml(error.message)}
</div>
`;
}
}
function renderUsageBar(usage) {
if (usage.unlimited) {
return `<div style="height: 8px; background: var(--purple-primary); border-radius: 4px;"></div>`;
}
const percentage = Math.min(100, usage.percentage);
let barColor = "var(--purple-primary)";
if (percentage >= 90) {
barColor = "var(--error)";
} else if (percentage >= 75) {
barColor = "var(--warning)";
}
return `
<div style="height: 8px; background: var(--bg-tertiary); border-radius: 4px; overflow: hidden;">
<div style="height: 100%; width: ${percentage}%; background: ${barColor}; border-radius: 4px; transition: width 0.3s;"></div>
</div>
`;
}
async function activateLicense() {
const licenseKey = document.getElementById("licenseKey").value.trim();
const licenseeName = document.getElementById("licenseeName").value.trim();
const licenseeEmail = document.getElementById("licenseeEmail").value.trim();
if (!licenseKey) {
showNotification("Please enter a license key", "error");
return;
}
if (!licenseeName) {
showNotification("Please enter the licensee name", "error");
return;
}
if (!licenseeEmail) {
showNotification("Please enter the licensee email", "error");
return;
}
try {
const response = await fetch("api.php?action=license_activate", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": CSRF_TOKEN
},
body: JSON.stringify({
license_key: licenseKey,
licensee_name: licenseeName,
licensee_email: licenseeEmail
})
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || "Failed to activate license");
}
showNotification(data.message || "License activated successfully", "success");
// Clear the form
document.getElementById("licenseKey").value = "";
document.getElementById("licenseeName").value = "";
document.getElementById("licenseeEmail").value = "";
// Reload license status
loadLicenseStatus();
loadLicenseUsage();
} catch (error) {
console.error("Error activating license:", error);
showNotification("Failed to activate license: " + error.message, "error");
}
}
async function deactivateLicense() {
if (!confirm("Are you sure you want to deactivate your license? This will revert your account to trial mode.")) {
return;
}
try {
const response = await fetch("api.php?action=license_deactivate", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": CSRF_TOKEN
}
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || "Failed to deactivate license");
}
showNotification(data.message || "License deactivated", "success");
// Reload license status
loadLicenseStatus();
loadLicenseUsage();
} catch (error) {
console.error("Error deactivating license:", error);
showNotification("Failed to deactivate license: " + error.message, "error");
}
}
</script>'; </script>';
// Include footer // Include footer