Files
Haven/public/js/modules/app-admin.js
T

1514 lines
70 KiB
JavaScript

// Server Admin settings: the first-time setup wizard, sign-in (OIDC) options,
// and filling in, saving and cancelling the admin settings.
export default {
// ── First-Time Setup Wizard ─────────────────────────────
_maybeShowSetupWizard() {
// Only show for admin, only if wizard hasn't been completed
if (!this.user?.isAdmin) return;
if (this.serverSettings?.setup_wizard_complete === 'true') return;
if (this._wizardShown) return;
this._wizardShown = true;
const modal = document.getElementById('setup-wizard-modal');
if (!modal) return;
this._wizardStep = 1;
this._wizardChannelCode = null;
this._wizardPortResult = null;
// Pre-fill server name from settings
const nameInput = document.getElementById('wizard-server-name');
if (nameInput && this.serverSettings?.server_name) {
nameInput.value = this.serverSettings.server_name;
}
this._wizardUpdateUI();
modal.style.display = 'flex';
// Button handlers (clean up old listeners)
const nextBtn = document.getElementById('wizard-next-btn');
const backBtn = document.getElementById('wizard-back-btn');
const skipBtn = document.getElementById('wizard-skip-btn');
const portBtn = document.getElementById('wizard-check-port-btn');
const copyBtn = document.getElementById('wizard-copy-code');
const newNext = nextBtn.cloneNode(true);
nextBtn.parentNode.replaceChild(newNext, nextBtn);
const newBack = backBtn.cloneNode(true);
backBtn.parentNode.replaceChild(newBack, backBtn);
const newSkip = skipBtn.cloneNode(true);
skipBtn.parentNode.replaceChild(newSkip, skipBtn);
const newPort = portBtn.cloneNode(true);
portBtn.parentNode.replaceChild(newPort, portBtn);
const newCopy = copyBtn.cloneNode(true);
copyBtn.parentNode.replaceChild(newCopy, copyBtn);
newNext.addEventListener('click', () => this._wizardNext());
newBack.addEventListener('click', () => this._wizardBack());
newSkip.addEventListener('click', () => this._wizardComplete());
newPort.addEventListener('click', () => this._wizardCheckPort());
newCopy.addEventListener('click', () => {
if (this._wizardChannelCode) {
const markCopied = () => {
newCopy.textContent = t('modals.wizard.copied_btn');
setTimeout(() => newCopy.textContent = t('modals.wizard.copy_btn'), 2000);
};
navigator.clipboard.writeText(this._wizardChannelCode).then(markCopied).catch(() => {
try {
const ta = document.createElement('textarea');
ta.value = this._wizardChannelCode;
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 */ }
});
}
});
},
_wizardUpdateUI() {
const step = this._wizardStep;
// Update step indicators
document.querySelectorAll('.wizard-indicator').forEach(ind => {
const s = parseInt(ind.dataset.step);
ind.classList.remove('active', 'done');
if (s === step) ind.classList.add('active');
else if (s < step) ind.classList.add('done');
});
// Show/hide steps
for (let i = 1; i <= 4; i++) {
const el = document.getElementById(`wizard-step-${i}`);
if (el) el.style.display = i === step ? 'block' : 'none';
}
// Back button
const backBtn = document.getElementById('wizard-back-btn');
if (backBtn) backBtn.style.display = step > 1 ? '' : 'none';
// Next/Finish button text
const nextBtn = document.getElementById('wizard-next-btn');
if (nextBtn) {
if (step === 4) {
nextBtn.textContent = `🚀 ${t('modals.wizard.get_started_btn')}`;
} else if (step === 2 && !this._wizardChannelCode) {
nextBtn.textContent = t('modals.wizard.create_continue_btn');
} else {
nextBtn.textContent = t('modals.wizard.next_btn');
}
}
// Step 4 summary
if (step === 4) {
const chanSummary = document.getElementById('wizard-summary-channel');
if (chanSummary) {
chanSummary.textContent = this._wizardChannelCode
? `✅ ${t('modals.wizard.channel_created_summary', { code: this._wizardChannelCode })}`
: `⏭️ ${t('modals.wizard.no_channel_created')}`;
}
const portSummary = document.getElementById('wizard-summary-port');
if (portSummary) {
if (this._wizardPortResult === true) portSummary.textContent = `✅ ${t('modals.wizard.port_open_summary')}`;
else if (this._wizardPortResult === false) portSummary.textContent = `⚠️ ${t('modals.wizard.port_blocked_summary')}`;
else portSummary.textContent = `⏭️ ${t('modals.wizard.check_port_skipped')}`;
}
// Set final URL
const urlEl = document.getElementById('wizard-final-url');
if (urlEl && this._wizardPublicIp) {
const port = location.port || (location.protocol === 'https:' ? '443' : '80');
urlEl.textContent = `${location.protocol}//${this._wizardPublicIp}:${port}`;
}
}
},
_wizardNext() {
const step = this._wizardStep;
if (step === 1) {
// Save server name if changed
const nameInput = document.getElementById('wizard-server-name');
const name = nameInput?.value?.trim();
if (name && name !== (this.serverSettings?.server_name || 'Haven')) {
this.socket.emit('update-server-setting', { key: 'server_name', value: name });
}
this._wizardStep = 2;
this._wizardUpdateUI();
} else if (step === 2) {
// Create channel if not already created
if (!this._wizardChannelCode) {
const nameInput = document.getElementById('wizard-channel-name');
const channelName = nameInput?.value?.trim() || 'General';
// Listen for channel creation result
const handler = (channel) => {
if (channel && channel.code) {
this._wizardChannelCode = channel.code;
const resultDiv = document.getElementById('wizard-channel-result');
const codeEl = document.getElementById('wizard-channel-code');
if (resultDiv) resultDiv.style.display = 'block';
if (codeEl) codeEl.textContent = channel.code;
nameInput.disabled = true;
// Auto-advance to step 3 after channel is created
this._wizardStep = 3;
this._wizardUpdateUI();
}
this.socket.off('channel-created', handler);
};
this.socket.on('channel-created', handler);
this.socket.emit('create-channel', { name: channelName });
} else {
this._wizardStep = 3;
this._wizardUpdateUI();
}
} else if (step === 3) {
this._wizardStep = 4;
this._wizardUpdateUI();
} else if (step === 4) {
this._wizardComplete();
}
},
_wizardBack() {
if (this._wizardStep > 1) {
this._wizardStep--;
this._wizardUpdateUI();
}
},
async _wizardCheckPort() {
const checkBtn = document.getElementById('wizard-check-port-btn');
const checking = document.getElementById('wizard-port-checking');
const result = document.getElementById('wizard-port-result');
if (checkBtn) checkBtn.style.display = 'none';
if (checking) checking.style.display = 'flex';
if (result) result.style.display = 'none';
try {
const resp = await fetch('/api/port-check', {
headers: { 'Authorization': `Bearer ${this.token}` }
});
const data = await resp.json();
if (checking) checking.style.display = 'none';
if (result) result.style.display = 'block';
this._wizardPublicIp = data.publicIp;
if (data.reachable) {
this._wizardPortResult = true;
result.innerHTML = `
<div class="wizard-port-success">
✅ <strong>${t('modals.wizard.port_reachable_title')}</strong><br>
${t('modals.wizard.public_ip_label')} <code>${this._escapeHtml(data.publicIp)}</code><br>
${t('modals.wizard.friends_connect_at')} <code>${location.protocol}//${this._escapeHtml(data.publicIp)}:${location.port || 3000}</code>
</div>`;
} else {
this._wizardPortResult = false;
const port = location.port || 3000;
result.innerHTML = `
<div class="wizard-port-fail">
⚠️ <strong>${t('modals.wizard.port_fail_title', { port })}</strong><br>
${data.publicIp ? t('modals.wizard.port_fail_blocked_ip', { ip: `<code>${this._escapeHtml(data.publicIp)}</code>` }) : this._escapeHtml(data.error || t('modals.wizard.port_fail_error'))}<br><br>
<strong>${t('modals.wizard.to_fix')}</strong>
<ol>
<li>${t('modals.wizard.fix_step_1')}</li>
<li>${t('modals.wizard.fix_step_2')}</li>
<li>${t('modals.wizard.fix_step_3', { port })}</li>
<li>${t('modals.wizard.fix_step_4', { port })}</li>
<li>${t('modals.wizard.fix_step_5')}</li>
</ol>
<strong>${t('modals.wizard.lan_only')}</strong> ${t('modals.wizard.lan_detail')}
</div>`;
if (checkBtn) {
checkBtn.textContent = `🔄 ${t('modals.wizard.recheck_btn')}`;
checkBtn.style.display = '';
}
}
} catch (err) {
if (checking) checking.style.display = 'none';
if (result) {
result.style.display = 'block';
result.innerHTML = `<div class="wizard-port-fail">❌ ${t('modals.wizard.check_failed', { error: this._escapeHtml(err.message) })}</div>`;
}
if (checkBtn) {
checkBtn.textContent = `🔄 ${t('modals.wizard.retry_btn')}`;
checkBtn.style.display = '';
}
}
},
_wizardComplete() {
// Mark wizard as complete in server settings
this.socket.emit('update-server-setting', { key: 'setup_wizard_complete', value: 'true' });
// Close the modal
const modal = document.getElementById('setup-wizard-modal');
if (modal) modal.style.display = 'none';
this._showToast(t('modals.wizard.setup_complete'), 'success');
},
/**
* (#12) Fill the SSO fields from server settings, and warn when the toggle is
* on but the server still reports SSO unusable, which in practice always
* means OIDC_CLIENT_SECRET is missing from the environment, the one piece of
* this configuration that is not stored in the database.
*/
_applyOidcSettings() {
const s = this.serverSettings || {};
const set = (id, value) => { const el = document.getElementById(id); if (el) el.value = value || ''; };
const check = (id, on) => { const el = document.getElementById(id); if (el) el.checked = !!on; };
set('oidc-issuer-url', s.oidc_issuer_url);
set('oidc-client-id', s.oidc_client_id);
set('oidc-scopes', s.oidc_scopes);
set('oidc-button-label', s.oidc_button_label);
check('oidc-enabled', s.oidc_enabled === '1');
check('oidc-create-users', s.oidc_create_users !== '0');
const warn = document.getElementById('oidc-secret-warning');
if (!warn) return;
const configured = s.oidc_enabled === '1' && !!s.oidc_issuer_url && !!s.oidc_client_id;
if (!configured) { warn.style.display = 'none'; return; }
fetch('/api/public-config')
.then(r => r.json())
.then(cfg => { warn.style.display = cfg && cfg.oidc_enabled ? 'none' : 'block'; })
.catch(() => { /* leave the warning hidden rather than guess */ });
},
// Settings that can arrive either from the admin panel or from an environment
// variable. The stored setting always wins and the environment is the
// fallback, but nothing in the panel said so: a server started with
// SERVER_NAME=Foo showed an empty Server Name box, and typing in it silently
// took over from the env var for good. These notes spell out which value is
// live and what saving will do. (#5489)
_envHintFields: [
{ key: 'server_name', input: 'server-name-input' },
{ key: 'stun_urls', input: 'stun-urls-input' },
{ key: 'turn_url', input: 'turn-url-input' },
{ key: 'turn_username', input: 'turn-username-input' },
{ key: 'turn_password', input: 'turn-password-input' },
// TURN_SECRET has no field of its own; it belongs to the TURN block as a
// whole, so its note hangs off the TURN server row.
{ key: 'turn_secret', input: 'turn-url-input', standalone: true }
],
_applyEnvSettingHints() {
const env = this.serverEnvSettings || {};
for (const field of this._envHintFields) {
const input = document.getElementById(field.input);
if (!input) continue;
const row = input.closest('.select-row') || input;
const hintId = `env-hint-${field.key}`;
let hint = document.getElementById(hintId);
const info = env[field.key];
if (!info) {
if (hint) hint.remove();
// Only the field's own entry may clear a placeholder it set.
if (!field.standalone && input.dataset.envPlaceholder) {
input.placeholder = input.dataset.envPlaceholder === '__none__' ? '' : input.dataset.envPlaceholder;
delete input.dataset.envPlaceholder;
}
continue;
}
const stored = (this.serverSettings?.[field.key] || '').trim();
let text;
if (field.standalone) {
text = t('settings.admin.env_turn_secret', { var: info.var });
} else if (stored) {
text = t('settings.admin.env_overridden', { var: info.var });
} else if (info.secret) {
text = t('settings.admin.env_in_use_secret', { var: info.var });
} else {
text = t('settings.admin.env_in_use', { var: info.var, value: info.value });
}
// Show the environment's value as the placeholder while nothing is stored,
// so an empty box reads as "inherited" rather than "unset".
if (!field.standalone && !stored && !info.secret && info.value) {
if (input.dataset.envPlaceholder === undefined) {
input.dataset.envPlaceholder = input.placeholder || '__none__';
}
input.placeholder = info.value;
} else if (!field.standalone && input.dataset.envPlaceholder !== undefined) {
input.placeholder = input.dataset.envPlaceholder === '__none__' ? '' : input.dataset.envPlaceholder;
delete input.dataset.envPlaceholder;
}
if (!hint) {
hint = document.createElement('small');
hint.id = hintId;
hint.className = 'settings-hint env-setting-hint';
// Two settings can share a row (TURN_URL and TURN_SECRET), so queue
// behind any note already sitting under it instead of jumping ahead.
let anchor = row;
while (anchor.nextElementSibling?.classList.contains('env-setting-hint')) {
anchor = anchor.nextElementSibling;
}
anchor.insertAdjacentElement('afterend', hint);
}
hint.textContent = text;
}
},
_applyServerSettings() {
// Don't overwrite admin form inputs when settings modal is open (user may be editing)
const modalOpen = document.getElementById('settings-modal')?.style.display === 'flex';
if (!modalOpen) {
const vis = document.getElementById('member-visibility-select');
if (vis && this.serverSettings.member_visibility) {
vis.value = this.serverSettings.member_visibility;
}
const refPol = document.getElementById('referrer-policy-select');
if (refPol && this.serverSettings.referrer_policy) {
refPol.value = this.serverSettings.referrer_policy;
}
// (#12) SSO. The client secret lives in the environment, so the only way
// to tell an admin it is missing is to compare what they configured here
// against whether the server reports SSO as actually usable.
this._applyOidcSettings?.();
// Auto-mod controls apply immediately rather than through the Save flow,
// so they only need populating here. (v3.42.0)
if (typeof this._applyAutomodSettings === 'function') this._applyAutomodSettings();
const nameInput = document.getElementById('server-name-input');
if (nameInput && this.serverSettings.server_name !== undefined) {
nameInput.value = this.serverSettings.server_name || '';
}
const titleInput = document.getElementById('server-title-input');
if (titleInput && this.serverSettings.server_title !== undefined) {
titleInput.value = this.serverSettings.server_title || '';
}
const welcomeInput = document.getElementById('welcome-message-input');
if (welcomeInput) {
welcomeInput.value = this.serverSettings.welcome_message || '';
}
const cleanupEnabled = document.getElementById('cleanup-enabled');
if (cleanupEnabled) {
cleanupEnabled.checked = this.serverSettings.cleanup_enabled === 'true';
}
const cleanupAge = document.getElementById('cleanup-max-age');
if (cleanupAge && this.serverSettings.cleanup_max_age_days) {
cleanupAge.value = this.serverSettings.cleanup_max_age_days;
}
const cleanupSize = document.getElementById('cleanup-max-size');
if (cleanupSize && this.serverSettings.cleanup_max_size_mb) {
cleanupSize.value = this.serverSettings.cleanup_max_size_mb;
}
const cleanupUploads = document.getElementById('cleanup-max-uploads');
if (cleanupUploads) cleanupUploads.value = this.serverSettings.cleanup_max_uploads_mb || '0';
const deletedRet = document.getElementById('deleted-retention-days');
if (deletedRet) deletedRet.value = this.serverSettings.deleted_retention_days || '7';
const keepSd = document.getElementById('keep-self-destructed');
if (keepSd) keepSd.checked = this.serverSettings.keep_self_destructed_attachments !== 'false';
const maxUpload = document.getElementById('max-upload-mb');
if (maxUpload) {
maxUpload.value = this.serverSettings.max_upload_mb || '25';
}
const maxAttach = document.getElementById('max-attachments');
if (maxAttach) {
maxAttach.value = this.serverSettings.max_attachments || '10';
}
this._fillMediaLayoutAdmin?.(this.serverSettings.media_group_layout);
const maxTagsPer = document.getElementById('max-tags-per-attachment');
if (maxTagsPer) {
maxTagsPer.value = this.serverSettings.max_tags_per_attachment || '3';
}
const maxTagLen = document.getElementById('max-tag-len');
if (maxTagLen) {
maxTagLen.value = this.serverSettings.max_tag_len || '20';
}
const maxSoundKb = document.getElementById('max-sound-kb');
if (maxSoundKb) {
maxSoundKb.value = this.serverSettings.max_sound_kb || '1024';
}
const maxAvatarMb = document.getElementById('max-avatar-mb');
if (maxAvatarMb) maxAvatarMb.value = this.serverSettings.max_avatar_mb || '2';
const maxEmojiKb = document.getElementById('max-emoji-kb');
if (maxEmojiKb) {
maxEmojiKb.value = this.serverSettings.max_emoji_kb || '256';
}
const maxStickerKb = document.getElementById('max-sticker-kb');
if (maxStickerKb) {
maxStickerKb.value = this.serverSettings.max_sticker_kb || '1024';
}
const maxPollOpts = document.getElementById('max-poll-options');
if (maxPollOpts) {
maxPollOpts.value = this.serverSettings.max_poll_options || '10';
}
const sessionDur = document.getElementById('session-duration-days');
if (sessionDur) {
// Default to '0' (never) for new installs. Existing servers seeded with
// '7' on older versions report '7' here and keep that value until the
// admin picks something else. (#5391)
sessionDur.value = this.serverSettings.session_duration_days ?? '0';
}
const maxMsgChars = document.getElementById('max-message-chars');
if (maxMsgChars) {
maxMsgChars.value = this.serverSettings.max_message_chars || '2000';
}
const autoAwayVisible = document.getElementById('auto-away-visible-minutes');
if (autoAwayVisible) autoAwayVisible.value = this.serverSettings.auto_away_visible_minutes || '5';
const autoAwayHidden = document.getElementById('auto-away-hidden-minutes');
if (autoAwayHidden) autoAwayHidden.value = this.serverSettings.auto_away_hidden_minutes || '2';
const autoAwayEnabled = document.getElementById('auto-away-enabled');
if (autoAwayEnabled) autoAwayEnabled.checked = this.serverSettings.auto_away_enabled !== 'false';
const whitelistToggle = document.getElementById('whitelist-enabled');
if (whitelistToggle) {
whitelistToggle.checked = this.serverSettings.whitelist_enabled === 'true';
}
const adminPwReset = document.getElementById('admin-password-reset-enabled');
if (adminPwReset) {
adminPwReset.checked = this.serverSettings.admin_password_reset_enabled === 'true';
}
const emojiAutoUpdate = document.getElementById('unicode-emoji-auto-update');
if (emojiAutoUpdate) {
emojiAutoUpdate.checked = this.serverSettings.unicode_emoji_auto_update === 'true';
}
// ── Voice & Connectivity (STUN/TURN), #5399 ───
const stunUrls = document.getElementById('stun-urls-input');
if (stunUrls) stunUrls.value = this.serverSettings.stun_urls || '';
const iceDisabled = document.getElementById('voice-ice-disabled');
if (iceDisabled) iceDisabled.checked = this.serverSettings.voice_ice_disabled === 'true';
const turnUrl = document.getElementById('turn-url-input');
if (turnUrl) turnUrl.value = this.serverSettings.turn_url || '';
const turnUser = document.getElementById('turn-username-input');
if (turnUser) turnUser.value = this.serverSettings.turn_username || '';
const turnPass = document.getElementById('turn-password-input');
if (turnPass) turnPass.value = this.serverSettings.turn_password || '';
this._applyEnvSettingHints?.(); // (#5489)
// ── Auto-backup form ───
const abEnabled = document.getElementById('auto-backup-enabled');
if (abEnabled) abEnabled.checked = this.serverSettings.auto_backup_enabled === 'true';
const abInterval = document.getElementById('auto-backup-interval');
if (abInterval) abInterval.value = this.serverSettings.auto_backup_interval_hours || '24';
const abRetention = document.getElementById('auto-backup-retention');
if (abRetention) abRetention.value = this.serverSettings.auto_backup_retention || '7';
const abSections = (this.serverSettings.auto_backup_sections || 'channels,users,settings,messages')
.split(',').map(s => s.trim()).filter(Boolean);
document.querySelectorAll('.auto-backup-include').forEach(el => {
el.checked = abSections.includes(el.value);
});
// The backup list is admin only; asking for it as anyone else only logs a 403.
if (this.user?.isAdmin && typeof this._refreshAutoBackupList === 'function') this._refreshAutoBackupList();
const updateBannerAdminOnly = document.getElementById('update-banner-admin-only');
if (updateBannerAdminOnly) {
updateBannerAdminOnly.checked = this.serverSettings.update_banner_admin_only === 'true';
}
const allowSelfPurge = document.getElementById('allow-self-purge');
if (allowSelfPurge) allowSelfPurge.checked = this.serverSettings.allow_self_purge === 'true';
// The member-facing button follows the switch (#5686).
const selfPurgeBlock = document.getElementById('self-purge-block');
if (selfPurgeBlock) selfPurgeBlock.style.display = (this.serverSettings.allow_self_purge === 'true' && !this.user?.isGuest) ? '' : 'none';
const hideDisabledBadges = document.getElementById('hide-disabled-badges');
if (hideDisabledBadges) hideDisabledBadges.checked = this.serverSettings.hide_disabled_channel_badges === 'true';
const categoryTabs = document.getElementById('category-layout-tabs');
if (categoryTabs) categoryTabs.checked = this.serverSettings.category_layout === 'tabs';
const defaultTheme = document.getElementById('default-theme-select');
if (defaultTheme) {
defaultTheme.value = this.serverSettings.default_theme || '';
}
const defaultLocale = document.getElementById('default-locale-select');
if (defaultLocale) {
defaultLocale.value = this.serverSettings.default_locale || '';
}
this._renderAdminThemeList();
// Tunnel settings (live state, not part of Save/Cancel flow)
const tunnelProvider = document.getElementById('tunnel-provider-select');
if (tunnelProvider && this.serverSettings.tunnel_provider) {
tunnelProvider.value = this.serverSettings.tunnel_provider;
}
this._refreshTunnelStatus();
if (typeof this._renderPermThresholds === 'function') this._renderPermThresholds();
}
// Server invite code: always update even while modal is open (live action, not Save flow)
const serverCodeEl = document.getElementById('server-code-value');
if (serverCodeEl) {
const code = this.serverSettings.server_code;
serverCodeEl.textContent = code || '-';
serverCodeEl.style.opacity = code ? '1' : '0.4';
}
// (#5344) Registration token: same live-update pattern as server code
const tokenEl = document.getElementById('registration-token-value');
if (tokenEl) {
const tok = this.serverSettings.registration_token;
tokenEl.textContent = tok || '-';
tokenEl.style.opacity = tok ? '1' : '0.4';
}
const tokenToggle = document.getElementById('registration-token-enabled');
if (tokenToggle) tokenToggle.checked = this.serverSettings.registration_token_enabled === 'true';
const invBpsTokenToggle = document.getElementById('invites-bypass-registration-token');
if (invBpsTokenToggle) invBpsTokenToggle.checked = this.serverSettings.invites_bypass_registration_token === 'true';
// These save with the Save button, so leave them alone while the panel is
// open: the token switches above save at once, and the refresh that follows
// used to put back the stored values over whatever was being typed here.
if (!modalOpen) {
const capToggle = document.getElementById('registration-captcha-enabled');
if (capToggle) capToggle.checked = this.serverSettings.registration_captcha_enabled === 'true';
const capSite = document.getElementById('turnstile-site-key');
if (capSite) capSite.value = this.serverSettings.turnstile_site_key || '';
const capSecret = document.getElementById('turnstile-secret-key');
if (capSecret) capSecret.value = this.serverSettings.turnstile_secret_key || '';
const rlToggle = document.getElementById('registration-rate-limit-enabled');
if (rlToggle) rlToggle.checked = this.serverSettings.registration_rate_limit_enabled === 'true';
const rlNum = document.getElementById('registration-rate-limit-per-hour');
if (rlNum) rlNum.value = this.serverSettings.registration_rate_limit_per_hour || '20';
const maxInvUses = document.getElementById('max-invite-uses');
if (maxInvUses) maxInvUses.value = this.serverSettings.max_invite_uses || '0';
}
// (#5345) Default join channels: re-render when settings or channel list refresh
if (typeof this._renderDefaultJoinChannels === 'function') {
try { this._renderDefaultJoinChannels(); } catch (err) { console.warn('[Admin] _renderDefaultJoinChannels failed', err); }
}
// (#5381) Guest channel whitelist: re-render when settings change
if (typeof this._renderGuestChannels === 'function') {
try { this._renderGuestChannels(); } catch (err) { console.warn('[Admin] _renderGuestChannels failed', err); }
}
// Managed invite links: paint the create-form channel list and pull the
// current set of codes from the server (list arrives via 'invite-codes-list').
if (typeof this._renderInviteCreateChannels === 'function') {
try { this._renderInviteCreateChannels(); } catch (err) { console.warn('[Admin] _renderInviteCreateChannels failed', err); }
}
if (this.socket?.connected) {
try { this.socket.emit('get-invite-codes'); } catch (err) { console.warn('[Admin] could not request invite codes', err); }
}
// Apply configurable message length limit to message input and edit textareas
const _maxMsgChars = parseInt(this.serverSettings?.max_message_chars) || 2000;
const msgInput = document.getElementById('message-input');
if (msgInput) msgInput.maxLength = _maxMsgChars;
// The thread and pop-out DM boxes had no cap, so a long reply was only
// refused after sending (#5691).
for (const id of ['thread-input', 'dm-pip-input']) {
const el = document.getElementById(id);
if (el) el.maxLength = _maxMsgChars;
}
document.querySelectorAll('.edit-textarea').forEach(el => { el.maxLength = _maxMsgChars; });
// Refresh DM cleanup notice (#5340) when cleanup_enabled / cleanup_max_age_days
// change live, so the banner appears or disappears without needing a channel switch.
if (typeof this._updateDmCleanupNotice === 'function' && this.currentChannel) {
const ch = this.channels.find(c => c.code === this.currentChannel);
this._updateDmCleanupNotice(ch);
}
// Vanity code: update input if modal is open
if (!modalOpen) {
const vanityInput = document.getElementById('vanity-code-input');
if (vanityInput) vanityInput.value = this.serverSettings.vanity_code || '';
}
// Server banner: always update display (display prefs from localStorage)
const bannerDisplay = document.getElementById('server-banner-display');
const bannerImg = document.getElementById('server-banner-img');
const bannerPreview = document.getElementById('server-banner-preview');
const mainEl = document.querySelector('.main');
const headerMode = localStorage.getItem('haven_banner_header_mode') || 'full';
const bannerHeight = parseInt(localStorage.getItem('haven_banner_height')) || 180;
const bannerOffset = parseInt(localStorage.getItem('haven_banner_offset')) || 0;
const hasBanner = !!this.serverSettings.server_banner;
// Show/hide the banner display section in user settings
const bannerSection = document.getElementById('section-banner-display');
if (bannerSection) bannerSection.style.display = hasBanner ? '' : 'none';
const bannerNavItem = document.querySelector('.settings-nav-item[data-target="section-banner-display"]');
if (bannerNavItem) bannerNavItem.style.display = hasBanner ? '' : 'none';
if (bannerDisplay && bannerImg) {
if (hasBanner) {
bannerImg.src = this.serverSettings.server_banner;
bannerDisplay.style.display = '';
bannerDisplay.style.height = bannerHeight + 'px';
bannerImg.style.objectPosition = 'center ' + bannerOffset + '%';
mainEl?.classList.add('has-banner');
mainEl?.classList.remove('banner-mode-shaded', 'banner-mode-minimal', 'banner-mode-transparent');
if (headerMode !== 'full') {
mainEl?.classList.add('banner-mode-' + headerMode);
}
} else {
bannerDisplay.style.display = 'none';
bannerImg.src = '';
mainEl?.classList.remove('has-banner', 'banner-mode-shaded', 'banner-mode-minimal', 'banner-mode-transparent');
}
}
// Banner header mode dropdown (user settings)
const headerModeSelect = document.getElementById('banner-header-mode');
if (headerModeSelect) headerModeSelect.value = headerMode;
// Banner height slider (user settings)
const heightSlider = document.getElementById('banner-height-slider');
const heightValue = document.getElementById('banner-height-value');
if (heightSlider) {
heightSlider.value = bannerHeight;
if (heightValue) heightValue.textContent = bannerHeight + 'px';
}
// Banner offset slider (user settings)
const offsetSlider = document.getElementById('banner-offset-slider');
const offsetValue = document.getElementById('banner-offset-value');
if (offsetSlider) {
offsetSlider.value = bannerOffset;
if (offsetValue) offsetValue.textContent = bannerOffset + '%';
}
// Role icon display checkboxes
const riSidebar = document.getElementById('role-icon-sidebar');
if (riSidebar) riSidebar.checked = (this.serverSettings.role_icon_sidebar || 'true') === 'true';
const riChat = document.getElementById('role-icon-chat');
if (riChat) riChat.checked = this.serverSettings.role_icon_chat === 'true';
const riAfter = document.getElementById('role-icon-after-name');
if (riAfter) riAfter.checked = this.serverSettings.role_icon_after_name === 'true';
// (#5461) Reflect the saved channel-creator-role choice.
this._renderChannelCreatorRoleSelect();
if (bannerPreview) {
if (this.serverSettings.server_banner) {
bannerPreview.innerHTML = `<img src="${this._escapeHtml(this.serverSettings.server_banner)}" style="max-width:100%;max-height:80px;border-radius:6px;object-fit:cover">`;
} else {
bannerPreview.innerHTML = `<span class="muted-text" style="font-size:0.6875rem">${t('settings.admin.no_banner')}</span>`;
}
}
// Always update visual branding regardless of modal state
this._applyServerBranding();
// Re-evaluate update banner visibility whenever settings change
this._applyUpdateBanner();
// Re-render channels in case sort mode changed
if (!localStorage.getItem('haven_server_sort_mode')) this._renderChannels();
if (!modalOpen && this.user && (this.user.isAdmin || this._hasPerm('manage_server'))) {
this.socket.emit('get-whitelist');
}
},
/* ── Admin settings save / cancel ───────────────────── */
_renderWebhooksList(webhooks) {
const container = document.getElementById('webhooks-list');
if (!container) return;
if (!webhooks.length) {
container.innerHTML = `<p class="muted-text">${t('settings.admin.no_bots')}</p>`;
return;
}
// Simple preview list for server settings; full management is in the bot modal
container.innerHTML = webhooks.map(wh => {
const statusDot = `<span class="webhook-status-icon" aria-hidden="true">${wh.is_active ? '🟢' : '🔴'}</span>`;
const avatarSrc = this._botAvatarSrcAttr(wh.avatar_url);
const avatarHtml = avatarSrc
? `<img ${avatarSrc} style="width:20px;height:20px;border-radius:50%;object-fit:cover">`
: '<span class="webhook-avatar-icon" aria-hidden="true">🤖</span>';
return `<div class="role-preview-item">${avatarHtml} <span style="font-weight:600">${this._escapeHtml(wh.name)}</span> <span style="opacity:0.5;font-size:0.6875rem">#${this._escapeHtml(wh.channel_name)}</span> ${statusDot}</div>`;
}).join('');
},
_syncSettingsNav() {
// Dict of admin settings sections and the roles that can access them.
// (Admin has access to all)
//
// Dict key: The section id to grant access to
// Key value: A list of roles that can access the section, or a dictionary
// of roles listing the sub-sections they can access. ('*' for all subsection access)
const settingsSectionsAccess = {
'section-update': [],
'section-extension-updates': [],
'section-branding': ['manage_server'],
'section-presence': [],
'section-members': ['manage_server'],
// Idle-online oversight (v3.46.0): moderators who can act on it see it too,
// the same bar the server enforces. Keep this list in step with
// _hasAnyAdminSettingsAccess in app.js, which gates the Admin tab switch.
'section-moderation': ['view_audit_log', 'ban_user', 'kick_user', 'view_all_members'],
'section-security': [],
// The server lets Manage Server holders change Auto-mod
// (_canManageAutomod in socketHandlers/admin.js), so they see it too.
'section-automod': ['manage_server'],
'section-whitelist': ['manage_server'],
'section-invite': {'manage_server': '*', 'invite_users': ['invite-links-block']}, // invite_users only have access to the id="invite-links-block" section within id="section-invite"
'section-guests': [],
'section-cleanup': ['manage_server'],
'section-backup': ['manage_server'],
'section-template': [],
'section-uploads': ['manage_server'],
'section-tags-admin': ['manage_tags'],
'section-connectivity': [],
'section-large-server': [], // admin only: the relay opens ports on the host
'section-tunnel': ['manage_server'],
'section-bots': ['manage_server', 'manage_webhooks'],
'section-ferry': [],
'section-custom-tos': [],
'section-import': ['manage_server'],
'section-modmode': ['manage_server'],
'section-emojis': ['manage_emojis'],
'section-stickers': ['manage_stickers'],
'section-sounds-admin': ['manage_soundboard'],
'section-roles': ['manage_roles'],
'section-badges': ['manage_badges'],
'section-audit-log': ['view_audit_log']
};
// Use the canonical authoritative flag from the server, not DOM visibility.
const isAdmin = !!(this.user && this.user.isAdmin);
//Returns the actual access level for a settings section:
// '*' = full access
// ['some-block'] = limited access to specific subsections
// null = no access
const getSectionAccess = (target) => {
const access = settingsSectionsAccess[target];
if (isAdmin) return '*';
if (!access) return null;
// Simple permission list: any matching permission grants full access.
if (Array.isArray(access)) {
return access.some(permission => this._hasPerm(permission)) ? '*' : null;
}
// Permission -> subsection access map.
const allowedSubSections = new Set();
for (const [permission, subSections] of Object.entries(access)) {
if (!this._hasPerm(permission)) continue;
// Any full-access permission wins, regardless of object order.
if (subSections === '*') return '*';
for (const subSection of subSections) {
allowedSubSections.add(subSection);
}
}
return allowedSubSections.size > 0 ? [...allowedSubSections] : null;
};
const canAccessSection = (target) => {
return getSectionAccess(target) !== null;
};
// Determine whether the user has access to any admin settings.
const hasAnyAdminAccess = isAdmin || Object.keys(settingsSectionsAccess).some(target => canAccessSection(target));
// Admin settings navigation.
// Unknown nav targets are hidden for non-admins.
document.querySelectorAll('.settings-nav-item.settings-nav-admin').forEach(navItem => {
if (isAdmin) {
navItem.style.display = '';
return;
}
navItem.style.display = canAccessSection(navItem.dataset.target) ? '' : 'none';
});
// Admin settings navigation group labels.
document.querySelectorAll('.settings-nav-group-label.settings-nav-admin').forEach(label => {
if (isAdmin) {
label.style.display = '';
return;
}
let hasVisibleItem = false;
let sibling = label.nextElementSibling;
while (sibling && !sibling.classList.contains('settings-nav-group-label')) {
if (sibling.classList.contains('settings-nav-item') && sibling.classList.contains('settings-nav-admin') && sibling.style.display !== 'none') {
hasVisibleItem = true;
break;
}
sibling = sibling.nextElementSibling;
}
label.style.display = hasVisibleItem ? '' : 'none';
});
document.querySelectorAll('.settings-nav-admin-group').forEach(group => {
group.style.display = hasAnyAdminAccess ? '' : 'none';
});
// Admin settings sections.
// Every section must be explicitly represented in settingsSectionsAccess.
// This prevents an unlisted admin section from becoming visible to
// non-admin users simply because its nav item was hidden.
document.querySelectorAll('#admin-mod-panel .admin-settings').forEach(section => {
const access = getSectionAccess(section.id);
section.style.display = (access === null) ? 'none' : '';
});
// Apply subsection restrictions.
// Sections using object-form access can restrict access to specific
// direct-child subsections.
Object.entries(settingsSectionsAccess).forEach(([sectionId, access]) => {
if (isAdmin || !access || Array.isArray(access)) return;
const section = document.getElementById(sectionId);
if (!section) return;
const sectionAccess = getSectionAccess(sectionId);
if (sectionAccess === null || sectionAccess === '*') return;
section.querySelectorAll(':scope > *').forEach(subSection => {
subSection.style.display = sectionAccess.includes(subSection.id) ? '' : 'none';
});
});
// With the server-config blocks above it hidden, the invite-links divider has
// nothing to divide. (#5470)
document.getElementById('invite-links-block')
?.classList.toggle('invite-links-flush', Array.isArray(getSectionAccess('section-invite')));
const adminTab = document.querySelector('.settings-tab-admin');
if (adminTab) {
adminTab.style.display = hasAnyAdminAccess ? '' : 'none';
}
const adminPanel = document.getElementById('settings-body-admin');
if (adminPanel) {
adminPanel.style.display = hasAnyAdminAccess ? '' : 'none';
}
const saveBar = document.querySelector('.admin-save-bar');
if (saveBar) {
const adminTabActive = adminTab?.classList.contains('active');
saveBar.style.display = (hasAnyAdminAccess && adminTabActive) ? '' : 'none';
}
},
_snapshotAdminSettings() {
this._adminSnapshot = {
// Empty means "nothing stored", which is what lets SERVER_NAME (or the
// built-in default) take over. Snapshotting it as 'HAVEN' made clearing
// the box save the literal word instead of falling back. (#5489)
server_name: this.serverSettings.server_name || '',
server_title: this.serverSettings.server_title || '',
welcome_message: this.serverSettings.welcome_message || '',
member_visibility: this.serverSettings.member_visibility || 'online',
referrer_policy: this.serverSettings.referrer_policy || 'same-origin', // must match DEFAULT_REFERRER_POLICY in server.js
cleanup_enabled: this.serverSettings.cleanup_enabled || 'false',
cleanup_max_age_days: this.serverSettings.cleanup_max_age_days || '0',
cleanup_max_size_mb: this.serverSettings.cleanup_max_size_mb || '0',
cleanup_max_uploads_mb: this.serverSettings.cleanup_max_uploads_mb || '0',
deleted_retention_days: this.serverSettings.deleted_retention_days || '7',
keep_self_destructed_attachments: this.serverSettings.keep_self_destructed_attachments || 'true',
whitelist_enabled: this.serverSettings.whitelist_enabled || 'false',
max_upload_mb: this.serverSettings.max_upload_mb || '25',
max_attachments: this.serverSettings.max_attachments || '10',
media_group_layout: this.serverSettings.media_group_layout || 'grid',
max_tags_per_attachment: this.serverSettings.max_tags_per_attachment || '3',
max_tag_len: this.serverSettings.max_tag_len || '20',
max_sound_kb: this.serverSettings.max_sound_kb || '1024',
max_avatar_mb: this.serverSettings.max_avatar_mb || '2',
max_emoji_kb: this.serverSettings.max_emoji_kb || '256',
max_sticker_kb: this.serverSettings.max_sticker_kb || '1024',
max_poll_options: this.serverSettings.max_poll_options || '10',
session_duration_days: this.serverSettings.session_duration_days || '7',
max_message_chars: this.serverSettings.max_message_chars || '2000',
auto_away_visible_minutes: this.serverSettings.auto_away_visible_minutes || '5',
auto_away_hidden_minutes: this.serverSettings.auto_away_hidden_minutes || '2',
auto_away_enabled: this.serverSettings.auto_away_enabled || 'true',
update_banner_admin_only: this.serverSettings.update_banner_admin_only || 'false',
allow_self_purge: this.serverSettings.allow_self_purge || 'false',
hide_disabled_channel_badges: this.serverSettings.hide_disabled_channel_badges || 'false',
category_layout: this.serverSettings.category_layout || 'list',
admin_password_reset_enabled: this.serverSettings.admin_password_reset_enabled || 'false',
unicode_emoji_auto_update: this.serverSettings.unicode_emoji_auto_update || 'false',
registration_captcha_enabled: this.serverSettings.registration_captcha_enabled || 'false',
turnstile_site_key: this.serverSettings.turnstile_site_key || '',
turnstile_secret_key: this.serverSettings.turnstile_secret_key || '',
registration_rate_limit_enabled: this.serverSettings.registration_rate_limit_enabled || 'false',
registration_rate_limit_per_hour: this.serverSettings.registration_rate_limit_per_hour || '20',
max_invite_uses: this.serverSettings.max_invite_uses || '0',
default_theme: this.serverSettings.default_theme || '',
default_locale: this.serverSettings.default_locale || '',
published_themes: this.serverSettings.published_themes || '[]',
custom_tos: this.serverSettings.custom_tos || '',
role_icon_sidebar: this.serverSettings.role_icon_sidebar || 'true',
role_icon_chat: this.serverSettings.role_icon_chat || 'false',
role_icon_after_name: this.serverSettings.role_icon_after_name || 'false',
stun_urls: this.serverSettings.stun_urls || '',
voice_ice_disabled: this.serverSettings.voice_ice_disabled || 'false',
turn_url: this.serverSettings.turn_url || '',
turn_username: this.serverSettings.turn_username || '',
turn_password: this.serverSettings.turn_password || '',
channel_creator_role: this.serverSettings.channel_creator_role || ''
};
const tosEl = document.getElementById('custom-tos-input');
if (tosEl) tosEl.value = this._adminSnapshot.custom_tos;
// _applyServerSettings skips its input pass while the modal is open, so
// refresh the environment notes here too, otherwise they stay stale from
// whenever the panel was last closed. (#5489)
this._applyEnvSettingHints?.();
// Load webhooks list for admin preview
if (this.user?.isAdmin || this._hasPerm('manage_webhooks')) {
this.socket.emit('get-webhooks');
}
// Ferry holds a bot token for an account on another platform, so it is
// admin-only rather than following manage_webhooks like the section above.
const ferrySection = document.getElementById('section-ferry');
if (ferrySection) {
ferrySection.style.display = this.user?.isAdmin ? '' : 'none';
if (this.user?.isAdmin) this.socket.emit('ferry:get-config');
}
this._renderLargeServerSection?.();
},
_saveAdminSettings() {
if (!this.user?.isAdmin && !this._hasPerm('manage_server')) {
document.getElementById('settings-modal').style.display = 'none';
return;
}
const snap = this._adminSnapshot || {};
let changed = false;
const name = document.getElementById('server-name-input')?.value.trim() || '';
if (name !== snap.server_name) {
this.socket.emit('update-server-setting', { key: 'server_name', value: name });
changed = true;
}
const title = document.getElementById('server-title-input')?.value.trim() || '';
if (title !== (snap.server_title || '')) {
this.socket.emit('update-server-setting', { key: 'server_title', value: title });
changed = true;
}
const welcomeMsg = document.getElementById('welcome-message-input')?.value.trim() || '';
if (welcomeMsg !== (snap.welcome_message || '')) {
this.socket.emit('update-server-setting', { key: 'welcome_message', value: welcomeMsg });
changed = true;
}
const vis = document.getElementById('member-visibility-select')?.value;
if (vis && vis !== snap.member_visibility) {
this.socket.emit('update-server-setting', { key: 'member_visibility', value: vis });
changed = true;
}
const refPol = document.getElementById('referrer-policy-select')?.value;
if (refPol && refPol !== snap.referrer_policy) {
this.socket.emit('update-server-setting', { key: 'referrer_policy', value: refPol });
changed = true;
}
// (#12) SSO / OIDC
for (const [id, key, kind] of [
['oidc-enabled', 'oidc_enabled', 'bool'],
['oidc-create-users', 'oidc_create_users', 'bool'],
['oidc-issuer-url', 'oidc_issuer_url', 'text'],
['oidc-client-id', 'oidc_client_id', 'text'],
['oidc-scopes', 'oidc_scopes', 'text'],
['oidc-button-label', 'oidc_button_label', 'text'],
]) {
const el = document.getElementById(id);
if (!el) continue;
const value = kind === 'bool' ? (el.checked ? '1' : '0') : el.value.trim();
// A never-set toggle reads as undefined in the snapshot; oidc_create_users
// defaults on, so treat undefined as its default rather than as a change.
const previous = snap[key] !== undefined ? snap[key]
: (key === 'oidc_create_users' ? '1' : (kind === 'bool' ? '0' : ''));
if (value !== previous) {
this.socket.emit('update-server-setting', { key, value });
changed = true;
}
}
const cleanEnabled = document.getElementById('cleanup-enabled')?.checked ? 'true' : 'false';
if (cleanEnabled !== snap.cleanup_enabled) {
this.socket.emit('update-server-setting', { key: 'cleanup_enabled', value: cleanEnabled });
changed = true;
}
const cleanAge = String(Math.max(0, Math.min(3650, parseInt(document.getElementById('cleanup-max-age')?.value) || 0)));
if (cleanAge !== (snap.cleanup_max_age_days || '0')) {
this.socket.emit('update-server-setting', { key: 'cleanup_max_age_days', value: cleanAge });
changed = true;
}
const cleanSize = String(Math.max(0, Math.min(100000, parseInt(document.getElementById('cleanup-max-size')?.value) || 0)));
const cleanUploads = String(Math.max(0, Math.min(10000000, parseInt(document.getElementById('cleanup-max-uploads')?.value) || 0)));
if (cleanUploads !== (snap.cleanup_max_uploads_mb || '0')) {
this.socket.emit('update-server-setting', { key: 'cleanup_max_uploads_mb', value: cleanUploads });
}
if (cleanSize !== (snap.cleanup_max_size_mb || '0')) {
this.socket.emit('update-server-setting', { key: 'cleanup_max_size_mb', value: cleanSize });
changed = true;
}
const deletedRet = String(Math.max(1, Math.min(3650, parseInt(document.getElementById('deleted-retention-days')?.value) || 7)));
if (deletedRet !== (snap.deleted_retention_days || '7')) {
this.socket.emit('update-server-setting', { key: 'deleted_retention_days', value: deletedRet });
changed = true;
}
const keepSd = document.getElementById('keep-self-destructed')?.checked ? 'true' : 'false';
if (keepSd !== snap.keep_self_destructed_attachments) {
this.socket.emit('update-server-setting', { key: 'keep_self_destructed_attachments', value: keepSd });
changed = true;
}
const wlEnabled = document.getElementById('whitelist-enabled')?.checked ? 'true' : 'false';
if (wlEnabled !== snap.whitelist_enabled) {
this.socket.emit('whitelist-toggle', { enabled: wlEnabled === 'true' });
this.socket.emit('update-server-setting', { key: 'whitelist_enabled', value: wlEnabled });
changed = true;
}
const maxUpload = String(Math.max(1, Math.min(102400, parseInt(document.getElementById('max-upload-mb')?.value) || 25)));
if (maxUpload !== (snap.max_upload_mb || '25')) {
this.socket.emit('update-server-setting', { key: 'max_upload_mb', value: maxUpload });
changed = true;
}
const maxAttach = String(Math.max(1, Math.min(50, parseInt(document.getElementById('max-attachments')?.value) || 10)));
if (maxAttach !== (snap.max_attachments || '10')) {
this.socket.emit('update-server-setting', { key: 'max_attachments', value: maxAttach });
changed = true;
}
if (this._saveMediaLayoutAdmin?.(snap)) changed = true;
const maxTagsPer = String(Math.max(1, Math.min(20, parseInt(document.getElementById('max-tags-per-attachment')?.value) || 3)));
if (maxTagsPer !== (snap.max_tags_per_attachment || '3')) {
this.socket.emit('update-server-setting', { key: 'max_tags_per_attachment', value: maxTagsPer });
changed = true;
}
const maxTagLen = String(Math.max(1, Math.min(50, parseInt(document.getElementById('max-tag-len')?.value) || 20)));
if (maxTagLen !== (snap.max_tag_len || '20')) {
this.socket.emit('update-server-setting', { key: 'max_tag_len', value: maxTagLen });
changed = true;
}
const maxSoundKb = String(Math.max(256, Math.min(10240, parseInt(document.getElementById('max-sound-kb')?.value) || 1024)));
if (maxSoundKb !== (snap.max_sound_kb || '1024')) {
this.socket.emit('update-server-setting', { key: 'max_sound_kb', value: maxSoundKb });
changed = true;
}
const maxAvatarMb = String(Math.max(1, Math.min(10, parseInt(document.getElementById('max-avatar-mb')?.value) || 2)));
if (maxAvatarMb !== (snap.max_avatar_mb || '2')) {
this.socket.emit('update-server-setting', { key: 'max_avatar_mb', value: maxAvatarMb });
changed = true;
}
const maxEmojiKb = String(Math.max(64, Math.min(1024, parseInt(document.getElementById('max-emoji-kb')?.value) || 256)));
if (maxEmojiKb !== (snap.max_emoji_kb || '256')) {
this.socket.emit('update-server-setting', { key: 'max_emoji_kb', value: maxEmojiKb });
changed = true;
}
const maxStickerKb = String(Math.max(256, Math.min(10240, parseInt(document.getElementById('max-sticker-kb')?.value) || 1024)));
if (maxStickerKb !== (snap.max_sticker_kb || '1024')) {
this.socket.emit('update-server-setting', { key: 'max_sticker_kb', value: maxStickerKb });
changed = true;
}
const maxPollOpts = String(Math.max(2, Math.min(25, parseInt(document.getElementById('max-poll-options')?.value) || 10)));
if (maxPollOpts !== (snap.max_poll_options || '10')) {
this.socket.emit('update-server-setting', { key: 'max_poll_options', value: maxPollOpts });
changed = true;
}
// session_duration_days: 0 means "never expire"; 1 to 365 days otherwise (#5391)
const sessionDurDays = String(Math.max(0, Math.min(365, parseInt(document.getElementById('session-duration-days')?.value) || 0)));
if (sessionDurDays !== (snap.session_duration_days ?? '0')) {
this.socket.emit('update-server-setting', { key: 'session_duration_days', value: sessionDurDays });
changed = true;
}
const maxMsgChars = String(Math.max(200, Math.min(100000, parseInt(document.getElementById('max-message-chars')?.value) || 2000)));
if (maxMsgChars !== (snap.max_message_chars || '2000')) {
this.socket.emit('update-server-setting', { key: 'max_message_chars', value: maxMsgChars });
changed = true;
}
for (const [key, id, fallback] of [
['auto_away_visible_minutes', 'auto-away-visible-minutes', '5'],
['auto_away_hidden_minutes', 'auto-away-hidden-minutes', '2']
]) {
const value = String(Math.max(1, Math.min(60, parseInt(document.getElementById(id)?.value, 10) || Number(fallback))));
if (value !== (snap[key] || fallback)) {
this.socket.emit('update-server-setting', { key, value });
changed = true;
}
}
const autoAwayEnabled = document.getElementById('auto-away-enabled')?.checked ? 'true' : 'false';
if (autoAwayEnabled !== (snap.auto_away_enabled || 'true')) {
this.socket.emit('update-server-setting', { key: 'auto_away_enabled', value: autoAwayEnabled });
changed = true;
}
const updateBannerAdminOnly = document.getElementById('update-banner-admin-only')?.checked ? 'true' : 'false';
if (updateBannerAdminOnly !== (snap.update_banner_admin_only || 'false')) {
this.socket.emit('update-server-setting', { key: 'update_banner_admin_only', value: updateBannerAdminOnly });
changed = true;
}
const allowSelfPurge = document.getElementById('allow-self-purge')?.checked ? 'true' : 'false';
if (allowSelfPurge !== (snap.allow_self_purge || 'false')) {
this.socket.emit('update-server-setting', { key: 'allow_self_purge', value: allowSelfPurge });
changed = true;
}
const hideDisabledBadges = document.getElementById('hide-disabled-badges')?.checked ? 'true' : 'false';
if (hideDisabledBadges !== (snap.hide_disabled_channel_badges || 'false')) {
this.socket.emit('update-server-setting', { key: 'hide_disabled_channel_badges', value: hideDisabledBadges });
changed = true;
}
const categoryLayout = document.getElementById('category-layout-tabs')?.checked ? 'tabs' : 'list';
if (categoryLayout !== (snap.category_layout || 'list')) {
this.socket.emit('update-server-setting', { key: 'category_layout', value: categoryLayout });
changed = true;
}
const adminPwReset = document.getElementById('admin-password-reset-enabled')?.checked ? 'true' : 'false';
if (adminPwReset !== (snap.admin_password_reset_enabled || 'false')) {
this.socket.emit('update-server-setting', { key: 'admin_password_reset_enabled', value: adminPwReset });
changed = true;
}
const emojiAutoUpdate = document.getElementById('unicode-emoji-auto-update')?.checked ? 'true' : 'false';
if (emojiAutoUpdate !== (snap.unicode_emoji_auto_update || 'false')) {
this.socket.emit('update-server-setting', { key: 'unicode_emoji_auto_update', value: emojiAutoUpdate });
changed = true;
}
const regCaptcha = document.getElementById('registration-captcha-enabled')?.checked ? 'true' : 'false';
if (regCaptcha !== (snap.registration_captcha_enabled || 'false')) {
this.socket.emit('update-server-setting', { key: 'registration_captcha_enabled', value: regCaptcha });
changed = true;
}
const tsSite = (document.getElementById('turnstile-site-key')?.value || '').trim();
if (tsSite !== (snap.turnstile_site_key || '')) {
this.socket.emit('update-server-setting', { key: 'turnstile_site_key', value: tsSite });
changed = true;
}
const tsSecret = (document.getElementById('turnstile-secret-key')?.value || '').trim();
if (tsSecret !== (snap.turnstile_secret_key || '')) {
this.socket.emit('update-server-setting', { key: 'turnstile_secret_key', value: tsSecret });
changed = true;
}
const rlEnabled = document.getElementById('registration-rate-limit-enabled')?.checked ? 'true' : 'false';
if (rlEnabled !== (snap.registration_rate_limit_enabled || 'false')) {
this.socket.emit('update-server-setting', { key: 'registration_rate_limit_enabled', value: rlEnabled });
changed = true;
}
const rlPerHour = (document.getElementById('registration-rate-limit-per-hour')?.value || '20').trim();
if (rlPerHour !== (snap.registration_rate_limit_per_hour || '20')) {
this.socket.emit('update-server-setting', { key: 'registration_rate_limit_per_hour', value: rlPerHour });
changed = true;
}
const maxInvUses = (document.getElementById('max-invite-uses')?.value || '0').trim();
if (maxInvUses !== (snap.max_invite_uses || '0')) {
this.socket.emit('update-server-setting', { key: 'max_invite_uses', value: maxInvUses });
changed = true;
}
const themeList = document.getElementById('admin-theme-list');
if (themeList?.dataset.loaded === '1') {
const publishedThemes = JSON.stringify(
[...themeList.querySelectorAll('input[type="checkbox"]')]
.filter(cb => cb.checked)
.map(cb => cb.dataset.file)
);
if (publishedThemes !== (snap.published_themes || '[]')) {
this.socket.emit('update-server-setting', { key: 'published_themes', value: publishedThemes });
changed = true;
}
}
// Publish first so a newly-published file can pass server validation when it
// is selected as the default in the same save operation.
const defaultTheme = document.getElementById('default-theme-select')?.value || '';
if (defaultTheme !== (snap.default_theme || '')) {
this.socket.emit('update-server-setting', { key: 'default_theme', value: defaultTheme });
changed = true;
}
const defaultLocale = document.getElementById('default-locale-select')?.value || '';
if (defaultLocale !== (snap.default_locale || '')) {
this.socket.emit('update-server-setting', { key: 'default_locale', value: defaultLocale });
changed = true;
}
const customTos = document.getElementById('custom-tos-input')?.value.trim() || '';
if (customTos !== (snap.custom_tos || '')) {
this.socket.emit('update-server-setting', { key: 'custom_tos', value: customTos });
changed = true;
}
const roleIconSidebar = document.getElementById('role-icon-sidebar')?.checked ? 'true' : 'false';
if (roleIconSidebar !== (snap.role_icon_sidebar || 'true')) {
this.socket.emit('update-server-setting', { key: 'role_icon_sidebar', value: roleIconSidebar });
changed = true;
}
const roleIconChat = document.getElementById('role-icon-chat')?.checked ? 'true' : 'false';
if (roleIconChat !== (snap.role_icon_chat || 'false')) {
this.socket.emit('update-server-setting', { key: 'role_icon_chat', value: roleIconChat });
changed = true;
}
const roleIconAfterName = document.getElementById('role-icon-after-name')?.checked ? 'true' : 'false';
if (roleIconAfterName !== (snap.role_icon_after_name || 'false')) {
this.socket.emit('update-server-setting', { key: 'role_icon_after_name', value: roleIconAfterName });
changed = true;
}
// ── Voice & Connectivity (STUN/TURN), #5399 ───
const iceDisabledVal = document.getElementById('voice-ice-disabled')?.checked ? 'true' : 'false';
if (iceDisabledVal !== (snap.voice_ice_disabled || 'false')) {
this.socket.emit('update-server-setting', { key: 'voice_ice_disabled', value: iceDisabledVal });
changed = true;
}
const stunUrlsVal = document.getElementById('stun-urls-input')?.value.trim() || '';
if (stunUrlsVal !== (snap.stun_urls || '')) {
this.socket.emit('update-server-setting', { key: 'stun_urls', value: stunUrlsVal });
changed = true;
}
const turnUrlVal = document.getElementById('turn-url-input')?.value.trim() || '';
if (turnUrlVal !== (snap.turn_url || '')) {
this.socket.emit('update-server-setting', { key: 'turn_url', value: turnUrlVal });
changed = true;
}
const turnUserVal = document.getElementById('turn-username-input')?.value.trim() || '';
if (turnUserVal !== (snap.turn_username || '')) {
this.socket.emit('update-server-setting', { key: 'turn_username', value: turnUserVal });
changed = true;
}
const turnPassVal = document.getElementById('turn-password-input')?.value || '';
if (turnPassVal !== (snap.turn_password || '')) {
this.socket.emit('update-server-setting', { key: 'turn_password', value: turnPassVal });
changed = true;
}
// The Channel Creator Role select writes straight through on `change`, so by
// the time Save runs the value is already stored. It still has to be counted
// here or the panel reports "No changes to save" for an edit that plainly
// happened. Comparing against the snapshot means re-picking the original
// value still correctly counts as no change. (#5461)
const ccrNow = (this.serverSettings?.channel_creator_role || '');
if (ccrNow !== (snap.channel_creator_role || '')) changed = true;
if (changed) {
this._showToast(t('settings.admin.settings_saved'), 'success');
} else {
this._showToast(t('settings.admin.no_changes'), 'info');
}
document.getElementById('settings-modal').style.display = 'none';
},
_cancelAdminSettings() {
const snap = this._adminSnapshot;
if (snap) {
const ni = document.getElementById('server-name-input');
if (ni) ni.value = snap.server_name;
const ti = document.getElementById('server-title-input');
if (ti) ti.value = snap.server_title || '';
const vis = document.getElementById('member-visibility-select');
if (vis) vis.value = snap.member_visibility;
const refPol = document.getElementById('referrer-policy-select');
if (refPol) refPol.value = snap.referrer_policy;
this._applyOidcSettings?.();
const ce = document.getElementById('cleanup-enabled');
if (ce) ce.checked = snap.cleanup_enabled === 'true';
const ca = document.getElementById('cleanup-max-age');
if (ca) ca.value = snap.cleanup_max_age_days;
const cs = document.getElementById('cleanup-max-size');
if (cs) cs.value = snap.cleanup_max_size_mb;
const cu = document.getElementById('cleanup-max-uploads');
if (cu) cu.value = snap.cleanup_max_uploads_mb;
const dr = document.getElementById('deleted-retention-days');
if (dr) dr.value = snap.deleted_retention_days;
const ks = document.getElementById('keep-self-destructed');
if (ks) ks.checked = snap.keep_self_destructed_attachments !== 'false';
const wl = document.getElementById('whitelist-enabled');
if (wl) wl.checked = snap.whitelist_enabled === 'true';
const mu = document.getElementById('max-upload-mb');
if (mu) mu.value = snap.max_upload_mb || '25';
const ma = document.getElementById('max-attachments');
if (ma) ma.value = snap.max_attachments || '10';
this._fillMediaLayoutAdmin?.(snap.media_group_layout);
const mtpa = document.getElementById('max-tags-per-attachment');
if (mtpa) mtpa.value = snap.max_tags_per_attachment || '3';
const mtl = document.getElementById('max-tag-len');
if (mtl) mtl.value = snap.max_tag_len || '20';
const msk = document.getElementById('max-sound-kb');
if (msk) msk.value = snap.max_sound_kb || '1024';
const mam = document.getElementById('max-avatar-mb');
if (mam) mam.value = snap.max_avatar_mb || '2';
const mek = document.getElementById('max-emoji-kb');
if (mek) mek.value = snap.max_emoji_kb || '256';
const mstk = document.getElementById('max-sticker-kb');
if (mstk) mstk.value = snap.max_sticker_kb || '1024';
const mpo = document.getElementById('max-poll-options');
if (mpo) mpo.value = snap.max_poll_options || '10';
const sdd = document.getElementById('session-duration-days');
if (sdd) sdd.value = snap.session_duration_days ?? '0';
const mmc = document.getElementById('max-message-chars');
if (mmc) mmc.value = snap.max_message_chars || '2000';
const uba = document.getElementById('update-banner-admin-only');
if (uba) uba.checked = snap.update_banner_admin_only === 'true';
const asp = document.getElementById('allow-self-purge');
if (asp) asp.checked = snap.allow_self_purge === 'true';
const hdb = document.getElementById('hide-disabled-badges');
if (hdb) hdb.checked = snap.hide_disabled_channel_badges === 'true';
const dt = document.getElementById('default-theme-select');
if (dt) dt.value = snap.default_theme || '';
const dl = document.getElementById('default-locale-select');
if (dl) dl.value = snap.default_locale || '';
const ct = document.getElementById('custom-tos-input');
if (ct) ct.value = snap.custom_tos || '';
const rcap = document.getElementById('registration-captcha-enabled');
if (rcap) rcap.checked = snap.registration_captcha_enabled === 'true';
const tss = document.getElementById('turnstile-site-key');
if (tss) tss.value = snap.turnstile_site_key || '';
const tsk = document.getElementById('turnstile-secret-key');
if (tsk) tsk.value = snap.turnstile_secret_key || '';
const rrl = document.getElementById('registration-rate-limit-enabled');
if (rrl) rrl.checked = snap.registration_rate_limit_enabled === 'true';
const rrn = document.getElementById('registration-rate-limit-per-hour');
if (rrn) rrn.value = snap.registration_rate_limit_per_hour || '20';
const miu = document.getElementById('max-invite-uses');
if (miu) miu.value = snap.max_invite_uses || '0';
}
document.getElementById('settings-modal').style.display = 'none';
},
async _renderAdminThemeList() {
const container = document.getElementById('admin-theme-list');
if (!container) return;
container.dataset.loaded = '0';
let themes = [];
try {
const response = await fetch('/api/themes');
if (!response.ok) throw new Error('Theme metadata request failed');
const result = await response.json();
if (!Array.isArray(result)) throw new Error('Invalid theme metadata response');
themes = result;
container.dataset.loaded = '1';
} catch { /* server not ready */ }
if (themes.length === 0) {
container.innerHTML = `<span style="font-size:0.75rem;color:var(--text-muted)">${t('settings.admin.no_themes')}</span>`;
return;
}
container.innerHTML = '';
for (const theme of themes) {
const label = document.createElement('label');
label.style.cssText = 'display:flex;align-items:center;gap:8px;font-size:0.8125rem;cursor:pointer';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.dataset.file = theme.file;
cb.checked = !!theme.published && theme.compatible !== false;
cb.disabled = theme.compatible === false;
const nameSpan = document.createElement('span');
nameSpan.textContent = theme.name || theme.file;
const descSpan = document.createElement('span');
descSpan.style.cssText = 'font-size:0.6875rem;color:var(--text-muted)';
const compatibility = theme.compatible === false
? ` [${t('settings.plugins_section.incompatible_theme')}]`
: '';
descSpan.textContent = `${theme.description || ''}${compatibility}`;
if (theme.compatible === false) {
label.style.opacity = '0.65';
label.title = t('settings.plugins_section.incompatible_theme_hint');
}
label.append(cb, nameSpan);
if (theme.description || theme.compatible === false) label.append(descSpan);
container.appendChild(label);
}
// Sync published file themes into the default-theme-select
const dtSelect = document.getElementById('default-theme-select');
if (dtSelect) {
// Remove any previously injected file: options
dtSelect.querySelectorAll('option[data-custom-theme]').forEach(o => o.remove());
const published = themes.filter(theme => theme.published && theme.compatible !== false);
if (published.length > 0) {
const seasonal = published.filter(t => window.HavenThemeCompat?.isSeasonalTheme?.(t)), standard = published.filter(t => !seasonal.includes(t));
if (standard.length > 0) {
const sep = document.createElement('option');
sep.disabled = true;
sep.textContent = `── ${t('settings.admin.custom_themes')} ──`;
sep.setAttribute('data-custom-theme', '1');
dtSelect.appendChild(sep);
for (const theme of standard) {
const opt = document.createElement('option');
opt.value = `file:${theme.file}`;
opt.textContent = theme.name || theme.file;
opt.setAttribute('data-custom-theme', '1');
dtSelect.appendChild(opt);
}
}
if (seasonal.length > 0) {
const sep = document.createElement('option');
sep.disabled = true;
sep.textContent = `── ${t('app.theme.seasonal')} ──`;
sep.setAttribute('data-custom-theme', '1');
dtSelect.appendChild(sep);
for (const theme of seasonal) {
const opt = document.createElement('option');
opt.value = `file:${theme.file}`;
opt.textContent = `${theme.icon ? theme.icon + ' ' : ''}${theme.name || theme.file}`;
opt.setAttribute('data-custom-theme', '1');
dtSelect.appendChild(opt);
}
}
}
const currentDefault = this.serverSettings.default_theme || '';
if (currentDefault.startsWith('file:') && !published.some(theme => `file:${theme.file}` === currentDefault)) {
const file = currentDefault.slice(5);
const theme = themes.find(item => item.file === file);
if (theme) {
const opt = document.createElement('option');
opt.value = currentDefault;
opt.textContent = `${theme.name || file} (${t('settings.plugins_section.incompatible_theme')})`;
opt.disabled = true;
opt.setAttribute('data-custom-theme', '1');
dtSelect.appendChild(opt);
}
}
// Re-apply saved value (may be a file: value)
dtSelect.value = this.serverSettings.default_theme || '';
}
},
_renderWhitelist(list) {
const el = document.getElementById('whitelist-list');
if (!el) return;
if (!list || list.length === 0) {
el.innerHTML = `<p class="muted-text">${t('settings.admin.no_whitelisted_users')}</p>`;
return;
}
el.innerHTML = list.map(w => `
<div class="whitelist-item">
<span class="whitelist-username">${this._escapeHtml(w.username)}</span>
<button class="btn-sm btn-danger-sm whitelist-remove-btn" data-username="${this._escapeHtml(w.username)}">✕</button>
</div>
`).join('');
el.querySelectorAll('.whitelist-remove-btn').forEach(btn => {
btn.addEventListener('click', () => {
this.socket.emit('whitelist-remove', { username: btn.dataset.username });
});
});
},
// ═══════════════════════════════════════════════════════
};