super-productivity/packages/super-sync-server/public/app.js
Johannes Millan 70414145a3
fix(sync): prevent magic link prefetch failures and improve auth resilience (#7175)
* fix(sync): prevent magic link prefetch failures and improve auth resilience

- Make /magic-login prefetch-immune: GET now renders a confirmation page
  instead of consuming the single-use token. A "Log In" button triggers
  POST /api/login/magic-link/verify which does the actual verification.
  This prevents email client link prefetchers (Outlook SafeLinks, Gmail)
  from consuming magic link tokens before users click them.

- Increase rate limits on auth endpoints for shared-IP scenarios (e.g.
  university NAT). Global: 100→500, register: 10→50, login: 5→30,
  verify: 10→50, magic-login page: 10→50.

- Add trustProxy to Fastify so req.ip uses X-Forwarded-For behind
  reverse proxies instead of collapsing all clients to one rate bucket.

- Clear stale SuperSync auth tokens after 3 consecutive AuthFailSPErrors
  instead of never clearing them. Prevents persistent failure loops where
  an invalid token keeps retrying indefinitely until app reinstall.

- Improve registration-to-login UX messaging to emphasize email
  verification is required before login links will work.

https://claude.ai/code/session_01WkbLzvhguQwRDtk7ht38aY

* chore: update package-lock.json from npm install

https://claude.ai/code/session_01WkbLzvhguQwRDtk7ht38aY

* fix(sync): address review feedback for auth resilience

- Remove conflicting style.css link from magic-login page
- Change trustProxy from true to 1 (trust single hop only)
- Increase /login/magic-link rate limit from 30 to 50 per 15min
- Delete dead magic-login-redirect.js (replaced by magic-login-confirm.js)
- Reset auth failure counter on non-auth errors (truly consecutive)
- Add test for counter reset on non-auth error between auth errors

https://claude.ai/code/session_01WkbLzvhguQwRDtk7ht38aY

* fix(sync): increase passkey rate limits and fix CSP inline script violations

- Increase passkey endpoint rate limits to match magic-link equivalents (50/15min)
- Extract inline scripts from /reset-password and /recover-passkey pages
  into external JS files to comply with CSP scriptSrc: ["'self'"]
- Remove dead safeJsonForScript helper (no longer needed)
- Increase /recover-passkey page rate limit to 50/15min for consistency

https://claude.ai/code/session_01WkbLzvhguQwRDtk7ht38aY

* fix(sync): update security tests for escapeHtml and fix recover/passkey rate limit

- Update server-security.spec.ts: tests now check for escapeHtml output
  (<, ") instead of safeJsonForScript output (\u003c), matching
  the new data-token attribute approach
- Increase /recover/passkey rate limit from 30 to 50 per 15min to match
  /login/magic-link (both send email with a secret link)

https://claude.ai/code/session_01WkbLzvhguQwRDtk7ht38aY

* fix(sync): reset auth failure counter after successful download

Move the counter reset from the final InSync return to right after
downloadRemoteOps() succeeds. This ensures early returns (cancelled,
LWW pending, payload rejected) also break the consecutive-failure
chain, since a successful download confirms auth is working.

https://claude.ai/code/session_01WkbLzvhguQwRDtk7ht38aY

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-10 14:46:57 +02:00

663 lines
18 KiB
JavaScript

const API_BASE = '/api';
// State
const state = {
token: null,
email: localStorage.getItem('supersync_email') || '',
};
// DOM Elements
const loginForm = document.getElementById('login-form');
const registerForm = document.getElementById('register-form');
const authForms = document.getElementById('auth-forms');
const tokenDisplay = document.getElementById('token-display');
const tokenArea = document.getElementById('token-area');
const messageArea = document.getElementById('message-area');
const copyBtn = document.getElementById('copy-btn');
const refreshBtn = document.getElementById('refresh-btn');
const logoutBtn = document.getElementById('logout-btn');
const deleteAccountBtn = document.getElementById('delete-account-btn');
const resetAccountBtn = document.getElementById('reset-account-btn');
const accountSettingsBtn = document.getElementById('account-settings-btn');
const accountSettings = document.getElementById('account-settings');
const backToTokenBtn = document.getElementById('back-to-token-btn');
const tabLoginBtn = document.getElementById('tab-login');
const tabRegisterBtn = document.getElementById('tab-register');
const lostPasskeyForm = document.getElementById('lost-passkey-form');
const showLostPasskeyBtn = document.getElementById('show-lost-passkey');
const backToLoginFromPasskeyBtn = document.getElementById('back-to-login-from-passkey');
const loginPasskeyBtn = document.getElementById('login-passkey-btn');
const loginMagicLinkBtn = document.getElementById('login-magic-link-btn');
const registerPasskeyBtn = document.getElementById('register-passkey-btn');
const registerMagicLinkBtn = document.getElementById('register-magic-link-btn');
// Email inputs (synced across forms)
const emailInputs = [
document.getElementById('login-email'),
document.getElementById('register-email'),
document.getElementById('lost-passkey-email'),
];
// --- Event Listeners ---
// Tab switching
tabLoginBtn.addEventListener('click', () => switchTab('login'));
tabRegisterBtn.addEventListener('click', () => switchTab('register'));
// Token area - select all on click
tokenArea.addEventListener('click', () => tokenArea.select());
// Copy token button
copyBtn.addEventListener('click', copyToken);
// Refresh token button
refreshBtn.addEventListener('click', refreshToken);
// Logout button
logoutBtn.addEventListener('click', logout);
// Delete account button
deleteAccountBtn.addEventListener('click', deleteAccount);
// Reset account button
resetAccountBtn.addEventListener('click', resetAccount);
// Account settings navigation
accountSettingsBtn.addEventListener('click', showAccountSettings);
backToTokenBtn.addEventListener('click', showTokenDisplay);
// Show lost passkey form
showLostPasskeyBtn.addEventListener('click', () => {
loginForm.classList.remove('active');
lostPasskeyForm.classList.add('active');
hideMessage();
});
// Back to login from lost passkey
backToLoginFromPasskeyBtn.addEventListener('click', () => {
lostPasskeyForm.classList.remove('active');
loginForm.classList.add('active');
hideMessage();
});
// Passkey login button
loginPasskeyBtn.addEventListener('click', loginWithPasskey);
// Magic link login button
loginMagicLinkBtn.addEventListener('click', requestMagicLink);
// Passkey register button
registerPasskeyBtn.addEventListener('click', registerWithPasskey);
// Magic link register button
registerMagicLinkBtn.addEventListener('click', registerWithMagicLink);
// Email input sync - when any email input changes, update all others
emailInputs.forEach((input) => {
input.addEventListener('input', (e) => {
const newEmail = e.target.value;
state.email = newEmail;
// Save to localStorage for persistence across page reloads
localStorage.setItem('supersync_email', newEmail);
// Sync to other inputs
emailInputs.forEach((otherInput) => {
if (otherInput !== e.target) {
otherInput.value = newEmail;
}
});
});
});
// Lost passkey form submit
lostPasskeyForm.addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('lost-passkey-email').value;
setLoading(true);
hideMessage();
try {
await fetch(`${API_BASE}/recover/passkey`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
// Always show success (security: don't reveal if email exists)
showMessage(
'If an account exists with that email, you will receive a passkey recovery link.',
'success',
);
// Return to login after a delay
setTimeout(() => {
lostPasskeyForm.classList.remove('active');
loginForm.classList.add('active');
}, 3000);
} catch (err) {
showMessage('An error occurred. Please try again.', 'error');
} finally {
setLoading(false);
}
});
// --- Functions ---
function switchTab(tab) {
// Update buttons
document.querySelectorAll('.tab-btn').forEach((btn) => {
btn.classList.remove('active');
if (btn.innerText.toLowerCase().includes(tab)) {
btn.classList.add('active');
}
});
// Update forms
document.querySelectorAll('.auth-form').forEach((form) => {
form.classList.remove('active');
});
document.getElementById(`${tab}-form`).classList.add('active');
// Clear messages
hideMessage();
}
function showToken(token) {
state.token = token;
tokenArea.value = token;
authForms.classList.add('hidden');
tokenDisplay.classList.remove('hidden');
showMessage('Logged in successfully!', 'success');
}
function logout() {
state.token = null;
tokenArea.value = '';
tokenDisplay.classList.add('hidden');
accountSettings.classList.add('hidden');
authForms.classList.remove('hidden');
hideMessage();
}
function showAccountSettings() {
tokenDisplay.classList.add('hidden');
accountSettings.classList.remove('hidden');
hideMessage();
}
function showTokenDisplay() {
accountSettings.classList.add('hidden');
tokenDisplay.classList.remove('hidden');
hideMessage();
}
async function copyToken() {
if (!state.token) return;
let copied = false;
// Try modern clipboard API first
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(state.token);
copied = true;
} catch (err) {
// Fall through to fallback
}
}
// Fallback for non-secure contexts (HTTP)
if (!copied) {
try {
tokenArea.select();
tokenArea.setSelectionRange(0, 99999); // For mobile
copied = document.execCommand('copy');
} catch (err) {
// Fallback failed
}
}
if (copied) {
const originalText = copyBtn.innerText;
copyBtn.innerText = 'Copied!';
copyBtn.classList.add('success');
setTimeout(() => {
copyBtn.innerText = originalText;
copyBtn.classList.remove('success');
}, 2000);
} else {
showMessage('Please manually select and copy the token', 'error');
tokenArea.select();
}
}
async function refreshToken() {
if (!state.token) return;
// Show confirmation dialog
const confirmed = confirm(
'Are you sure you want to REVOKE your current token?\n\n' +
'This will disconnect ALL devices using this account. ' +
'You will need to copy the new token and paste it into Super Productivity on every device.\n\n' +
'Note: If you just need to see your token again, simply log in again — ' +
'this does NOT revoke existing tokens.',
);
if (!confirmed) return;
setLoading(true);
hideMessage();
try {
const res = await fetch(`${API_BASE}/replace-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${state.token}`,
},
body: '{}',
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to refresh token');
}
// Update with new token
state.token = data.token;
tokenArea.value = data.token;
// Visual feedback
const originalText = refreshBtn.innerText;
refreshBtn.innerText = 'Replaced!';
refreshBtn.classList.add('success');
showMessage(
'Token replaced! All previous tokens are now invalid. ' +
'Copy this new token and update it on all your devices.',
'success',
);
setTimeout(() => {
refreshBtn.innerText = originalText;
refreshBtn.classList.remove('success');
}, 2000);
} catch (err) {
showMessage(err.message, 'error');
} finally {
setLoading(false);
}
}
async function deleteAccount() {
if (!state.token) return;
// Show confirmation dialog
const confirmed = confirm(
'Are you sure you want to DELETE your account?\n\n' +
'This will PERMANENTLY delete your account and ALL synced data. ' +
'This action cannot be undone.\n\n' +
'Your local data in Super Productivity will NOT be affected.',
);
if (!confirmed) return;
setLoading(true);
hideMessage();
try {
const res = await fetch(`${API_BASE}/account`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${state.token}`,
},
body: '{}',
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to delete account');
}
// Show success and return to login
showMessage('Account deleted successfully.', 'success');
// Clear state and return to auth forms
state.token = null;
tokenArea.value = '';
tokenDisplay.classList.add('hidden');
accountSettings.classList.add('hidden');
authForms.classList.remove('hidden');
} catch (err) {
showMessage(err.message, 'error');
} finally {
setLoading(false);
}
}
async function resetAccount() {
if (!state.token) return;
// Show confirmation dialog
const confirmed = confirm(
'Are you sure you want to RESET your account?\n\n' +
'This will delete ALL synced data from the server, but your account will remain active.\n\n' +
'Your local data in Super Productivity will NOT be affected.\n' +
'You can sync again after resetting.',
);
if (!confirmed) return;
setLoading(true);
hideMessage();
try {
const res = await fetch(`${API_BASE}/sync/data`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${state.token}`,
},
body: '{}',
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to reset account');
}
// Show success message
showMessage(
'Account reset successfully! All synced data has been cleared. You can now sync fresh data.',
'success',
);
} catch (err) {
showMessage(err.message, 'error');
} finally {
setLoading(false);
}
}
function showMessage(msg, type) {
messageArea.innerText = msg;
messageArea.className = type === 'error' ? 'msg-error' : 'msg-success';
messageArea.classList.remove('hidden');
}
function hideMessage() {
messageArea.classList.add('hidden');
}
function setLoading(isLoading) {
const btns = document.querySelectorAll('button');
btns.forEach((btn) => (btn.disabled = isLoading));
if (isLoading) {
document.body.style.cursor = 'wait';
} else {
document.body.style.cursor = 'default';
}
}
// --- Passkey Functions ---
async function loginWithPasskey() {
const email = document.getElementById('login-email').value;
if (!email) {
showMessage('Please enter your email address', 'error');
return;
}
setLoading(true);
hideMessage();
try {
// Step 1: Get authentication options from server
const optionsRes = await fetch(`${API_BASE}/login/passkey/options`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!optionsRes.ok) {
const data = await optionsRes.json();
throw new Error(data.error || 'Failed to get login options');
}
const options = await optionsRes.json();
// Step 2: Authenticate with passkey using browser API
showMessage('Please follow your browser/device prompt...', 'success');
const credential = await SimpleWebAuthnBrowser.startAuthentication({
optionsJSON: options,
});
// Step 3: Verify with server
const verifyRes = await fetch(`${API_BASE}/login/passkey/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, credential }),
});
const verifyData = await verifyRes.json();
if (!verifyRes.ok) {
throw new Error(verifyData.error || 'Authentication failed');
}
// Success - show token
showToken(verifyData.token);
} catch (err) {
console.error('Passkey login error:', err);
// Handle user cancellation gracefully
if (err.name === 'NotAllowedError') {
showMessage('Passkey authentication was cancelled', 'error');
} else {
showMessage(err.message || 'Passkey login failed', 'error');
}
} finally {
setLoading(false);
}
}
async function registerWithPasskey() {
const email = document.getElementById('register-email').value;
const termsAccepted = document.getElementById('register-terms').checked;
if (!email) {
showMessage('Please enter your email address', 'error');
return;
}
if (!termsAccepted) {
showMessage('You must accept the Terms of Service', 'error');
return;
}
setLoading(true);
hideMessage();
try {
// Step 1: Get registration options from server
const optionsRes = await fetch(`${API_BASE}/register/passkey/options`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, termsAccepted }),
});
if (!optionsRes.ok) {
const data = await optionsRes.json();
throw new Error(data.error || 'Failed to get registration options');
}
const options = await optionsRes.json();
// Step 2: Create passkey using browser API
showMessage(
'Please follow your browser/device prompt to create a passkey...',
'success',
);
const credential = await SimpleWebAuthnBrowser.startRegistration({
optionsJSON: options,
});
// Step 3: Verify registration with server
const verifyRes = await fetch(`${API_BASE}/register/passkey/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, credential }),
});
const verifyData = await verifyRes.json();
if (!verifyRes.ok) {
throw new Error(verifyData.error || 'Registration failed');
}
// Success - show message and prompt to verify email
showMessage(
verifyData.message ||
'Registration successful! Please check your email to verify your account.',
'success',
);
// Switch to login tab after delay
setTimeout(() => {
switchTab('login');
document.getElementById('login-email').value = email;
showMessage(
'Important: You must click the verification link in your email BEFORE logging in. ' +
'Login will NOT work until your email is verified.',
'success',
);
}, 3000);
} catch (err) {
console.error('Passkey registration error:', err);
// Handle user cancellation gracefully
if (err.name === 'NotAllowedError') {
showMessage('Passkey registration was cancelled', 'error');
} else {
showMessage(err.message || 'Passkey registration failed', 'error');
}
} finally {
setLoading(false);
}
}
async function registerWithMagicLink() {
const email = document.getElementById('register-email').value;
const termsAccepted = document.getElementById('register-terms').checked;
if (!email) {
showMessage('Please enter your email address', 'error');
return;
}
if (!termsAccepted) {
showMessage('You must accept the Terms of Service', 'error');
return;
}
setLoading(true);
hideMessage();
try {
const res = await fetch(`${API_BASE}/register/magic-link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, termsAccepted }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Registration failed');
}
showMessage(
data.message ||
'Registration successful! Please check your email to verify your account.',
'success',
);
// Switch to login tab after delay
setTimeout(() => {
switchTab('login');
document.getElementById('login-email').value = email;
showMessage(
'Important: You must click the verification link in your email BEFORE using "Send Login Link". ' +
'The login link will NOT work until your email is verified.',
'success',
);
}, 3000);
} catch (err) {
showMessage(err.message || 'Registration failed. Please try again.', 'error');
} finally {
setLoading(false);
}
}
// --- Page Load ---
// Check for token from magic link login (stored in sessionStorage by /magic-login page)
(function checkForMagicLinkToken() {
const token = sessionStorage.getItem('loginToken');
if (token) {
sessionStorage.removeItem('loginToken');
showToken(token);
}
})();
// Initialize email inputs from saved state
(function initEmailInputs() {
if (state.email) {
emailInputs.forEach((input) => {
input.value = state.email;
});
}
})();
// --- Magic Link Functions ---
async function requestMagicLink() {
const email = document.getElementById('login-email').value;
if (!email) {
showMessage('Please enter your email address', 'error');
return;
}
setLoading(true);
hideMessage();
try {
const res = await fetch(`${API_BASE}/login/magic-link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to send login link');
}
// Always show success message (security: don't reveal if email exists)
showMessage(
data.message ||
'If an account exists with that email, you will receive a login link.',
'success',
);
} catch (err) {
showMessage(err.message || 'An error occurred. Please try again.', 'error');
} finally {
setLoading(false);
}
}