|
|
@@ -1,5 +1,45 @@
|
|
|
// DebTik - Debian Linux Router Frontend Application Logic
|
|
|
+
|
|
|
+// ================= Interceptor: Automatic Auth Bearer & 401 Handling =================
|
|
|
+const originalFetch = window.fetch;
|
|
|
+window.fetch = async function(...args) {
|
|
|
+ let [resource, config] = args;
|
|
|
+ config = config || {};
|
|
|
+
|
|
|
+ // Clone or init headers
|
|
|
+ let headers = config.headers;
|
|
|
+ if (!headers) {
|
|
|
+ headers = {};
|
|
|
+ config.headers = headers;
|
|
|
+ }
|
|
|
+
|
|
|
+ const token = localStorage.getItem('debtik_token');
|
|
|
+ if (token) {
|
|
|
+ if (headers instanceof Headers) {
|
|
|
+ if (!headers.has('Authorization')) headers.set('Authorization', `Bearer ${token}`);
|
|
|
+ } else if (Array.isArray(headers)) {
|
|
|
+ if (!headers.some(([k]) => k.toLowerCase() === 'authorization')) headers.push(['Authorization', `Bearer ${token}`]);
|
|
|
+ } else {
|
|
|
+ if (!headers['Authorization']) headers['Authorization'] = `Bearer ${token}`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const response = await originalFetch(resource, config);
|
|
|
+
|
|
|
+ // If 401 Unauthorized on protected API endpoint, prompt login immediately
|
|
|
+ if (response.status === 401 && typeof resource === 'string' && resource.includes('/api/') && !resource.includes('/api/auth/login')) {
|
|
|
+ if (state.isPolling) {
|
|
|
+ state.isPolling = false;
|
|
|
+ showLoginScreen(true);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return response;
|
|
|
+};
|
|
|
+
|
|
|
let state = {
|
|
|
+ currentUser: null,
|
|
|
+ users: [],
|
|
|
interfaces: [],
|
|
|
vlans: [],
|
|
|
systemStatus: null,
|
|
|
@@ -75,17 +115,17 @@ function formatBytes(bytes) {
|
|
|
}
|
|
|
|
|
|
// Initialize on page load
|
|
|
-document.addEventListener('DOMContentLoaded', () => {
|
|
|
+document.addEventListener('DOMContentLoaded', async () => {
|
|
|
const hash = window.location.hash.replace('#', '') || 'dashboard';
|
|
|
navigate(hash);
|
|
|
initTableSorting();
|
|
|
|
|
|
- fetchData();
|
|
|
- initLiveTrafficStream();
|
|
|
+ // Verify authentication before bootstrapping telemetry
|
|
|
+ await checkAuthStatus();
|
|
|
|
|
|
// Full state telemetry polling every 5 seconds (smooth & flicker-free)
|
|
|
setInterval(() => {
|
|
|
- if (state.isPolling) {
|
|
|
+ if (state.isPolling && state.currentUser) {
|
|
|
fetchData(false);
|
|
|
}
|
|
|
}, 5000);
|
|
|
@@ -270,7 +310,8 @@ function navigate(tabName) {
|
|
|
bwtest: { title: 'Bandwidth Test Benchmark', sub: 'High-speed line-rate throughput benchmark, token-bucket rate limiting, loss & jitter telemetry' },
|
|
|
tools: { title: 'Diagnostic & Troubleshooting Tools', sub: 'Network latency analysis, ICMP/MTR telemetry, path tracing, DNS resolution and TCP probing' },
|
|
|
persistence: { title: 'Debian Configuration', sub: 'Native /etc/network/interfaces.d/ persistence manager' },
|
|
|
- settings: { title: 'Router Settings & DNS', sub: 'System DNS forwarder, upstream resolvers, static host mappings, and appliance identity' }
|
|
|
+ settings: { title: 'Router Settings & DNS', sub: 'System DNS forwarder, upstream resolvers, static host mappings, and appliance identity' },
|
|
|
+ users: { title: 'Local User Manager', sub: 'Manage local system administrator credentials and access permissions' }
|
|
|
};
|
|
|
|
|
|
const header = titles[tabName] || titles.dashboard;
|
|
|
@@ -321,6 +362,7 @@ function navigate(tabName) {
|
|
|
if (tabName === 'vrrp') loadVRRP();
|
|
|
if (tabName === 'bwtest') loadBwTestView();
|
|
|
if (tabName === 'settings') loadSettingsData();
|
|
|
+ if (tabName === 'users') loadUsers();
|
|
|
setTimeout(initTableSorting, 50);
|
|
|
}
|
|
|
|
|
|
@@ -419,6 +461,7 @@ async function fetchData(isManual = false) {
|
|
|
if (state.activeTab === 'dhcp-server') loadDHCPServer(false);
|
|
|
if (state.activeTab === 'vrrp') loadVRRP(false);
|
|
|
if (state.activeTab === 'settings') loadSettingsData(false);
|
|
|
+ if (state.activeTab === 'users') loadUsers(false);
|
|
|
fetchBwTestServerStatus();
|
|
|
|
|
|
} catch (err) {
|
|
|
@@ -15796,5 +15839,551 @@ async function saveSystemSettings(e) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+// =============================================================================
|
|
|
+// AUTHENTICATION & USER MANAGER
|
|
|
+// =============================================================================
|
|
|
+
|
|
|
+async function checkAuthStatus() {
|
|
|
+ try {
|
|
|
+ const res = await originalFetch(`${API_BASE}/auth/me`, {
|
|
|
+ headers: {
|
|
|
+ 'Authorization': `Bearer ${localStorage.getItem('debtik_token') || ''}`
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ if (res.ok) {
|
|
|
+ const user = await res.json();
|
|
|
+ state.currentUser = user;
|
|
|
+ updateUserProfileHeader(user);
|
|
|
+ hideLoginScreen();
|
|
|
+ state.isPolling = true;
|
|
|
+ fetchData();
|
|
|
+ initLiveTrafficStream();
|
|
|
+ return true;
|
|
|
+ } else {
|
|
|
+ showLoginScreen(false);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showLoginScreen(false);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function showLoginScreen(isExpired = false) {
|
|
|
+ const el = document.getElementById('login-screen');
|
|
|
+ if (el) el.classList.remove('hidden');
|
|
|
+
|
|
|
+ const errEl = document.getElementById('login-error');
|
|
|
+ const errTxt = document.getElementById('login-error-text');
|
|
|
+ if (isExpired && errEl && errTxt) {
|
|
|
+ errTxt.innerText = 'Session expired. Please sign in again.';
|
|
|
+ errEl.classList.remove('hidden');
|
|
|
+ } else if (errEl) {
|
|
|
+ errEl.classList.add('hidden');
|
|
|
+ }
|
|
|
+
|
|
|
+ const pwInput = document.getElementById('login-password');
|
|
|
+ if (pwInput) pwInput.value = '';
|
|
|
+
|
|
|
+ const userInput = document.getElementById('login-username');
|
|
|
+ if (userInput && !userInput.value) userInput.focus();
|
|
|
+ else if (pwInput) pwInput.focus();
|
|
|
+}
|
|
|
+
|
|
|
+function hideLoginScreen() {
|
|
|
+ const el = document.getElementById('login-screen');
|
|
|
+ if (el) el.classList.add('hidden');
|
|
|
+ const errEl = document.getElementById('login-error');
|
|
|
+ if (errEl) errEl.classList.add('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+async function handleLoginSubmit(event) {
|
|
|
+ event.preventDefault();
|
|
|
+
|
|
|
+ const username = (document.getElementById('login-username')?.value || '').trim();
|
|
|
+ const password = document.getElementById('login-password')?.value || '';
|
|
|
+ const errEl = document.getElementById('login-error');
|
|
|
+ const errTxt = document.getElementById('login-error-text');
|
|
|
+ const btn = document.getElementById('login-submit-btn');
|
|
|
+ const btnText = document.getElementById('login-btn-text');
|
|
|
+ const spinner = document.getElementById('login-btn-spinner');
|
|
|
+
|
|
|
+ if (!username || !password) {
|
|
|
+ if (errTxt) errTxt.innerText = 'Username and password are required.';
|
|
|
+ if (errEl) errEl.classList.remove('hidden');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (errEl) errEl.classList.add('hidden');
|
|
|
+ if (btn) btn.disabled = true;
|
|
|
+ if (btnText) btnText.innerText = 'Authenticating...';
|
|
|
+ if (spinner) spinner.classList.remove('hidden');
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await originalFetch(`${API_BASE}/auth/login`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ username, password })
|
|
|
+ });
|
|
|
+
|
|
|
+ const data = await res.json();
|
|
|
+ if (res.ok && data.success) {
|
|
|
+ localStorage.setItem('debtik_token', data.token);
|
|
|
+ state.currentUser = data.user;
|
|
|
+ updateUserProfileHeader(data.user);
|
|
|
+ hideLoginScreen();
|
|
|
+ state.isPolling = true;
|
|
|
+
|
|
|
+ showToast('Authenticated', `Signed in as ${data.user.username}`, 'success');
|
|
|
+ fetchData();
|
|
|
+ initLiveTrafficStream();
|
|
|
+ } else {
|
|
|
+ if (errTxt) errTxt.innerText = data.message || 'Invalid username or password';
|
|
|
+ if (errEl) errEl.classList.remove('hidden');
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ if (errTxt) errTxt.innerText = err.message || 'Failed to connect to router';
|
|
|
+ if (errEl) errEl.classList.remove('hidden');
|
|
|
+ } finally {
|
|
|
+ if (btn) btn.disabled = false;
|
|
|
+ if (btnText) btnText.innerText = 'Sign In to Controller';
|
|
|
+ if (spinner) spinner.classList.add('hidden');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function handleLogoutClick() {
|
|
|
+ try {
|
|
|
+ await originalFetch(`${API_BASE}/auth/logout`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: {
|
|
|
+ 'Authorization': `Bearer ${localStorage.getItem('debtik_token') || ''}`
|
|
|
+ }
|
|
|
+ });
|
|
|
+ } catch (e) {}
|
|
|
+
|
|
|
+ localStorage.removeItem('debtik_token');
|
|
|
+ state.currentUser = null;
|
|
|
+ state.isPolling = false;
|
|
|
+
|
|
|
+ if (trafficEventSource) {
|
|
|
+ trafficEventSource.close();
|
|
|
+ trafficEventSource = null;
|
|
|
+ }
|
|
|
+ if (trafficPollFallback) {
|
|
|
+ clearInterval(trafficPollFallback);
|
|
|
+ trafficPollFallback = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ showLoginScreen(false);
|
|
|
+ showToast('Signed Out', 'You have been signed out successfully', 'info');
|
|
|
+}
|
|
|
+
|
|
|
+function togglePasswordVisibility(id) {
|
|
|
+ const input = document.getElementById(id);
|
|
|
+ if (!input) return;
|
|
|
+ input.type = input.type === 'password' ? 'text' : 'password';
|
|
|
+}
|
|
|
+
|
|
|
+function toggleUserMenu() {
|
|
|
+ const menu = document.getElementById('user-menu-dropdown');
|
|
|
+ if (menu) menu.classList.toggle('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function closeUserMenu() {
|
|
|
+ const menu = document.getElementById('user-menu-dropdown');
|
|
|
+ if (menu) menu.classList.add('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+// Close user menu on outside click
|
|
|
+document.addEventListener('click', (e) => {
|
|
|
+ const menu = document.getElementById('user-menu-dropdown');
|
|
|
+ if (!menu || menu.classList.contains('hidden')) return;
|
|
|
+ if (!e.target.closest('#user-menu-dropdown') && !e.target.closest('button[onclick*="toggleUserMenu"]')) {
|
|
|
+ menu.classList.add('hidden');
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+function updateUserProfileHeader(user) {
|
|
|
+ if (!user) return;
|
|
|
+ const navName = document.getElementById('nav-user-name');
|
|
|
+ if (navName) navName.innerText = user.username;
|
|
|
+
|
|
|
+ const dropName = document.getElementById('dropdown-user-name');
|
|
|
+ if (dropName) dropName.innerText = user.username;
|
|
|
+
|
|
|
+ const navRole = document.getElementById('nav-user-role');
|
|
|
+ if (navRole) navRole.innerText = (user.role || 'read').toUpperCase();
|
|
|
+
|
|
|
+ const navAvatar = document.getElementById('nav-user-avatar');
|
|
|
+ if (navAvatar) {
|
|
|
+ const initials = (user.username || 'AD').slice(0, 2).toUpperCase();
|
|
|
+ navAvatar.innerText = initials;
|
|
|
+ }
|
|
|
+
|
|
|
+ const statCurUser = document.getElementById('stat-current-user');
|
|
|
+ if (statCurUser) statCurUser.innerText = user.username;
|
|
|
+
|
|
|
+ const statCurRole = document.getElementById('stat-current-role');
|
|
|
+ if (statCurRole) statCurRole.innerText = (user.role || 'read').toUpperCase();
|
|
|
+}
|
|
|
+
|
|
|
+// ================= User Management CRUD =================
|
|
|
+
|
|
|
+async function loadUsers(isManual = false) {
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/users`);
|
|
|
+ if (res.status === 403) {
|
|
|
+ const tbody = document.getElementById('users-table-tbody');
|
|
|
+ if (tbody) {
|
|
|
+ tbody.innerHTML = `
|
|
|
+ <tr>
|
|
|
+ <td colspan="6" class="p-6 text-center text-amber-400 font-sans">
|
|
|
+ <div class="inline-flex items-center gap-2">
|
|
|
+ <svg class="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
|
|
+ <span>Viewing and managing users requires Super Administrator (full) permissions.</span>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ `;
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (res.ok) {
|
|
|
+ state.users = await res.json();
|
|
|
+ renderUsersTable();
|
|
|
+
|
|
|
+ const totalUsers = document.getElementById('stat-total-users');
|
|
|
+ if (totalUsers) totalUsers.innerText = state.users.length;
|
|
|
+
|
|
|
+ const adminCount = state.users.filter(u => u.role === 'full' && !u.disabled).length;
|
|
|
+ const adminUsers = document.getElementById('stat-admin-users');
|
|
|
+ if (adminUsers) adminUsers.innerText = adminCount;
|
|
|
+
|
|
|
+ const badgeCount = document.getElementById('badge-users-count');
|
|
|
+ if (badgeCount) badgeCount.innerText = state.users.length;
|
|
|
+
|
|
|
+ if (isManual) showToast('Refreshed', 'User list updated', 'info');
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ console.error('Failed to load users:', err);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function renderUsersTable() {
|
|
|
+ const tbody = document.getElementById('users-table-tbody');
|
|
|
+ const emptyState = document.getElementById('users-empty-state');
|
|
|
+ if (!tbody) return;
|
|
|
+
|
|
|
+ tbody.innerHTML = '';
|
|
|
+ const query = (document.getElementById('users-search-input')?.value || '').trim().toLowerCase();
|
|
|
+
|
|
|
+ const filtered = (state.users || []).filter(u => {
|
|
|
+ if (!query) return true;
|
|
|
+ return u.username.toLowerCase().includes(query) ||
|
|
|
+ (u.role || '').toLowerCase().includes(query) ||
|
|
|
+ (u.comment || '').toLowerCase().includes(query);
|
|
|
+ });
|
|
|
+
|
|
|
+ if (filtered.length === 0) {
|
|
|
+ if (emptyState) emptyState.classList.remove('hidden');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (emptyState) emptyState.classList.add('hidden');
|
|
|
+
|
|
|
+ const currentAuthUser = state.currentUser?.username || '';
|
|
|
+
|
|
|
+ filtered.forEach(u => {
|
|
|
+ const isSelf = u.username === currentAuthUser;
|
|
|
+ const isFull = u.role === 'full';
|
|
|
+ const isWrite = u.role === 'write';
|
|
|
+
|
|
|
+ let roleBadge = `<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-800 text-slate-300 border border-slate-700">READ (OBSERVER)</span>`;
|
|
|
+ if (isFull) {
|
|
|
+ roleBadge = `<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-purple-500/15 text-purple-300 border border-purple-500/30">FULL (SUPER ADMIN)</span>`;
|
|
|
+ } else if (isWrite) {
|
|
|
+ roleBadge = `<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-sky-500/15 text-sky-300 border border-sky-500/30">WRITE (OPERATOR)</span>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ const statusBadge = u.disabled
|
|
|
+ ? `<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-rose-500/15 text-rose-300 border border-rose-500/30"><span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>Disabled</span>`
|
|
|
+ : `<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30"><span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>Active</span>`;
|
|
|
+
|
|
|
+ let lastLoginText = 'Never';
|
|
|
+ if (u.last_login_at && !u.last_login_at.startsWith('0001-01-01')) {
|
|
|
+ const dt = new Date(u.last_login_at);
|
|
|
+ lastLoginText = dt.toLocaleString();
|
|
|
+ }
|
|
|
+
|
|
|
+ const initials = u.username.slice(0, 2).toUpperCase();
|
|
|
+
|
|
|
+ const tr = document.createElement('tr');
|
|
|
+ tr.className = 'hover:bg-slate-800/40 transition group';
|
|
|
+
|
|
|
+ tr.innerHTML = `
|
|
|
+ <td class="py-3 px-4">
|
|
|
+ <div class="flex items-center gap-3">
|
|
|
+ <div class="w-8 h-8 rounded-xl bg-slate-800 border border-slate-700/80 flex items-center justify-center font-bold text-xs text-white">
|
|
|
+ ${initials}
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <div class="font-bold text-white text-xs flex items-center gap-2">
|
|
|
+ <span>${escapeHtml(u.username)}</span>
|
|
|
+ ${isSelf ? '<span class="text-[9px] bg-brand-500/20 text-brand-300 border border-brand-500/30 px-1.5 py-0.2 rounded font-mono">YOU</span>' : ''}
|
|
|
+ </div>
|
|
|
+ <div class="text-[10px] text-slate-500 font-sans">Created: ${u.created_at ? new Date(u.created_at).toLocaleDateString() : '-'}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ <td class="py-3 px-4 font-sans">
|
|
|
+ ${roleBadge}
|
|
|
+ </td>
|
|
|
+ <td class="py-3 px-4 font-sans">
|
|
|
+ ${statusBadge}
|
|
|
+ </td>
|
|
|
+ <td class="py-3 px-4 text-slate-300 font-sans text-xs">
|
|
|
+ ${escapeHtml(u.comment) || '<span class="text-slate-600">-</span>'}
|
|
|
+ </td>
|
|
|
+ <td class="py-3 px-4 text-slate-400 text-xs">
|
|
|
+ ${lastLoginText}
|
|
|
+ </td>
|
|
|
+ <td class="py-3 px-4 text-right space-x-1.5 font-sans">
|
|
|
+ <button onclick="openAdminResetPasswordModal('${escapeHtml(u.username)}')"
|
|
|
+ class="p-1.5 rounded-lg text-slate-400 hover:text-amber-400 hover:bg-slate-800 transition" title="Reset User Password">
|
|
|
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/></svg>
|
|
|
+ </button>
|
|
|
+ <button onclick="openEditUserModal('${escapeHtml(u.username)}')"
|
|
|
+ class="p-1.5 rounded-lg text-slate-400 hover:text-cyan-400 hover:bg-slate-800 transition" title="Edit User">
|
|
|
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
|
|
+ </button>
|
|
|
+ ${!isSelf ? `
|
|
|
+ <button onclick="deleteUser('${escapeHtml(u.username)}')"
|
|
|
+ class="p-1.5 rounded-lg text-slate-400 hover:text-rose-400 hover:bg-slate-800 transition" title="Delete User">
|
|
|
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
|
|
+ </button>
|
|
|
+ ` : ''}
|
|
|
+ </td>
|
|
|
+ `;
|
|
|
+ tbody.appendChild(tr);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function openAddUserModal() {
|
|
|
+ document.getElementById('user-add-username').value = '';
|
|
|
+ document.getElementById('user-add-password').value = '';
|
|
|
+ document.getElementById('user-add-confirm').value = '';
|
|
|
+ document.getElementById('user-add-role').value = 'full';
|
|
|
+ document.getElementById('user-add-comment').value = '';
|
|
|
+ document.getElementById('modal-add-user')?.classList.remove('hidden');
|
|
|
+ document.getElementById('user-add-username')?.focus();
|
|
|
+}
|
|
|
+
|
|
|
+function closeAddUserModal() {
|
|
|
+ document.getElementById('modal-add-user')?.classList.add('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+async function submitAddUser(event) {
|
|
|
+ event.preventDefault();
|
|
|
+ const username = (document.getElementById('user-add-username')?.value || '').trim();
|
|
|
+ const password = document.getElementById('user-add-password')?.value || '';
|
|
|
+ const confirm = document.getElementById('user-add-confirm')?.value || '';
|
|
|
+ const role = document.getElementById('user-add-role')?.value || 'full';
|
|
|
+ const comment = (document.getElementById('user-add-comment')?.value || '').trim();
|
|
|
+
|
|
|
+ if (password !== confirm) {
|
|
|
+ showToast('Validation Error', 'Passwords do not match', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (password.length < 4) {
|
|
|
+ showToast('Validation Error', 'Password must be at least 4 characters', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/users`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ username, password, role, comment })
|
|
|
+ });
|
|
|
+ const data = await res.json();
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('User Created', `Created user ${username} with ${role.toUpperCase()} permissions`, 'success');
|
|
|
+ closeAddUserModal();
|
|
|
+ loadUsers(false);
|
|
|
+ } else {
|
|
|
+ showToast('Error', data.message || 'Failed to create user', 'error');
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function openEditUserModal(username) {
|
|
|
+ const user = (state.users || []).find(u => u.username === username);
|
|
|
+ if (!user) return;
|
|
|
+
|
|
|
+ document.getElementById('user-edit-username').value = user.username;
|
|
|
+ document.getElementById('user-edit-username-title').innerText = `@${user.username}`;
|
|
|
+ document.getElementById('user-edit-role').value = user.role || 'read';
|
|
|
+ document.getElementById('user-edit-comment').value = user.comment || '';
|
|
|
+ document.getElementById('user-edit-disabled').checked = !!user.disabled;
|
|
|
+
|
|
|
+ document.getElementById('modal-edit-user')?.classList.remove('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+function closeEditUserModal() {
|
|
|
+ document.getElementById('modal-edit-user')?.classList.add('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+async function submitEditUser(event) {
|
|
|
+ event.preventDefault();
|
|
|
+ const username = document.getElementById('user-edit-username')?.value;
|
|
|
+ const role = document.getElementById('user-edit-role')?.value || 'read';
|
|
|
+ const comment = (document.getElementById('user-edit-comment')?.value || '').trim();
|
|
|
+ const disabled = document.getElementById('user-edit-disabled')?.checked || false;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/users/${encodeURIComponent(username)}`, {
|
|
|
+ method: 'PUT',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ role, comment, disabled })
|
|
|
+ });
|
|
|
+ const data = await res.json();
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('User Updated', `Updated settings for ${username}`, 'success');
|
|
|
+ closeEditUserModal();
|
|
|
+ loadUsers(false);
|
|
|
+
|
|
|
+ // If updated own role/disabled status, refresh self profile
|
|
|
+ if (username === state.currentUser?.username) {
|
|
|
+ checkAuthStatus();
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ showToast('Error', data.message || 'Failed to update user', 'error');
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function openAdminResetPasswordModal(username) {
|
|
|
+ document.getElementById('admin-reset-username').value = username;
|
|
|
+ document.getElementById('admin-reset-username-title').innerText = `@${username}`;
|
|
|
+ document.getElementById('admin-reset-new-pw').value = '';
|
|
|
+ document.getElementById('admin-reset-confirm-pw').value = '';
|
|
|
+ document.getElementById('modal-admin-reset-pw')?.classList.remove('hidden');
|
|
|
+ document.getElementById('admin-reset-new-pw')?.focus();
|
|
|
+}
|
|
|
+
|
|
|
+function closeAdminResetPasswordModal() {
|
|
|
+ document.getElementById('modal-admin-reset-pw')?.classList.add('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+async function submitAdminResetPassword(event) {
|
|
|
+ event.preventDefault();
|
|
|
+ const username = document.getElementById('admin-reset-username')?.value;
|
|
|
+ const newPassword = document.getElementById('admin-reset-new-pw')?.value || '';
|
|
|
+ const confirm = document.getElementById('admin-reset-confirm-pw')?.value || '';
|
|
|
+
|
|
|
+ if (newPassword !== confirm) {
|
|
|
+ showToast('Validation Error', 'Passwords do not match', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (newPassword.length < 4) {
|
|
|
+ showToast('Validation Error', 'Password must be at least 4 characters', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/users/${encodeURIComponent(username)}/password`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ new_password: newPassword })
|
|
|
+ });
|
|
|
+ const data = await res.json();
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('Password Reset', `Password for user ${username} updated successfully`, 'success');
|
|
|
+ closeAdminResetPasswordModal();
|
|
|
+ } else {
|
|
|
+ showToast('Error', data.message || 'Failed to reset password', 'error');
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function deleteUser(username) {
|
|
|
+ if (username === state.currentUser?.username) {
|
|
|
+ showToast('Action Forbidden', 'You cannot delete your own account', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!confirm(`Are you sure you want to completely remove user "${username}"?`)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/users/${encodeURIComponent(username)}`, {
|
|
|
+ method: 'DELETE'
|
|
|
+ });
|
|
|
+ const data = await res.json();
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('User Deleted', `User ${username} has been removed`, 'success');
|
|
|
+ loadUsers(false);
|
|
|
+ } else {
|
|
|
+ showToast('Error', data.message || 'Failed to delete user', 'error');
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function openSelfPasswordModal() {
|
|
|
+ document.getElementById('self-current-password').value = '';
|
|
|
+ document.getElementById('self-new-password').value = '';
|
|
|
+ document.getElementById('self-confirm-password').value = '';
|
|
|
+ document.getElementById('modal-self-password')?.classList.remove('hidden');
|
|
|
+ document.getElementById('self-current-password')?.focus();
|
|
|
+}
|
|
|
+
|
|
|
+function closeSelfPasswordModal() {
|
|
|
+ document.getElementById('modal-self-password')?.classList.add('hidden');
|
|
|
+}
|
|
|
+
|
|
|
+async function submitSelfPassword(event) {
|
|
|
+ event.preventDefault();
|
|
|
+ const oldPassword = document.getElementById('self-current-password')?.value || '';
|
|
|
+ const newPassword = document.getElementById('self-new-password')?.value || '';
|
|
|
+ const confirm = document.getElementById('self-confirm-password')?.value || '';
|
|
|
+
|
|
|
+ if (newPassword !== confirm) {
|
|
|
+ showToast('Validation Error', 'New passwords do not match', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (newPassword.length < 4) {
|
|
|
+ showToast('Validation Error', 'Password must be at least 4 characters', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await fetch(`${API_BASE}/auth/change-password`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ old_password: oldPassword, new_password: newPassword })
|
|
|
+ });
|
|
|
+ const data = await res.json();
|
|
|
+ if (res.ok) {
|
|
|
+ showToast('Password Changed', 'Your password has been changed successfully', 'success');
|
|
|
+ closeSelfPasswordModal();
|
|
|
+ } else {
|
|
|
+ showToast('Error', data.message || 'Failed to change password', 'error');
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ showToast('Error', err.message, 'error');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
|
|
|
|