495 lines
16 KiB
JavaScript
495 lines
16 KiB
JavaScript
// 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('#admin-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 actions = document.createElement('div');
|
|
actions.style.display = 'flex';
|
|
actions.style.gap = '8px';
|
|
|
|
const editButton = document.createElement('button');
|
|
editButton.type = 'button';
|
|
editButton.textContent = 'Edit';
|
|
editButton.addEventListener('click', () => {
|
|
showAdminInlineLabelEdit(row, label);
|
|
});
|
|
|
|
const deleteButton = document.createElement('button');
|
|
deleteButton.type = 'button';
|
|
deleteButton.className = 'danger-button';
|
|
deleteButton.textContent = 'Delete';
|
|
deleteButton.addEventListener('click', async () => {
|
|
if (!confirm(`Are you sure you want to delete "${label.name}"?`)) return;
|
|
const response = await fetch(`/api/admin/labels/${label.id}`, {method: 'DELETE', headers: authHeaders()});
|
|
if (response.ok) loadLabels();
|
|
});
|
|
|
|
actions.append(editButton, deleteButton);
|
|
row.append(text, actions);
|
|
return row;
|
|
}));
|
|
}
|
|
|
|
function showAdminInlineLabelEdit(rowContainer, label) {
|
|
rowContainer.replaceChildren();
|
|
|
|
const editForm = document.createElement('form');
|
|
editForm.style.display = 'flex';
|
|
editForm.style.gap = '8px';
|
|
editForm.style.width = '100%';
|
|
editForm.style.alignItems = 'center';
|
|
|
|
const input = document.createElement('input');
|
|
input.type = 'text';
|
|
input.value = label.name;
|
|
input.required = true;
|
|
input.style.flex = '1';
|
|
|
|
const saveButton = document.createElement('button');
|
|
saveButton.type = 'submit';
|
|
saveButton.textContent = 'Save';
|
|
|
|
const cancelButton = document.createElement('button');
|
|
cancelButton.type = 'button';
|
|
cancelButton.textContent = 'Cancel';
|
|
cancelButton.addEventListener('click', () => {
|
|
loadLabels();
|
|
});
|
|
|
|
editForm.append(input, saveButton, cancelButton);
|
|
|
|
editForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const newName = input.value.trim();
|
|
if (!newName) return;
|
|
const response = await fetch(`/api/admin/labels/${label.id}`, {
|
|
method: 'PUT',
|
|
headers: authHeaders(true),
|
|
body: JSON.stringify({ name: newName }),
|
|
});
|
|
if (response.ok) {
|
|
loadLabels();
|
|
} else {
|
|
alert(await responseError(response, 'Could not update label'));
|
|
}
|
|
});
|
|
|
|
rowContainer.appendChild(editForm);
|
|
input.focus();
|
|
}
|
|
|
|
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 initPanelToggles() {
|
|
const panels = document.querySelectorAll('#admin-controls .settings-panel');
|
|
panels.forEach((panel) => {
|
|
panel.classList.add('minimized');
|
|
const h2 = panel.querySelector('h2');
|
|
if (!h2) return;
|
|
let btn = h2.querySelector('.panel-toggle-btn');
|
|
if (!btn) {
|
|
const titleText = h2.textContent.trim();
|
|
h2.replaceChildren();
|
|
btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'panel-toggle-btn';
|
|
btn.setAttribute('aria-expanded', 'false');
|
|
const textSpan = document.createElement('span');
|
|
textSpan.textContent = titleText;
|
|
const iconSpan = document.createElement('span');
|
|
iconSpan.className = 'panel-toggle-icon';
|
|
iconSpan.setAttribute('aria-hidden', 'true');
|
|
iconSpan.textContent = '▼';
|
|
btn.append(textSpan, iconSpan);
|
|
h2.appendChild(btn);
|
|
} else {
|
|
btn.setAttribute('aria-expanded', 'false');
|
|
}
|
|
|
|
if (h2.dataset.initialized) return;
|
|
h2.dataset.initialized = 'true';
|
|
|
|
h2.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
const isMinimized = panel.classList.toggle('minimized');
|
|
if (btn) {
|
|
btn.setAttribute('aria-expanded', String(!isMinimized));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function showAdminState(isAdmin) {
|
|
adminControls.classList.toggle('hidden', !isAdmin);
|
|
adminAuthNotice.classList.toggle('hidden', isAdmin);
|
|
if (isAdmin) {
|
|
initPanelToggles();
|
|
}
|
|
}
|
|
|
|
function showSignedOutState() {
|
|
showAdminState(false);
|
|
adminAuthNotice.innerHTML = 'Administrator sign-in required. <a href="/login">Sign in</a>';
|
|
}
|
|
|
|
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();
|
|
});
|
|
|
|
document.addEventListener('DOMContentLoaded', initPanelToggles);
|
|
initPanelToggles();
|
|
})();
|