Files
Haven/public/js/auth.js
T

1266 lines
53 KiB
JavaScript

// ── Auth Page Logic (with theme support + i18n) ───────────────────────────
(async function () {
// Preserve invite param across login/register so vanity invite links work for new users
const _urlParams = new URLSearchParams(window.location.search);
let _pendingInvite = _urlParams.get('invite') || sessionStorage.getItem('haven_pending_invite') || '';
if (_pendingInvite) sessionStorage.setItem('haven_pending_invite', _pendingInvite);
// Preserve channel/message deep-link params (?channel=CODE&message=ID) too
const _pendingChannel = _urlParams.get('channel') || sessionStorage.getItem('haven_pending_channel') || '';
const _pendingMessage = _urlParams.get('message') || sessionStorage.getItem('haven_pending_message') || '';
if (_pendingChannel) sessionStorage.setItem('haven_pending_channel', _pendingChannel);
if (_pendingMessage) sessionStorage.setItem('haven_pending_message', _pendingMessage);
function _buildAppUrl() {
const _appQuery = (() => {
const parts = [];
if (_pendingInvite) parts.push('invite=' + encodeURIComponent(_pendingInvite));
if (_pendingChannel) parts.push('channel=' + encodeURIComponent(_pendingChannel));
if (_pendingMessage) parts.push('message=' + encodeURIComponent(_pendingMessage));
return parts.length ? '?' + parts.join('&') : '';
})();
return '/app' + _appQuery;
}
let _appUrl = _buildAppUrl();
// (#12) A returning SSO callback stashes its session here and bounces to
// this page. Claim it before the already-logged-in check below, so an old
// token in localStorage can't send us to the app and strand a fresh login
// that still has to set up its encryption passphrase.
let _oidcHandoff = null;
if (_urlParams.get('oidc') === '1') {
try {
const raw = sessionStorage.getItem('haven_oidc_handoff');
sessionStorage.removeItem('haven_oidc_handoff');
if (raw) _oidcHandoff = JSON.parse(raw);
} catch { /* malformed handoff, fall through to the normal login page */ }
history.replaceState({}, '', window.location.pathname);
}
const _oidcError = _urlParams.get('oidc_error') || '';
if (_oidcError) history.replaceState({}, '', window.location.pathname);
// If already logged in, redirect to app
if (!_oidcHandoff && localStorage.getItem('haven_token')) {
window.location.href = _appUrl;
return;
}
// Initialise translations before rendering any UI text
await window.i18n.init();
// Opens the app after a password sign-in, with the key that unlocks the
// encrypted-DM backup. An account with its own encryption passphrase is not
// unlocked from the password (the server receives that at every sign-in,
// which is what the passphrase avoids), so it asks for the passphrase.
async function enterApp(user, password) {
if (user && user.e2ePassphrase) {
sessionStorage.removeItem('haven_e2e_wrap');
showE2EPassStep(true, (wrapKey) => {
if (wrapKey) sessionStorage.setItem('haven_e2e_wrap', wrapKey);
window.location.href = _appUrl;
}, 'auth.e2e_pass.unlock_desc_own');
return;
}
sessionStorage.setItem('haven_e2e_wrap', await deriveE2EWrappingKey(password));
window.location.href = _appUrl;
}
// ── E2E wrapping key derivation (mirrors HavenE2E.deriveWrappingKey) ───
async function deriveE2EWrappingKey(password) {
const enc = new TextEncoder();
const raw = await crypto.subtle.importKey(
'raw', enc.encode(password), 'PBKDF2', false, ['deriveBits']
);
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', hash: 'SHA-256', salt: enc.encode('haven-e2e-wrapping-v3'), iterations: 210_000 },
raw, 256
);
return Array.from(new Uint8Array(bits)).map(b => b.toString(16).padStart(2, '0')).join('');
}
// ── Theme switching ───────────────────────────────────
initThemeSwitcher('auth-theme-bar');
// Themes an admin has published are .theme.css files rather than built-in
// data-theme values, and plugin-loader.js (which knows about them) only runs
// on the app page. So the login page fetched nothing and showed none of them.
// /api/themes is unauthenticated, which is what makes this possible here. (#5537)
const publishedThemesPromise = (window.HavenThemeCompat?.fetchThemes?.(fetch) || fetch('/api/themes')
.then(r => {
if (!r.ok) throw new Error(`Theme metadata request failed (${r.status})`);
return r.json();
}))
.then(themes => {
if (!Array.isArray(themes)) throw new Error('Invalid theme metadata response');
injectPublishedThemeBar('auth-theme-bar', themes);
return themes;
})
.catch(() => null); // Preserve saved choices when theme metadata is temporarily unavailable.
// ── Language switcher ─────────────────────────────────
const langSelect = document.getElementById('auth-lang-select');
if (langSelect) {
langSelect.value = window.i18n.preference;
window.i18n.buildLocalePicker(langSelect);
langSelect.addEventListener('change', e => window.i18n.setLocale(e.target.value));
}
// ── Fetch and display server version ──────────────────
fetch('/api/version').then(r => r.json()).then(d => {
const el = document.getElementById('auth-version');
if (el && d.version) el.textContent = 'v' + d.version;
}).catch(() => { /* the version label is cosmetic: leave it blank */ });
// ── Apply server default theme for first-time visitors ──
// Only applies when the user has no personal theme preference stored locally.
// Also fetch server title for login page branding.
fetch('/api/public-config').then(r => r.json()).then(async d => {
if (d.default_theme && !localStorage.getItem('haven_theme')) {
// A published theme is stored as "file:whatever.theme.css". Writing that
// straight into data-theme matched no stylesheet at all, so an admin who
// picked a custom theme as the server default got an unstyled login page
// on a first visit, and only saw the theme once a later page load found
// it in localStorage. Neither branch persists: this is the server's
// suggestion for someone who has not chosen, not a choice they made, and
// storing it would stop a later change to the default from ever reaching
// a returning visitor who has not signed in. (#5537, #5536)
if (d.default_theme.startsWith('file:')) {
const file = d.default_theme.slice(5);
const themes = await publishedThemesPromise;
const meta = themes?.find(theme => theme?.file === file);
if (meta) applyPublishedThemeBase(file, false, meta);
} else {
applyThemeFromServer(d.default_theme, false);
}
}
if (d.server_title) {
const titleEl = document.getElementById('server-title');
if (titleEl) titleEl.textContent = d.server_title;
}
// Tab branding for the login page (issue #5284). Mirrors what the
// authenticated app does so multi-server tabs stay distinguishable
// even before signing in.
if (d.server_name && d.server_name.toLowerCase() !== 'haven') {
document.title = `Haven: ${d.server_name}`;
}
if (d.server_icon) {
let link = document.querySelector('link[rel="icon"]');
if (!link) {
link = document.createElement('link');
link.rel = 'icon';
document.head.appendChild(link);
}
link.removeAttribute('type');
link.href = d.server_icon;
}
if (d.custom_tos) {
const section = document.getElementById('custom-tos-section');
const content = document.getElementById('custom-tos-content');
if (section && content) {
// Render as plain text with paragraph breaks
content.innerHTML = d.custom_tos.split(/\n\n+/).map(p =>
'<p>' + p.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\n/g,'<br>') + '</p>'
).join('');
section.style.display = 'block';
}
}
}).catch((err) => { console.warn('[Auth] could not load the public config', err); });
// ── EULA ─────────────────────────────────────────────
const ageCheckbox = document.getElementById('age-checkbox');
const eulaCheckbox = document.getElementById('eula-checkbox');
const eulaModal = document.getElementById('eula-modal');
const eulaLink = document.getElementById('eula-link');
const eulaAcceptBtn = document.getElementById('eula-accept-btn');
const eulaDeclineBtn = document.getElementById('eula-decline-btn');
const eulaNotice = document.getElementById('eula-notice');
// Restore EULA acceptance from localStorage (v2.0 requires re-acceptance)
if (localStorage.getItem('haven_eula_accepted') === '2.0') {
eulaCheckbox.checked = true;
ageCheckbox.checked = true;
}
eulaLink.addEventListener('click', (e) => {
e.preventDefault();
eulaModal.style.display = 'flex';
});
// The login consent note links to the same Terms popup. Its anchor is injected
// via data-i18n-html and re-created on every language change, so bind it by
// delegation instead of a direct reference a re-render would orphan.
document.addEventListener('click', (e) => {
if (e.target.closest?.('#login-tos-link')) {
e.preventDefault();
eulaModal.style.display = 'flex';
}
});
eulaAcceptBtn.addEventListener('click', () => {
eulaCheckbox.checked = true;
ageCheckbox.checked = true;
localStorage.setItem('haven_eula_accepted', '2.0');
eulaModal.style.display = 'none';
});
eulaDeclineBtn.addEventListener('click', () => {
eulaCheckbox.checked = false;
ageCheckbox.checked = false;
localStorage.removeItem('haven_eula_accepted');
eulaModal.style.display = 'none';
});
eulaModal.addEventListener('click', (e) => {
if (e.target === e.currentTarget) eulaModal.style.display = 'none';
});
function checkEula() {
if (!ageCheckbox.checked) {
showError(t('auth.errors.must_be_18'));
return false;
}
if (!eulaCheckbox.checked) {
showError(t('auth.errors.must_accept_tos'));
return false;
}
return true;
}
// ── Tab switching ─────────────────────────────────────
const tabs = document.querySelectorAll('.auth-tab');
const loginForm = document.getElementById('login-form');
const registerForm = document.getElementById('register-form');
const ssoForm = document.getElementById('sso-form');
const totpForm = document.getElementById('totp-form');
const forcedChangeForm = document.getElementById('forced-change-form');
const banAppealForm = document.getElementById('ban-appeal-form');
const errorEl = document.getElementById('auth-error');
// Pending TOTP challenge state (set after successful password auth)
let _pendingChallenge = null; // { challengeToken, password }
// Pending forced password change after admin reset (#5300).
// Holds the temp-pw session token + the password the user typed (which
// was the temp password). Cleared once the change-password flow completes.
let _pendingForcedChange = null; // { token, user, originalPassword }
// Pending ban appeal (#5457): the credentials the user just proved with a
// successful password check, reused to authenticate the appeal submission.
let _pendingBanAppeal = null; // { username, password }
function showTotpForm() {
loginForm.style.display = 'none';
registerForm.style.display = 'none';
if (ssoForm) ssoForm.style.display = 'none';
totpForm.style.display = 'flex';
document.querySelector('.auth-tabs').style.display = 'none';
document.getElementById('totp-code').value = '';
document.getElementById('totp-code').focus();
hideError();
}
function hideTotpForm() {
totpForm.style.display = 'none';
loginForm.style.display = 'flex';
document.querySelector('.auth-tabs').style.display = 'flex';
_pendingChallenge = null;
hideError();
}
function showForcedChangeForm() {
loginForm.style.display = 'none';
registerForm.style.display = 'none';
if (ssoForm) ssoForm.style.display = 'none';
totpForm.style.display = 'none';
forcedChangeForm.style.display = 'flex';
document.querySelector('.auth-tabs').style.display = 'none';
document.getElementById('forced-new-password').value = '';
document.getElementById('forced-confirm-password').value = '';
document.getElementById('forced-old-password').value = '';
document.getElementById('forced-change-recall').open = false;
document.getElementById('forced-new-password').focus();
hideError();
}
// (#5457) Shown when login is rejected because the account is banned. The
// reason came back from the server only after the password was verified.
function showBanAppeal(username, password, reason) {
_pendingBanAppeal = { username, password };
loginForm.style.display = 'none';
registerForm.style.display = 'none';
if (ssoForm) ssoForm.style.display = 'none';
totpForm.style.display = 'none';
forcedChangeForm.style.display = 'none';
document.getElementById('recover-form').style.display = 'none';
banAppealForm.style.display = 'flex';
document.querySelector('.auth-tabs').style.display = 'none';
const reasonEl = document.getElementById('ban-appeal-reason');
if (reasonEl) {
reasonEl.textContent = reason
? `${t('auth.ban_appeal.reason_prefix')} ${reason}`
: t('auth.ban_appeal.no_reason');
}
document.getElementById('ban-appeal-text').value = '';
errorEl.style.color = '';
document.getElementById('ban-appeal-text').focus();
hideError();
}
// function to swap the active tab and form
function showTab(target) {
tabs.forEach(t => {
t.classList.toggle('active', t.dataset.tab === target);
});
loginForm.style.display = target === 'login' ? 'flex' : 'none';
registerForm.style.display = target === 'register' ? 'flex' : 'none';
if (ssoForm) ssoForm.style.display = target === 'sso' ? 'flex' : 'none';
totpForm.style.display = 'none';
document.getElementById('recover-form').style.display = 'none';
// The age / ToS checkboxes are only for creating an account, so they show on
// the register and SSO tabs and stay hidden on login (where the consent note
// under the button covers re-affirmation). The guest flow re-shows them.
if (eulaNotice) eulaNotice.style.display = (target === 'login') ? 'none' : '';
hideError();
}
// listen for clicks on the login tabs to swap the tab and form
tabs.forEach(tab => {
tab.addEventListener('click', () => {
showTab(tab.dataset.tab);
});
});
// Switch to registration page if an invite link is detected.
if (_pendingInvite) {
showTab('register');
}
function showError(msg) {
errorEl.textContent = msg;
errorEl.style.display = 'block';
}
function hideError() {
errorEl.style.display = 'none';
}
// ── Admin Recovery ────────────────────────────────────
document.getElementById('admin-recover-show').addEventListener('click', (e) => {
e.preventDefault();
document.querySelector('.auth-recovery-links').style.display = 'none';
document.getElementById('admin-recover-section').style.display = '';
});
document.addEventListener('click', async (e) => {
if (e.target.id !== 'admin-recover-btn') return;
hideError();
const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value;
if (!username || !password) return showError(t('auth.errors.enter_admin_credentials'));
const codeInput = document.getElementById('admin-recover-code');
const code = codeInput ? codeInput.value.trim() : '';
try {
const res = await fetch('/api/auth/admin-recover', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, ...(code ? { code } : {}) })
});
const data = await res.json();
// An account with two-factor on asks for its code here too.
if (data && data.needsCode && codeInput) {
codeInput.style.display = '';
codeInput.focus();
}
if (!res.ok) return showError(data.error || t('auth.errors.recovery_failed'));
localStorage.setItem('haven_token', data.token);
localStorage.setItem('haven_user', JSON.stringify(data.user));
await enterApp(data.user, password);
} catch {
showError(t('auth.errors.connection_error'));
}
});
// ── Forgot Password / Account Recovery ───────────────
const recoverForm = document.getElementById('recover-form');
function showRecoverForm() {
loginForm.style.display = 'none';
registerForm.style.display = 'none';
if (ssoForm) ssoForm.style.display = 'none';
totpForm.style.display = 'none';
recoverForm.style.display = 'flex';
document.querySelector('.auth-tabs').style.display = 'none';
hideError();
}
function hideRecoverForm() {
recoverForm.style.display = 'none';
loginForm.style.display = 'flex';
document.querySelector('.auth-tabs').style.display = 'flex';
const recoveryLinks = document.querySelector('.auth-recovery-links');
if (recoveryLinks) recoveryLinks.style.display = '';
hideError();
}
document.getElementById('forgot-password-show').addEventListener('click', (e) => {
e.preventDefault();
showRecoverForm();
});
document.getElementById('recover-back-btn').addEventListener('click', (e) => {
e.preventDefault();
hideRecoverForm();
});
recoverForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideError();
const username = document.getElementById('recover-username').value.trim();
const code = document.getElementById('recover-code').value.trim().toUpperCase();
const newPassword = document.getElementById('recover-new-password').value;
const confirmPassword = document.getElementById('recover-confirm-password').value;
if (!username || !code || !newPassword || !confirmPassword) return showError(t('auth.errors.all_fields_required'));
if (newPassword.length < 8) return showError(t('auth.errors.password_too_short'));
if (newPassword !== confirmPassword) return showError(t('auth.errors.passwords_no_match'));
try {
const res = await fetch('/api/auth/recover-account', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, code, newPassword })
});
const data = await res.json();
if (!res.ok) return showError(data.error || t('auth.errors.recovery_failed'));
// Success: go back to login with a success message
hideRecoverForm();
showError(t('auth.errors.password_reset_success'));
document.getElementById('auth-error').style.color = 'var(--success, #2ecc71)';
document.getElementById('login-username').value = username;
} catch {
showError(t('auth.errors.connection_error'));
}
});
// ── Login ─────────────────────────────────────────────
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideError();
// No EULA gate on login: an existing account already accepted at
// registration (recorded server-side in eula_acceptances). The consent note
// under the button covers re-affirmation of the current terms. The account-
// creation flows (register / SSO / guest) still call checkEula().
const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value;
if (!username || !password) return showError(t('auth.errors.fill_all_fields'));
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, eulaVersion: '2.0', ageVerified: true })
});
const data = await res.json();
if (!res.ok) {
// ── Banned: offer an appeal instead of a dead-end error (#5457) ──
if (data.banned) {
showBanAppeal(username, password, data.reason);
return;
}
return showError(data.error || t('auth.errors.login_failed'));
}
// ── TOTP challenge ──
if (data.requiresTOTP) {
_pendingChallenge = { challengeToken: data.challengeToken, password };
showTotpForm();
return;
}
// ── Forced change-password after admin reset (#5300) ──
if (data.mustChangePassword) {
_pendingForcedChange = { token: data.token, user: data.user, originalPassword: password };
showForcedChangeForm();
return;
}
localStorage.setItem('haven_token', data.token);
localStorage.setItem('haven_user', JSON.stringify(data.user));
localStorage.setItem('haven_eula_accepted', '2.0');
// The E2E key is derived here, client-side, and never sent anywhere.
await enterApp(data.user, password);
} catch (err) {
showError(t('auth.errors.connection_error'));
}
});
// ── Ban appeal submit (#5457) ─────────────────────────
banAppealForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideError();
if (!_pendingBanAppeal) return showError(t('auth.errors.session_expired'));
const appeal = document.getElementById('ban-appeal-text').value.trim();
if (!appeal) return showError(t('auth.ban_appeal.errors.empty'));
try {
const res = await fetch('/api/auth/ban-appeal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: _pendingBanAppeal.username,
password: _pendingBanAppeal.password,
appeal
})
});
const data = await res.json().catch(() => ({}));
if (!res.ok) return showError(data.error || t('auth.ban_appeal.errors.failed'));
_pendingBanAppeal = null;
document.getElementById('ban-appeal-text').value = '';
document.getElementById('ban-appeal-text').disabled = true;
showError(t('auth.ban_appeal.submitted'));
errorEl.style.color = 'var(--success, #2ecc71)';
} catch (err) {
showError(t('auth.errors.connection_error'));
}
});
document.getElementById('ban-appeal-back-btn').addEventListener('click', (e) => {
e.preventDefault();
_pendingBanAppeal = null;
banAppealForm.style.display = 'none';
document.getElementById('ban-appeal-text').disabled = false;
loginForm.style.display = 'flex';
document.querySelector('.auth-tabs').style.display = 'flex';
errorEl.style.color = '';
hideError();
});
// ── TOTP verification ────────────────────────────────
totpForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideError();
if (!_pendingChallenge) return showError(t('auth.errors.session_expired'));
const code = document.getElementById('totp-code').value.trim();
if (!code) return showError(t('auth.errors.enter_auth_code'));
try {
const res = await fetch('/api/auth/totp/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challengeToken: _pendingChallenge.challengeToken, code })
});
const data = await res.json();
if (!res.ok) return showError(data.error || t('auth.errors.verification_failed'));
// ── Forced change-password after admin reset (#5300) ──
if (data.mustChangePassword) {
_pendingForcedChange = { token: data.token, user: data.user, originalPassword: _pendingChallenge.password };
_pendingChallenge = null;
showForcedChangeForm();
return;
}
localStorage.setItem('haven_token', data.token);
localStorage.setItem('haven_user', JSON.stringify(data.user));
localStorage.setItem('haven_eula_accepted', '2.0');
// The E2E key comes from the original password.
const password = _pendingChallenge.password;
_pendingChallenge = null;
await enterApp(data.user, password);
} catch (err) {
showError(t('auth.errors.connection_error'));
}
});
// Toggle between TOTP code and backup code mode
const totpCodeInput = document.getElementById('totp-code');
const backupToggle = document.getElementById('totp-use-backup');
let _backupMode = false;
if (backupToggle) {
backupToggle.addEventListener('click', (e) => {
e.preventDefault();
_backupMode = !_backupMode;
if (_backupMode) {
totpCodeInput.placeholder = 'XXXX-XXXX';
totpCodeInput.maxLength = 9;
totpCodeInput.inputMode = 'text';
totpCodeInput.removeAttribute('pattern');
backupToggle.textContent = t('auth.totp.use_authenticator');
} else {
totpCodeInput.placeholder = '000000';
totpCodeInput.maxLength = 6;
totpCodeInput.inputMode = 'numeric';
totpCodeInput.setAttribute('pattern', '[0-9]*');
backupToggle.textContent = t('auth.totp.use_backup');
}
totpCodeInput.value = '';
totpCodeInput.focus();
});
}
// Back to login from TOTP form
const totpBackBtn = document.getElementById('totp-back-btn');
if (totpBackBtn) {
totpBackBtn.addEventListener('click', (e) => {
e.preventDefault();
hideTotpForm();
});
}
// ── Forced change-password after admin reset (#5300) ──
// Two paths:
// • User remembers their original password, enters it in the recall
// section. Server clears the admin reset and the user signs in with
// their original password. We derive the E2E wrap key from THAT
// original password so existing DM history stays decryptable.
// • User just picks a new password. Server rotates password_hash.
// We derive a fresh E2E wrap key from the new password. Existing
// DM history is permanently unreadable on the client (warned about
// in the form copy).
forcedChangeForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideError();
if (!_pendingForcedChange) return showError(t('auth.errors.session_expired'));
const newPw = document.getElementById('forced-new-password').value;
const confirmPw = document.getElementById('forced-confirm-password').value;
const oldPw = document.getElementById('forced-old-password').value;
const useRecall = !!oldPw;
if (!useRecall) {
if (!newPw || newPw.length < 8) return showError(t('auth.forced_change.errors.too_short'));
if (newPw !== confirmPw) return showError(t('auth.forced_change.errors.mismatch'));
}
try {
const body = useRecall ? { oldPassword: oldPw }
: { newPassword: newPw };
const res = await fetch('/api/auth/change-password-required', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${_pendingForcedChange.token}` },
body: JSON.stringify(body)
});
const data = await res.json();
if (!res.ok) {
if (data.code === 'old_password_invalid') {
return showError(t('auth.forced_change.errors.old_invalid'));
}
return showError(data.error || t('auth.errors.connection_error'));
}
// Derive wrap key from whichever password the account is now using.
// preserved=true means password_hash was untouched, so the original
// password remains the wrap key source. preserved=false means the
// new password is now the wrap key source (DMs unrecoverable).
const wrapSource = data.preserved ? oldPw : newPw;
localStorage.setItem('haven_token', data.token);
localStorage.setItem('haven_user', JSON.stringify(data.user));
localStorage.setItem('haven_eula_accepted', '2.0');
_pendingForcedChange = null;
// A separate encryption passphrase is untouched by the password change.
await enterApp(data.user, wrapSource);
} catch (err) {
showError(t('auth.errors.connection_error'));
}
});
// ── SSO (Link Server) ──────────────────────────────────
if (ssoForm) {
// Populate the recent-servers datalist from localStorage
try {
const servers = JSON.parse(localStorage.getItem('haven_servers') || '[]');
const datalist = document.getElementById('sso-recent-servers');
if (datalist && Array.isArray(servers)) {
for (const s of servers) {
if (s.url) {
const opt = document.createElement('option');
opt.value = s.url;
if (s.name) opt.label = s.name;
datalist.appendChild(opt);
}
}
}
} catch { /* corrupt or blocked storage: no recent servers to suggest */ }
let ssoAuthCode = null;
let ssoServerUrl = null;
let ssoProfileData = null;
let ssoWaiting = false;
let ssoPollTimer = null;
let ssoTimeoutTimer = null;
const ssoConnectBtn = document.getElementById('sso-connect-btn');
const ssoStepServer = document.getElementById('sso-step-server');
const ssoStepRegister = document.getElementById('sso-step-register');
const ssoPreviewAvatar = document.getElementById('sso-preview-avatar');
const ssoPreviewUsername = document.getElementById('sso-preview-username');
const ssoRegisterBtn = document.getElementById('sso-register-btn');
const ssoBackBtn = document.getElementById('sso-back-btn');
const ssoServerInput = document.getElementById('sso-server-url');
const ssoUsernameInput = document.getElementById('sso-username');
// Haven usernames are letters, numbers and underscores. A legacy home
// server may send a display-name-like value, so fold it into that shape.
const normalizeSsoUsername = (value) => {
if (typeof value !== 'string') return '';
return value
.trim()
.replace(/[^a-zA-Z0-9_]/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 20);
};
const stopSsoPolling = () => {
if (ssoPollTimer) {
clearInterval(ssoPollTimer);
ssoPollTimer = null;
}
if (ssoTimeoutTimer) {
clearTimeout(ssoTimeoutTimer);
ssoTimeoutTimer = null;
}
};
const getSsoOrigin = () => {
try { return new URL(ssoServerUrl).origin; } catch { return ssoServerUrl; }
};
const applySsoProfile = (profile, sourceOrigin = null) => {
if (!profile) return;
ssoProfileData = profile;
ssoWaiting = false;
stopSsoPolling();
ssoConnectBtn.textContent = t('auth.sso.connect');
ssoConnectBtn.disabled = false;
const profileUsername = (typeof ssoProfileData.username === 'string' ? ssoProfileData.username.trim() : '');
const previewName = (typeof ssoProfileData.displayName === 'string' ? ssoProfileData.displayName.trim() : '') || profileUsername;
// The picture comes from the other server, so it is never trusted as
// markup: only an http(s) URL, or a path on that server, becomes an
// <img> built with createElement.
let avatarSrc = null;
const pic = ssoProfileData.profilePicture;
if (typeof pic === 'string' && pic) {
const isPath = pic.startsWith('/') && !pic.startsWith('//') && !pic.startsWith('/\\');
if (isPath || /^https?:\/\//i.test(pic)) {
try {
const u = isPath ? new URL(pic, sourceOrigin || getSsoOrigin()) : new URL(pic);
if (u.protocol === 'https:' || u.protocol === 'http:') avatarSrc = u.href;
} catch (err) {
console.warn('[SSO] ignoring unreadable profile picture URL:', err.message);
}
}
}
if (avatarSrc) {
const img = document.createElement('img');
img.src = avatarSrc;
img.alt = '';
img.style.cssText = 'width:100%;height:100%;object-fit:cover';
ssoPreviewAvatar.replaceChildren(img);
} else {
ssoPreviewAvatar.textContent = (previewName || '?')[0].toUpperCase();
}
ssoPreviewUsername.textContent = previewName || '-';
// The imported name may already belong to someone here (#5734), so it
// is only a suggestion the person can change.
let suggested = normalizeSsoUsername(profileUsername);
if (suggested.length < 3) suggested = normalizeSsoUsername(ssoProfileData.displayName);
ssoUsernameInput.value = suggested;
ssoStepServer.style.display = 'none';
ssoStepRegister.style.display = '';
hideError();
};
const tryFetchSsoProfile = async (surfaceError = false) => {
if (!ssoWaiting || !ssoAuthCode || !ssoServerUrl) return false;
try {
const res = await fetch(`${ssoServerUrl}/api/auth/SSO/authenticate?authCode=${encodeURIComponent(ssoAuthCode)}`);
if (!res.ok) {
if (surfaceError && res.status !== 404) {
const data = await res.json().catch(() => ({}));
showError(data.error || t('auth.sso.failed'));
}
return false;
}
const data = await res.json();
applySsoProfile(data, getSsoOrigin());
return true;
} catch {
if (surfaceError) showError(t('auth.sso.unreachable'));
return false;
}
};
function ssoReset() {
stopSsoPolling();
ssoAuthCode = null;
ssoServerUrl = null;
ssoProfileData = null;
ssoWaiting = false;
ssoStepServer.style.display = '';
ssoStepRegister.style.display = 'none';
ssoPreviewAvatar.innerHTML = '?';
ssoPreviewUsername.textContent = '-';
ssoUsernameInput.value = '';
document.getElementById('sso-password').value = '';
document.getElementById('sso-confirm').value = '';
hideError();
}
// Step 1: Connect to home server
ssoConnectBtn.addEventListener('click', () => {
hideError();
let raw = ssoServerInput.value.trim();
if (!raw) return showError(t('auth.sso.enter_address'));
// Normalise the URL
raw = raw.replace(/\/+$/, '');
if (!/^https?:\/\//i.test(raw)) {
raw = (raw.startsWith('localhost') || raw.startsWith('127.0.0.1'))
? 'http://' + raw
: 'https://' + raw;
}
ssoServerUrl = raw;
// Generate a cryptographically secure auth code
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
ssoAuthCode = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
// Open the consent page on the home server in a new tab
const consentUrl = `${ssoServerUrl}/api/auth/SSO?authCode=${encodeURIComponent(ssoAuthCode)}&origin=${encodeURIComponent(window.location.origin)}`;
window.open(consentUrl, '_blank');
ssoWaiting = true;
ssoConnectBtn.textContent = t('auth.sso.waiting');
ssoConnectBtn.disabled = true;
stopSsoPolling();
ssoPollTimer = setInterval(() => {
tryFetchSsoProfile(false);
}, 2000);
ssoTimeoutTimer = setTimeout(() => {
if (!ssoWaiting) return;
ssoWaiting = false;
stopSsoPolling();
ssoConnectBtn.textContent = t('auth.sso.connect');
ssoConnectBtn.disabled = false;
showError(t('auth.sso.timeout'));
}, 90000);
});
// When user returns to this tab after approving on home server
window.addEventListener('focus', async () => {
if (!ssoWaiting || !ssoAuthCode || !ssoServerUrl) return;
await tryFetchSsoProfile(true);
});
// Preferred path: SSO popup posts profile data back to this window.
window.addEventListener('message', (event) => {
if (!ssoWaiting || !ssoAuthCode || !ssoServerUrl) return;
const data = event.data || {};
if (data.type !== 'haven-sso-approved') return;
if (data.authCode !== ssoAuthCode) return;
const expectedOrigin = getSsoOrigin();
if (event.origin !== expectedOrigin) return;
if (!data.profile || !data.profile.username) return;
applySsoProfile(data.profile, data.serverOrigin || expectedOrigin);
});
// Back button: return to step 1
ssoBackBtn.addEventListener('click', (e) => {
e.preventDefault();
ssoReset();
});
// Step 2: Register with imported profile
ssoRegisterBtn.addEventListener('click', async () => {
hideError();
if (!checkEula()) return;
if (!ssoProfileData) return showError(t('auth.sso.connect_first'));
const password = document.getElementById('sso-password').value;
const confirm = document.getElementById('sso-confirm').value;
if (!password || !confirm) return showError(t('auth.errors.fill_all_fields'));
if (password.length < 8) return showError(t('auth.errors.password_too_short'));
if (password !== confirm) return showError(t('auth.errors.passwords_no_match'));
const registerUsername = ssoUsernameInput.value.trim();
if (!/^[a-zA-Z0-9_]{3,20}$/.test(registerUsername)) {
ssoUsernameInput.focus();
return showError(t('auth.sso.invalid_username'));
}
// Build the full profile picture URL for the server to download
let profilePicUrl = ssoProfileData.profilePicture || null;
if (profilePicUrl && profilePicUrl.startsWith('/')) {
profilePicUrl = ssoServerUrl + profilePicUrl;
}
const ssoCaptchaToken = _captchaTokenFor('sso');
if (ssoCaptchaToken === '') return showError(t('auth.errors.captcha_incomplete'));
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: registerUsername,
password,
eulaVersion: '2.0',
ageVerified: true,
ssoProfilePicture: profilePicUrl,
// (#5344) Reuse the same token field the standard form uses;
// when the server requires a token the user will have already
// typed it in the visible field.
registrationToken: (document.getElementById('reg-token')?.value || '').trim(),
captchaToken: ssoCaptchaToken || ''
})
});
const data = await res.json();
if (!res.ok) {
_resetCaptcha('sso');
if (data.code === 'username_unavailable') {
ssoUsernameInput.focus();
ssoUsernameInput.select();
return showError(t('auth.sso.username_unavailable'));
}
return showError(data.error || t('auth.errors.registration_failed'));
}
// Derive E2E wrapping key from password
const e2eWrap = await deriveE2EWrappingKey(password);
sessionStorage.setItem('haven_e2e_wrap', e2eWrap);
localStorage.setItem('haven_token', data.token);
localStorage.setItem('haven_user', JSON.stringify(data.user));
localStorage.setItem('haven_eula_accepted', '2.0');
window.location.href = _appUrl;
} catch (err) {
showError(t('auth.errors.connection_error'));
}
});
}
// ── Opt-in Turnstile CAPTCHA on registration ──────────
// Rendered only when the server reports it enabled. Two widgets: one for the
// standard register form, one for the SSO register step (both hit the same
// /register endpoint, which enforces the challenge server-side).
let _captchaSiteKey = '';
let _turnstileMain = null;
let _turnstileSso = null;
function _loadTurnstileScript() {
return new Promise((resolve, reject) => {
if (window.turnstile) return resolve();
let s = document.getElementById('cf-turnstile-script');
if (s) { s.addEventListener('load', () => resolve()); s.addEventListener('error', () => reject(new Error('load failed'))); return; }
s = document.createElement('script');
s.id = 'cf-turnstile-script';
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
s.async = true; s.defer = true;
s.onload = () => resolve();
s.onerror = () => reject(new Error('load failed'));
document.head.appendChild(s);
});
}
async function _initRegistrationCaptcha(siteKey) {
_captchaSiteKey = siteKey;
try { await _loadTurnstileScript(); } catch { return; }
if (!window.turnstile) return;
const mainBox = document.getElementById('reg-captcha');
if (mainBox && _turnstileMain === null) {
const g = document.getElementById('reg-captcha-group'); if (g) g.style.display = '';
_turnstileMain = window.turnstile.render(mainBox, { sitekey: siteKey, theme: 'auto' });
}
const ssoBox = document.getElementById('sso-captcha');
if (ssoBox && _turnstileSso === null) {
const g = document.getElementById('sso-captcha-group'); if (g) g.style.display = '';
_turnstileSso = window.turnstile.render(ssoBox, { sitekey: siteKey, theme: 'auto' });
}
}
// null = captcha not active (skip it);
// '' = active but the user hasn't solved it yet (block submit);
// token = solved (send it).
function _captchaTokenFor(which) {
if (!_captchaSiteKey || !window.turnstile) return null;
const id = which === 'sso' ? _turnstileSso : _turnstileMain;
if (id === null || id === undefined) return null;
return window.turnstile.getResponse(id) || '';
}
function _resetCaptcha(which) {
if (!window.turnstile) return;
const id = which === 'sso' ? _turnstileSso : _turnstileMain;
if (id !== null && id !== undefined) { try { window.turnstile.reset(id); } catch { /* widget already removed */ } }
}
// ── Register ──────────────────────────────────────────
// (#5344) If the server requires a registration token, reveal the
// token field. Best-effort fetch: if it fails we just leave the
// field hidden and the server will reject without the token.
// field is also hidden if an invite link is used and is allowed to override the token requirement.
async function _initRegistrationForm() {
try {
const r = await fetch('/api/auth/registration-info');
if (!r.ok) return;
const info = await r.json();
if (info && info.requiresToken && (!_pendingInvite || !info.invitesBypassToken)) {
const grp = document.getElementById('reg-token-group');
const inp = document.getElementById('reg-token');
if (grp) grp.style.display = '';
if (inp) inp.required = true;
}
if (info && info.captchaEnabled && info.turnstileSiteKey) {
_initRegistrationCaptcha(info.turnstileSiteKey);
}
} catch (err) { console.warn('[Auth] could not load registration info', err); }
}
_initRegistrationForm();
registerForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideError();
if (!checkEula()) return;
const username = document.getElementById('reg-username').value.trim();
const password = document.getElementById('reg-password').value;
const confirm = document.getElementById('reg-confirm').value;
const tokenInput = document.getElementById('reg-token');
const registrationToken = tokenInput ? tokenInput.value.trim() : '';
if (!username || !password || !confirm) return showError(t('auth.errors.fill_all_fields'));
if (password !== confirm) return showError(t('auth.errors.passwords_no_match'));
if (password.length < 8) return showError(t('auth.errors.password_too_short'));
const captchaToken = _captchaTokenFor('main');
if (captchaToken === '') return showError(t('auth.errors.captcha_incomplete'));
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, eulaVersion: '2.0', ageVerified: true, registrationToken, inviteCode: _pendingInvite, captchaToken: captchaToken || ''})
});
const data = await res.json();
if (!res.ok) {
_resetCaptcha('main');
const error = data.error || t('auth.errors.registration_failed');
// if registration error is due to an invalid invite link, display the invite error and show the registration key field if required.
// This allows the user to attempt registration again, with the registration code, or a new invitation link.
if (error.toLowerCase().includes('invite link')) {
sessionStorage.removeItem('haven_pending_invite');
_pendingInvite = '';
const url = new URL(window.location.href);
url.searchParams.delete('invite');
window.history.replaceState({}, '', url.pathname + url.search + url.hash);
_appUrl = _buildAppUrl();
await _initRegistrationForm();
}
showError(error);
return;
}
// Derive E2E wrapping key from password (client-side only, never sent to server)
const e2eWrap = await deriveE2EWrappingKey(password);
sessionStorage.setItem('haven_e2e_wrap', e2eWrap);
localStorage.setItem('haven_token', data.token);
localStorage.setItem('haven_user', JSON.stringify(data.user));
localStorage.setItem('haven_eula_accepted', '2.0');
window.location.href = _appUrl;
} catch (err) {
showError(t('auth.errors.connection_error'));
}
});
// ── (#5381) Join as Guest ────────────────────────────────
// Reveal the guest button only if the server has guests enabled.
(async () => {
try {
const r = await fetch('/api/auth/guest-info');
if (!r.ok) return;
const info = await r.json();
if (info && info.guestsEnabled) {
const sec = document.getElementById('guest-login-section');
if (sec) sec.style.display = '';
}
} catch (err) { console.warn('[Auth] could not check guest access', err); }
})();
// ── (#12) SSO button, only when the server reports OIDC usable ──
(async () => {
try {
const r = await fetch('/api/public-config');
if (!r.ok) return;
const cfg = await r.json();
if (!cfg || !cfg.oidc_enabled) return;
const sec = document.getElementById('oidc-login-section');
const btn = document.getElementById('oidc-login-btn');
if (btn && cfg.oidc_button_label) btn.textContent = cfg.oidc_button_label;
if (sec) sec.style.display = '';
} catch (err) { console.warn('[Auth] could not check single sign-on', err); }
})();
const guestShowBtn = document.getElementById('guest-login-show-btn');
const guestForm = document.getElementById('guest-form');
const guestBackBtn = document.getElementById('guest-back-btn');
// loginForm is already declared at the top of this IIFE (see line ~152).
// Re-declaring with `const` here was a fatal SyntaxError that blanked the
// entire login page (#5399 follow-up); reuse the outer binding.
if (guestShowBtn && guestForm) {
guestShowBtn.addEventListener('click', () => {
hideError();
if (loginForm) loginForm.style.display = 'none';
if (registerForm) registerForm.style.display = 'none';
const ssoForm = document.getElementById('sso-form');
if (ssoForm) ssoForm.style.display = 'none';
guestForm.style.display = '';
// Guest creation still requires consent (guest submit calls checkEula), so
// the checkboxes hidden on the login tab must reappear here.
if (eulaNotice) eulaNotice.style.display = '';
const u = document.getElementById('guest-username');
if (u) u.focus();
});
}
if (guestBackBtn) {
guestBackBtn.addEventListener('click', (e) => {
e.preventDefault();
hideError();
if (guestForm) guestForm.style.display = 'none';
if (loginForm) loginForm.style.display = '';
if (eulaNotice) eulaNotice.style.display = 'none';
});
}
if (guestForm) {
guestForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideError();
if (!checkEula()) return;
const username = document.getElementById('guest-username').value.trim();
if (!username) return showError(t('auth.guest.errors.empty'));
try {
const res = await fetch('/api/auth/guest-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, eulaVersion: '2.0', ageVerified: true })
});
const data = await res.json();
if (!res.ok) return showError(data.error || t('auth.guest.errors.failed'));
// Guests have no password, so no E2E wrap key. DM tab is hidden
// for them on the app side.
localStorage.setItem('haven_token', data.token);
localStorage.setItem('haven_user', JSON.stringify(data.user));
localStorage.setItem('haven_eula_accepted', '2.0');
sessionStorage.removeItem('haven_e2e_wrap');
window.location.href = _appUrl;
} catch {
showError(t('auth.errors.connection_error'));
}
});
}
/* ── SSO / OIDC (#12) ─────────────────────────────────
Two halves. The button just leaves for the provider. Coming back, the
server has already handed us a session, and the only thing left is the
encryption passphrase: an SSO user types no password into Haven, so
there is nothing to derive the private-key wrapping key from. We ask for
a passphrase, stretch it exactly like a password (same PBKDF2 salt and
iteration count), and drop the result into the same sessionStorage slot
the password path uses, so everything downstream is unchanged. */
const OIDC_ERRORS = {
cancelled: 'auth.oidc.errors.cancelled',
disabled: 'auth.oidc.errors.disabled',
no_account: 'auth.oidc.errors.no_account',
banned: 'auth.oidc.errors.banned',
expired: 'auth.oidc.errors.expired',
provider_unreachable: 'auth.oidc.errors.provider_unreachable',
};
if (_oidcError) showError(t(OIDC_ERRORS[_oidcError] || 'auth.oidc.errors.failed'));
const oidcBtn = document.getElementById('oidc-login-btn');
if (oidcBtn) {
oidcBtn.addEventListener('click', () => {
oidcBtn.disabled = true;
window.location.href = '/api/auth/oidc/start';
});
}
// The encryption passphrase step: a setup (with a confirm field) or, when
// the key already exists on the server, an unlock. finish(wrapKey) gets
// the derived key, or null when the user skips.
function showE2EPassStep(unlock, finish, unlockDescKey = 'auth.e2e_pass.unlock_desc') {
const passForm = document.getElementById('e2e-pass-form');
const passInput = document.getElementById('e2e-pass-input');
const confirmGroup = document.getElementById('e2e-pass-confirm-group');
const confirmInput = document.getElementById('e2e-pass-confirm');
const titleEl = document.getElementById('e2e-pass-title');
const blurbEl = document.getElementById('e2e-pass-blurb');
const hintEl = document.getElementById('e2e-pass-hint');
// Hide every other form and show the passphrase step.
document.querySelectorAll('.auth-form').forEach(f => { f.style.display = 'none'; });
document.querySelector('.auth-tabs')?.style.setProperty('display', 'none');
passForm.style.display = 'block';
// Returning on a second device: the key already exists on the server, so
// this is an unlock, not a setup. No confirm field, different wording.
if (unlock) {
titleEl.textContent = t('auth.e2e_pass.unlock_title');
blurbEl.textContent = t(unlockDescKey);
hintEl.textContent = t('auth.e2e_pass.unlock_hint');
confirmGroup.style.display = 'none';
confirmInput.removeAttribute('required');
passInput.setAttribute('autocomplete', 'current-password');
}
passForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideError();
const pass = passInput.value;
if (!pass || pass.length < 8) return showError(t('auth.e2e_pass.errors.too_short'));
if (!unlock && pass !== confirmInput.value) {
return showError(t('auth.e2e_pass.errors.mismatch'));
}
// Only the derived key is kept. The passphrase itself never leaves this
// function, and never goes to the server in any form.
finish(await deriveE2EWrappingKey(pass));
});
document.getElementById('e2e-pass-skip').addEventListener('click', (ev) => {
ev.preventDefault();
// Same state as any auto-login without a password: the app runs, and DMs
// stay locked on this device until the passphrase is supplied. Nothing
// is generated or overwritten, so no history is lost by skipping.
finish(null);
});
}
if (_oidcHandoff && _oidcHandoff.token) {
showE2EPassStep(!!_oidcHandoff.e2eReady, (wrapKey) => {
if (wrapKey) sessionStorage.setItem('haven_e2e_wrap', wrapKey);
else sessionStorage.removeItem('haven_e2e_wrap');
localStorage.setItem('haven_token', _oidcHandoff.token);
localStorage.setItem('haven_user', JSON.stringify(_oidcHandoff.user));
window.location.href = _appUrl;
});
}
})();