// Copyright © 2026 Olaf Kolkman // SPDX-License-Identifier: GPL-3.0-or-later (() => { const pluginList = document.querySelector('#plugin-list'); const adminLabelList = document.querySelector('#admin-label-list'); const userList = document.querySelector('#user-list'); const userForm = document.querySelector('#user-form'); const adminControls = document.querySelector('#admin-controls'); const adminAuthNotice = document.querySelector('#admin-auth-notice'); const smtpForm = document.querySelector('#smtp-form'); const smtpTestButton = document.querySelector('#smtp-test-button'); const smtpStatus = document.querySelector('#smtp-status'); const themesForm = document.querySelector('#themes-form'); const themeOptions = document.querySelector('#theme-options'); const themeStatus = document.querySelector('#theme-status'); let smtpNextAllowedAt = null; let smtpTimerHandle = null; const accessToken = localStorage.getItem('linklogAccessToken'); let currentUserId = null; function authHeaders(includeJson = false) { return { ...(includeJson ? {'Content-Type': 'application/json'} : {}), ...(accessToken ? {Authorization: `Bearer ${accessToken}`} : {}), }; } async function responseError(response, fallback) { try { const result = await response.json(); return result.detail || result.message || fallback; } catch (error) { return fallback; } } function renderPlugins(plugins) { pluginList.replaceChildren(...plugins.map((plugin) => { const row = document.createElement('div'); row.className = 'plugin-row'; const label = document.createElement('span'); label.textContent = `${plugin.name} (${plugin.version})`; const button = document.createElement('button'); button.type = 'button'; button.textContent = plugin.enabled ? 'Disable' : 'Enable'; button.addEventListener('click', () => updatePlugin(plugin, button)); row.append(label, button); return row; })); } function renderLabels(labels) { adminLabelList.replaceChildren(...labels.map((label) => { const row = document.createElement('div'); row.className = 'plugin-row'; const text = document.createElement('span'); text.textContent = `${label.name}${label.creator ? ` (${label.creator})` : ' (default)'}`; const button = document.createElement('button'); button.type = 'button'; button.className = 'danger-button'; button.textContent = 'Delete'; button.addEventListener('click', async () => { const response = await fetch(`/api/admin/labels/${label.id}`, {method: 'DELETE', headers: authHeaders()}); if (response.ok) loadLabels(); }); row.append(text, button); return row; })); } async function loadLabels() { const response = await fetch('/api/admin/labels', {headers: authHeaders()}); if (!response.ok) throw new Error('Could not load labels'); renderLabels(await response.json()); } function showSmtpStatus(message, isError = false) { smtpStatus.textContent = message; smtpStatus.style.color = isError ? '#f38ba8' : '#94e2d5'; } function updateSmtpTimer() { if (smtpTimerHandle) window.clearTimeout(smtpTimerHandle); if (!smtpNextAllowedAt) { smtpTestButton.disabled = false; return; } const seconds = Math.max(0, Math.ceil((smtpNextAllowedAt - Date.now()) / 1000)); if (seconds === 0) { smtpNextAllowedAt = null; updateSmtpTimer(); return; } const minutes = Math.floor(seconds / 60); showSmtpStatus(`Next validation email available in ${minutes ? `${minutes}m ` : ''}${seconds % 60}s.`); smtpTestButton.disabled = true; smtpTimerHandle = window.setTimeout(updateSmtpTimer, 1000); } async function loadSmtpSettings() { const response = await fetch('/api/admin/smtp', {headers: authHeaders()}); if (!response.ok) throw new Error('Could not load SMTP settings'); const settings = await response.json(); for (const [name, value] of Object.entries(settings)) { const field = smtpForm.elements[name]; if (!field || name === 'password_configured') continue; if (field.type === 'checkbox') { field.checked = value; } else { field.value = value; } } } async function loadThemes() { const response = await fetch('/api/admin/themes', {headers: authHeaders()}); if (!response.ok) throw new Error('Could not load themes'); const result = await response.json(); themeOptions.replaceChildren(...Object.entries(result.themes).map(([id, theme]) => { const label = document.createElement('label'); const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.name = 'theme'; checkbox.value = id; checkbox.checked = result.enabled.includes(id); label.append(checkbox, document.createTextNode(` ${theme.label}`)); return label; })); } function renderUsers(users) { userList.replaceChildren(...users.map((user) => { const row = document.createElement('div'); row.className = 'plugin-row'; const label = document.createElement('span'); label.textContent = `${user.username} (${user.email})${user.is_admin ? ' - admin' : ''}`; const privilegeLabel = document.createElement('label'); privilegeLabel.className = 'user-admin-toggle'; const privilegeCheckbox = document.createElement('input'); privilegeCheckbox.type = 'checkbox'; privilegeCheckbox.checked = user.is_admin; const isCurrentUser = user.id === currentUserId; privilegeCheckbox.disabled = isCurrentUser; privilegeCheckbox.setAttribute('aria-label', `Administrator rights for ${user.username}`); if (!isCurrentUser) { privilegeCheckbox.addEventListener('change', () => updateUserPrivilege(user, privilegeCheckbox)); } privilegeLabel.append(privilegeCheckbox, document.createTextNode(' Administrator')); row.append(label, privilegeLabel); if (!isCurrentUser) { const otpButton = document.createElement('button'); otpButton.type = 'button'; otpButton.textContent = 'Reset OTP'; otpButton.addEventListener('click', () => resetUserOtp(user, otpButton)); row.append(otpButton); const button = document.createElement('button'); button.type = 'button'; button.className = 'danger-button'; button.textContent = 'Remove'; button.addEventListener('click', () => removeUser(user, button)); row.append(button); } return row; })); } async function resetUserOtp(user, button) { if (!window.confirm(`Disable OTP for ${user.username}?`)) return; button.disabled = true; const status = document.querySelector('#user-status'); try { const response = await fetch(`/api/admin/users/${encodeURIComponent(user.id)}/otp/reset`, { method: 'POST', headers: authHeaders(), }); if (!response.ok) { throw new Error(await responseError(response, `Request failed (${response.status})`)); } status.textContent = `OTP disabled for ${user.username}.`; status.style.color = '#94e2d5'; } catch (error) { status.textContent = `Could not reset OTP for ${user.username}: ${error.message}`; status.style.color = '#f38ba8'; button.disabled = false; } } async function loadUsers() { const response = await fetch('/api/admin/users', {headers: authHeaders()}); if (!response.ok) throw new Error('Could not load users'); renderUsers(await response.json()); } function showAdminState(isAdmin) { adminControls.classList.toggle('hidden', !isAdmin); adminAuthNotice.classList.toggle('hidden', isAdmin); } function showSignedOutState() { showAdminState(false); adminAuthNotice.innerHTML = 'Administrator sign-in required. Sign in'; } function showUnauthorizedState() { showAdminState(false); adminAuthNotice.textContent = 'You are signed in, but you are not authorized to access this page.'; } async function loadAdminState() { if (!accessToken) { showSignedOutState(); return; } const sessionResponse = await fetch('/api/auth/me', {headers: authHeaders()}); if (!sessionResponse.ok) { localStorage.removeItem('linklogAccessToken'); showSignedOutState(); return; } const user = await sessionResponse.json(); currentUserId = user.id; if (!user.is_admin) { showUnauthorizedState(); return; } await Promise.all([loadUsers(), loadPlugins(), loadLabels(), loadSmtpSettings(), loadThemes()]); showAdminState(true); } async function loadPlugins() { const response = await fetch('/api/admin/plugins', {headers: authHeaders()}); if (!response.ok) throw new Error('Could not load plugins'); renderPlugins(await response.json()); } async function updatePlugin(plugin, button) { button.disabled = true; const response = await fetch(`/api/admin/plugins/${encodeURIComponent(plugin.name)}`, { method: 'PUT', headers: authHeaders(true), body: JSON.stringify({enabled: !plugin.enabled}), }); if (response.ok) { await loadPlugins(); } else { button.disabled = false; } } async function removeUser(user, button) { if (!window.confirm(`Remove ${user.username}?`)) return; button.disabled = true; const status = document.querySelector('#user-status'); try { const response = await fetch(`/api/admin/users/${encodeURIComponent(user.id)}`, { method: 'DELETE', headers: authHeaders(), }); if (!response.ok) { const detail = await response.text(); throw new Error(detail || `Request failed (${response.status})`); } await loadUsers(); status.textContent = `Removed ${user.username}.`; status.style.color = '#94e2d5'; } catch (error) { status.textContent = `Could not remove ${user.username}: ${error.message}`; status.style.color = '#f38ba8'; button.disabled = false; } } async function updateUserPrivilege(user, checkbox) { checkbox.disabled = true; const response = await fetch(`/api/admin/users/${encodeURIComponent(user.id)}`, { method: 'PUT', headers: authHeaders(true), body: JSON.stringify({is_admin: checkbox.checked}), }); if (response.ok) { await loadUsers(); } else { checkbox.checked = user.is_admin; checkbox.disabled = false; } } userForm.addEventListener('submit', async (event) => { event.preventDefault(); const values = Object.fromEntries(new FormData(userForm)); values.is_admin = userForm.elements.is_admin.checked; const status = document.querySelector('#user-status'); try { const response = await fetch('/api/admin/users', { method: 'POST', headers: authHeaders(true), body: JSON.stringify(values), }); status.textContent = response.ok ? 'User added.' : await responseError(response, 'Could not add user.'); status.style.color = response.ok ? '#94e2d5' : '#f38ba8'; if (response.ok) { userForm.reset(); await loadUsers(); } } catch (error) { status.textContent = `Could not add user: ${error.message}`; status.style.color = '#f38ba8'; } }); smtpForm.addEventListener('submit', async (event) => { event.preventDefault(); const values = Object.fromEntries(new FormData(smtpForm)); values.smtp_port = Number(values.smtp_port); values.smtp_use_tls = smtpForm.elements.smtp_use_tls.checked; try { const response = await fetch('/api/admin/smtp', { method: 'PUT', headers: authHeaders(true), body: JSON.stringify(values), }); showSmtpStatus(response.ok ? 'SMTP settings saved.' : await responseError(response, 'Could not save SMTP settings.'), !response.ok); if (response.ok) smtpForm.elements.smtp_password.value = ''; } catch (error) { showSmtpStatus(`Could not save SMTP settings: ${error.message}`, true); } }); smtpTestButton.addEventListener('click', async () => { smtpTestButton.disabled = true; const values = Object.fromEntries(new FormData(smtpForm)); values.smtp_port = Number(values.smtp_port); values.smtp_use_tls = smtpForm.elements.smtp_use_tls.checked; try { const response = await fetch('/api/admin/smtp/test', { method: 'POST', headers: authHeaders(true), body: JSON.stringify(values), }); const result = response.ok ? await response.json() : {}; showSmtpStatus(response.ok ? result.message : await responseError(response, 'SMTP validation failed.'), !response.ok); if (response.headers.get('Retry-After')) { smtpNextAllowedAt = Date.now() + Number(response.headers.get('Retry-After')) * 1000; } else if (result.next_allowed_at) { smtpNextAllowedAt = Date.parse(result.next_allowed_at); } } catch (error) { showSmtpStatus(`SMTP validation failed: ${error.message}`, true); } updateSmtpTimer(); }); themesForm.addEventListener('submit', async (event) => { event.preventDefault(); const themes = [...themesForm.querySelectorAll('input[name="theme"]:checked')].map((input) => input.value); const response = await fetch('/api/admin/themes', { method: 'PUT', headers: authHeaders(true), body: JSON.stringify({themes}), }); const result = await response.json(); themeStatus.textContent = response.ok ? 'Themes saved.' : (result.detail || 'Could not save themes.'); themeStatus.style.color = response.ok ? '#94e2d5' : '#f38ba8'; }); loadAdminState().catch((error) => { showAdminState(false); adminAuthNotice.textContent = accessToken ? `Could not verify administrator access: ${error.message}` : 'Administrator sign-in required.'; adminAuthNotice.classList.remove('hidden'); userList.textContent = ''; pluginList.textContent = ''; adminLabelList.textContent = ''; smtpForm.reset(); themesForm.reset(); }); })();