- 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>
1647 lines
81 KiB
PHP
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';
|
|
?>
|