295 lines
12 KiB
JavaScript
295 lines
12 KiB
JavaScript
// Copyright © 2026 Olaf Kolkman
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
(() => {
|
|
const profileForm = document.querySelector('#profile-form');
|
|
const passwordForm = document.querySelector('#password-form');
|
|
const mastodonForm = document.querySelector('#mastodon-form');
|
|
const profileLogoutButton = document.querySelector('#logout-button');
|
|
const accessToken = localStorage.getItem('linklogAccessToken');
|
|
const defaultPostPrefix = 'From my #LinkLog: ';
|
|
const mastodonConnectButton = document.querySelector('#mastodon-connect');
|
|
const otpSetupButton = document.querySelector('#otp-setup');
|
|
const otpEnableButton = document.querySelector('#otp-enable');
|
|
const otpDisableButton = document.querySelector('#otp-disable');
|
|
const otpProvisioning = document.querySelector('#otp-provisioning');
|
|
const otpDisabled = document.querySelector('#otp-disabled');
|
|
const otpEnabled = document.querySelector('#otp-enabled');
|
|
const otpSecret = document.querySelector('#otp-secret');
|
|
const otpUri = document.querySelector('#otp-uri');
|
|
const otpStatus = document.querySelector('#otp-status');
|
|
const emailAddressList = document.querySelector('#email-address-list');
|
|
const additionalEmailForm = document.querySelector('#additional-email-form');
|
|
const emailAddressStatus = document.querySelector('#email-address-status');
|
|
|
|
function authHeaders(includeJson = false) {
|
|
return {
|
|
...(includeJson ? {'Content-Type': 'application/json'} : {}),
|
|
...(accessToken ? {Authorization: `Bearer ${accessToken}`} : {}),
|
|
};
|
|
}
|
|
|
|
function setStatus(selector, message, isError = false) {
|
|
const status = document.querySelector(selector);
|
|
status.textContent = message;
|
|
status.style.color = isError ? '#b91c1c' : '#166534';
|
|
}
|
|
|
|
function setOtpStatus(message, isError = false) {
|
|
otpStatus.textContent = message;
|
|
otpStatus.style.color = isError ? '#b91c1c' : '#166534';
|
|
}
|
|
|
|
function setEmailAddressStatus(message, isError = false) {
|
|
emailAddressStatus.textContent = message;
|
|
emailAddressStatus.style.color = isError ? '#b91c1c' : '#166534';
|
|
}
|
|
|
|
function renderEmailAddresses(addresses) {
|
|
emailAddressList.replaceChildren(...addresses.map((address) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'email-address-row';
|
|
const label = document.createElement('span');
|
|
label.textContent = `${address.email} - ${address.verified ? 'validated' : 'not validated'}${address.primary ? ' (primary)' : ''}`;
|
|
row.appendChild(label);
|
|
if (!address.primary) {
|
|
if (!address.verified) {
|
|
const resend = document.createElement('button');
|
|
resend.type = 'button';
|
|
resend.textContent = 'Resend validation';
|
|
resend.addEventListener('click', async () => {
|
|
resend.disabled = true;
|
|
const response = await fetch(`/api/user/emails/${encodeURIComponent(address.id)}/resend`, {method: 'POST', headers: authHeaders()});
|
|
const result = await response.json();
|
|
setEmailAddressStatus(response.ok ? result.message : (result.detail || 'Could not send validation email.'), !response.ok);
|
|
if (response.ok && result.next_allowed_at) window.setTimeout(() => { resend.disabled = false; }, Math.max(0, Date.parse(result.next_allowed_at) - Date.now()));
|
|
else if (!response.ok) resend.disabled = false;
|
|
});
|
|
row.appendChild(resend);
|
|
}
|
|
if (address.verified && address.can_be_primary) {
|
|
const makePrimary = document.createElement('button');
|
|
makePrimary.type = 'button';
|
|
makePrimary.textContent = 'Make primary';
|
|
makePrimary.addEventListener('click', async () => {
|
|
const response = await fetch(`/api/user/emails/${encodeURIComponent(address.id)}/make-primary`, {method: 'POST', headers: authHeaders()});
|
|
const result = await response.json();
|
|
setEmailAddressStatus(response.ok ? `${result.email} is now the primary email address.` : (result.detail || 'Could not change primary email.'), !response.ok);
|
|
if (response.ok) {
|
|
await loadEmailAddresses();
|
|
}
|
|
});
|
|
row.appendChild(makePrimary);
|
|
}
|
|
const remove = document.createElement('button');
|
|
remove.type = 'button';
|
|
remove.textContent = 'Remove';
|
|
remove.addEventListener('click', async () => {
|
|
const response = await fetch(`/api/user/emails/${encodeURIComponent(address.id)}`, {method: 'DELETE', headers: authHeaders()});
|
|
if (response.ok) loadEmailAddresses();
|
|
else setEmailAddressStatus('Could not remove email address.', true);
|
|
});
|
|
row.appendChild(remove);
|
|
}
|
|
return row;
|
|
}));
|
|
}
|
|
|
|
async function loadEmailAddresses() {
|
|
const response = await fetch('/api/user/emails', {headers: authHeaders()});
|
|
if (!response.ok) throw new Error('Could not load email addresses');
|
|
renderEmailAddresses(await response.json());
|
|
}
|
|
|
|
additionalEmailForm.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
const response = await fetch('/api/user/emails', {
|
|
method: 'POST', headers: authHeaders(true), body: JSON.stringify({email: additionalEmailForm.elements.email.value.trim()}),
|
|
});
|
|
const result = await response.json();
|
|
setEmailAddressStatus(response.ok ? 'Email address added. Check your inbox to validate it.' : (result.detail || 'Could not add email address.'), !response.ok);
|
|
if (response.ok) {
|
|
additionalEmailForm.reset();
|
|
loadEmailAddresses();
|
|
}
|
|
});
|
|
|
|
async function loadOtp() {
|
|
const response = await fetch('/api/user/otp', {headers: authHeaders()});
|
|
if (!response.ok) throw new Error('Could not load one-time password settings');
|
|
const result = await response.json();
|
|
otpDisabled.classList.toggle('hidden', result.enabled);
|
|
otpEnabled.classList.toggle('hidden', !result.enabled);
|
|
}
|
|
|
|
otpSetupButton.addEventListener('click', async () => {
|
|
const response = await fetch('/api/user/otp/setup', {method: 'POST', headers: authHeaders()});
|
|
const result = await response.json();
|
|
if (!response.ok) {
|
|
setOtpStatus(result.detail || 'Could not start one-time password setup.', true);
|
|
return;
|
|
}
|
|
otpSecret.textContent = result.secret;
|
|
otpUri.href = result.otpauth_url;
|
|
otpProvisioning.classList.remove('hidden');
|
|
setOtpStatus('Enter a code from your authenticator app to confirm setup.');
|
|
});
|
|
|
|
otpEnableButton.addEventListener('click', async () => {
|
|
const code = document.querySelector('#otp-setup-code').value.trim();
|
|
const response = await fetch('/api/user/otp', {
|
|
method: 'POST', headers: authHeaders(true), body: JSON.stringify({action: 'enable', code}),
|
|
});
|
|
const result = await response.json();
|
|
if (!response.ok) {
|
|
setOtpStatus(result.detail || 'Could not enable one-time password.', true);
|
|
return;
|
|
}
|
|
otpDisabled.classList.add('hidden');
|
|
otpEnabled.classList.remove('hidden');
|
|
otpProvisioning.classList.add('hidden');
|
|
setOtpStatus('One-time password enabled.');
|
|
});
|
|
|
|
otpDisableButton.addEventListener('click', async () => {
|
|
const code = document.querySelector('#otp-disable-code').value.trim();
|
|
const response = await fetch('/api/user/otp', {
|
|
method: 'POST', headers: authHeaders(true), body: JSON.stringify({action: 'disable', code}),
|
|
});
|
|
const result = await response.json();
|
|
if (!response.ok) {
|
|
setOtpStatus(result.detail || 'Could not disable one-time password.', true);
|
|
return;
|
|
}
|
|
otpDisabled.classList.remove('hidden');
|
|
otpEnabled.classList.add('hidden');
|
|
document.querySelector('#otp-disable-code').value = '';
|
|
setOtpStatus('One-time password disabled.');
|
|
});
|
|
|
|
async function loadProfile() {
|
|
const response = await fetch('/api/user/me', {headers: authHeaders()});
|
|
if (!response.ok) throw new Error('Could not load profile');
|
|
const profile = await response.json();
|
|
document.querySelector('#username').textContent = profile.username || '';
|
|
document.querySelector('#email').textContent = profile.email || '';
|
|
document.querySelector('#bio').value = profile.bio || '';
|
|
const avatarPreview = document.querySelector('#avatar-preview');
|
|
if (profile.avatar_url) {
|
|
avatarPreview.src = profile.avatar_url;
|
|
avatarPreview.classList.remove('hidden');
|
|
}
|
|
if (profile.is_admin) {
|
|
document.querySelector('#auth-admin-link')?.classList.remove('hidden');
|
|
}
|
|
profileLogoutButton.classList.remove('hidden');
|
|
}
|
|
|
|
async function loadMastodonConfig() {
|
|
const response = await fetch('/api/user/plugins/mastodon', {headers: authHeaders()});
|
|
if (!response.ok) throw new Error('Could not load Mastodon settings');
|
|
const config = await response.json();
|
|
document.querySelector('#mastodon-instance').value = config.instance || 'mastodon.social';
|
|
document.querySelector('#mastodon-post-prefix').value = config.post_prefix || defaultPostPrefix;
|
|
}
|
|
|
|
profileForm.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
const formData = new FormData(profileForm);
|
|
formData.delete('avatar');
|
|
const response = await fetch('/api/user/me', {
|
|
method: 'PUT',
|
|
headers: authHeaders(true),
|
|
body: JSON.stringify(Object.fromEntries(formData)),
|
|
});
|
|
setStatus('#profile-status', response.ok ? 'Profile saved.' : 'Could not save profile.', !response.ok);
|
|
});
|
|
|
|
document.querySelector('#avatar-file').addEventListener('change', async (event) => {
|
|
const file = event.target.files[0];
|
|
if (!file) return;
|
|
const formData = new FormData();
|
|
formData.append('avatar', file);
|
|
const response = await fetch('/api/user/avatar', {
|
|
method: 'POST',
|
|
headers: authHeaders(),
|
|
body: formData,
|
|
});
|
|
if (!response.ok) {
|
|
setStatus('#profile-status', 'Could not upload avatar.', true);
|
|
return;
|
|
}
|
|
const data = await response.json();
|
|
const avatarPreview = document.querySelector('#avatar-preview');
|
|
avatarPreview.src = data.avatar_url;
|
|
avatarPreview.classList.remove('hidden');
|
|
setStatus('#profile-status', 'Avatar uploaded.');
|
|
});
|
|
|
|
mastodonForm.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
const formData = new FormData(mastodonForm);
|
|
const response = await fetch('/api/user/plugins/mastodon', {
|
|
method: 'PUT',
|
|
headers: authHeaders(true),
|
|
body: JSON.stringify(Object.fromEntries(formData)),
|
|
});
|
|
setStatus('#mastodon-status', response.ok ? 'Mastodon settings saved.' : 'Could not save Mastodon settings.', !response.ok);
|
|
});
|
|
|
|
mastodonConnectButton.addEventListener('click', async () => {
|
|
mastodonConnectButton.disabled = true;
|
|
const instance = document.querySelector('#mastodon-instance').value.trim();
|
|
if (!instance) {
|
|
setStatus('#mastodon-status', 'Enter the Mastodon server you want to use.', true);
|
|
mastodonConnectButton.disabled = false;
|
|
return;
|
|
}
|
|
try {
|
|
const settingsResponse = await fetch('/api/user/plugins/mastodon', {
|
|
method: 'PUT',
|
|
headers: authHeaders(true),
|
|
body: JSON.stringify({instance}),
|
|
});
|
|
if (!settingsResponse.ok) throw new Error('Could not save the Mastodon server.');
|
|
setStatus('#mastodon-status', `Authorizing with ${instance}...`);
|
|
const response = await fetch(`/api/mastodon/oauth/start?instance=${encodeURIComponent(instance)}`, {headers: authHeaders()});
|
|
const result = await response.json();
|
|
if (!response.ok || !result.authorization_url) throw new Error(result.detail || result.error || 'Could not start Mastodon authorization.');
|
|
window.location.assign(result.authorization_url);
|
|
} catch (error) {
|
|
setStatus('#mastodon-status', error.message, true);
|
|
mastodonConnectButton.disabled = false;
|
|
}
|
|
});
|
|
|
|
const mastodonParams = new URLSearchParams(window.location.search);
|
|
if (mastodonParams.get('mastodon') === 'connected') setStatus('#mastodon-status', 'Mastodon connected.');
|
|
if (mastodonParams.get('mastodon_error')) setStatus('#mastodon-status', mastodonParams.get('mastodon_error'), true);
|
|
|
|
passwordForm.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
const password = passwordForm.elements.new_password.value;
|
|
const confirmation = passwordForm.elements.new_password_confirmation.value;
|
|
if (password !== confirmation) {
|
|
const status = document.querySelector('#password-status');
|
|
status.textContent = 'New passwords do not match.';
|
|
status.style.color = '#f38ba8';
|
|
return;
|
|
}
|
|
const response = await fetch('/api/user/password', {
|
|
method: 'PUT',
|
|
headers: authHeaders(true),
|
|
body: JSON.stringify(Object.fromEntries(new FormData(passwordForm))),
|
|
});
|
|
const status = document.querySelector('#password-status');
|
|
status.textContent = response.ok ? 'Password changed.' : 'Could not change password.';
|
|
status.style.color = response.ok ? '#94e2d5' : '#f38ba8';
|
|
if (response.ok) passwordForm.reset();
|
|
});
|
|
|
|
Promise.all([loadProfile(), loadMastodonConfig(), loadOtp(), loadEmailAddresses()]).catch((error) => {
|
|
setStatus('#profile-status', accessToken ? error.message : 'Please sign in first.', true);
|
|
});
|
|
})();
|