Files
ip-manager/webapp/settings.php
Purple 3ae39830e2 Fix WebDAV backup error handling and license_info table queries
- Wrap license_info queries in try-catch for backup export functions
- Add detailed error messages to WebDAV backup responses (URL, HTTP code)
- Include WebDAV response body in error messages for debugging
- Update JS to display detailed error info in the UI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 13:48:56 +00:00

1647 lines
81 KiB
PHP

<?php
/**
* Settings Page - Admin Only
*
* Contains: Integrations, Audit Log, Advanced, Whitelabel, Developer tabs
*/
// Include config
$configFile = __DIR__ . '/config.php';
if (file_exists($configFile)) {
require_once $configFile;
}
// Fallback CSRF
if (!function_exists('generateCSRFToken')) {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
function generateCSRFToken() {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
}
// Require authentication
if (function_exists('requireAuth')) {
requireAuth();
}
// Include auth helper
require_once __DIR__ . '/includes/auth.php';
// Require admin role
if (!isAdmin()) {
header('Location: index.php?error=unauthorized');
exit;
}
// Set page variables for header
$pageTitle = 'Settings - ISP IP Manager';
$currentPage = 'settings';
$showSettingsLink = false;
// Get current tab from URL
$currentTab = $_GET['tab'] ?? 'integrations';
$validTabs = ['integrations', 'users', 'audit', 'advanced', 'whitelabel', 'license', 'developer'];
if (!in_array($currentTab, $validTabs)) {
$currentTab = 'integrations';
}
// Include header
require_once __DIR__ . '/includes/header.php';
?>
<h1 class="settings-page-title">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
Settings
</h1>
<!-- Settings Navigation -->
<div class="settings-nav">
<a href="?tab=integrations" class="settings-nav-item <?php echo $currentTab === 'integrations' ? 'active' : ''; ?>">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
Integrations
</a>
<a href="?tab=users" class="settings-nav-item <?php echo $currentTab === 'users' ? 'active' : ''; ?>">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
Users
</a>
<a href="?tab=audit" class="settings-nav-item <?php echo $currentTab === 'audit' ? 'active' : ''; ?>">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
Audit Log
</a>
<a href="?tab=advanced" class="settings-nav-item <?php echo $currentTab === 'advanced' ? 'active' : ''; ?>">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
Client Settings
</a>
<a href="?tab=whitelabel" class="settings-nav-item <?php echo $currentTab === 'whitelabel' ? 'active' : ''; ?>">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
Whitelabel
</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' : ''; ?>">
<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="8 6 2 12 8 18"/>
</svg>
Developer
</a>
</div>
<!-- Integrations Section -->
<div class="settings-section <?php echo $currentTab === 'integrations' ? 'active' : ''; ?>" id="section-integrations">
<!-- AWS Route53 Settings -->
<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 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
AWS Route53 Settings
</h2>
<p class="advanced-section-desc">Configure AWS credentials and hosted zones for PTR record management.</p>
<div class="form-grid" style="margin-top: 16px;">
<div class="form-group">
<label class="form-label">AWS Access Key ID</label>
<input type="text" class="form-input" id="awsAccessKeyId" placeholder="AKIAIOSFODNN7EXAMPLE">
</div>
<div class="form-group">
<label class="form-label">AWS Secret Access Key</label>
<input type="password" class="form-input" id="awsSecretAccessKey" placeholder="••••••••••••••••">
</div>
</div>
<div class="form-group">
<label class="form-label">AWS Region</label>
<select class="form-select" id="awsRegion">
<option value="us-east-1">US East (N. Virginia)</option>
<option value="us-east-2">US East (Ohio)</option>
<option value="us-west-1">US West (N. California)</option>
<option value="us-west-2">US West (Oregon)</option>
<option value="eu-west-1">EU (Ireland)</option>
<option value="eu-west-2">EU (London)</option>
<option value="eu-central-1">EU (Frankfurt)</option>
<option value="ap-southeast-1">Asia Pacific (Singapore)</option>
<option value="ap-southeast-2">Asia Pacific (Sydney)</option>
<option value="ap-northeast-1">Asia Pacific (Tokyo)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Route53 Hosted Zone IDs (comma separated)</label>
<input type="text" class="form-input" id="awsHostedZones" placeholder="Z1234567890ABC, Z0987654321DEF">
<small style="color: var(--text-tertiary); font-size: 12px; margin-top: 4px; display: block;">Enter the hosted zone IDs for your forward DNS zones (A records)</small>
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-top: 16px;">
<button class="btn btn-primary" onclick="saveAwsSettings()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
Save AWS Settings
</button>
<button class="btn btn-secondary" onclick="testAwsConnection()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
Test Connection
</button>
</div>
<div id="awsTestResult" style="margin-top: 16px; display: none;"></div>
</div>
<!-- IP Registry Settings -->
<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">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
IP Registry Integration
</h2>
<p class="advanced-section-desc">Enrich IP entries with ISP, organization, and security flag data from <a href="https://ipregistry.co" target="_blank" rel="noopener" style="color: var(--purple-primary);">ipregistry.co</a>.</p>
<div class="form-group">
<label class="form-label">
<input type="checkbox" id="ipRegistryEnabled" style="margin-right: 8px; vertical-align: middle;">
Enable IP Registry Auto-Enrichment
</label>
</div>
<div class="form-group">
<label class="form-label">API Key</label>
<input type="password" class="form-input" id="ipRegistryApiKey" placeholder="Enter your ipregistry.co API key">
<div class="form-hint">Get your API key from <a href="https://ipregistry.co" target="_blank" rel="noopener" style="color: var(--purple-primary);">ipregistry.co</a>.</div>
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<button class="btn btn-primary" onclick="saveIpRegistrySettings()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
Save Settings
</button>
<button class="btn btn-secondary" onclick="enrichAllIps(this)">
<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="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
Enrich All Un-enriched IPs
</button>
</div>
</div>
<!-- n8n Webhook Settings -->
<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="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
n8n Webhook Integration
</h2>
<p class="advanced-section-desc">Configure webhooks to notify n8n when geofeed data changes.</p>
<div class="form-group">
<label class="form-label">
<input type="checkbox" id="webhookEnabled" style="margin-right: 8px; vertical-align: middle;">
Enable Webhook Notifications
</label>
</div>
<div class="form-group">
<label class="form-label">Webhook URL</label>
<input type="url" class="form-input" id="webhookUrl" placeholder="https://your-n8n-instance.com/webhook/xxx">
</div>
<div class="form-group">
<label class="form-label">Debounce Delay (minutes)</label>
<input type="number" class="form-input" id="webhookDelay" min="1" max="60" value="3" style="max-width: 120px;">
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 20px;">
<button class="btn btn-primary" onclick="saveWebhookSettings()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
Save Settings
</button>
<button class="btn btn-secondary" onclick="testWebhook()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
Test Connection
</button>
<button class="btn btn-secondary" onclick="triggerWebhookNow()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
</svg>
Trigger Now
</button>
</div>
<div class="table-container" style="margin-top: 16px;">
<div class="table-header">
<h3 class="table-title">Webhook Queue</h3>
<div style="display: flex; gap: 8px;">
<button class="btn btn-ghost btn-sm" onclick="loadWebhookQueueStatus()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
Refresh
</button>
<select id="clearQueueType" class="form-select" style="width: auto; padding: 6px 8px; font-size: 12px;">
<option value="pending">Pending</option>
<option value="failed">Failed</option>
<option value="all">All History</option>
</select>
<button class="btn btn-danger btn-sm" onclick="clearWebhookQueue()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
Clear
</button>
</div>
</div>
<div id="webhookQueueContainer" style="padding: 20px;">
<div class="loading"><div class="spinner"></div></div>
</div>
</div>
</div>
</div>
<!-- Users Section -->
<div class="settings-section <?php echo $currentTab === 'users' ? 'active' : ''; ?>" id="section-users">
<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="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
User Management
</h2>
<p class="advanced-section-desc">Manage admin and staff users who can access this application. Users are authenticated via Cloudflare Access.</p>
<!-- Add User Form -->
<div class="form-grid" style="margin-top: 16px; margin-bottom: 24px;">
<div class="form-group">
<label class="form-label">Email Address</label>
<input type="email" class="form-input" id="newUserEmail" placeholder="user@example.com">
</div>
<div class="form-group">
<label class="form-label">Display Name (optional)</label>
<input type="text" class="form-input" id="newUserDisplayName" placeholder="John Doe">
</div>
</div>
<div class="form-group" style="margin-bottom: 16px;">
<label class="form-label">Role</label>
<select class="form-select" id="newUserRole" style="max-width: 300px;">
<option value="staff">Staff - Can view and edit Geofeed/PTR entries</option>
<option value="admin">Admin - Full access including settings</option>
</select>
</div>
<button class="btn btn-primary" onclick="addUser()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Add User
</button>
<!-- Users Table -->
<div class="table-container" style="margin-top: 24px;">
<table class="data-table" id="usersTable">
<thead>
<tr>
<th>Email</th>
<th>Display Name</th>
<th>Role</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="usersTableBody">
<tr>
<td colspan="6" style="text-align: center; padding: 40px;">
<div class="loading"><div class="spinner"></div></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Audit Log Section -->
<div class="settings-section <?php echo $currentTab === 'audit' ? 'active' : ''; ?>" id="section-audit">
<div class="advanced-section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<p class="advanced-section-desc" style="margin: 0;">View all changes made to geofeed entries including creates, updates, and deletes.</p>
<button class="btn btn-secondary btn-sm" onclick="exportAuditLog()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Export CSV
</button>
</div>
<div class="table-container">
<div class="audit-log-container" id="auditLogContainer">
<div class="loading"><div class="spinner"></div></div>
</div>
<div class="pagination" id="auditPagination" style="display: none;"></div>
</div>
</div>
</div>
<!-- Advanced Section -->
<div class="settings-section <?php echo $currentTab === 'advanced' ? 'active' : ''; ?>" id="section-advanced">
<!-- Client Shortnames -->
<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="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
Client Shortnames
</h2>
<p class="advanced-section-desc">Client shortnames currently in use across geofeed entries.</p>
<div id="shortnamesContainer" style="margin-top: 16px;">
<div class="loading"><div class="spinner"></div></div>
</div>
</div>
<!-- Company Logos -->
<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="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
Company Logos
</h2>
<p class="advanced-section-desc">Manage logo images for client shortnames. Logos will appear in the entries table as 1:1 square icons.</p>
<div class="form-row" style="margin-bottom: 16px; margin-top: 16px;">
<div class="form-group" style="margin-bottom: 0;">
<label class="form-label">Client Short Name</label>
<select class="form-select" id="logoShortName">
<option value="">Select or type a shortname...</option>
</select>
</div>
<div class="form-group" style="margin-bottom: 0;">
<label class="form-label">Logo URL (PNG)</label>
<input type="url" class="form-input" id="logoUrl" placeholder="https://example.com/logo.png">
</div>
</div>
<button class="btn btn-primary" onclick="saveLogo()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
Save Logo
</button>
<div class="logo-grid" id="logoGrid" style="margin-top: 16px;">
<div class="loading"><div class="spinner"></div></div>
</div>
</div>
</div>
<!-- Whitelabel Section -->
<div class="settings-section <?php echo $currentTab === 'whitelabel' ? 'active' : ''; ?>" id="section-whitelabel">
<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="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
Branding
</h2>
<p class="advanced-section-desc">Customize the appearance of the application with your company branding.</p>
<div class="form-grid" style="margin-top: 16px;">
<div class="form-group">
<label class="form-label">App Name</label>
<input type="text" class="form-input" id="whitelabelAppName" placeholder="ISP IP Manager">
<div class="form-hint">Displayed in the header and browser tab title</div>
</div>
<div class="form-group">
<label class="form-label">Company Name</label>
<input type="text" class="form-input" id="whitelabelCompanyName" placeholder="Your Company">
<div class="form-hint">Displayed as subtitle in the header</div>
</div>
<div class="form-group">
<label class="form-label">Webapp Icon URL</label>
<input type="text" class="form-input" id="whitelabelIconUrl" placeholder="https://example.com/logo.svg">
<div class="form-hint">URL to an SVG or PNG logo for the header</div>
</div>
<div class="form-group">
<label class="form-label">Favicon URL</label>
<input type="text" class="form-input" id="whitelabelFaviconUrl" placeholder="https://example.com/favicon.ico">
<div class="form-hint">URL to a favicon (.ico, .png, or .svg)</div>
</div>
<div class="form-group">
<label class="form-label">Default Geofeed Import URL</label>
<input type="text" class="form-input" id="whitelabelDefaultImportUrl" placeholder="https://example.com/geofeed.csv">
<div class="form-hint">Pre-populated URL when importing from URL</div>
</div>
</div>
<div style="display: flex; gap: 12px; margin-top: 16px;">
<button class="btn btn-primary" onclick="saveWhitelabelSettings()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
Save Settings
</button>
</div>
<!-- Preview -->
<div style="margin-top: 24px; padding: 16px; background: var(--bg-tertiary); border-radius: var(--radius-md);">
<h3 style="font-size: 14px; font-weight: 600; color: var(--text-secondary); margin-bottom: 12px;">Preview</h3>
<div style="display: flex; align-items: center; gap: 12px; padding: 12px; background: var(--bg-secondary); border-radius: var(--radius-md);">
<div id="whitelabelPreviewIcon" style="width: 40px; height: 40px; background: var(--purple-primary); border-radius: 8px; display: flex; align-items: center; justify-content: center; overflow: hidden;">
<svg viewBox="0 0 258 258" fill="none" width="24" height="24">
<path fill="white" d="M241.13 56.2A26.53 26.53 0 11188.07 56.2a26.53 26.53 0 0153.06 0zm-5.34-.05a21.19 21.19 0 10-42.38 0 21.19 21.19 0 0042.38 0z" transform="translate(-30,-5) scale(0.75)"/>
<path fill="white" d="M21.42 37.38h55.28a.32.32 0 01.32.32v12.21a.46.46 0 00.8.3c13.2-14.73 32.09-17.47 50.68-12.7 35.19 9.03 47.69 43.89 45.07 77C170.91 148.16 150.93 173.81 115.1 175.14q-22.52.84-37.38-15.22a.65.65 0 00-1.13.47c.06 1.2.49 2.44.49 4.15q-.04 23.9.01 56.37a.42.41 0 01-.42.41H21.66a.88.88 0 01-.88-.88V38.01a.64.63 0 01.64-.63zM77.02 104.64c0 12.43 5.67 26.28 20.24 26.28s20.25-13.85 20.25-26.28-5.67-26.28-20.25-26.28-20.24 13.85-20.24 26.28z" transform="translate(30,30) scale(0.75)"/>
</svg>
</div>
<div>
<span id="whitelabelPreviewAppName" style="font-weight: 600; color: var(--text-primary);">ISP IP Manager</span>
<div id="whitelabelPreviewCompanyName" style="font-size: 11px; color: var(--text-tertiary);"></div>
</div>
</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 -->
<div class="settings-section <?php echo $currentTab === 'developer' ? 'active' : ''; ?>" id="section-developer">
<!-- Import Section -->
<div class="advanced-section">
<h2 class="advanced-section-title">Import Geofeed Data</h2>
<p class="advanced-section-desc">Import geofeed entries from a CSV file or a remote URL.</p>
<div class="import-options" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; margin-top: 16px;">
<!-- File Upload -->
<div class="import-card" style="padding: 20px; background: var(--bg-tertiary); border-radius: var(--radius-md); border: 2px dashed var(--border-strong);">
<h3 style="font-size: 14px; font-weight: 600; margin-bottom: 12px;">Upload CSV File</h3>
<input type="file" id="csvFileInput" accept=".csv,.txt" style="display: none;" onchange="handleFileUpload(this)">
<button class="btn btn-secondary" onclick="document.getElementById('csvFileInput').click()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Choose File
</button>
<div id="fileUploadStatus" style="margin-top: 8px; font-size: 12px; color: var(--text-secondary);"></div>
</div>
<!-- URL Import -->
<div class="import-card" style="padding: 20px; background: var(--bg-tertiary); border-radius: var(--radius-md); border: 2px dashed var(--border-strong);">
<h3 style="font-size: 14px; font-weight: 600; margin-bottom: 12px;">Import from URL</h3>
<div style="display: flex; gap: 8px;">
<input type="url" class="form-input" id="importUrl" placeholder="https://example.com/geofeed.csv" style="flex: 1;">
<button class="btn btn-primary" onclick="importFromUrl()">Import</button>
</div>
<div id="urlImportStatus" style="margin-top: 8px; font-size: 12px; color: var(--text-secondary);"></div>
</div>
</div>
</div>
<!-- Schema Updates -->
<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">
<ellipse cx="12" cy="5" rx="9" ry="3"/>
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
</svg>
Database Schema Updates
</h2>
<p class="advanced-section-desc">Check for and apply missing database columns or tables from the repository schema.</p>
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-top: 16px;">
<button class="btn btn-primary" onclick="checkSchemaUpdates()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
Check for Updates
</button>
<button class="btn btn-secondary" id="applySchemaBtn" onclick="applySchemaUpdates()" style="display: none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Apply Schema Changes
</button>
</div>
<div id="schemaCheckResult" style="margin-top: 16px; display: none;"></div>
</div>
<!-- Database Backup/Restore -->
<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 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Database Backup & Restore
</h2>
<p class="advanced-section-desc">Export or import a full database backup as JSON (entries, settings, logos, audit log).</p>
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-top: 16px;">
<button class="btn btn-primary" onclick="downloadBackup()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Download Backup
</button>
<input type="file" id="restoreFileInput" accept=".json" style="display: none;" onchange="handleRestoreFile(this)">
<button class="btn btn-secondary" onclick="document.getElementById('restoreFileInput').click()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Restore from File
</button>
</div>
<div id="backupRestoreStatus" style="margin-top: 12px; display: none;"></div>
</div>
<!-- WebDAV Backup -->
<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="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
WebDAV Backup
</h2>
<p class="advanced-section-desc">Backup database to a WebDAV server (e.g., Nextcloud public share).</p>
<div class="form-grid" style="margin-top: 16px;">
<div class="form-group">
<label class="form-label">WebDAV Server URL</label>
<input type="url" class="form-input" id="webdavServerUrl" placeholder="https://cloud.example.com">
</div>
<div class="form-group">
<label class="form-label">Username / Share Token</label>
<input type="text" class="form-input" id="webdavUsername" placeholder="SoXpcAG5WPdBTKi">
</div>
</div>
<div class="form-group">
<label class="form-label">Password (leave empty if none)</label>
<input type="password" class="form-input" id="webdavPassword" placeholder="Optional password" style="max-width: 300px;">
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-top: 16px;">
<button class="btn btn-primary" onclick="backupToWebDAV()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Backup to WebDAV
</button>
<button class="btn btn-secondary" onclick="saveWebDAVSettings()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
Save Settings
</button>
</div>
<div id="webdavBackupStatus" style="margin-top: 12px; display: none;"></div>
</div>
<!-- System Info -->
<div class="advanced-section">
<h2 class="advanced-section-title">System Information</h2>
<div id="systemInfoContent" style="margin-top: 16px;">
<div class="loading"><div class="spinner"></div></div>
</div>
</div>
<!-- Error Logs -->
<div class="advanced-section">
<h2 class="advanced-section-title">Error Logs</h2>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<div style="display: flex; gap: 8px; align-items: center;">
<label class="form-label" style="margin: 0;">Show last</label>
<select id="errorLogLines" class="form-input" style="width: auto;" onchange="loadErrorLogs()">
<option value="50">50 lines</option>
<option value="100" selected>100 lines</option>
<option value="250">250 lines</option>
<option value="500">500 lines</option>
</select>
</div>
<button class="btn btn-secondary btn-sm" onclick="clearErrorLogs()">Clear Logs</button>
</div>
<div id="errorLogsContent" style="margin-top: 16px;">
<div class="loading"><div class="spinner"></div></div>
</div>
</div>
<!-- Danger Zone -->
<div class="advanced-section" style="border: 2px solid var(--error); background: var(--error-bg);">
<h2 class="advanced-section-title" style="color: var(--error);">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
Danger Zone
</h2>
<p class="advanced-section-desc">Irreversible actions. Proceed with caution.</p>
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-top: 16px;">
<button class="btn btn-danger" onclick="confirmClearAll()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
Clear All Entries
</button>
</div>
</div>
</div>
<?php
// Set additional scripts to be loaded AFTER app.js
$additionalScripts = '<script>
// Initialize settings page
document.addEventListener("DOMContentLoaded", function() {
const currentTab = "' . $currentTab . '";
// Load data for the current tab
switch(currentTab) {
case "integrations":
loadAwsSettings();
loadIpRegistrySettings();
loadWebhookSettings();
loadWebhookQueueStatus();
break;
case "users":
loadUsers();
break;
case "audit":
loadAuditLog();
break;
case "advanced":
loadShortnamesGrid();
loadShortnames();
loadLogosGrid();
break;
case "whitelabel":
loadWhitelabelSettings();
break;
case "license":
loadLicenseStatus();
loadLicenseUsage();
break;
case "developer":
loadSystemInfo();
loadErrorLogs();
break;
}
});
// User Management Functions
async function loadUsers() {
try {
const response = await fetch("api.php?action=admin_users_list", {
headers: { "X-CSRF-Token": CSRF_TOKEN }
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || "Failed to load users");
}
const tbody = document.getElementById("usersTableBody");
if (data.users.length === 0) {
tbody.innerHTML = \'<tr><td colspan="6" style="text-align: center; padding: 40px; color: var(--text-tertiary);">No users found. Add a user above.</td></tr>\';
return;
}
tbody.innerHTML = data.users.map(user => `
<tr data-user-id="${user.id}">
<td>${escapeHtml(user.email)}</td>
<td>${user.display_name ? escapeHtml(user.display_name) : \'<span style="color: var(--text-tertiary);">-</span>\'}</td>
<td>
<span class="badge ${user.role === \'admin\' ? \'badge-purple\' : \'badge-blue\'}">
${user.role === \'admin\' ? \'Admin\' : \'Staff\'}
</span>
</td>
<td>
<span class="badge ${user.active == 1 ? \'badge-success\' : \'badge-warning\'}">
${user.active == 1 ? \'Active\' : \'Inactive\'}
</span>
</td>
<td>${formatDate(user.created_at)}</td>
<td>
<div style="display: flex; gap: 8px;">
<button class="btn btn-secondary btn-sm" onclick="toggleUser(${user.id})" title="${user.active == 1 ? \'Deactivate\' : \'Activate\'}">
${user.active == 1 ?
\'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>\' :
\'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>\'
}
</button>
<button class="btn btn-danger btn-sm" onclick="deleteUser(${user.id}, \'${escapeHtml(user.email)}\')" title="Delete">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</td>
</tr>
`).join(\'\');
} catch (error) {
console.error("Error loading users:", error);
showNotification("Failed to load users: " + error.message, "error");
}
}
async function addUser() {
const email = document.getElementById("newUserEmail").value.trim();
const displayName = document.getElementById("newUserDisplayName").value.trim();
const role = document.getElementById("newUserRole").value;
if (!email) {
showNotification("Please enter an email address", "error");
return;
}
try {
const response = await fetch("api.php?action=admin_user_save", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": CSRF_TOKEN
},
body: JSON.stringify({
email: email,
display_name: displayName || null,
role: role
})
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || "Failed to add user");
}
showNotification("User added successfully", "success");
document.getElementById("newUserEmail").value = "";
document.getElementById("newUserDisplayName").value = "";
document.getElementById("newUserRole").value = "staff";
loadUsers();
} catch (error) {
console.error("Error adding user:", error);
showNotification("Failed to add user: " + error.message, "error");
}
}
async function toggleUser(userId) {
try {
const response = await fetch("api.php?action=admin_user_toggle", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": CSRF_TOKEN
},
body: JSON.stringify({ id: userId })
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || "Failed to toggle user status");
}
showNotification("User status updated", "success");
loadUsers();
} catch (error) {
console.error("Error toggling user:", error);
showNotification("Failed to toggle user: " + error.message, "error");
}
}
async function deleteUser(userId, email) {
if (!confirm("Are you sure you want to delete the user \"" + email + "\"? This action cannot be undone.")) {
return;
}
try {
const response = await fetch("api.php?action=admin_user_delete", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": CSRF_TOKEN
},
body: JSON.stringify({ id: userId })
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || "Failed to delete user");
}
showNotification("User deleted successfully", "success");
loadUsers();
} catch (error) {
console.error("Error deleting user:", error);
showNotification("Failed to delete user: " + error.message, "error");
}
}
function escapeHtml(text) {
if (!text) return "";
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
function formatDate(dateStr) {
if (!dateStr) return "-";
const date = new Date(dateStr);
return date.toLocaleDateString("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "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");
}
}
// Schema Update Functions
let pendingSchemaChanges = [];
async function checkSchemaUpdates() {
const resultContainer = document.getElementById("schemaCheckResult");
const applyBtn = document.getElementById("applySchemaBtn");
resultContainer.style.display = "block";
resultContainer.innerHTML = \'<div class="loading"><div class="spinner"></div></div>\';
applyBtn.style.display = "none";
try {
const response = await fetch("api.php?action=schema_check", {
headers: { "X-CSRF-Token": CSRF_TOKEN }
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || "Failed to check schema");
}
if (data.changes && data.changes.length > 0) {
pendingSchemaChanges = data.changes;
let changesHtml = \'<div style="padding: 12px; background: var(--warning-bg); border: 1px solid var(--warning); border-radius: var(--radius-md); margin-bottom: 12px;">\';
changesHtml += \'<strong style="color: var(--warning);">Schema updates available:</strong>\';
changesHtml += \'<ul style="margin: 8px 0 0 20px; padding: 0;">\';
data.changes.forEach(change => {
changesHtml += `<li style="margin-bottom: 4px;">${escapeHtml(change.description || change.type + \': \' + change.name)}</li>`;
});
changesHtml += \'</ul></div>\';
resultContainer.innerHTML = changesHtml;
applyBtn.style.display = "inline-flex";
} else {
resultContainer.innerHTML = \'<div style="padding: 12px; background: var(--success-bg); border: 1px solid var(--success); border-radius: var(--radius-md);"><strong style="color: var(--success);">Database schema is up to date!</strong></div>\';
}
} catch (error) {
console.error("Error checking schema:", error);
resultContainer.innerHTML = `<div style="padding: 12px; background: var(--error-bg); border: 1px solid var(--error); border-radius: var(--radius-md);"><strong style="color: var(--error);">Error:</strong> ${escapeHtml(error.message)}</div>`;
}
}
async function applySchemaUpdates() {
const resultContainer = document.getElementById("schemaCheckResult");
const applyBtn = document.getElementById("applySchemaBtn");
if (!confirm("Are you sure you want to apply the schema changes? This will modify your database structure.")) {
return;
}
resultContainer.innerHTML = \'<div class="loading"><div class="spinner"></div></div>\';
applyBtn.disabled = true;
try {
const response = await fetch("api.php?action=schema_apply", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": CSRF_TOKEN
},
credentials: "same-origin",
body: JSON.stringify({ csrf_token: CSRF_TOKEN })
});
const text = await response.text();
let data;
try {
data = JSON.parse(text);
} catch (parseError) {
console.error("Failed to parse response:", text);
throw new Error("Server returned invalid response. Check error logs.");
}
if (!data.success) {
throw new Error(data.error || "Failed to apply schema changes");
}
let resultsHtml = \'<div style="padding: 12px; background: var(--success-bg); border: 1px solid var(--success); border-radius: var(--radius-md);">\';
resultsHtml += \'<strong style="color: var(--success);">Schema changes applied successfully!</strong>\';
if (data.results && data.results.length > 0) {
resultsHtml += \'<ul style="margin: 8px 0 0 20px; padding: 0;">\';
data.results.forEach(result => {
const icon = result.success ? \'✓\' : \'✗\';
const color = result.success ? \'var(--success)\' : \'var(--error)\';
resultsHtml += `<li style="margin-bottom: 4px; color: ${color};">${icon} ${escapeHtml(result.description || result.name)}</li>`;
});
resultsHtml += \'</ul>\';
}
resultsHtml += \'</div>\';
resultContainer.innerHTML = resultsHtml;
applyBtn.style.display = "none";
pendingSchemaChanges = [];
showNotification("Schema changes applied successfully", "success");
} catch (error) {
console.error("Error applying schema:", error);
resultContainer.innerHTML = `<div style="padding: 12px; background: var(--error-bg); border: 1px solid var(--error); border-radius: var(--radius-md);"><strong style="color: var(--error);">Error:</strong> ${escapeHtml(error.message)}</div>`;
applyBtn.disabled = false;
}
}
// Database Backup/Restore Functions
async function downloadBackup() {
try {
showNotification("Generating backup...", "info");
const response = await fetch("api.php?action=backup_export", {
headers: { "X-CSRF-Token": CSRF_TOKEN },
credentials: "same-origin"
});
if (!response.ok) {
throw new Error("Failed to generate backup");
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || "Backup failed");
}
// Create download
const blob = new Blob([JSON.stringify(data.backup, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
a.href = url;
a.download = `ipmanager-backup-${timestamp}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showNotification("Backup downloaded successfully", "success");
} catch (error) {
console.error("Backup error:", error);
showNotification("Backup failed: " + error.message, "error");
}
}
async function handleRestoreFile(input) {
const file = input.files[0];
if (!file) return;
if (!confirm("WARNING: This will replace all existing data with the backup. Are you sure you want to continue?")) {
input.value = "";
return;
}
const statusDiv = document.getElementById("backupRestoreStatus");
statusDiv.style.display = "block";
statusDiv.innerHTML = \'<div class="loading"><div class="spinner"></div></div>\';
try {
const text = await file.text();
const backupData = JSON.parse(text);
const response = await fetch("api.php?action=backup_import", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": CSRF_TOKEN
},
credentials: "same-origin",
body: JSON.stringify({ backup_data: backupData, csrf_token: CSRF_TOKEN })
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || "Restore failed");
}
statusDiv.innerHTML = \'<div style="padding: 12px; background: var(--success-bg); border: 1px solid var(--success); border-radius: var(--radius-md);"><strong style="color: var(--success);">Restore completed successfully!</strong></div>\';
showNotification("Database restored successfully", "success");
} catch (error) {
console.error("Restore error:", error);
statusDiv.innerHTML = `<div style="padding: 12px; background: var(--error-bg); border: 1px solid var(--error); border-radius: var(--radius-md);"><strong style="color: var(--error);">Restore failed:</strong> ${escapeHtml(error.message)}</div>`;
}
input.value = "";
}
// WebDAV Backup Functions
async function loadWebDAVSettings() {
try {
const response = await fetch("api.php?action=webdav_settings_get", {
headers: { "X-CSRF-Token": CSRF_TOKEN },
credentials: "same-origin"
});
const data = await response.json();
if (data.success) {
document.getElementById("webdavServerUrl").value = data.settings.webdav_server_url || "";
document.getElementById("webdavUsername").value = data.settings.webdav_username || "";
// Password is not returned for security
}
} catch (error) {
console.error("Failed to load WebDAV settings:", error);
}
}
async function saveWebDAVSettings() {
const serverUrl = document.getElementById("webdavServerUrl").value.trim();
const username = document.getElementById("webdavUsername").value.trim();
const password = document.getElementById("webdavPassword").value;
try {
const response = await fetch("api.php?action=webdav_settings_save", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": CSRF_TOKEN
},
credentials: "same-origin",
body: JSON.stringify({
webdav_server_url: serverUrl,
webdav_username: username,
webdav_password: password,
csrf_token: CSRF_TOKEN
})
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || "Failed to save settings");
}
showNotification("WebDAV settings saved", "success");
} catch (error) {
console.error("Save WebDAV settings error:", error);
showNotification("Failed to save settings: " + error.message, "error");
}
}
async function backupToWebDAV() {
const serverUrl = document.getElementById("webdavServerUrl").value.trim();
const username = document.getElementById("webdavUsername").value.trim();
const password = document.getElementById("webdavPassword").value;
if (!serverUrl || !username) {
showNotification("Please enter WebDAV server URL and username", "error");
return;
}
const statusDiv = document.getElementById("webdavBackupStatus");
statusDiv.style.display = "block";
statusDiv.innerHTML = \'<div class="loading"><div class="spinner"></div></div>\';
try {
const response = await fetch("api.php?action=webdav_backup", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": CSRF_TOKEN
},
credentials: "same-origin",
body: JSON.stringify({
webdav_server_url: serverUrl,
webdav_username: username,
webdav_password: password,
csrf_token: CSRF_TOKEN
})
});
const text = await response.text();
let data;
try {
data = JSON.parse(text);
} catch (parseError) {
console.error("Failed to parse response:", text);
throw new Error("Server returned invalid response. Check error logs.");
}
if (!data.success) {
// Include extra details if available
let errorMsg = data.error || "WebDAV backup failed";
if (data.url) {
errorMsg += ` (URL: ${data.url})`;
}
if (data.http_code) {
errorMsg += ` (HTTP: ${data.http_code})`;
}
throw new Error(errorMsg);
}
statusDiv.innerHTML = `<div style="padding: 12px; background: var(--success-bg); border: 1px solid var(--success); border-radius: var(--radius-md);"><strong style="color: var(--success);">Backup uploaded successfully!</strong><br><small>Filename: ${escapeHtml(data.filename)}</small></div>`;
showNotification("Backup uploaded to WebDAV", "success");
} catch (error) {
console.error("WebDAV backup error:", error);
statusDiv.innerHTML = `<div style="padding: 12px; background: var(--error-bg); border: 1px solid var(--error); border-radius: var(--radius-md); word-break: break-word;"><strong style="color: var(--error);">WebDAV backup failed:</strong> ${escapeHtml(error.message)}</div>`;
}
}
// Load WebDAV settings when on developer tab
if (document.getElementById("webdavServerUrl")) {
loadWebDAVSettings();
}
</script>';
// Include footer
require_once __DIR__ . '/includes/footer.php';
?>