Files
zfs/internal/server/templates/static/admin.js
2026-02-14 00:29:52 +01:00

602 lines
28 KiB
JavaScript

let currentTab = 'clients';
// Check authentication on load
async function checkAuth() {
try {
const res = await fetch('/admin/check');
const data = await res.json();
if (!data.authenticated) {
window.location.href = '/admin/';
}
} catch (e) {
console.error('Auth check failed:', e);
}
}
// Logout
async function logout() {
await fetch('/admin/logout', { method: 'POST' });
location.reload();
}
// Load stats
async function loadStats() {
try {
const res = await fetch('/admin/stats');
const data = await res.json();
document.getElementById('stats-grid').innerHTML =
'<div class="group relative bg-white dark:bg-slate-900/80 backdrop-blur-xl rounded-2xl p-6 border border-slate-200 dark:border-slate-700/50 hover:border-violet-400 dark:hover:border-violet-500/50 transition-all duration-300 overflow-hidden shadow-sm dark:shadow-none">' +
'<div class="absolute inset-0 bg-gradient-to-br from-violet-500/5 via-purple-500/5 to-transparent dark:from-violet-600/10 dark:via-purple-600/5"></div>' +
'<div class="relative">' +
'<div class="flex items-center justify-between mb-4">' +
'<span class="text-slate-500 dark:text-slate-400 text-sm font-medium">Clients</span>' +
'<div class="w-10 h-10 rounded-xl bg-violet-100 dark:bg-violet-500/20 flex items-center justify-center">' +
'<i class="fas fa-users text-violet-600 dark:text-violet-400"></i>' +
'</div>' +
'</div>' +
'<div class="text-3xl font-bold text-slate-900 dark:text-white">' + data.client_count + '</div>' +
'</div>' +
'</div>' +
'<div class="group relative bg-white dark:bg-slate-900/80 backdrop-blur-xl rounded-2xl p-6 border border-slate-200 dark:border-slate-700/50 hover:border-violet-400 dark:hover:border-violet-500/50 transition-all duration-300 overflow-hidden shadow-sm dark:shadow-none">' +
'<div class="absolute inset-0 bg-gradient-to-br from-violet-500/5 via-purple-500/5 to-transparent dark:from-violet-600/10 dark:via-purple-600/5"></div>' +
'<div class="relative">' +
'<div class="flex items-center justify-between mb-4">' +
'<span class="text-slate-500 dark:text-slate-400 text-sm font-medium">Total Snapshots</span>' +
'<div class="w-10 h-10 rounded-xl bg-violet-100 dark:bg-violet-500/20 flex items-center justify-center">' +
'<i class="fas fa-camera text-violet-600 dark:text-violet-400"></i>' +
'</div>' +
'</div>' +
'<div class="text-3xl font-bold text-slate-900 dark:text-white">' + data.total_snapshots + '</div>' +
'</div>' +
'</div>' +
'<div class="group relative bg-white dark:bg-slate-900/80 backdrop-blur-xl rounded-2xl p-6 border border-slate-200 dark:border-slate-700/50 hover:border-violet-400 dark:hover:border-violet-500/50 transition-all duration-300 overflow-hidden shadow-sm dark:shadow-none">' +
'<div class="absolute inset-0 bg-gradient-to-br from-violet-500/5 via-purple-500/5 to-transparent dark:from-violet-600/10 dark:via-purple-600/5"></div>' +
'<div class="relative">' +
'<div class="flex items-center justify-between mb-4">' +
'<span class="text-slate-500 dark:text-slate-400 text-sm font-medium">Total Storage</span>' +
'<div class="w-10 h-10 rounded-xl bg-violet-100 dark:bg-violet-500/20 flex items-center justify-center">' +
'<i class="fas fa-hard-drive text-violet-600 dark:text-violet-400"></i>' +
'</div>' +
'</div>' +
'<div class="text-3xl font-bold text-slate-900 dark:text-white">' + data.total_storage_gb.toFixed(2) + ' GB</div>' +
'</div>' +
'</div>';
} catch (e) {
console.error('Failed to load stats:', e);
}
}
// Load clients
async function loadClients() {
try {
const res = await fetch('/admin/clients');
const clients = await res.json();
const tbody = document.getElementById('clients-table');
tbody.innerHTML = clients.map(c => {
const usedPercent = c.max_size_bytes > 0 ? (c.current_usage / c.max_size_bytes * 100).toFixed(1) : 0;
const usedGB = (c.current_usage / (1024*1024*1024)).toFixed(2);
const maxGB = (c.max_size_bytes / (1024*1024*1024)).toFixed(0);
return '<tr class="border-b border-slate-100 dark:border-slate-700/30 hover:bg-slate-50 dark:hover:bg-slate-800/30 transition-colors">' +
'<td class="py-4 px-4 font-semibold text-slate-900 dark:text-white">' + c.client_id + '</td>' +
'<td class="py-4 px-4"><span class="px-3 py-1.5 rounded-lg text-xs font-semibold bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400 border border-blue-200 dark:border-blue-500/30">' + c.storage_type + '</span></td>' +
'<td class="py-4 px-4 text-slate-600 dark:text-slate-300">' + maxGB + ' GB</td>' +
'<td class="py-4 px-4">' +
'<div class="text-slate-600 dark:text-slate-300">' + usedGB + ' GB (' + usedPercent + '%)</div>' +
'<div class="w-24 h-2 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden mt-2"><div class="h-full bg-gradient-to-r from-violet-500 to-fuchsia-500 transition-all rounded-full" style="width: ' + Math.min(usedPercent, 100) + '%"></div></div>' +
'</td>' +
'<td class="py-4 px-4 text-slate-600 dark:text-slate-300">' + c.snapshot_count + '</td>' +
'<td class="py-4 px-4">' + (c.enabled ? '<span class="px-3 py-1.5 rounded-lg text-xs font-semibold bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-500/30">Enabled</span>' : '<span class="px-3 py-1.5 rounded-lg text-xs font-semibold bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-400 border border-red-200 dark:border-red-500/30">Disabled</span>') + '</td>' +
'<td class="py-4 px-4 whitespace-nowrap">' +
'<button class="px-3 py-1.5 text-xs rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-400 hover:to-orange-400 text-white mr-1.5 shadow-lg shadow-amber-500/20 transition-all" data-action="edit-client" data-client-id="' + c.client_id + '"><i class="fas fa-edit mr-1"></i>Edit</button>' +
'<button class="px-3 py-1.5 text-xs rounded-lg bg-gradient-to-r from-purple-500 to-violet-500 hover:from-purple-400 hover:to-violet-400 text-white mr-1.5 shadow-lg shadow-purple-500/20 transition-all" data-action="set-client-key" data-client-id="' + c.client_id + '"><i class="fas fa-key mr-1"></i>Set Key</button>' +
'<button class="px-3 py-1.5 text-xs rounded-lg bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-400 hover:to-amber-400 text-white mr-1.5 shadow-lg shadow-orange-500/20 transition-all" data-action="reset-client-key" data-client-id="' + c.client_id + '"><i class="fas fa-rotate mr-1"></i>Reset</button>' +
'<button class="px-3 py-1.5 text-xs rounded-lg bg-gradient-to-r from-red-500 to-rose-500 hover:from-red-400 hover:to-rose-400 text-white shadow-lg shadow-red-500/20 transition-all" data-action="delete-client" data-client-id="' + c.client_id + '"><i class="fas fa-trash mr-1"></i>Delete</button>' +
'</td>' +
'</tr>';
}).join('');
// Update snapshot filter
const filter = document.getElementById('snapshot-client-filter');
filter.innerHTML = '<option value="">All Clients</option>' +
clients.map(c => '<option value="' + c.client_id + '">' + c.client_id + '</option>').join('');
} catch (e) {
console.error('Failed to load clients:', e);
}
}
// Load snapshots
async function loadSnapshots() {
const clientId = document.getElementById('snapshot-client-filter').value;
const url = '/admin/snapshots' + (clientId ? '?client_id=' + clientId : '');
try {
const res = await fetch(url);
const snapshots = await res.json();
const tbody = document.getElementById('snapshots-table');
tbody.innerHTML = snapshots.map(s => {
const sizeGB = (s.size_bytes / (1024*1024*1024)).toFixed(2);
return '<tr class="border-b border-slate-100 dark:border-slate-700/30 hover:bg-slate-50 dark:hover:bg-slate-800/30 transition-colors">' +
'<td class="py-4 px-4 text-slate-600 dark:text-slate-300">' + s.client_id + '</td>' +
'<td class="py-4 px-4 font-semibold text-slate-900 dark:text-white">' + s.snapshot_id + '</td>' +
'<td class="py-4 px-4 text-slate-600 dark:text-slate-300">' + new Date(s.timestamp).toLocaleString() + '</td>' +
'<td class="py-4 px-4 text-slate-600 dark:text-slate-300">' + sizeGB + ' GB</td>' +
'<td class="py-4 px-4">' +
(s.incremental ? '<span class="px-3 py-1.5 rounded-lg text-xs font-semibold bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400 border border-blue-200 dark:border-blue-500/30">Incremental</span>' : '<span class="px-3 py-1.5 rounded-lg text-xs font-semibold bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-500/30">Full</span>') +
(s.compressed ? ' <span class="px-3 py-1.5 rounded-lg text-xs font-semibold bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-400 border border-purple-200 dark:border-purple-500/30">LZ4</span>' : '') +
'</td>' +
'<td class="py-4 px-4"><button class="px-3 py-1.5 text-xs rounded-lg bg-gradient-to-r from-red-500 to-rose-500 hover:from-red-400 hover:to-rose-400 text-white shadow-lg shadow-red-500/20 transition-all" data-action="delete-snapshot" data-client-id="' + s.client_id + '" data-snapshot-id="' + s.snapshot_id + '"><i class="fas fa-trash mr-1"></i>Delete</button></td>' +
'</tr>';
}).join('');
} catch (e) {
console.error('Failed to load snapshots:', e);
}
}
// Load admins
async function loadAdmins() {
try {
const res = await fetch('/admin/admins');
const admins = await res.json();
const tbody = document.getElementById('admins-table');
tbody.innerHTML = admins.map(a =>
'<tr class="border-b border-slate-100 dark:border-slate-700/30 hover:bg-slate-50 dark:hover:bg-slate-800/30 transition-colors">' +
'<td class="py-4 px-4 text-slate-400 dark:text-slate-500">' + a.id + '</td>' +
'<td class="py-4 px-4 font-semibold text-slate-900 dark:text-white">' + a.username + '</td>' +
'<td class="py-4 px-4"><span class="px-3 py-1.5 rounded-lg text-xs font-semibold bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400 border border-blue-200 dark:border-blue-500/30">' + a.role + '</span></td>' +
'<td class="py-4 px-4 text-slate-600 dark:text-slate-300">' + new Date(a.created_at).toLocaleDateString() + '</td>' +
'<td class="py-4 px-4 whitespace-nowrap">' +
'<button class="px-3 py-1.5 text-xs rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-400 hover:to-orange-400 text-white mr-1.5 shadow-lg shadow-amber-500/20 transition-all" data-action="change-admin-password" data-admin-id="' + a.id + '" data-admin-username="' + a.username + '"><i class="fas fa-key mr-1"></i>Change Password</button>' +
'<button class="px-3 py-1.5 text-xs rounded-lg bg-gradient-to-r from-red-500 to-rose-500 hover:from-red-400 hover:to-rose-400 text-white shadow-lg shadow-red-500/20 transition-all" data-action="delete-admin" data-admin-id="' + a.id + '"><i class="fas fa-trash mr-1"></i>Delete</button>' +
'</td>' +
'</tr>'
).join('');
} catch (e) {
console.error('Failed to load admins:', e);
}
}
// Tab switching
function showTab(tab) {
currentTab = tab;
document.querySelectorAll('[data-tab]').forEach(t => {
t.classList.remove('bg-gradient-to-r', 'from-violet-600', 'to-fuchsia-600', 'text-white', 'shadow-lg', 'shadow-violet-500/25');
t.classList.add('bg-slate-100', 'dark:bg-slate-800', 'text-slate-600', 'dark:text-slate-400', 'hover:bg-slate-200', 'dark:hover:bg-slate-700', 'hover:text-slate-900', 'dark:hover:text-white', 'border', 'border-slate-200', 'dark:border-slate-700');
});
const activeTab = document.querySelector('[data-tab="' + tab + '"]');
activeTab.classList.remove('bg-slate-100', 'dark:bg-slate-800', 'text-slate-600', 'dark:text-slate-400', 'hover:bg-slate-200', 'dark:hover:bg-slate-700', 'hover:text-slate-900', 'dark:hover:text-white', 'border', 'border-slate-200', 'dark:border-slate-700');
activeTab.classList.add('bg-gradient-to-r', 'from-violet-600', 'to-fuchsia-600', 'text-white', 'shadow-lg', 'shadow-violet-500/25');
document.getElementById('clients-tab').classList.add('hidden');
document.getElementById('snapshots-tab').classList.add('hidden');
document.getElementById('admins-tab').classList.add('hidden');
document.getElementById(tab + '-tab').classList.remove('hidden');
if (tab === 'snapshots') loadSnapshots();
if (tab === 'admins') loadAdmins();
}
// Modal functions
function showModal(id) {
const modal = document.getElementById(id);
modal.classList.remove('hidden');
modal.classList.add('flex');
}
function closeModal(id) {
const modal = document.getElementById(id);
modal.classList.add('hidden');
modal.classList.remove('flex');
}
// Edit client
async function editClient(clientId) {
try {
const res = await fetch('/admin/client?client_id=' + clientId);
const data = await res.json();
const c = data.client;
document.getElementById('edit-client-id').value = c.client_id;
document.getElementById('edit-client-apikey').value = '';
document.getElementById('edit-client-storage').value = c.storage_type;
document.getElementById('edit-client-dataset').value = c.dataset;
document.getElementById('edit-client-quota').value = Math.round(c.max_size_bytes / (1024*1024*1024));
document.getElementById('edit-client-enabled').checked = c.enabled;
if (c.rotation_policy) {
document.getElementById('edit-client-hourly').value = c.rotation_policy.keep_hourly || 0;
document.getElementById('edit-client-daily').value = c.rotation_policy.keep_daily || 0;
document.getElementById('edit-client-weekly').value = c.rotation_policy.keep_weekly || 0;
document.getElementById('edit-client-monthly').value = c.rotation_policy.keep_monthly || 0;
}
showModal('edit-client-modal');
} catch (e) {
alert('Failed to load client data');
}
}
// Delete client
async function deleteClient(clientId) {
if (!confirm('Are you sure you want to delete client "' + clientId + '"? This will also delete all snapshots.')) {
return;
}
try {
const res = await fetch('/admin/client/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: clientId })
});
const data = await res.json();
if (data.success) {
loadClients();
loadStats();
} else {
alert(data.message || 'Failed to delete client');
}
} catch (e) {
alert('Failed to delete client');
}
}
// Delete snapshot
async function deleteSnapshot(clientId, snapshotId) {
if (!confirm('Are you sure you want to delete snapshot "' + snapshotId + '"?')) {
return;
}
try {
const res = await fetch('/admin/snapshot/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: clientId, snapshot_id: snapshotId })
});
const data = await res.json();
if (data.success) {
loadSnapshots();
loadStats();
} else {
alert(data.message || 'Failed to delete snapshot');
}
} catch (e) {
alert('Failed to delete snapshot');
}
}
// Delete admin
async function deleteAdmin(adminId) {
if (!confirm('Are you sure you want to delete this admin?')) {
return;
}
try {
const res = await fetch('/admin/admin/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ admin_id: adminId })
});
const data = await res.json();
if (data.success) {
loadAdmins();
} else {
alert(data.message || 'Failed to delete admin');
}
} catch (e) {
alert('Failed to delete admin');
}
}
// Show change password modal
function showChangePassword(adminId, username) {
document.getElementById('change-password-admin-id').value = adminId;
document.getElementById('change-password-username').value = username;
document.getElementById('change-password-new').value = '';
document.getElementById('change-password-confirm').value = '';
showModal('change-password-modal');
}
// Show client change password modal
function showClientChangePassword(clientId) {
document.getElementById('client-password-client-id').value = clientId;
document.getElementById('client-password-client-name').value = clientId;
document.getElementById('client-password-new').value = '';
document.getElementById('client-password-confirm').value = '';
showModal('client-password-modal');
}
// Reset client password
async function resetClientPassword(clientId) {
if (!confirm('Are you sure you want to reset the API key for "' + clientId + '"? A new random key will be generated.')) {
return;
}
try {
const res = await fetch('/admin/client/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: clientId })
});
const data = await res.json();
if (data.success) {
alert('New API key for ' + clientId + ': ' + data.api_key + '\n\nPlease save this key as it will not be shown again.');
} else {
alert(data.message || 'Failed to reset API key');
}
} catch (e) {
alert('Failed to reset API key');
}
}
// Event delegation for click handlers
document.addEventListener('click', function(e) {
const target = e.target;
// Handle modal content clicks - stop propagation
if (target.classList.contains('modal-content') || target.closest('.modal-content')) {
// Don't process if clicking on close button
if (!target.classList.contains('modal-close')) {
return;
}
}
// Handle modal close buttons
if (target.classList.contains('modal-close')) {
const modalId = target.getAttribute('data-modal-id');
if (modalId) {
closeModal(modalId);
}
return;
}
// Handle modal overlay clicks (close on backdrop click)
if (target.classList.contains('fixed') && target.hasAttribute('data-modal-id')) {
closeModal(target.id);
return;
}
// Handle data-action buttons
const action = target.getAttribute('data-action');
if (!action) return;
switch (action) {
case 'show-modal':
const modalId = target.getAttribute('data-modal');
if (modalId) showModal(modalId);
break;
case 'edit-client':
const clientId = target.getAttribute('data-client-id');
if (clientId) editClient(clientId);
break;
case 'delete-client':
const delClientId = target.getAttribute('data-client-id');
if (delClientId) deleteClient(delClientId);
break;
case 'set-client-key':
const setKeyClientId = target.getAttribute('data-client-id');
if (setKeyClientId) showClientChangePassword(setKeyClientId);
break;
case 'reset-client-key':
const resetKeyClientId = target.getAttribute('data-client-id');
if (resetKeyClientId) resetClientPassword(resetKeyClientId);
break;
case 'delete-snapshot':
const snapClientId = target.getAttribute('data-client-id');
const snapshotId = target.getAttribute('data-snapshot-id');
if (snapClientId && snapshotId) deleteSnapshot(snapClientId, snapshotId);
break;
case 'change-admin-password':
const adminId = target.getAttribute('data-admin-id');
const adminUsername = target.getAttribute('data-admin-username');
if (adminId && adminUsername) showChangePassword(adminId, adminUsername);
break;
case 'delete-admin':
const delAdminId = target.getAttribute('data-admin-id');
if (delAdminId) deleteAdmin(parseInt(delAdminId));
break;
}
});
// Tab click handler
document.addEventListener('click', function(e) {
const tab = e.target.getAttribute('data-tab');
if (tab) {
showTab(tab);
}
});
// Snapshot filter change handler
document.addEventListener('change', function(e) {
if (e.target.id === 'snapshot-client-filter') {
loadSnapshots();
}
});
// Add client form
document.getElementById('add-client-form').addEventListener('submit', async (e) => {
e.preventDefault();
const client = {
client_id: document.getElementById('new-client-id').value,
api_key: document.getElementById('new-client-apikey').value,
storage_type: document.getElementById('new-client-storage').value,
dataset: document.getElementById('new-client-dataset').value,
max_size_bytes: parseInt(document.getElementById('new-client-quota').value) * 1024 * 1024 * 1024,
enabled: document.getElementById('new-client-enabled').checked,
rotation_policy: {
keep_hourly: parseInt(document.getElementById('new-client-hourly').value) || 0,
keep_daily: parseInt(document.getElementById('new-client-daily').value) || 0,
keep_weekly: parseInt(document.getElementById('new-client-weekly').value) || 0,
keep_monthly: parseInt(document.getElementById('new-client-monthly').value) || 0
}
};
try {
const res = await fetch('/admin/client/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(client)
});
const data = await res.json();
if (data.success) {
closeModal('add-client-modal');
document.getElementById('add-client-form').reset();
loadClients();
loadStats();
} else {
alert(data.message || 'Failed to create client');
}
} catch (e) {
alert('Failed to create client');
}
});
// Edit client form
document.getElementById('edit-client-form').addEventListener('submit', async (e) => {
e.preventDefault();
const client = {
client_id: document.getElementById('edit-client-id').value,
api_key: document.getElementById('edit-client-apikey').value,
storage_type: document.getElementById('edit-client-storage').value,
dataset: document.getElementById('edit-client-dataset').value,
max_size_bytes: parseInt(document.getElementById('edit-client-quota').value) * 1024 * 1024 * 1024,
enabled: document.getElementById('edit-client-enabled').checked,
rotation_policy: {
keep_hourly: parseInt(document.getElementById('edit-client-hourly').value) || 0,
keep_daily: parseInt(document.getElementById('edit-client-daily').value) || 0,
keep_weekly: parseInt(document.getElementById('edit-client-weekly').value) || 0,
keep_monthly: parseInt(document.getElementById('edit-client-monthly').value) || 0
}
};
try {
const res = await fetch('/admin/client/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(client)
});
const data = await res.json();
if (data.success) {
closeModal('edit-client-modal');
loadClients();
loadStats();
} else {
alert(data.message || 'Failed to update client');
}
} catch (e) {
alert('Failed to update client');
}
});
// Add admin form
document.getElementById('add-admin-form').addEventListener('submit', async (e) => {
e.preventDefault();
const admin = {
username: document.getElementById('new-admin-username').value,
password: document.getElementById('new-admin-password').value,
role: document.getElementById('new-admin-role').value
};
try {
const res = await fetch('/admin/admin/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(admin)
});
const data = await res.json();
if (data.success) {
closeModal('add-admin-modal');
document.getElementById('add-admin-form').reset();
loadAdmins();
} else {
alert(data.message || 'Failed to create admin');
}
} catch (e) {
alert('Failed to create admin');
}
});
// Change password form
document.getElementById('change-password-form').addEventListener('submit', async (e) => {
e.preventDefault();
const adminId = document.getElementById('change-password-admin-id').value;
const newPassword = document.getElementById('change-password-new').value;
const confirmPassword = document.getElementById('change-password-confirm').value;
if (newPassword !== confirmPassword) {
alert('Passwords do not match');
return;
}
try {
const res = await fetch('/admin/admin/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
admin_id: parseInt(adminId),
new_password: newPassword
})
});
const data = await res.json();
if (data.success) {
closeModal('change-password-modal');
} else {
alert(data.message || 'Failed to change password');
}
} catch (e) {
alert('Failed to change password');
}
});
// Client password form
document.getElementById('client-password-form').addEventListener('submit', async (e) => {
e.preventDefault();
const clientId = document.getElementById('client-password-client-id').value;
const newKey = document.getElementById('client-password-new').value;
const confirmKey = document.getElementById('client-password-confirm').value;
if (newKey !== confirmKey) {
alert('API keys do not match');
return;
}
try {
const res = await fetch('/admin/client/set-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: clientId,
new_api_key: newKey
})
});
const data = await res.json();
if (data.success) {
closeModal('client-password-modal');
} else {
alert(data.message || 'Failed to set API key');
}
} catch (e) {
alert('Failed to set API key');
}
});
// Initialize
checkAuth();
loadStats();
loadClients();