mirror of
https://github.com/ancsemi/Haven
synced 2026-04-21 13:37:41 +00:00
659 lines
26 KiB
JavaScript
659 lines
26 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);
|
|
const _pendingInvite = _urlParams.get('invite') || sessionStorage.getItem('haven_pending_invite') || '';
|
|
if (_pendingInvite) sessionStorage.setItem('haven_pending_invite', _pendingInvite);
|
|
const _appUrl = _pendingInvite ? `/app?invite=${encodeURIComponent(_pendingInvite)}` : '/app';
|
|
|
|
// If already logged in, redirect to app
|
|
if (localStorage.getItem('haven_token')) {
|
|
window.location.href = _appUrl;
|
|
return;
|
|
}
|
|
|
|
// Initialise translations before rendering any UI text
|
|
await window.i18n.init();
|
|
|
|
// ── 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');
|
|
|
|
// ── Language switcher ─────────────────────────────────
|
|
const langSelect = document.getElementById('auth-lang-select');
|
|
if (langSelect) {
|
|
langSelect.value = window.i18n.locale;
|
|
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(() => {});
|
|
|
|
// ── 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(d => {
|
|
if (d.default_theme && !localStorage.getItem('haven_theme')) {
|
|
document.documentElement.setAttribute('data-theme', d.default_theme);
|
|
}
|
|
if (d.server_title) {
|
|
const titleEl = document.getElementById('server-title');
|
|
if (titleEl) titleEl.textContent = d.server_title;
|
|
}
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\n/g,'<br>') + '</p>'
|
|
).join('');
|
|
section.style.display = 'block';
|
|
}
|
|
}
|
|
}).catch(() => {});
|
|
|
|
// ── 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');
|
|
|
|
// 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';
|
|
});
|
|
|
|
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 errorEl = document.getElementById('auth-error');
|
|
|
|
// Pending TOTP challenge state (set after successful password auth)
|
|
let _pendingChallenge = null; // { challengeToken, 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();
|
|
}
|
|
|
|
tabs.forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
tabs.forEach(t => t.classList.remove('active'));
|
|
tab.classList.add('active');
|
|
|
|
const target = tab.dataset.tab;
|
|
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';
|
|
hideError();
|
|
});
|
|
});
|
|
|
|
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'));
|
|
try {
|
|
const res = await fetch('/api/auth/admin-recover', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username, password })
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) return showError(data.error || t('auth.errors.recovery_failed'));
|
|
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));
|
|
window.location.href = _appUrl;
|
|
} 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();
|
|
if (!checkEula()) return;
|
|
|
|
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) return showError(data.error || t('auth.errors.login_failed'));
|
|
|
|
// ── TOTP challenge ──
|
|
if (data.requiresTOTP) {
|
|
_pendingChallenge = { challengeToken: data.challengeToken, password };
|
|
showTotpForm();
|
|
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'));
|
|
}
|
|
});
|
|
|
|
// ── 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'));
|
|
|
|
// Derive E2E wrapping key from the original password
|
|
const e2eWrap = await deriveE2EWrappingKey(_pendingChallenge.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');
|
|
_pendingChallenge = null;
|
|
window.location.href = _appUrl;
|
|
} 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();
|
|
});
|
|
}
|
|
|
|
// ── 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 { /* ignore */ }
|
|
|
|
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 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 = 'Connect';
|
|
ssoConnectBtn.disabled = false;
|
|
|
|
const profileUsername = (typeof ssoProfileData.username === 'string' ? ssoProfileData.username.trim() : '');
|
|
const previewName = (typeof ssoProfileData.displayName === 'string' ? ssoProfileData.displayName.trim() : '') || profileUsername;
|
|
|
|
if (ssoProfileData.profilePicture) {
|
|
let src = ssoProfileData.profilePicture;
|
|
if (src.startsWith('/')) {
|
|
const base = sourceOrigin || getSsoOrigin();
|
|
src = base + src;
|
|
}
|
|
ssoPreviewAvatar.innerHTML = `<img src="${src}" style="width:100%;height:100%;object-fit:cover" alt="">`;
|
|
} else {
|
|
ssoPreviewAvatar.textContent = (previewName || '?')[0].toUpperCase();
|
|
}
|
|
ssoPreviewUsername.textContent = previewName || '—';
|
|
|
|
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 || 'SSO failed — please try again');
|
|
}
|
|
return false;
|
|
}
|
|
const data = await res.json();
|
|
applySsoProfile(data, getSsoOrigin());
|
|
return true;
|
|
} catch {
|
|
if (surfaceError) showError('Could not reach home server — please try again');
|
|
return false;
|
|
}
|
|
};
|
|
|
|
function ssoReset() {
|
|
stopSsoPolling();
|
|
ssoAuthCode = null;
|
|
ssoServerUrl = null;
|
|
ssoProfileData = null;
|
|
ssoWaiting = false;
|
|
ssoStepServer.style.display = '';
|
|
ssoStepRegister.style.display = 'none';
|
|
ssoPreviewAvatar.innerHTML = '?';
|
|
ssoPreviewUsername.textContent = '—';
|
|
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('Enter the address of your Haven server');
|
|
|
|
// 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 = 'Waiting for approval…';
|
|
ssoConnectBtn.disabled = true;
|
|
|
|
stopSsoPolling();
|
|
ssoPollTimer = setInterval(() => {
|
|
tryFetchSsoProfile(false);
|
|
}, 2000);
|
|
ssoTimeoutTimer = setTimeout(() => {
|
|
if (!ssoWaiting) return;
|
|
ssoWaiting = false;
|
|
stopSsoPolling();
|
|
ssoConnectBtn.textContent = 'Connect';
|
|
ssoConnectBtn.disabled = false;
|
|
showError('SSO approval timed out — try connecting again');
|
|
}, 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('Please connect to your home server 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'));
|
|
|
|
// Prefer canonical username from SSO payload. If a legacy server sends
|
|
// display-name-like values, normalize into a valid Haven username.
|
|
const normalizeUsername = (value) => {
|
|
if (typeof value !== 'string') return '';
|
|
return value
|
|
.trim()
|
|
.replace(/[^a-zA-Z0-9_]/g, '_')
|
|
.replace(/_+/g, '_')
|
|
.replace(/^_+|_+$/g, '')
|
|
.slice(0, 20);
|
|
};
|
|
let registerUsername = normalizeUsername(ssoProfileData.username);
|
|
if (registerUsername.length < 3) {
|
|
registerUsername = normalizeUsername(ssoProfileData.displayName);
|
|
}
|
|
if (registerUsername.length < 3) {
|
|
return showError('SSO username is invalid. Please use standard registration.');
|
|
}
|
|
|
|
// Build the full profile picture URL for the server to download
|
|
let profilePicUrl = ssoProfileData.profilePicture || null;
|
|
if (profilePicUrl && profilePicUrl.startsWith('/')) {
|
|
profilePicUrl = ssoServerUrl + profilePicUrl;
|
|
}
|
|
|
|
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
|
|
})
|
|
});
|
|
|
|
const data = await res.json();
|
|
if (!res.ok) 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'));
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Register ──────────────────────────────────────────
|
|
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;
|
|
|
|
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'));
|
|
|
|
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 })
|
|
});
|
|
|
|
const data = await res.json();
|
|
if (!res.ok) return showError(data.error || t('auth.errors.registration_failed'));
|
|
|
|
// 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'));
|
|
}
|
|
});
|
|
})();
|