mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-30 02:25:38 +00:00
932 lines
44 KiB
JavaScript
932 lines
44 KiB
JavaScript
// The Settings window: opening it, the User and Admin tabs, the section nav,
|
|
// language, the composer layout options, time zone, password, two-factor
|
|
// login, recovery codes, the encryption passphrase, and deleting your
|
|
// account or your messages.
|
|
|
|
export default {
|
|
|
|
_bindSettings() {
|
|
// ── Settings popout modal ────────────────────────────
|
|
const openSettingsModal = () => {
|
|
this._snapshotAdminSettings();
|
|
document.getElementById('settings-modal').style.display = 'flex';
|
|
this._syncSettingsNav();
|
|
// Always open on User tab
|
|
this._switchSettingsTab('user');
|
|
// Sync language select with current locale
|
|
const langSelect = document.getElementById('language-select');
|
|
if (langSelect && window.i18n) {
|
|
langSelect.value = i18n.preference;
|
|
i18n.syncLocalePicker(langSelect);
|
|
}
|
|
// Show desktop-only sections when running inside Haven Desktop
|
|
if (window.havenDesktop?.isDesktopApp) {
|
|
document.getElementById('desktop-shortcuts-nav')?.style.removeProperty('display');
|
|
document.getElementById('desktop-app-nav')?.style.removeProperty('display');
|
|
document.getElementById('section-desktop-shortcuts')?.style.removeProperty('display');
|
|
document.getElementById('section-desktop-app')?.style.removeProperty('display');
|
|
document.getElementById('pref-force-sdr-row')?.style.removeProperty('display');
|
|
document.getElementById('pref-disable-gpu-vsync-row')?.style.removeProperty('display');
|
|
document.getElementById('pref-unlimit-frame-rate-row')?.style.removeProperty('display');
|
|
// Linux only, and only from a Desktop build that has the setting (#57).
|
|
if (window.havenDesktop.platform === 'linux' && typeof window.havenDesktop.prefs?.setLinuxVaapiBypass === 'function') {
|
|
document.getElementById('pref-linux-vaapi-row')?.style.removeProperty('display');
|
|
}
|
|
// Same, for X11 mode (#5721).
|
|
if (window.havenDesktop.platform === 'linux' && typeof window.havenDesktop.prefs?.setLinuxForceX11 === 'function') {
|
|
document.getElementById('pref-linux-x11-row')?.style.removeProperty('display');
|
|
}
|
|
}
|
|
// Eagerly fetch data that requires async calls so sections don't
|
|
// sit on "Loading..." indefinitely if the user never clicks the nav item.
|
|
loadTotpStatus();
|
|
if (this.user?.isAdmin) this._loadRoles();
|
|
// (#36) Eagerly wire up Desktop-only sections too. Without this the Shortcut
|
|
// record buttons and Desktop App / Debug prefs only get their event handlers
|
|
// attached when the user clicks the matching left-nav item, so a user who
|
|
// opens Settings and scrolls straight to a checkbox or to the keybind
|
|
// recorder finds them unresponsive until they happen to click the nav.
|
|
if (window.havenDesktop?.isDesktopApp) {
|
|
this._setupDesktopShortcuts?.();
|
|
this._setupDesktopAppPrefs?.();
|
|
}
|
|
};
|
|
document.getElementById('open-settings-btn').addEventListener('click', openSettingsModal);
|
|
document.getElementById('mobile-settings-btn')?.addEventListener('click', () => {
|
|
openSettingsModal();
|
|
document.getElementById('app-body')?.classList.remove('mobile-sidebar-open');
|
|
document.getElementById('mobile-overlay')?.classList.remove('active');
|
|
});
|
|
document.getElementById('close-settings-btn').addEventListener('click', () => {
|
|
this._cancelAdminSettings();
|
|
});
|
|
document.getElementById('settings-modal').addEventListener('click', (e) => {
|
|
if (e.target !== e.currentTarget) return;
|
|
// Don't close while TOTP setup flow is active; user could lose progress
|
|
const setupArea = document.getElementById('totp-setup-area');
|
|
const backupArea = document.getElementById('totp-backup-area');
|
|
if ((setupArea && setupArea.style.display !== 'none') ||
|
|
(backupArea && backupArea.style.display !== 'none')) return;
|
|
this._cancelAdminSettings();
|
|
});
|
|
document.getElementById('admin-save-btn')?.addEventListener('click', () => {
|
|
this._saveAdminSettings();
|
|
});
|
|
|
|
// ── Settings tab switching (User / Admin) ────────────
|
|
this._switchSettingsTab = (tab) => {
|
|
const userBody = document.getElementById('settings-body-user');
|
|
const adminBody = document.getElementById('settings-body-admin');
|
|
const userNav = document.querySelector('.settings-nav-user');
|
|
const adminNav = document.querySelector('.settings-nav-admin-group');
|
|
const saveBar = document.querySelector('.admin-save-bar');
|
|
|
|
// Only the Settings modal's own tabs: plugins reuse the settings-tab
|
|
// look for buttons of their own (Channel Tabs, #5786).
|
|
document.querySelectorAll('#settings-tab-bar .settings-tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelector(`#settings-tab-bar .settings-tab[data-tab="${tab}"]`)?.classList.add('active');
|
|
|
|
if (tab === 'admin') {
|
|
// Defensive gate: refuse switching to the admin tab if the user has no
|
|
// admin/manage permissions, regardless of where the call came from.
|
|
const hasAdminAccess = !!this.user && this._hasAnyAdminSettingsAccess();
|
|
if (!hasAdminAccess) return this._switchSettingsTab('user');
|
|
if (userBody) userBody.style.display = 'none';
|
|
if (adminBody) adminBody.style.display = '';
|
|
if (userNav) userNav.style.display = 'none';
|
|
if (adminNav) adminNav.style.display = '';
|
|
if (saveBar) saveBar.style.display = '';
|
|
// Activate first admin nav item
|
|
document.querySelectorAll('.settings-nav-item').forEach(n => n.classList.remove('active'));
|
|
const firstAdmin = adminNav?.querySelector('.settings-nav-item:not([style*="display: none"])');
|
|
if (firstAdmin) firstAdmin.classList.add('active');
|
|
} else {
|
|
if (userBody) userBody.style.display = '';
|
|
if (adminBody) adminBody.style.display = 'none';
|
|
if (userNav) userNav.style.display = '';
|
|
if (adminNav) adminNav.style.display = 'none';
|
|
if (saveBar) saveBar.style.display = 'none';
|
|
// Activate first user nav item
|
|
document.querySelectorAll('.settings-nav-item').forEach(n => n.classList.remove('active'));
|
|
const firstUser = userNav?.querySelector('.settings-nav-item');
|
|
if (firstUser) firstUser.classList.add('active');
|
|
}
|
|
};
|
|
|
|
document.querySelectorAll('#settings-tab-bar .settings-tab').forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
this._switchSettingsTab(tab.dataset.tab);
|
|
});
|
|
});
|
|
|
|
// ── Settings nav click-to-scroll ─────────────────────
|
|
document.querySelectorAll('.settings-nav-item').forEach(item => {
|
|
// The rows are <div>s; make them reachable and usable from the keyboard.
|
|
item.tabIndex = 0;
|
|
item.setAttribute('role', 'button');
|
|
item.addEventListener('keydown', (e) => {
|
|
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
e.preventDefault();
|
|
item.click();
|
|
});
|
|
item.addEventListener('click', () => {
|
|
const targetId = item.dataset.target;
|
|
const target = document.getElementById(targetId);
|
|
if (!target) return;
|
|
// Scroll into view within the settings body
|
|
target.scrollIntoView({ behavior: this._scrollBehavior(), block: 'start' });
|
|
// Update active state
|
|
document.querySelectorAll('.settings-nav-item').forEach(n => n.classList.remove('active'));
|
|
item.classList.add('active');
|
|
// Suppress scroll-spy briefly so the smooth-scroll animation passing over
|
|
// intermediate sections doesn't steal the highlight from what was clicked.
|
|
this._settingsSpyMuteUntil = Date.now() + 800;
|
|
});
|
|
});
|
|
|
|
// ── Settings scroll-spy ──────────────────────────────
|
|
// Settings bodies are long scrolling columns rather than tab switchers.
|
|
// Keep the corresponding nav item highlighted as the user scrolls.
|
|
//
|
|
// User settings:
|
|
// #settings-body-user
|
|
// .settings-nav-user
|
|
//
|
|
// Admin settings:
|
|
// #settings-body-admin
|
|
// .settings-nav-admin-group
|
|
//
|
|
// Each body has its own independent scroll-spy so the user and admin nav
|
|
// states cannot interfere with each other.
|
|
const setupSettingsScrollSpy = (settingsBody, navSelector) => {
|
|
if (!settingsBody) return;
|
|
|
|
const syncNavHighlight = () => {
|
|
if (Date.now() < (this._settingsSpyMuteUntil || 0)) return;
|
|
|
|
// Only consider nav items whose corresponding section currently exists
|
|
// and is visible. This is important for admin settings because many of
|
|
// the admin nav entries start with display:none.
|
|
const navItems = Array.from(document.querySelectorAll(`${navSelector} .settings-nav-item`));
|
|
const visibleNavItems = navItems.filter(item => {
|
|
if (item.offsetParent === null) return false;
|
|
|
|
const section = document.getElementById(item.dataset.target);
|
|
return section && section.offsetParent !== null;
|
|
});
|
|
|
|
if (!visibleNavItems.length) return;
|
|
|
|
const bodyTop = settingsBody.getBoundingClientRect().top;
|
|
let current = null;
|
|
for (const item of visibleNavItems) {
|
|
const section = document.getElementById(item.dataset.target);
|
|
if (!section) continue;
|
|
|
|
// The last section whose top has passed the top of the scrolling
|
|
// body is the section currently being viewed.
|
|
if (section.getBoundingClientRect().top - bodyTop <= 8) {
|
|
current = item;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Before the first section reaches the top, highlight the first
|
|
// visible section.
|
|
if (!current) current = visibleNavItems[0];
|
|
|
|
// Nothing to do if the correct item is already highlighted.
|
|
if (current.classList.contains('active')) return;
|
|
|
|
// Only modify nav items belonging to this scroll-spy.
|
|
visibleNavItems.forEach(item => item.classList.remove('active'));
|
|
current.classList.add('active');
|
|
// Keep the highlighted entry reachable in a long nav list.
|
|
current.scrollIntoView({ block: 'nearest' });
|
|
};
|
|
|
|
let spyQueued = false;
|
|
settingsBody.addEventListener('scroll', () => {
|
|
if (spyQueued) return;
|
|
spyQueued = true;
|
|
requestAnimationFrame(() => { spyQueued = false; syncNavHighlight(); });
|
|
}, { passive: true });
|
|
|
|
// Set the correct highlight immediately in case the settings body is
|
|
// already scrolled when the spy is initialized.
|
|
syncNavHighlight();
|
|
};
|
|
// User settings scroll-spy
|
|
setupSettingsScrollSpy(document.getElementById('settings-body-user'), '.settings-nav-user');
|
|
// Admin settings scroll-spy
|
|
setupSettingsScrollSpy(document.getElementById('settings-body-admin'), '.settings-nav-admin-group');
|
|
|
|
// ── Language switcher ────────────────────────────────
|
|
document.getElementById('language-select')?.addEventListener('change', (e) => {
|
|
if (window.i18n) i18n.setLocale(e.target.value);
|
|
});
|
|
this._buildLanguagePicker();
|
|
|
|
// ── Voice messages (#5665) ────────────────────────────
|
|
document.getElementById('voice-btn')?.addEventListener('click', () => this._toggleVoiceMessage());
|
|
document.getElementById('voice-rec-cancel')?.addEventListener('click', () => this._stopVoiceMessage(false));
|
|
document.getElementById('voice-rec-send')?.addEventListener('click', () => this._stopVoiceMessage(true));
|
|
|
|
// ── One + button in place of the toolbar (#5654) ──────
|
|
// With the setting on, the toolbar is hidden and becomes the menu the +
|
|
// opens; the buttons keep their own handlers, only their home moves.
|
|
const plusBtn = document.getElementById('composer-plus-btn');
|
|
const actionsBox = document.querySelector('#message-input-area .input-actions-box');
|
|
this._closeComposerMenu = () => {
|
|
actionsBox?.classList.remove('open');
|
|
plusBtn?.setAttribute('aria-expanded', 'false');
|
|
};
|
|
if (plusBtn && actionsBox) {
|
|
plusBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const open = !actionsBox.classList.contains('open');
|
|
actionsBox.classList.toggle('open', open);
|
|
plusBtn.setAttribute('aria-expanded', String(open));
|
|
});
|
|
// Picking a tool closes the menu; the tool's own picker takes over.
|
|
actionsBox.addEventListener('click', (e) => {
|
|
if (document.documentElement.hasAttribute('data-compact-composer') && e.target.closest('button')) setTimeout(() => this._closeComposerMenu(), 0);
|
|
});
|
|
document.addEventListener('click', (e) => {
|
|
if (actionsBox.classList.contains('open') && !e.target.closest('.input-actions-box') && e.target !== plusBtn) this._closeComposerMenu();
|
|
});
|
|
}
|
|
|
|
// ── Formatting guide and command list (#5654) ─────────
|
|
const formatBtn = document.getElementById('format-btn');
|
|
const formatPicker = document.getElementById('format-picker');
|
|
if (formatBtn && formatPicker) {
|
|
let formatTab = 'markdown';
|
|
const renderFormatPicker = () => {
|
|
formatPicker.querySelectorAll('.gif-tab').forEach(b => b.classList.toggle('active', b.dataset.formatTab === formatTab));
|
|
const list = document.getElementById('format-picker-list');
|
|
if (list) list.innerHTML = formatTab === 'markdown' ? this._formatGuideHtml() : this._commandGuideHtml();
|
|
};
|
|
formatBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const open = formatPicker.style.display === 'none';
|
|
const emojiPicker = document.getElementById('emoji-picker');
|
|
const gifPicker = document.getElementById('gif-picker');
|
|
if (emojiPicker) emojiPicker.style.display = 'none';
|
|
if (gifPicker) gifPicker.style.display = 'none';
|
|
formatPicker.style.display = open ? 'flex' : 'none';
|
|
if (open) renderFormatPicker();
|
|
});
|
|
formatPicker.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const tab = e.target.closest('[data-format-tab]');
|
|
if (tab) { formatTab = tab.dataset.formatTab; renderFormatPicker(); return; }
|
|
const row = e.target.closest('.format-row');
|
|
if (!row) return;
|
|
if (row.dataset.cmd) this._insertGuideCommand(row.dataset.cmd);
|
|
else this._wrapComposerSelection(row.dataset.before || '', row.dataset.after || '', row.dataset.sample || '', row.dataset.block === '1');
|
|
formatPicker.style.display = 'none';
|
|
});
|
|
document.addEventListener('click', (e) => {
|
|
if (formatPicker.style.display !== 'none' && !e.target.closest('#format-picker') && !e.target.closest('#format-btn')) formatPicker.style.display = 'none';
|
|
});
|
|
}
|
|
|
|
// ── Timezone (Configure Time) ────────────────────────
|
|
document.getElementById('configure-time-btn')?.addEventListener('click', () => {
|
|
this._openTimezoneModal({ firstRun: false });
|
|
});
|
|
this._updateTimezoneSummary?.();
|
|
|
|
// ── Password change ──────────────────────────────────
|
|
document.getElementById('change-password-btn').addEventListener('click', async () => {
|
|
const cur = document.getElementById('current-password').value;
|
|
const np = document.getElementById('new-password').value;
|
|
const conf = document.getElementById('confirm-password').value;
|
|
const hint = document.getElementById('password-status');
|
|
hint.textContent = '';
|
|
hint.className = 'settings-hint';
|
|
|
|
if (!cur || !np) return hint.textContent = t('settings.password_section.fill_fields');
|
|
if (np.length < 8) return hint.textContent = t('settings.password_section.too_short');
|
|
if (np !== conf) return hint.textContent = t('settings.password_section.mismatch');
|
|
|
|
// Flag to prevent force-logout from kicking us out
|
|
this._justChangedPassword = true;
|
|
|
|
try {
|
|
const res = await fetch('/api/auth/change-password', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${this.token}`
|
|
},
|
|
body: JSON.stringify({ currentPassword: cur, newPassword: np })
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
hint.textContent = data.error || t('settings.password_section.failed');
|
|
hint.classList.add('error');
|
|
return;
|
|
}
|
|
// Store the fresh token
|
|
this.token = data.token;
|
|
localStorage.setItem('haven_token', data.token);
|
|
// Update socket auth so auto-reconnect uses the new token
|
|
this.socket.auth.token = data.token;
|
|
|
|
// Re-wrap E2E private key with a key derived from the NEW password
|
|
// so the server backup can be unlocked with the new credentials. A
|
|
// backup locked with a separate passphrase stays as it is.
|
|
if (this.e2e && this.e2e.ready && typeof HavenE2E !== 'undefined' && !this.user?.e2ePassphrase) {
|
|
try {
|
|
const newWrap = await HavenE2E.deriveWrappingKey(np);
|
|
await this.e2e.reWrapKey(this.socket, newWrap);
|
|
// Re-encrypt server list blob with the new wrapping key
|
|
this._e2eWrappingKey = newWrap;
|
|
this._pushServerListToServer();
|
|
} catch (err) {
|
|
console.warn('[E2E] Failed to re-wrap key:', err);
|
|
}
|
|
}
|
|
|
|
hint.textContent = '✅ ' + t('settings.password_section.changed');
|
|
hint.classList.add('success');
|
|
document.getElementById('current-password').value = '';
|
|
document.getElementById('new-password').value = '';
|
|
document.getElementById('confirm-password').value = '';
|
|
// Clear the flag after a delay so socket reconnects go through
|
|
setTimeout(() => { this._justChangedPassword = false; }, 5000);
|
|
} catch {
|
|
this._justChangedPassword = false;
|
|
hint.textContent = t('settings.password_section.network_error');
|
|
hint.classList.add('error');
|
|
}
|
|
});
|
|
|
|
// ── Encryption passphrase ────────────────────────────
|
|
// The E2E key backup is normally locked with the login password, which the
|
|
// server receives at every sign-in. A passphrase of the user's own keeps the
|
|
// server from ever being able to open it.
|
|
this._setupE2EPassphraseSection();
|
|
|
|
// ── Two-Factor Authentication settings ─────────────
|
|
const totpStatusText = document.getElementById('totp-status-text');
|
|
const totpEnableArea = document.getElementById('totp-enable-area');
|
|
const totpSetupArea = document.getElementById('totp-setup-area');
|
|
const totpBackupArea = document.getElementById('totp-backup-area');
|
|
const totpManageArea = document.getElementById('totp-manage-area');
|
|
const totpSetupStatus = document.getElementById('totp-setup-status');
|
|
const totpManageStatus = document.getElementById('totp-manage-status');
|
|
|
|
const loadTotpStatus = async () => {
|
|
if (!totpStatusText) return;
|
|
try {
|
|
const res = await fetch('/api/auth/totp/status', {
|
|
headers: { 'Authorization': `Bearer ${this.token}` }
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) { totpStatusText.textContent = data.error || t('settings.two_factor_section.error'); return; }
|
|
|
|
// Hide all sub-areas first
|
|
totpEnableArea.style.display = 'none';
|
|
totpSetupArea.style.display = 'none';
|
|
totpBackupArea.style.display = 'none';
|
|
totpManageArea.style.display = 'none';
|
|
|
|
if (data.enabled) {
|
|
totpStatusText.textContent = '';
|
|
totpManageArea.style.display = 'block';
|
|
const remaining = document.getElementById('totp-backup-remaining');
|
|
if (remaining) remaining.textContent = data.backupCodesRemaining === 1
|
|
? t('settings.two_factor_section.backup_codes_remaining_one', { count: data.backupCodesRemaining })
|
|
: t('settings.two_factor_section.backup_codes_remaining_other', { count: data.backupCodesRemaining });
|
|
// Clear password input
|
|
const pwInput = document.getElementById('totp-disable-password');
|
|
if (pwInput) pwInput.value = '';
|
|
if (totpManageStatus) { totpManageStatus.textContent = ''; totpManageStatus.className = 'settings-hint'; }
|
|
} else {
|
|
totpStatusText.textContent = '';
|
|
totpEnableArea.style.display = 'block';
|
|
}
|
|
} catch {
|
|
totpStatusText.textContent = t('settings.two_factor_section.connection_error');
|
|
}
|
|
};
|
|
|
|
// Load status when the 2FA section becomes visible
|
|
const settingsNav = document.getElementById('settings-nav');
|
|
if (settingsNav) {
|
|
settingsNav.addEventListener('click', (e) => {
|
|
const item = e.target.closest('.settings-nav-item');
|
|
if (item && item.dataset.target === 'section-2fa') loadTotpStatus();
|
|
if (item && item.dataset.target === 'section-tags-admin') this._loadAdminTags();
|
|
if (item && item.dataset.target === 'section-sessions') this._refreshSessions();
|
|
if (item && item.dataset.target === 'section-desktop-shortcuts') this._setupDesktopShortcuts();
|
|
if (item && item.dataset.target === 'section-desktop-app') this._setupDesktopAppPrefs();
|
|
});
|
|
}
|
|
|
|
// Enable button → start setup
|
|
document.getElementById('totp-enable-btn')?.addEventListener('click', async () => {
|
|
totpEnableArea.style.display = 'none';
|
|
totpSetupArea.style.display = 'block';
|
|
if (totpSetupStatus) { totpSetupStatus.textContent = ''; totpSetupStatus.className = 'settings-hint'; }
|
|
document.getElementById('totp-verify-code').value = '';
|
|
const pwField = document.getElementById('totp-setup-password');
|
|
if (pwField) pwField.value = '';
|
|
|
|
try {
|
|
const res = await fetch('/api/auth/totp/setup', {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${this.token}`, 'Content-Type': 'application/json' }
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) { totpSetupStatus.textContent = data.error || t('settings.two_factor_section.setup_failed'); return; }
|
|
|
|
document.getElementById('totp-qr-img').src = data.qrDataUrl;
|
|
document.getElementById('totp-secret-text').textContent = data.base32Secret;
|
|
} catch {
|
|
totpSetupStatus.textContent = t('settings.two_factor_section.connection_error');
|
|
}
|
|
});
|
|
|
|
// Copy secret button
|
|
document.getElementById('totp-copy-secret')?.addEventListener('click', () => {
|
|
const secret = document.getElementById('totp-secret-text')?.textContent;
|
|
if (!secret) return;
|
|
const copyBtn = document.getElementById('totp-copy-secret');
|
|
const markCopied = () => {
|
|
copyBtn.textContent = '✅ ' + t('common.copied');
|
|
setTimeout(() => { copyBtn.textContent = '📋 ' + t('common.copy'); }, 1500);
|
|
};
|
|
navigator.clipboard.writeText(secret).then(markCopied).catch(() => {
|
|
// Fallback for Electron / contexts where Clipboard API is restricted
|
|
try {
|
|
const ta = document.createElement('textarea');
|
|
ta.value = secret;
|
|
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none';
|
|
document.body.appendChild(ta);
|
|
ta.focus(); ta.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
markCopied();
|
|
} catch { /* could not copy */ }
|
|
});
|
|
});
|
|
|
|
// Cancel setup
|
|
document.getElementById('totp-cancel-setup-btn')?.addEventListener('click', () => {
|
|
totpSetupArea.style.display = 'none';
|
|
totpEnableArea.style.display = 'block';
|
|
});
|
|
|
|
// Verify & Activate
|
|
document.getElementById('totp-verify-setup-btn')?.addEventListener('click', async () => {
|
|
const code = document.getElementById('totp-verify-code')?.value.trim();
|
|
if (!code || code.length !== 6) {
|
|
if (totpSetupStatus) totpSetupStatus.textContent = t('settings.two_factor_section.verify_prompt');
|
|
return;
|
|
}
|
|
const password = document.getElementById('totp-setup-password')?.value || '';
|
|
try {
|
|
const res = await fetch('/api/auth/totp/verify-setup', {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${this.token}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ code, password })
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
if (totpSetupStatus) { totpSetupStatus.textContent = data.error || t('settings.two_factor_section.verify_failed'); totpSetupStatus.classList.add('error'); }
|
|
return;
|
|
}
|
|
// Store fresh token: server bumped password_version to invalidate other sessions
|
|
if (data.token) {
|
|
this._justEnabledTotp = true;
|
|
this.token = data.token;
|
|
localStorage.setItem('haven_token', data.token);
|
|
if (this.socket) this.socket.auth.token = data.token;
|
|
}
|
|
// Show backup codes
|
|
totpSetupArea.style.display = 'none';
|
|
totpBackupArea.style.display = 'block';
|
|
const codesEl = document.getElementById('totp-backup-codes');
|
|
if (codesEl) codesEl.innerHTML = data.backupCodes.map(c => `<div>${c}</div>`).join('');
|
|
} catch {
|
|
if (totpSetupStatus) totpSetupStatus.textContent = t('settings.two_factor_section.connection_error');
|
|
}
|
|
});
|
|
|
|
// Copy backup codes to clipboard
|
|
document.getElementById('totp-copy-backup-btn')?.addEventListener('click', () => {
|
|
const codesEl = document.getElementById('totp-backup-codes');
|
|
if (!codesEl) return;
|
|
const codes = Array.from(codesEl.querySelectorAll('div')).map(d => d.textContent).join('\n');
|
|
const btn = document.getElementById('totp-copy-backup-btn');
|
|
const markCopied = () => {
|
|
btn.textContent = '✅ ' + t('common.copied') + '!';
|
|
setTimeout(() => { btn.textContent = '📋 ' + t('settings.two_factor_section.copy_backup_btn'); }, 2000);
|
|
};
|
|
navigator.clipboard.writeText(codes).then(markCopied).catch(() => {
|
|
// Fallback for Electron / contexts where Clipboard API is restricted
|
|
try {
|
|
const ta = document.createElement('textarea');
|
|
ta.value = codes;
|
|
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none';
|
|
document.body.appendChild(ta);
|
|
ta.focus(); ta.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
markCopied();
|
|
} catch { /* could not copy */ }
|
|
});
|
|
});
|
|
|
|
// Done viewing backup codes
|
|
document.getElementById('totp-backup-done-btn')?.addEventListener('click', () => {
|
|
loadTotpStatus();
|
|
});
|
|
|
|
// Disable 2FA
|
|
document.getElementById('totp-disable-btn')?.addEventListener('click', async () => {
|
|
const pw = document.getElementById('totp-disable-password')?.value;
|
|
if (!pw) { if (totpManageStatus) totpManageStatus.textContent = t('settings.two_factor_section.disable_prompt'); return; }
|
|
try {
|
|
const res = await fetch('/api/auth/totp/disable', {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${this.token}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password: pw })
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
if (totpManageStatus) { totpManageStatus.textContent = data.error || t('settings.two_factor_section.failed'); totpManageStatus.classList.add('error'); }
|
|
return;
|
|
}
|
|
this._showToast(t('toasts.2fa_disabled'), 'info');
|
|
loadTotpStatus();
|
|
} catch {
|
|
if (totpManageStatus) totpManageStatus.textContent = t('settings.two_factor_section.connection_error');
|
|
}
|
|
});
|
|
|
|
// Regenerate backup codes
|
|
document.getElementById('totp-regen-backup-btn')?.addEventListener('click', async () => {
|
|
const pw = document.getElementById('totp-disable-password')?.value;
|
|
if (!pw) { if (totpManageStatus) totpManageStatus.textContent = t('settings.two_factor_section.regen_prompt'); return; }
|
|
try {
|
|
const res = await fetch('/api/auth/totp/regenerate-backup', {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${this.token}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password: pw })
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
if (totpManageStatus) { totpManageStatus.textContent = data.error || t('settings.two_factor_section.failed'); totpManageStatus.classList.add('error'); }
|
|
return;
|
|
}
|
|
// Show the new backup codes
|
|
totpManageArea.style.display = 'none';
|
|
totpBackupArea.style.display = 'block';
|
|
const codesEl = document.getElementById('totp-backup-codes');
|
|
if (codesEl) codesEl.innerHTML = data.backupCodes.map(c => `<div>${c}</div>`).join('');
|
|
} catch {
|
|
if (totpManageStatus) totpManageStatus.textContent = t('settings.two_factor_section.connection_error');
|
|
}
|
|
});
|
|
|
|
// ── Recovery Codes section ───────────────────────────
|
|
const loadRecoveryStatus = async () => {
|
|
const statusEl = document.getElementById('recovery-code-status');
|
|
if (!statusEl) return;
|
|
try {
|
|
const res = await fetch('/api/auth/recovery-codes/status', {
|
|
headers: { 'Authorization': `Bearer ${this.token}` }
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) { statusEl.textContent = data.error || t('settings.two_factor_section.error'); return; }
|
|
statusEl.textContent = data.count > 0
|
|
? (data.count === 1
|
|
? t('settings.recovery_section.status_one', { count: data.count })
|
|
: t('settings.recovery_section.status_other', { count: data.count }))
|
|
: t('settings.recovery_section.no_codes');
|
|
} catch {
|
|
statusEl.textContent = t('settings.recovery_section.connection_error');
|
|
}
|
|
};
|
|
|
|
// Load status when Recovery section becomes visible
|
|
if (settingsNav) {
|
|
const _origSettingsNavHandler = settingsNav._recoveryNavAdded;
|
|
if (!_origSettingsNavHandler) {
|
|
settingsNav._recoveryNavAdded = true;
|
|
settingsNav.addEventListener('click', (e) => {
|
|
const item = e.target.closest('.settings-nav-item');
|
|
if (item && item.dataset.target === 'section-recovery') {
|
|
loadRecoveryStatus();
|
|
document.getElementById('recovery-gen-status').textContent = '';
|
|
document.getElementById('recovery-gen-password').value = '';
|
|
document.getElementById('recovery-generate-area').style.display = '';
|
|
document.getElementById('recovery-codes-area').style.display = 'none';
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
document.getElementById('recovery-generate-btn')?.addEventListener('click', async () => {
|
|
const password = document.getElementById('recovery-gen-password')?.value;
|
|
const statusEl = document.getElementById('recovery-gen-status');
|
|
if (!password) { statusEl.textContent = t('settings.recovery_section.confirm_prompt'); return; }
|
|
statusEl.textContent = '';
|
|
try {
|
|
const res = await fetch('/api/auth/recovery-codes/generate', {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${this.token}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password })
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) { statusEl.textContent = data.error || t('settings.recovery_section.failed'); return; }
|
|
|
|
const codesEl = document.getElementById('recovery-codes-list');
|
|
if (codesEl) codesEl.innerHTML = data.codes.map(c => `<div>${c}</div>`).join('');
|
|
document.getElementById('recovery-generate-area').style.display = 'none';
|
|
document.getElementById('recovery-codes-area').style.display = '';
|
|
loadRecoveryStatus();
|
|
} catch {
|
|
statusEl.textContent = t('settings.recovery_section.connection_error');
|
|
}
|
|
});
|
|
|
|
document.getElementById('recovery-copy-btn')?.addEventListener('click', () => {
|
|
const codesEl = document.getElementById('recovery-codes-list');
|
|
if (!codesEl) return;
|
|
const text = Array.from(codesEl.querySelectorAll('div')).map(d => d.textContent).join('\n');
|
|
const btn = document.getElementById('recovery-copy-btn');
|
|
const markCopied = () => {
|
|
btn.textContent = '✅ ' + t('common.copied') + '!';
|
|
setTimeout(() => { btn.textContent = '📋 ' + t('settings.recovery_section.copy_codes_btn'); }, 2000);
|
|
};
|
|
navigator.clipboard.writeText(text).then(markCopied).catch(() => {
|
|
try {
|
|
const ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none';
|
|
document.body.appendChild(ta);
|
|
ta.focus(); ta.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
markCopied();
|
|
} catch { /* could not copy */ }
|
|
});
|
|
});
|
|
|
|
document.getElementById('recovery-codes-done-btn')?.addEventListener('click', () => {
|
|
document.getElementById('recovery-codes-area').style.display = 'none';
|
|
document.getElementById('recovery-generate-area').style.display = '';
|
|
document.getElementById('recovery-gen-password').value = '';
|
|
});
|
|
|
|
// ── Plugin refresh button ─────────────────────────────
|
|
document.getElementById('plugin-refresh-btn')?.addEventListener('click', () => {
|
|
if (window.HavenPluginLoader) {
|
|
window.HavenPluginLoader.refresh();
|
|
this._showToast(t('toasts.plugins_refreshing'), 'info');
|
|
}
|
|
});
|
|
|
|
// ── Self-delete account ─────────────────────────────
|
|
document.getElementById('delete-account-btn').addEventListener('click', () => {
|
|
// Build a confirmation overlay dynamically
|
|
const existing = document.querySelector('.self-delete-overlay');
|
|
if (existing) existing.remove();
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'modal-overlay self-delete-overlay';
|
|
overlay.style.display = 'flex';
|
|
overlay.innerHTML = `
|
|
<div class="modal" style="max-width:380px">
|
|
<h3>⚠️ ${t('settings.delete_account_section.title')}</h3>
|
|
<p class="modal-desc">${t('settings.delete_account_section.desc')}</p>
|
|
<div class="form-group compact">
|
|
<input type="password" id="self-delete-pw" placeholder="${t('settings.delete_account_section.password_placeholder')}" maxlength="128" autocomplete="current-password">
|
|
</div>
|
|
<label class="toggle-row" style="margin:8px 0">
|
|
<span>${t('settings.delete_account_section.delete_messages')}</span>
|
|
<input type="checkbox" id="self-delete-scrub">
|
|
</label>
|
|
<small class="settings-hint" style="margin-bottom:8px;display:block">${t('settings.delete_account_section.delete_messages_hint')}</small>
|
|
<small class="settings-hint self-delete-status" style="display:block;margin-bottom:8px"></small>
|
|
<div class="modal-actions">
|
|
<button class="btn-sm self-delete-cancel">${t('modals.common.cancel')}</button>
|
|
<button class="btn-sm btn-danger-fill self-delete-confirm">${t('settings.delete_account_section.btn')}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(overlay);
|
|
|
|
overlay.querySelector('.self-delete-cancel').addEventListener('click', () => overlay.remove());
|
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
|
|
|
overlay.querySelector('.self-delete-confirm').addEventListener('click', async () => {
|
|
const pw = document.getElementById('self-delete-pw').value;
|
|
const scrub = document.getElementById('self-delete-scrub').checked;
|
|
const status = overlay.querySelector('.self-delete-status');
|
|
|
|
if (!pw) { status.textContent = t('settings.delete_account_section.password_required'); return; }
|
|
const ok = await this._showConfirmModal(t('confirm.delete_account'), '', { danger: true });
|
|
if (!ok) return;
|
|
|
|
status.textContent = t('settings.delete_account_section.deleting');
|
|
overlay.querySelector('.self-delete-confirm').disabled = true;
|
|
|
|
this.socket.emit('self-delete-account', { password: pw, scrubMessages: scrub }, (res) => {
|
|
if (res && res.error) {
|
|
status.textContent = res.error;
|
|
overlay.querySelector('.self-delete-confirm').disabled = false;
|
|
return;
|
|
}
|
|
// Account deleted: clear local storage and redirect to login
|
|
this._clearChannelCodeMap?.();
|
|
localStorage.removeItem('haven_token');
|
|
localStorage.removeItem('haven_e2e_privkey');
|
|
localStorage.removeItem('haven_sync_key');
|
|
window.location.reload();
|
|
});
|
|
});
|
|
});
|
|
|
|
// Delete every message you wrote (#5686). Same shape as Delete Account:
|
|
// password, a second confirm, then the server does it and says how many.
|
|
document.getElementById('self-purge-btn')?.addEventListener('click', () => {
|
|
document.querySelector('.self-purge-overlay')?.remove();
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'modal-overlay self-purge-overlay';
|
|
overlay.style.display = 'flex';
|
|
overlay.innerHTML = `
|
|
<div class="modal" style="max-width:380px">
|
|
<h3>🧹 ${t('settings.self_purge.title')}</h3>
|
|
<p class="modal-desc">${t('settings.self_purge.confirm_desc')}</p>
|
|
<div class="form-group compact">
|
|
<input type="password" id="self-purge-pw" placeholder="${t('settings.delete_account_section.password_placeholder')}" maxlength="128" autocomplete="current-password">
|
|
</div>
|
|
<small class="settings-hint self-purge-status" style="display:block;margin-bottom:8px"></small>
|
|
<div class="modal-actions">
|
|
<button class="btn-sm self-purge-cancel">${t('modals.common.cancel')}</button>
|
|
<button class="btn-sm btn-danger-fill self-purge-confirm">${t('settings.self_purge.btn')}</button>
|
|
</div>
|
|
</div>`;
|
|
document.body.appendChild(overlay);
|
|
overlay.querySelector('.self-purge-cancel').addEventListener('click', () => overlay.remove());
|
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
|
overlay.querySelector('.self-purge-confirm').addEventListener('click', async () => {
|
|
const pw = document.getElementById('self-purge-pw').value;
|
|
const status = overlay.querySelector('.self-purge-status');
|
|
if (!pw) { status.textContent = t('settings.delete_account_section.password_required'); return; }
|
|
const ok = await this._showConfirmModal(t('settings.self_purge.confirm_title'), t('settings.self_purge.confirm_body'), { danger: true, confirmLabel: t('settings.self_purge.btn') });
|
|
if (!ok) return;
|
|
status.textContent = t('settings.delete_account_section.deleting');
|
|
overlay.querySelector('.self-purge-confirm').disabled = true;
|
|
this.socket.emit('self-purge-messages', { password: pw }, (res) => {
|
|
if (!res || res.error) {
|
|
status.textContent = res?.error || t('settings.self_purge.failed');
|
|
overlay.querySelector('.self-purge-confirm').disabled = false;
|
|
return;
|
|
}
|
|
overlay.remove();
|
|
this._showToast(res.kept
|
|
? t('settings.self_purge.done_kept', { n: res.deleted, kept: res.kept })
|
|
: t('settings.self_purge.done', { n: res.deleted }), 'success');
|
|
});
|
|
});
|
|
});
|
|
},
|
|
|
|
// ═══════════════════════════════════════════════════════
|
|
// CHANNEL & MESSAGE LINKS: copy/share deep-links
|
|
// ═══════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Replace the native language <select> with a custom dropdown that can show
|
|
* real flag artwork.
|
|
*
|
|
* Windows browsers refuse to render Unicode regional-indicator flags and fall
|
|
* back to the bare two-letter code, so "🇬🇧 English" displayed as "GB English".
|
|
* The emoji picker already solved this by shipping SVGs (see builtinEmojis in
|
|
* app.js), but that fix can't apply here: an <option> element renders text
|
|
* only (no images, no markup), so no amount of CSS or emoji font work will
|
|
* put a flag inside a native select.
|
|
*
|
|
* The original <select> is kept in the DOM as the source of truth and still
|
|
* receives its 'change' event, so the existing i18n wiring is untouched; this
|
|
* only swaps the visible control.
|
|
*/
|
|
_buildLanguagePicker() {
|
|
const select = document.getElementById('language-select');
|
|
if (!select || !window.i18n) return;
|
|
select.value = i18n.preference;
|
|
i18n.buildLocalePicker(select);
|
|
},
|
|
|
|
// Settings > Encryption: lock the E2E key backup with a passphrase of the
|
|
// user's own instead of the login password, or go back. SSO accounts already
|
|
// use a passphrase and guests have no password, so neither sees it.
|
|
_setupE2EPassphraseSection() {
|
|
const section = document.getElementById('section-e2e-passphrase');
|
|
if (!section) return;
|
|
const navItem = document.querySelector('.settings-nav-item[data-target="section-e2e-passphrase"]');
|
|
const stateEl = document.getElementById('e2e-pp-state');
|
|
const statusEl = document.getElementById('e2e-pp-status');
|
|
const newEl = document.getElementById('e2e-pp-new');
|
|
const confirmEl = document.getElementById('e2e-pp-confirm');
|
|
const saveBtn = document.getElementById('e2e-pp-save-btn');
|
|
const revertArea = document.getElementById('e2e-pp-revert-area');
|
|
const pwEl = document.getElementById('e2e-pp-password');
|
|
const revertBtn = document.getElementById('e2e-pp-revert-btn');
|
|
|
|
const say = (msg, kind) => {
|
|
statusEl.textContent = msg || '';
|
|
statusEl.className = 'settings-hint' + (kind ? ` ${kind}` : '');
|
|
};
|
|
const render = () => {
|
|
const hidden = !!(this.user?.isSso || this.user?.isGuest);
|
|
section.style.display = hidden ? 'none' : '';
|
|
if (navItem) navItem.style.display = hidden ? 'none' : '';
|
|
const own = !!this.user?.e2ePassphrase;
|
|
stateEl.textContent = t(own ? 'settings.e2e_passphrase.state_own' : 'settings.e2e_passphrase.state_password');
|
|
saveBtn.textContent = t(own ? 'settings.e2e_passphrase.change_btn' : 'settings.e2e_passphrase.save_btn');
|
|
revertArea.style.display = own ? '' : 'none';
|
|
};
|
|
// session-info fills in the flags after this runs, so it re-renders too.
|
|
this._renderE2EPassphraseSection = render;
|
|
render();
|
|
|
|
// Either change re-locks the backup, so the key has to be unlocked first.
|
|
const whenUnlocked = (fn) => {
|
|
if (this.e2e?.ready) return fn();
|
|
say(t('settings.e2e_passphrase.unlock_first'), 'error');
|
|
this._requireE2E(fn);
|
|
};
|
|
|
|
// The backup is locked with wrapKey from now on, and the server list sync
|
|
// (which shares the key) follows it.
|
|
const adopt = async (wrapKey, own) => {
|
|
await this.e2e.reWrapKey(this.socket, wrapKey, { separatePassphrase: own });
|
|
this.user.e2ePassphrase = own;
|
|
try { localStorage.setItem('haven_user', JSON.stringify(this.user)); } catch { /* private mode */ }
|
|
this._e2eWrappingKey = wrapKey;
|
|
try { localStorage.setItem('haven_sync_key', wrapKey); } catch { /* private mode */ }
|
|
this._pushServerListToServer?.();
|
|
render();
|
|
};
|
|
|
|
saveBtn.addEventListener('click', () => {
|
|
const pass = newEl.value;
|
|
if (!pass || pass.length < 8) return say(t('settings.e2e_passphrase.too_short'), 'error');
|
|
if (pass !== confirmEl.value) return say(t('settings.e2e_passphrase.mismatch'), 'error');
|
|
whenUnlocked(async () => {
|
|
saveBtn.disabled = true;
|
|
say(t('settings.e2e_passphrase.saving'));
|
|
try {
|
|
await adopt(await HavenE2E.deriveWrappingKey(pass), true);
|
|
newEl.value = '';
|
|
confirmEl.value = '';
|
|
say('✅ ' + t('settings.e2e_passphrase.saved'), 'success');
|
|
} catch (err) {
|
|
console.warn('[E2E] Could not lock the backup with the passphrase:', err);
|
|
say(t('settings.e2e_passphrase.failed'), 'error');
|
|
} finally {
|
|
saveBtn.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
|
|
revertBtn.addEventListener('click', () => {
|
|
const password = pwEl.value;
|
|
if (!password) return say(t('settings.e2e_passphrase.enter_password'), 'error');
|
|
whenUnlocked(async () => {
|
|
revertBtn.disabled = true;
|
|
try {
|
|
// Checked first: a mistyped password would lock the backup with a
|
|
// key nobody can reproduce.
|
|
const res = await fetch('/api/auth/verify-password', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.token}` },
|
|
body: JSON.stringify({ password })
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!data.valid) return say(t('settings.e2e_passphrase.wrong_password'), 'error');
|
|
await adopt(await HavenE2E.deriveWrappingKey(password), false);
|
|
pwEl.value = '';
|
|
say('✅ ' + t('settings.e2e_passphrase.reverted'), 'success');
|
|
} catch (err) {
|
|
console.warn('[E2E] Could not lock the backup with the password:', err);
|
|
say(t('settings.e2e_passphrase.failed'), 'error');
|
|
} finally {
|
|
revertBtn.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
},
|
|
|
|
};
|