// 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 otpRecoverButton = document.querySelector('#otp-recover'); 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 otpRecoveryCodes = document.querySelector('#otp-recovery-codes'); 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; otpRecoveryCodes.textContent = result.recovery_codes.join('\n'); 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 currentPassword = document.querySelector('#otp-current-password').value; const response = await fetch('/api/user/otp', { method: 'POST', headers: authHeaders(true), body: JSON.stringify({action: 'disable', code, current_password: currentPassword}), }); 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.'); }); otpRecoverButton.addEventListener('click', async () => { const currentPassword = document.querySelector('#otp-current-password').value; const recoveryCode = document.querySelector('#otp-recovery-code').value.trim(); const response = await fetch('/api/user/otp/recover', { method: 'POST', headers: authHeaders(true), body: JSON.stringify({current_password: currentPassword, recovery_code: recoveryCode}), }); const result = await response.json(); if (!response.ok) { setOtpStatus(result.detail || 'Could not recover one-time password access.', true); return; } otpDisabled.classList.remove('hidden'); otpEnabled.classList.add('hidden'); document.querySelector('#otp-current-password').value = ''; document.querySelector('#otp-recovery-code').value = ''; setOtpStatus('One-time password access recovered.'); }); 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); }); })();