mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-29 01:55:29 +00:00
1284 lines
65 KiB
JavaScript
1284 lines
65 KiB
JavaScript
// Server Admin controls in Settings: kick, mute, ban and delete-user
|
||
// confirmations, bans, banned IPs, deleted users, the member list, cleanup,
|
||
// backups and restore, server updates, sign-ups, auto-mod,
|
||
// tunnel, the server invite code, registration token, default and guest
|
||
// channels, invite links, and the voice connectivity test.
|
||
|
||
export default {
|
||
|
||
_bindAdminModeration() {
|
||
// ── Admin moderation bindings ───────────────────────
|
||
document.getElementById('cancel-admin-action-btn').addEventListener('click', () => {
|
||
document.getElementById('admin-action-modal').style.display = 'none';
|
||
});
|
||
|
||
document.getElementById('admin-action-modal').addEventListener('click', (e) => {
|
||
if (e.target === e.currentTarget) e.currentTarget.style.display = 'none';
|
||
});
|
||
|
||
document.getElementById('confirm-admin-action-btn').addEventListener('click', async () => {
|
||
if (!this.adminActionTarget) return;
|
||
const { action, userId, username } = this.adminActionTarget;
|
||
const reason = document.getElementById('admin-action-reason').value.trim();
|
||
const duration = parseInt(document.getElementById('admin-action-duration').value) || 10;
|
||
const scrubMessages = document.getElementById('admin-scrub-checkbox').checked;
|
||
const scrubScope = document.getElementById('admin-scrub-scope').value;
|
||
|
||
if (action === 'kick') {
|
||
this.socket.emit('kick-user', { userId, reason, scrubMessages, scrubScope });
|
||
} else if (action === 'ban') {
|
||
const purgeCheckbox = document.getElementById('admin-purge-checkbox');
|
||
const purgeInput = document.getElementById('admin-purge-message');
|
||
const purgeMessages = !!(purgeCheckbox && purgeCheckbox.checked);
|
||
const purgeMessage = purgeInput ? purgeInput.value.trim() : '';
|
||
const banIpCheckbox = document.getElementById('admin-ban-ip-checkbox');
|
||
const banIp = !!(banIpCheckbox && banIpCheckbox.checked);
|
||
this.socket.emit('ban-user', { userId, reason, scrubMessages, purgeMessages, purgeMessage, banIp });
|
||
} else if (action === 'mute') {
|
||
this.socket.emit('mute-user', { userId, reason, duration });
|
||
} else if (action === 'delete-user') {
|
||
const ok = await this._showConfirmModal(t('confirm.delete_user', { username }), '', { danger: true });
|
||
if (!ok) return;
|
||
this.socket.emit('delete-user', { userId, reason, scrubMessages });
|
||
}
|
||
|
||
document.getElementById('admin-action-modal').style.display = 'none';
|
||
this.adminActionTarget = null;
|
||
});
|
||
},
|
||
|
||
_bindAdminControls() {
|
||
// Member visibility select (admin), saved via admin Save button
|
||
|
||
// View bans button
|
||
document.getElementById('view-bans-btn').addEventListener('click', () => {
|
||
this.socket.emit('get-bans');
|
||
document.getElementById('bans-modal').style.display = 'flex';
|
||
});
|
||
|
||
document.getElementById('close-bans-btn').addEventListener('click', () => {
|
||
document.getElementById('bans-modal').style.display = 'none';
|
||
});
|
||
|
||
document.getElementById('bans-modal').addEventListener('click', (e) => {
|
||
if (e.target === e.currentTarget) e.currentTarget.style.display = 'none';
|
||
});
|
||
|
||
// ── Banned IPs modal (v3.20.0) ─────────────────────
|
||
const viewIpBansBtn = document.getElementById('view-ip-bans-btn');
|
||
if (viewIpBansBtn) {
|
||
viewIpBansBtn.addEventListener('click', () => {
|
||
this.socket.emit('get-ip-bans');
|
||
document.getElementById('ip-bans-modal').style.display = 'flex';
|
||
});
|
||
}
|
||
const closeIpBansBtn = document.getElementById('close-ip-bans-btn');
|
||
if (closeIpBansBtn) {
|
||
closeIpBansBtn.addEventListener('click', () => {
|
||
document.getElementById('ip-bans-modal').style.display = 'none';
|
||
});
|
||
}
|
||
const ipBansModal = document.getElementById('ip-bans-modal');
|
||
if (ipBansModal) {
|
||
ipBansModal.addEventListener('click', (e) => {
|
||
if (e.target === e.currentTarget) e.currentTarget.style.display = 'none';
|
||
});
|
||
}
|
||
const ipBanAddBtn = document.getElementById('ip-ban-add-btn');
|
||
if (ipBanAddBtn) {
|
||
ipBanAddBtn.addEventListener('click', () => {
|
||
const ipEl = document.getElementById('ip-ban-input');
|
||
const reasonEl = document.getElementById('ip-ban-reason-input');
|
||
const ip = (ipEl && ipEl.value || '').trim();
|
||
const reason = (reasonEl && reasonEl.value || '').trim();
|
||
if (!ip) return;
|
||
this.socket.emit('ban-ip', { ip, reason });
|
||
if (ipEl) ipEl.value = '';
|
||
if (reasonEl) reasonEl.value = '';
|
||
// Server refreshes the list after unban; for direct ban, refresh manually.
|
||
setTimeout(() => this.socket.emit('get-ip-bans'), 150);
|
||
});
|
||
}
|
||
|
||
// View deleted users button
|
||
document.getElementById('view-deleted-users-btn').addEventListener('click', () => {
|
||
this.socket.emit('get-deleted-users');
|
||
document.getElementById('deleted-users-modal').style.display = 'flex';
|
||
});
|
||
|
||
document.getElementById('close-deleted-users-btn').addEventListener('click', () => {
|
||
document.getElementById('deleted-users-modal').style.display = 'none';
|
||
});
|
||
|
||
document.getElementById('deleted-users-modal').addEventListener('click', (e) => {
|
||
if (e.target === e.currentTarget) e.currentTarget.style.display = 'none';
|
||
});
|
||
|
||
// View all members buttons (sidebar + admin settings)
|
||
document.getElementById('sidebar-members-btn').addEventListener('click', () => {
|
||
this._openAllMembersModal();
|
||
});
|
||
document.getElementById('view-all-members-btn').addEventListener('click', () => {
|
||
this._openAllMembersModal();
|
||
});
|
||
document.getElementById('close-all-members-btn').addEventListener('click', () => {
|
||
document.getElementById('all-members-modal').style.display = 'none';
|
||
});
|
||
document.getElementById('all-members-modal').addEventListener('click', (e) => {
|
||
if (e.target === e.currentTarget) e.currentTarget.style.display = 'none';
|
||
});
|
||
document.getElementById('all-members-search').addEventListener('input', () => this._filterAllMembers());
|
||
document.getElementById('all-members-filter').addEventListener('change', () => this._filterAllMembers());
|
||
|
||
// Members-list shortcuts to bans / deleted users (visibility gated by perms in
|
||
// _openAllMembersModal; server handlers re-check permissions on emit).
|
||
document.getElementById('aml-view-bans-btn')?.addEventListener('click', () => {
|
||
document.getElementById('all-members-modal').style.display = 'none';
|
||
this.socket.emit('get-bans');
|
||
document.getElementById('bans-modal').style.display = 'flex';
|
||
});
|
||
document.getElementById('aml-view-deleted-btn')?.addEventListener('click', () => {
|
||
document.getElementById('all-members-modal').style.display = 'none';
|
||
this.socket.emit('get-deleted-users');
|
||
document.getElementById('deleted-users-modal').style.display = 'flex';
|
||
});
|
||
document.getElementById('aml-bulk-cleanup-btn')?.addEventListener('click', () => {
|
||
if (this._openBulkCleanup) this._openBulkCleanup();
|
||
});
|
||
|
||
// ── Cleanup controls (admin), saved via admin Save button ──
|
||
const cleanupAge = document.getElementById('cleanup-max-age');
|
||
if (cleanupAge) {
|
||
cleanupAge.addEventListener('change', () => {
|
||
const val = Math.max(0, Math.min(3650, parseInt(cleanupAge.value) || 0));
|
||
cleanupAge.value = val;
|
||
});
|
||
}
|
||
const cleanupSize = document.getElementById('cleanup-max-size');
|
||
if (cleanupSize) {
|
||
cleanupSize.addEventListener('change', () => {
|
||
const val = Math.max(0, Math.min(100000, parseInt(cleanupSize.value) || 0));
|
||
cleanupSize.value = val;
|
||
});
|
||
}
|
||
const cleanupUploads = document.getElementById('cleanup-max-uploads');
|
||
if (cleanupUploads) {
|
||
cleanupUploads.addEventListener('change', () => {
|
||
cleanupUploads.value = Math.max(0, Math.min(10000000, parseInt(cleanupUploads.value) || 0));
|
||
});
|
||
}
|
||
|
||
const runCleanupBtn = document.getElementById('run-cleanup-now-btn');
|
||
if (runCleanupBtn) {
|
||
runCleanupBtn.addEventListener('click', () => {
|
||
this.socket.emit('run-cleanup-now');
|
||
this._showToast(t('toasts.cleanup_triggered'), 'success');
|
||
});
|
||
}
|
||
|
||
// ── Server backup / restore (admin) ──────────────────
|
||
const startBackupDownload = async (include) => {
|
||
const token = localStorage.getItem('haven_token');
|
||
if (!token) return this._showToast(t('toasts.not_logged_in'), 'error');
|
||
// Password (and two-factor code) for a one-time download ticket.
|
||
const ticket = await this._adminDownloadTicket({ purpose: 'backup', include });
|
||
if (!ticket) return;
|
||
const a = document.createElement('a');
|
||
a.href = `/api/admin/backup?ticket=${encodeURIComponent(ticket)}`;
|
||
a.style.display = 'none';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
setTimeout(() => a.remove(), 1000);
|
||
this._showToast(t('toasts.backup_started'), 'info');
|
||
};
|
||
const getBackupIncludes = () => {
|
||
return Array.from(document.querySelectorAll('.backup-include:checked')).map(el => el.value);
|
||
};
|
||
document.getElementById('backup-download-btn')?.addEventListener('click', () => {
|
||
const includes = getBackupIncludes();
|
||
if (!includes.length) {
|
||
return this._showToast(t('toasts.backup_pick_one'), 'error');
|
||
}
|
||
const heavy = includes.includes('messages') || includes.includes('files');
|
||
if (heavy && !confirm(t('confirm.backup_heavy'))) return;
|
||
startBackupDownload(includes.join(','));
|
||
});
|
||
document.getElementById('backup-select-all-btn')?.addEventListener('click', () => {
|
||
document.querySelectorAll('.backup-include').forEach(el => { el.checked = true; });
|
||
});
|
||
document.getElementById('backup-select-none-btn')?.addEventListener('click', () => {
|
||
document.querySelectorAll('.backup-include').forEach(el => { el.checked = false; });
|
||
});
|
||
|
||
const restoreBtn = document.getElementById('backup-restore-btn');
|
||
if (restoreBtn) {
|
||
const progWrap = document.getElementById('restore-progress');
|
||
const progFill = document.getElementById('restore-progress-fill');
|
||
const progLabel = document.getElementById('restore-progress-label');
|
||
const fmtBytesR = (n) => {
|
||
if (n < 1024) return n + ' B';
|
||
if (n < 1048576) return (n / 1024).toFixed(1) + ' KB';
|
||
if (n < 1073741824) return (n / 1048576).toFixed(1) + ' MB';
|
||
return (n / 1073741824).toFixed(2) + ' GB';
|
||
};
|
||
const setBar = (pct, indeterminate) => {
|
||
if (progWrap) progWrap.classList.toggle('indeterminate', !!indeterminate);
|
||
if (progFill) progFill.style.width = indeterminate ? '40%' : Math.max(0, Math.min(100, pct)) + '%';
|
||
};
|
||
|
||
restoreBtn.addEventListener('click', async () => {
|
||
const fileInput = document.getElementById('backup-restore-file');
|
||
const file = fileInput?.files?.[0];
|
||
if (!file) return this._showToast(t('toasts.backup_no_file'), 'error');
|
||
if (!confirm(t('confirm.backup_restore'))) return;
|
||
const token = localStorage.getItem('haven_token');
|
||
if (!token) return this._showToast(t('toasts.not_logged_in'), 'error');
|
||
const reauth = await this._askAdminReauth();
|
||
if (!reauth) return;
|
||
|
||
restoreBtn.disabled = true;
|
||
const origText = restoreBtn.innerHTML;
|
||
|
||
// The server streams the uploaded zip to disk after the upload lands and
|
||
// emits `restore-progress` over the socket so we can show a real
|
||
// extraction bar instead of an opaque wait on large backups (#5438).
|
||
const onExtractProgress = (p) => {
|
||
if (!p || p.phase !== 'extract') return;
|
||
if (p.bytesTotal) {
|
||
const pct = Math.round((p.bytesDone / p.bytesTotal) * 100);
|
||
setBar(pct, false);
|
||
if (progLabel) progLabel.textContent = t('settings.admin.restore_extract_progress', { pct, done: fmtBytesR(p.bytesDone), total: fmtBytesR(p.bytesTotal) });
|
||
} else {
|
||
setBar(0, true);
|
||
if (progLabel) progLabel.textContent = t('settings.admin.restore_extracting');
|
||
}
|
||
};
|
||
this.socket?.on('restore-progress', onExtractProgress);
|
||
const cleanup = () => { this.socket?.off('restore-progress', onExtractProgress); };
|
||
|
||
if (progWrap) progWrap.style.display = 'block';
|
||
setBar(0, false);
|
||
if (progLabel) progLabel.textContent = t('settings.admin.restore_upload_progress', { pct: 0 });
|
||
restoreBtn.innerHTML = `⏳ ${t('settings.admin.restore_uploading')}`;
|
||
|
||
try {
|
||
const data = await new Promise((resolve, reject) => {
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.open('POST', '/api/admin/restore');
|
||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||
xhr.setRequestHeader('X-Haven-Password', encodeURIComponent(reauth.password || ''));
|
||
if (reauth.code) xhr.setRequestHeader('X-Haven-Code', reauth.code);
|
||
xhr.timeout = 0; // multi-GB uploads can run for many minutes
|
||
xhr.upload.onprogress = (e) => {
|
||
if (!e.lengthComputable) return;
|
||
const pct = Math.round((e.loaded / e.total) * 100);
|
||
setBar(pct, false);
|
||
if (progLabel) progLabel.textContent = t('settings.admin.restore_upload_progress_bytes', { pct, done: fmtBytesR(e.loaded), total: fmtBytesR(e.total) });
|
||
};
|
||
xhr.upload.onload = () => {
|
||
// Upload finished: server now stages the zip to disk. Flip to the
|
||
// extraction phase; socket events refine this if/when they arrive.
|
||
setBar(0, true);
|
||
if (progLabel) progLabel.textContent = t('settings.admin.restore_upload_complete');
|
||
restoreBtn.innerHTML = `⏳ ${t('settings.admin.restore_extracting_short')}`;
|
||
};
|
||
xhr.onload = () => {
|
||
let d = {};
|
||
try { d = JSON.parse(xhr.responseText); } catch { /* no JSON body: the status code below decides */ }
|
||
if (xhr.status >= 200 && xhr.status < 300) resolve(d);
|
||
else reject(new Error(d.error || `HTTP ${xhr.status}`));
|
||
};
|
||
xhr.onerror = () => reject(new Error(t('settings.admin.restore_network_error')));
|
||
xhr.ontimeout = () => reject(new Error(t('settings.admin.restore_upload_timeout')));
|
||
const fd = new FormData();
|
||
fd.append('backup', file);
|
||
xhr.send(fd);
|
||
});
|
||
cleanup();
|
||
setBar(100, false);
|
||
if (progLabel) progLabel.textContent = t('settings.admin.restore_done');
|
||
this._showToast(data.message || t('settings.admin.restore_staged'), 'success');
|
||
restoreBtn.innerHTML = `✓ ${t('settings.admin.restore_restarting')}`;
|
||
} catch (err) {
|
||
cleanup();
|
||
if (progWrap) progWrap.style.display = 'none';
|
||
this._showToast(t('toasts.backup_restore_failed') + err.message, 'error');
|
||
restoreBtn.disabled = false;
|
||
restoreBtn.innerHTML = origText;
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── Auto-backup admin controls ─────────────────────
|
||
const fmtBytes = (n) => {
|
||
if (n < 1024) return n + ' B';
|
||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
||
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB';
|
||
return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB';
|
||
};
|
||
this._refreshAutoBackupList = async () => {
|
||
const listEl = document.getElementById('auto-backup-list');
|
||
if (!listEl) return;
|
||
const token = localStorage.getItem('haven_token');
|
||
if (!token) return;
|
||
try {
|
||
const res = await fetch('/api/admin/auto-backups', { headers: { 'Authorization': `Bearer ${token}` } });
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const data = await res.json();
|
||
const files = data.files || [];
|
||
if (!files.length) {
|
||
listEl.innerHTML = `<small class="settings-hint">${t('settings.admin.auto_backup_none')}</small>`;
|
||
return;
|
||
}
|
||
listEl.innerHTML = files.map(f => {
|
||
const safeName = f.name.replace(/[<>"&]/g, c => ({ '<': '<', '>': '>', '"': '"', '&': '&' }[c]));
|
||
const when = this._fmtDateTime(f.mtime);
|
||
return `<div style="display:flex;gap:6px;align-items:center;justify-content:space-between;border:1px solid var(--border);padding:6px 8px;border-radius:4px">
|
||
<div style="min-width:0;flex:1">
|
||
<div style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:monospace;font-size:0.85em">${safeName}</div>
|
||
<small class="settings-hint">${when} · ${fmtBytes(f.size)}</small>
|
||
</div>
|
||
<div style="display:flex;gap:4px;flex-shrink:0">
|
||
<button class="btn-sm auto-backup-dl-btn" data-name="${safeName}">⬇️</button>
|
||
<button class="btn-sm auto-backup-del-btn" data-name="${safeName}" title="${t('msg_toolbar.delete')}">🗑️</button>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
listEl.querySelectorAll('.auto-backup-dl-btn').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
const name = btn.dataset.name;
|
||
const ticket = await this._adminDownloadTicket({ purpose: 'auto-backup', name });
|
||
if (!ticket) return;
|
||
const url = `/api/admin/auto-backups/${encodeURIComponent(name)}?ticket=${encodeURIComponent(ticket)}`;
|
||
const a = document.createElement('a');
|
||
a.href = url; a.download = name; a.style.display = 'none';
|
||
document.body.appendChild(a); a.click(); setTimeout(() => a.remove(), 1000);
|
||
});
|
||
});
|
||
listEl.querySelectorAll('.auto-backup-del-btn').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
const name = btn.dataset.name;
|
||
if (!confirm(t('settings.admin.auto_backup_delete_confirm', { name }))) return;
|
||
const r = await fetch(`/api/admin/auto-backups/${encodeURIComponent(name)}`, {
|
||
method: 'DELETE',
|
||
headers: { 'Authorization': `Bearer ${token}` },
|
||
});
|
||
if (r.ok) this._refreshAutoBackupList();
|
||
else this._showToast(t('settings.admin.auto_backup_delete_failed'), 'error');
|
||
});
|
||
});
|
||
} catch (err) {
|
||
listEl.innerHTML = `<small class="settings-hint" style="color:var(--danger)">${t('settings.admin.auto_backup_load_failed', { error: this._escapeHtml(err.message) })}</small>`;
|
||
}
|
||
};
|
||
document.getElementById('auto-backup-save-btn')?.addEventListener('click', () => {
|
||
const enabled = document.getElementById('auto-backup-enabled')?.checked ? 'true' : 'false';
|
||
const interval = document.getElementById('auto-backup-interval')?.value || '24';
|
||
const retention = document.getElementById('auto-backup-retention')?.value || '7';
|
||
const sections = Array.from(document.querySelectorAll('.auto-backup-include:checked')).map(el => el.value);
|
||
if (enabled === 'true' && !sections.length) {
|
||
return this._showToast(t('toasts.backup_pick_one'), 'error');
|
||
}
|
||
this.socket.emit('update-server-setting', { key: 'auto_backup_enabled', value: enabled });
|
||
this.socket.emit('update-server-setting', { key: 'auto_backup_interval_hours', value: String(interval) });
|
||
this.socket.emit('update-server-setting', { key: 'auto_backup_retention', value: String(retention) });
|
||
this.socket.emit('update-server-setting', { key: 'auto_backup_sections', value: sections.join(',') });
|
||
this._showToast(t('settings.admin.auto_backup_saved'), 'success');
|
||
});
|
||
document.getElementById('auto-backup-run-now-btn')?.addEventListener('click', async () => {
|
||
const token = localStorage.getItem('haven_token');
|
||
if (!token) return;
|
||
try {
|
||
const r = await fetch('/api/admin/auto-backups/run-now', {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${token}` },
|
||
});
|
||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||
this._showToast(t('settings.admin.auto_backup_triggered'), 'info');
|
||
setTimeout(() => this._refreshAutoBackupList(), 3000);
|
||
} catch (err) {
|
||
this._showToast(t('settings.admin.auto_backup_run_failed', { error: err.message }), 'error');
|
||
}
|
||
});
|
||
document.getElementById('auto-backup-refresh-btn')?.addEventListener('click', () => this._refreshAutoBackupList());
|
||
|
||
// ── In-app update controls ─────────────────────────
|
||
let lastUpdateCheck = null;
|
||
const updStatusEl = () => document.getElementById('update-status');
|
||
const updRunBtn = () => document.getElementById('update-run-btn');
|
||
document.getElementById('update-check-btn')?.addEventListener('click', async () => {
|
||
const token = localStorage.getItem('haven_token');
|
||
if (!token) return;
|
||
const status = updStatusEl();
|
||
if (status) { status.style.display = 'block'; status.textContent = t('settings.admin.update_checking'); }
|
||
try {
|
||
const r = await fetch('/api/admin/update/check', { headers: { 'Authorization': `Bearer ${token}` } });
|
||
const data = await r.json();
|
||
lastUpdateCheck = data;
|
||
if (status) {
|
||
const upToDate = !data.updateAvailable;
|
||
const esc = s => String(s || '').replace(/[<>&"]/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c]));
|
||
const cmdBlock = (!data.runnable && data.command) ? `
|
||
<div style="margin-top:8px">
|
||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px"><strong>${t('settings.admin.update_run_on_host')}</strong>
|
||
<button type="button" class="btn-sm" id="update-copy-cmd-btn" title="${t('settings.admin.update_copy_command')}">📋 ${t('common.copy')}</button>
|
||
</div>
|
||
<pre style="background:var(--bg-input);border:1px solid var(--border);border-radius:4px;padding:6px 8px;margin:0;white-space:pre-wrap;word-break:break-all"><code>${esc(data.command)}</code></pre>
|
||
</div>` : '';
|
||
status.innerHTML = `
|
||
<div><strong>${t('settings.admin.update_installed')}</strong> v${esc(data.currentVersion)}</div>
|
||
<div><strong>${t('settings.admin.update_latest')}</strong> ${data.latestVersion ? 'v' + esc(data.latestVersion) : t('settings.admin.update_unknown')}</div>
|
||
<div><strong>${t('settings.admin.update_install_method')}</strong> ${esc(data.method)}</div>
|
||
<div style="margin-top:6px">${upToDate ? t('settings.admin.update_current') : t('settings.admin.update_available')}</div>
|
||
<div style="margin-top:6px"><small>${esc(data.message || '')}</small></div>
|
||
${cmdBlock}
|
||
${data.releaseUrl ? `<div style="margin-top:6px"><a href="${esc(data.releaseUrl)}" target="_blank" rel="noopener">${t('settings.admin.update_release_notes')} →</a></div>` : ''}
|
||
`;
|
||
const copyBtn = document.getElementById('update-copy-cmd-btn');
|
||
if (copyBtn) copyBtn.addEventListener('click', () => {
|
||
try {
|
||
navigator.clipboard.writeText(data.command).then(() => {
|
||
copyBtn.textContent = `✅ ${t('common.copied')}`;
|
||
setTimeout(() => { copyBtn.textContent = `📋 ${t('common.copy')}`; }, 1500);
|
||
}).catch(() => { /* clipboard refused: the button keeps saying Copy and the command stays selectable */ });
|
||
} catch { /* no clipboard API (plain http): the command stays selectable */ }
|
||
});
|
||
}
|
||
// Keep the Update Now button enabled even when the install method
|
||
// isn't auto-runnable (Docker, manual). Click handler will surface
|
||
// the right manual command instead of failing silently. (#5267)
|
||
if (updRunBtn()) updRunBtn().disabled = !data.updateAvailable;
|
||
} catch (err) {
|
||
if (status) status.textContent = t('settings.admin.update_check_failed', { error: err.message });
|
||
}
|
||
});
|
||
document.getElementById('update-run-btn')?.addEventListener('click', async () => {
|
||
const status = updStatusEl();
|
||
// If the user clicks Run before clicking Check, do the check first so
|
||
// we always have a fresh `lastUpdateCheck` to act on. (#5267)
|
||
if (!lastUpdateCheck) {
|
||
document.getElementById('update-check-btn')?.click();
|
||
if (status) {
|
||
if (status.style) status.style.display = 'block';
|
||
status.textContent = t('settings.admin.update_check_first');
|
||
}
|
||
return;
|
||
}
|
||
if (!lastUpdateCheck.updateAvailable) {
|
||
if (status) {
|
||
if (status.style) status.style.display = 'block';
|
||
status.textContent = t('settings.admin.update_nothing_to_install');
|
||
}
|
||
return;
|
||
}
|
||
if (!lastUpdateCheck.runnable) {
|
||
// Most common case: Docker install. Re-run check to surface the
|
||
// copyable command block instead of silently doing nothing.
|
||
document.getElementById('update-check-btn')?.click();
|
||
this._showToast?.(lastUpdateCheck.message || t('settings.admin.update_not_supported', { method: lastUpdateCheck.method }), 'info');
|
||
return;
|
||
}
|
||
if (!confirm(t('settings.admin.update_confirm', { version: lastUpdateCheck.latestVersion }))) return;
|
||
const token = localStorage.getItem('haven_token');
|
||
if (!token) return;
|
||
// Visible status before the fetch so admins always see *something*
|
||
// happen on click. This helps diagnose cases where the request fails
|
||
// silently or the host blocks the request. (#5267)
|
||
if (status) {
|
||
if (status.style) status.style.display = 'block';
|
||
status.textContent = t('settings.admin.update_sending');
|
||
}
|
||
try {
|
||
const r = await fetch('/api/admin/update/run', {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${token}` },
|
||
});
|
||
const data = await r.json();
|
||
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
|
||
if (status) status.innerHTML = `<div>${t('settings.admin.update_started')}</div><div style="margin-top:6px"><small>${this._escapeHtml(data.message || '')}</small></div>`;
|
||
if (updRunBtn()) updRunBtn().disabled = true;
|
||
} catch (err) {
|
||
if (status) status.textContent = t('settings.admin.update_failed', { error: err.message });
|
||
}
|
||
});
|
||
|
||
// ── Whitelist controls (admin) ───────────────────────
|
||
// Whitelist toggle, saved via admin Save button
|
||
|
||
document.getElementById('whitelist-add-btn').addEventListener('click', () => {
|
||
const input = document.getElementById('whitelist-username-input');
|
||
const username = input.value.trim();
|
||
if (!username) return;
|
||
this.socket.emit('whitelist-add', { username });
|
||
input.value = '';
|
||
});
|
||
|
||
document.getElementById('whitelist-username-input').addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') document.getElementById('whitelist-add-btn').click();
|
||
});
|
||
|
||
// Listen for whitelist list updates
|
||
this.socket.on('whitelist-list', (list) => {
|
||
this._renderWhitelist(list);
|
||
});
|
||
|
||
// ── Auto-Mod panel (v3.42.0) ─────────────────────────────
|
||
// Wired here alongside the other admin-settings controls. Its settings
|
||
// apply immediately rather than through the Save flow, matching how the
|
||
// whitelist and tunnel controls already behave.
|
||
if (typeof this._initAutomodPanel === 'function') {
|
||
this._initAutomodPanel();
|
||
this.socket.emit('get-automod-domains');
|
||
this.socket.emit('get-automod-log', { limit: 100 });
|
||
}
|
||
|
||
// ── Tunnel settings (immediate, not part of Save flow) ──
|
||
const tunnelToggleBtn = document.getElementById('tunnel-toggle-btn');
|
||
if (tunnelToggleBtn) {
|
||
tunnelToggleBtn.addEventListener('click', () => {
|
||
// Determine desired state from button text
|
||
const wantStart = tunnelToggleBtn.textContent.trim().startsWith('Start');
|
||
this.socket.emit('update-server-setting', {
|
||
key: 'tunnel_enabled',
|
||
value: wantStart ? 'true' : 'false'
|
||
});
|
||
this._syncTunnelState(wantStart);
|
||
});
|
||
}
|
||
|
||
const tunnelProvEl = document.getElementById('tunnel-provider-select');
|
||
if (tunnelProvEl) {
|
||
tunnelProvEl.addEventListener('change', () => {
|
||
this.socket.emit('update-server-setting', {
|
||
key: 'tunnel_provider',
|
||
value: tunnelProvEl.value
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── Server invite code (immediate, not part of Save flow) ──
|
||
document.getElementById('generate-server-code-btn')?.addEventListener('click', () => {
|
||
this.socket.emit('generate-server-code');
|
||
});
|
||
document.getElementById('clear-server-code-btn')?.addEventListener('click', () => {
|
||
if (!confirm(t('confirm.clear_invite_code'))) return;
|
||
this.socket.emit('clear-server-code');
|
||
});
|
||
document.getElementById('copy-server-code-btn')?.addEventListener('click', () => {
|
||
const code = document.getElementById('server-code-value')?.textContent;
|
||
if (code && code !== '-') {
|
||
const onCopied = () => this._showToast(t('toasts.server_code_copied'), 'success');
|
||
navigator.clipboard.writeText(code).then(onCopied).catch(() => {
|
||
try {
|
||
const ta = document.createElement('textarea');
|
||
ta.value = code;
|
||
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);
|
||
onCopied();
|
||
} catch { /* could not copy */ }
|
||
});
|
||
}
|
||
});
|
||
|
||
// ── Registration token (#5344), independent of whitelist ──
|
||
document.getElementById('registration-token-enabled')?.addEventListener('change', (e) => {
|
||
this.socket.emit('update-server-setting', {
|
||
key: 'registration_token_enabled',
|
||
value: e.target.checked ? 'true' : 'false'
|
||
});
|
||
});
|
||
document.getElementById('invites-bypass-registration-token')?.addEventListener('change', (e) => {
|
||
this.socket.emit('update-server-setting', {
|
||
key: 'invites_bypass_registration_token',
|
||
value: e.target.checked ? 'true' : 'false'
|
||
});
|
||
});
|
||
document.getElementById('test-connectivity-btn')?.addEventListener('click', () => {
|
||
this._runConnectivityTest();
|
||
});
|
||
document.getElementById('generate-registration-token-btn')?.addEventListener('click', () => {
|
||
this.socket.emit('generate-registration-token');
|
||
});
|
||
document.getElementById('clear-registration-token-btn')?.addEventListener('click', () => {
|
||
if (!confirm(t('settings.admin.registration.clear_confirm'))) return;
|
||
this.socket.emit('clear-registration-token');
|
||
});
|
||
document.getElementById('copy-registration-token-btn')?.addEventListener('click', async () => {
|
||
const tok = document.getElementById('registration-token-value')?.textContent?.trim();
|
||
if (!tok || tok === '-') return;
|
||
const onCopied = () => this._showToast?.(t('settings.admin.registration.copied'), 'success');
|
||
// The old handler toasted "copied" from the rejection path too, so in the
|
||
// desktop app (clipboard write refused without a fresh user activation)
|
||
// the toast lied while the clipboard kept its previous contents.
|
||
try {
|
||
const res = await window.havenDesktop?.clipboardWriteText?.(tok);
|
||
if (res?.ok) return onCopied();
|
||
} catch { /* desktop bridge refused: try the browser clipboard next */ }
|
||
try {
|
||
if (!navigator.clipboard?.writeText) throw new Error('no clipboard api');
|
||
await navigator.clipboard.writeText(tok);
|
||
return onCopied();
|
||
} catch {
|
||
let ok = false;
|
||
this._copyTextFallback(tok, () => { ok = true; onCopied(); });
|
||
if (!ok) this._showToast?.(t('settings.admin.registration.copy_failed'), 'error');
|
||
}
|
||
});
|
||
|
||
// ── Default join channels (#5345) ──────────────────
|
||
const _renderDefaultJoinChannels = () => {
|
||
const host = document.getElementById('default-join-channels-list');
|
||
if (!host) return;
|
||
const all = (this.channels || []).filter(c =>
|
||
!c.is_dm && !c.parent_channel_id &&
|
||
!c.is_private && c.code_visibility !== 'private'
|
||
);
|
||
if (all.length === 0) {
|
||
host.innerHTML = `<p class="muted-text" style="margin:4px 0;font-size:0.85rem">${t('settings.admin.invite_links.no_public_channels')}</p>`;
|
||
return;
|
||
}
|
||
let selected = null; // null = "all"
|
||
try {
|
||
const raw = this.serverSettings?.default_join_channels;
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
if (Array.isArray(parsed)) selected = new Set(parsed.map(n => parseInt(n)));
|
||
}
|
||
} catch { /* fall back to all */ }
|
||
host.innerHTML = all.map(ch => {
|
||
const checked = (selected === null) || selected.has(ch.id);
|
||
return `<label style="display:flex;align-items:center;gap:6px;padding:3px 4px;font-size:0.85rem">
|
||
<input type="checkbox" class="default-join-channel-cb" data-cid="${ch.id}" ${checked ? 'checked' : ''}>
|
||
<span>#${this._escapeHtml(ch.name || '')}</span>
|
||
</label>`;
|
||
}).join('');
|
||
};
|
||
this._renderDefaultJoinChannels = _renderDefaultJoinChannels;
|
||
document.getElementById('default-join-channels-all-btn')?.addEventListener('click', () => {
|
||
document.querySelectorAll('.default-join-channel-cb').forEach(cb => { cb.checked = true; });
|
||
});
|
||
document.getElementById('default-join-channels-none-btn')?.addEventListener('click', () => {
|
||
document.querySelectorAll('.default-join-channel-cb').forEach(cb => { cb.checked = false; });
|
||
});
|
||
document.getElementById('default-join-channels-save-btn')?.addEventListener('click', () => {
|
||
const cbs = Array.from(document.querySelectorAll('.default-join-channel-cb'));
|
||
const total = cbs.length;
|
||
const picked = cbs.filter(cb => cb.checked).map(cb => parseInt(cb.dataset.cid)).filter(Number.isFinite);
|
||
// "All checked" → store empty string so the default ("all public") logic kicks in
|
||
const value = (picked.length === total) ? '' : JSON.stringify(picked);
|
||
this.socket.emit('update-server-setting', { key: 'default_join_channels', value });
|
||
this._showToast?.(picked.length === total
|
||
? t('settings.admin.default_join.all')
|
||
: t(picked.length === 1 ? 'settings.admin.default_join.one' : 'settings.admin.default_join.other', { count: picked.length }),
|
||
'success');
|
||
});
|
||
|
||
// ── Guest channel whitelist (#5381) ────────────────
|
||
const _renderGuestChannels = () => {
|
||
const host = document.getElementById('guest-channels-list');
|
||
if (!host) return;
|
||
const chans = (this.channels || []).filter(c => !c.is_dm);
|
||
if (chans.length === 0) {
|
||
host.innerHTML = `<p class="muted-text" style="margin:4px 0;font-size:0.85rem">${t('settings.admin.invite_links.no_channels')}</p>`;
|
||
return;
|
||
}
|
||
// CSV of channel ids. Empty string = no channels (guests can log in but have nowhere to go).
|
||
// (#5401) Sub-channels and voice rooms are listed individually so admins
|
||
// grant guests exactly the channels they intend, no implicit cascade.
|
||
const raw = this.serverSettings?.guest_channels || '';
|
||
const selected = new Set(
|
||
raw.split(',').map(s => s.trim()).filter(Boolean).map(s => parseInt(s)).filter(Number.isFinite)
|
||
);
|
||
|
||
const parents = chans.filter(c => !c.parent_channel_id)
|
||
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0) || String(a.name || '').localeCompare(String(b.name || '')));
|
||
const subsByParent = new Map();
|
||
chans.filter(c => c.parent_channel_id).forEach(c => {
|
||
if (!subsByParent.has(c.parent_channel_id)) subsByParent.set(c.parent_channel_id, []);
|
||
subsByParent.get(c.parent_channel_id).push(c);
|
||
});
|
||
for (const list of subsByParent.values()) {
|
||
list.sort((a, b) => (a.position ?? 0) - (b.position ?? 0) || String(a.name || '').localeCompare(String(b.name || '')));
|
||
}
|
||
|
||
const tagsFor = (ch) => {
|
||
const tags = [];
|
||
if (ch.is_private || ch.code_visibility === 'private') tags.push(t('settings.admin.guest_access.private'));
|
||
if (ch.text_enabled === 0 && ch.voice_enabled) tags.push(t('settings.admin.guest_access.voice'));
|
||
return tags.length
|
||
? ` <small style="color:var(--text-muted)">(${tags.join(', ')})</small>` : '';
|
||
};
|
||
const row = (ch, isSub) => {
|
||
const checked = selected.has(ch.id);
|
||
const prefix = isSub ? '↳ ' : '#';
|
||
const parentAttr = isSub ? ` data-parent="${ch.parent_channel_id}"` : '';
|
||
return `<label style="display:flex;align-items:center;gap:6px;padding:3px 4px;font-size:0.85rem${isSub ? ';margin-left:18px' : ''}">
|
||
<input type="checkbox" class="guest-channel-cb" data-cid="${ch.id}"${parentAttr} ${checked ? 'checked' : ''}>
|
||
<span>${prefix}${this._escapeHtml(ch.name || '')}${tagsFor(ch)}</span>
|
||
</label>`;
|
||
};
|
||
|
||
let html = '';
|
||
for (const p of parents) {
|
||
html += row(p, false);
|
||
for (const sub of (subsByParent.get(p.id) || [])) html += row(sub, true);
|
||
}
|
||
host.innerHTML = html;
|
||
const toggle = document.getElementById('guests-enabled');
|
||
if (toggle) toggle.checked = (this.serverSettings?.guests_enabled === 'true');
|
||
const voiceToggle = document.getElementById('guests-allow-voice');
|
||
if (voiceToggle) voiceToggle.checked = (this.serverSettings?.guests_allow_voice !== 'false');
|
||
};
|
||
this._renderGuestChannels = _renderGuestChannels;
|
||
document.getElementById('guests-enabled')?.addEventListener('change', (e) => {
|
||
this.socket.emit('update-server-setting', { key: 'guests_enabled', value: e.target.checked ? 'true' : 'false' });
|
||
this._showToast?.(t(e.target.checked ? 'settings.admin.guest_access.enabled' : 'settings.admin.guest_access.disabled'), 'success');
|
||
});
|
||
document.getElementById('guests-allow-voice')?.addEventListener('change', (e) => {
|
||
this.socket.emit('update-server-setting', { key: 'guests_allow_voice', value: e.target.checked ? 'true' : 'false' });
|
||
this._showToast?.(t(e.target.checked ? 'settings.admin.guest_access.voice_on' : 'settings.admin.guest_access.voice_off'), 'success');
|
||
});
|
||
document.getElementById('guest-channels-all-btn')?.addEventListener('click', () => {
|
||
document.querySelectorAll('.guest-channel-cb').forEach(cb => { cb.checked = true; });
|
||
});
|
||
document.getElementById('guest-channels-none-btn')?.addEventListener('click', () => {
|
||
document.querySelectorAll('.guest-channel-cb').forEach(cb => { cb.checked = false; });
|
||
});
|
||
// A sub-channel only appears in the sidebar nested under its parent, so a
|
||
// guest needs the parent too. Keep the checkboxes consistent: ticking a sub
|
||
// ticks its parent; unticking a parent unticks its sub-channels. (#5401)
|
||
document.getElementById('guest-channels-list')?.addEventListener('change', (e) => {
|
||
const cb = e.target.closest?.('.guest-channel-cb');
|
||
if (!cb) return;
|
||
if (cb.checked && cb.dataset.parent) {
|
||
const parent = document.querySelector(`.guest-channel-cb[data-cid="${cb.dataset.parent}"]`);
|
||
if (parent) parent.checked = true;
|
||
}
|
||
if (!cb.checked && !cb.dataset.parent) {
|
||
document.querySelectorAll(`.guest-channel-cb[data-parent="${cb.dataset.cid}"]`)
|
||
.forEach(sub => { sub.checked = false; });
|
||
}
|
||
});
|
||
document.getElementById('guest-channels-save-btn')?.addEventListener('click', () => {
|
||
const cbs = Array.from(document.querySelectorAll('.guest-channel-cb'));
|
||
const picked = cbs.filter(cb => cb.checked).map(cb => parseInt(cb.dataset.cid)).filter(Number.isFinite);
|
||
const value = picked.join(',');
|
||
this.socket.emit('update-server-setting', { key: 'guest_channels', value });
|
||
this._showToast?.(picked.length === 0
|
||
? t('settings.admin.guest_access.zero')
|
||
: t(picked.length === 1 ? 'settings.admin.guest_access.one' : 'settings.admin.guest_access.other', { count: picked.length }),
|
||
'success');
|
||
});
|
||
|
||
// ── Managed invite links (multi-code menu) ─────────
|
||
const _invitePublicChannels = () => (this.channels || []).filter(c =>
|
||
!c.is_dm && !c.parent_channel_id && !c.is_private && c.code_visibility !== 'private');
|
||
|
||
// Build a list of public-channel checkboxes. selectedSet === null → all checked
|
||
// ("grant all public"); otherwise only the ids in the set are checked.
|
||
const _inviteChannelChecks = (cls, selectedSet) => {
|
||
const all = _invitePublicChannels();
|
||
if (!all.length) return `<p class="muted-text" style="margin:4px 0;font-size:0.85rem">${t('settings.admin.invite_links.no_public_channels')}</p>`;
|
||
return all.map(ch => {
|
||
const checked = (selectedSet === null) || selectedSet.has(ch.id);
|
||
return `<label style="display:flex;align-items:center;gap:6px;padding:3px 4px;font-size:0.85rem">
|
||
<input type="checkbox" class="${cls}" data-cid="${ch.id}" ${checked ? 'checked' : ''}>
|
||
<span>#${this._escapeHtml(ch.name || '')}</span></label>`;
|
||
}).join('');
|
||
};
|
||
|
||
const _renderInviteCreateChannels = (force) => {
|
||
const host = document.getElementById('invite-new-channels');
|
||
if (!host) return;
|
||
// Don't clobber an in-progress selection on routine settings refreshes.
|
||
if (!force && host.querySelector('.invite-new-channel-cb')) return;
|
||
host.innerHTML = _inviteChannelChecks('invite-new-channel-cb', null);
|
||
};
|
||
this._renderInviteCreateChannels = _renderInviteCreateChannels;
|
||
|
||
const _renderInviteCodes = (list) => {
|
||
this._inviteCodes = Array.isArray(list) ? list : [];
|
||
const countEl = document.getElementById('invite-links-count');
|
||
if (countEl) {
|
||
const active = this._inviteCodes.filter(c => c.enabled && !c.is_expired).length;
|
||
countEl.textContent = this._inviteCodes.length ? t('settings.admin.invite_links.count', { active, total: this._inviteCodes.length }) : '';
|
||
}
|
||
const host = document.getElementById('invite-codes-list');
|
||
if (!host) return;
|
||
if (!this._inviteCodes.length) {
|
||
host.innerHTML = `<p class="muted-text" style="margin:4px 0;font-size:0.85rem">${t('settings.admin.invite_links.none')}</p>`;
|
||
return;
|
||
}
|
||
// determine invite usage input limits
|
||
const parsedMaxInvtUses = parseInt(this.serverSettings?.max_invite_uses, 10);
|
||
const maxInvtUses = Number.isNaN(parsedMaxInvtUses) ? 0 : parsedMaxInvtUses;
|
||
const restrictUses = !this.user?.isAdmin && !this._hasPerm('manage_server') && maxInvtUses > 0;
|
||
const maxUsesInput = restrictUses ? maxInvtUses : 100000;
|
||
const minUsesInput = restrictUses ? 1 : 0;
|
||
|
||
// Invite links are for other people, so they carry the server's public
|
||
// address. Opened on the server machine itself, the page's own address
|
||
// is localhost, which only works there.
|
||
const origin = this._shareOrigin();
|
||
if (this._shareOriginAt === undefined) {
|
||
this._fetchShareOrigin().then(() => _renderInviteCodes(this._inviteCodes));
|
||
}
|
||
const localOnly = this._shareOriginValue === null &&
|
||
/^https?:\/\/(localhost|127\.0\.0\.1|\[?::1\]?)(:|$)/i.test(origin);
|
||
host.innerHTML = (localOnly
|
||
? `<p class="settings-hint invite-local-hint" style="margin:0 0 8px">${t('settings.admin.invite_links.local_address_hint')}</p>`
|
||
: '') + this._inviteCodes.map(ic => {
|
||
const status = !ic.enabled
|
||
? `<span style="color:var(--text-muted)">● ${t('settings.admin.invite_links.disabled')}</span>`
|
||
: ic.max_uses > 0 && ic.use_count >= ic.max_uses
|
||
? `<span style="color:var(--text-secondary,#9498b3)">● ${t('settings.admin.invite_links.used')}</span>`
|
||
: ic.is_expired
|
||
? `<span style="color:var(--danger,#e84a4a)">● ${t('settings.admin.invite_links.expired')}</span>`
|
||
: `<span style="color:var(--green,#43b581)">● ${t('settings.admin.invite_links.active')}</span>`;
|
||
const link = `${origin}/?invite=${encodeURIComponent(ic.code)}`;
|
||
const chCount = t(ic.channels.length === 1
|
||
? 'settings.admin.invite_links.channel_one'
|
||
: 'settings.admin.invite_links.channel_other', { count: ic.channels.length }
|
||
);
|
||
const uses = ic.max_uses > 0 ? `${ic.use_count} / ${ic.max_uses}` : `${ic.use_count}`;
|
||
const expiry = ic.expires_at ? this._fmtDateTime(ic.expires_at) : t('settings.admin.invite_links.never');
|
||
const label = ic.label ? this._escapeHtml(ic.label) : `<em style="opacity:.6">${t('settings.admin.invite_links.no_label')}</em>`;
|
||
const editorChannels = _inviteChannelChecks('invite-edit-channel-cb', new Set(ic.channels || []));
|
||
return `<div class="invite-code-card" data-id="${ic.id}" style="border:1px solid var(--border);border-radius:8px;padding:10px">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap">
|
||
<div style="font-weight:600">${label} <code style="font-size:.85rem">${this._escapeHtml(ic.code)}</code></div>
|
||
<div style="font-size:.8rem">${status}</div>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:6px;margin-top:6px">
|
||
<input type="text" readonly value="${this._escapeHtml(link)}" class="settings-text-input" style="flex:1;min-width:0;font-size:.8rem" data-role="invite-link">
|
||
<button class="btn-sm" data-act="copy" title="${t('settings.admin.invite_links.copy_link')}">📋</button>
|
||
</div>
|
||
<div style="font-size:.8rem;opacity:.8;margin-top:6px;display:flex;gap:12px;flex-wrap:wrap">
|
||
<span>${t('settings.admin.invite_links.grants')} ${chCount}</span><span>${t('settings.admin.invite_links.uses')} ${uses}</span><span>${t('settings.admin.invite_links.expires')} ${this._escapeHtml(expiry)}</span>
|
||
</div>
|
||
<div style="display:flex;gap:4px;margin-top:8px;flex-wrap:wrap">
|
||
<button class="btn-sm" data-act="toggle">${t(ic.enabled ? 'settings.admin.invite_links.disable' : 'settings.admin.invite_links.enable')}</button>
|
||
<button class="btn-sm" data-act="edit">${t('msg_toolbar.edit')}</button>
|
||
<button class="btn-sm" data-act="delete">${t('msg_toolbar.delete')}</button>
|
||
<button class="btn-sm" data-act="copy_card" title="${t('settings.admin.invite_links.copy_email_card')}" style="margin-left:auto">✉️</button>
|
||
</div>
|
||
<div class="invite-code-editor" style="display:none;margin-top:10px;padding-top:8px;border-top:1px dashed var(--border)">
|
||
<h6 style="margin:0 0 4px;font-size:.8rem;font-weight:600">${t('settings.admin.invite_links.channels_granted')}</h6>
|
||
<div class="invite-edit-channels" style="max-height:160px;overflow-y:auto;border:1px solid var(--border);border-radius:6px;padding:6px">${editorChannels}</div>
|
||
<div style="display:flex;gap:4px;margin-top:4px">
|
||
<button class="btn-sm" data-act="edit-all">${t('settings.admin.invite_links.select_all')}</button>
|
||
<button class="btn-sm" data-act="edit-none">${t('settings.admin.invite_links.select_none')}</button>
|
||
</div>
|
||
<label class="select-row" style="margin-top:8px"><span>${t('settings.admin.invite_links.max_uses')}</span><input type="number" min="${minUsesInput}" max="${maxUsesInput}" value="${ic.max_uses || 0}" class="settings-number-input" data-role="edit-maxuses"></label>
|
||
<label class="select-row" style="margin-top:4px"><span>${t('settings.admin.invite_links.reset_expiry')}</span>
|
||
<select class="settings-number-input" data-role="edit-expiry" style="width: 6.5rem;">
|
||
<option value="-1" selected>${t('settings.admin.invite_links.keep_current')}</option>
|
||
<option value="0">${t('settings.admin.invite_links.never')}</option>
|
||
<option value="1">${t('settings.admin.invite_links.after_hour')}</option>
|
||
<option value="24">${t('settings.admin.invite_links.after_day')}</option>
|
||
<option value="168">${t('settings.admin.invite_links.after_7_days')}</option>
|
||
<option value="720">${t('settings.admin.invite_links.after_30_days')}</option>
|
||
</select>
|
||
</label>
|
||
<div style="display:flex;gap:4px;margin-top:8px">
|
||
<button class="btn-sm btn-accent" data-act="save">${t('settings.admin.invite_links.save_changes')}</button>
|
||
<button class="btn-sm" data-act="cancel">${t('modals.common.cancel')}</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
};
|
||
this._renderInviteCodes = _renderInviteCodes;
|
||
|
||
this.socket.on('invite-codes-list', (list) => { _renderInviteCodes(list); });
|
||
|
||
// Create form: select all / none + create
|
||
document.getElementById('invite-new-channels-all')?.addEventListener('click', () => {
|
||
document.querySelectorAll('.invite-new-channel-cb').forEach(cb => { cb.checked = true; });
|
||
});
|
||
document.getElementById('invite-new-channels-none')?.addEventListener('click', () => {
|
||
document.querySelectorAll('.invite-new-channel-cb').forEach(cb => { cb.checked = false; });
|
||
});
|
||
document.getElementById('invite-create-btn')?.addEventListener('click', () => {
|
||
const label = document.getElementById('invite-new-label')?.value.trim() || '';
|
||
const cbs = Array.from(document.querySelectorAll('.invite-new-channel-cb'));
|
||
const total = cbs.length;
|
||
const picked = cbs.filter(cb => cb.checked).map(cb => parseInt(cb.dataset.cid)).filter(Number.isFinite);
|
||
// All checked → [] = "grant all public" (future-proof as new channels appear).
|
||
const channels = picked;
|
||
|
||
const maxUsesValue = document.getElementById('invite-new-maxuses')?.value;
|
||
const maxUses = maxUsesValue === '' ? 1 : parseInt(maxUsesValue);
|
||
|
||
const expiryValue = document.getElementById('invite-new-expiry')?.value;
|
||
const expiresInHours = expiryValue === '' ? 720 : parseInt(expiryValue);
|
||
|
||
const slug = document.getElementById('invite-new-slug')?.value.trim() || '';
|
||
const payload = { label, channels, maxUses, expiresInHours };
|
||
if (slug) payload.code = slug;
|
||
this.socket.emit('create-invite-code', payload);
|
||
// Reset the form fields (channel checks reset on the next list render).
|
||
const lblEl = document.getElementById('invite-new-label'); if (lblEl) lblEl.value = '';
|
||
const slugEl = document.getElementById('invite-new-slug'); if (slugEl) slugEl.value = '';
|
||
const muEl = document.getElementById('invite-new-maxuses'); if (muEl) muEl.value = '1';
|
||
const expEl = document.getElementById('invite-new-expiry'); if (expEl) expEl.value = '720';
|
||
});
|
||
|
||
// One delegated handler for all per-card actions (the list re-renders often).
|
||
document.getElementById('invite-codes-list')?.addEventListener('click', (e) => {
|
||
const btn = e.target.closest('button[data-act]');
|
||
if (!btn) return;
|
||
const card = e.target.closest('.invite-code-card');
|
||
if (!card) return;
|
||
const id = parseInt(card.dataset.id);
|
||
const act = btn.dataset.act;
|
||
const ic = (this._inviteCodes || []).find(x => x.id === id);
|
||
if (act === 'copy') {
|
||
const input = card.querySelector('[data-role="invite-link"]');
|
||
const val = input?.value || '';
|
||
if (!val) return;
|
||
const done = () => this._showToast?.(t('settings.admin.invite_links.copied'), 'success');
|
||
// navigator.clipboard.writeText() rejects (or fails silently in Electron's
|
||
// BrowserView) when the document isn't focused, so fall back to selecting
|
||
// the field and execCommand('copy'). Only toast success if a copy worked.
|
||
const fallback = () => {
|
||
try {
|
||
if (input) { input.focus(); input.select(); }
|
||
const ok = document.execCommand('copy');
|
||
if (input) input.setSelectionRange(0, 0);
|
||
if (ok) { done(); return; }
|
||
} catch { /* fall through */ }
|
||
this._showToast?.(t('settings.admin.invite_links.copy_manually'), 'info');
|
||
};
|
||
if (navigator.clipboard?.writeText) {
|
||
navigator.clipboard.writeText(val).then(done).catch(fallback);
|
||
} else {
|
||
fallback();
|
||
}
|
||
} else if (act === 'copy_card'){
|
||
this._copyInviteCard(card);
|
||
} else if (act === 'toggle') {
|
||
this.socket.emit('update-invite-code', { id, enabled: ic ? !ic.enabled : true });
|
||
} else if (act === 'delete') {
|
||
if (confirm(t('settings.admin.invite_links.delete_confirm', { code: ic?.code || id }))) {
|
||
this.socket.emit('delete-invite-code', { id });
|
||
}
|
||
} else if (act === 'edit') {
|
||
const ed = card.querySelector('.invite-code-editor');
|
||
if (ed) ed.style.display = ed.style.display === 'none' ? '' : 'none';
|
||
} else if (act === 'cancel') {
|
||
const ed = card.querySelector('.invite-code-editor');
|
||
if (ed) ed.style.display = 'none';
|
||
} else if (act === 'edit-all') {
|
||
card.querySelectorAll('.invite-edit-channel-cb').forEach(cb => { cb.checked = true; });
|
||
} else if (act === 'edit-none') {
|
||
card.querySelectorAll('.invite-edit-channel-cb').forEach(cb => { cb.checked = false; });
|
||
} else if (act === 'save') {
|
||
const cbs = Array.from(card.querySelectorAll('.invite-edit-channel-cb'));
|
||
const total = cbs.length;
|
||
const picked = cbs.filter(cb => cb.checked).map(cb => parseInt(cb.dataset.cid)).filter(Number.isFinite);
|
||
const channels = picked;
|
||
const maxUses = parseInt(card.querySelector('[data-role="edit-maxuses"]')?.value) || 0;
|
||
const payload = { id, channels, maxUses };
|
||
const exp = parseInt(card.querySelector('[data-role="edit-expiry"]')?.value);
|
||
if (Number.isFinite(exp) && exp >= 0) payload.expiresInHours = exp;
|
||
this.socket.emit('update-invite-code', payload);
|
||
}
|
||
});
|
||
|
||
// Invite Links popout: open/close. Refresh the list and create-form channels
|
||
// on open so the modal always reflects current state.
|
||
// Active sessions. Refreshed whenever the Account settings pane is opened
|
||
// rather than polled, since the list is only interesting while you look at it.
|
||
document.getElementById('revoke-sessions-btn')?.addEventListener('click', async () => {
|
||
const status = document.getElementById('sessions-status');
|
||
const pw = prompt(t('settings.sessions_section.confirm_prompt'));
|
||
if (pw === null) return; // cancelled
|
||
if (!pw) { status.textContent = t('settings.sessions_section.need_password'); return; }
|
||
status.classList.remove('error', 'success');
|
||
status.textContent = t('settings.sessions_section.working');
|
||
// Set before the request: the server disconnects every socket including
|
||
// ours, and this is what tells our own force-logout handler to sit still.
|
||
this._justRevokedSessions = true;
|
||
try {
|
||
const res = await fetch('/api/auth/revoke-sessions', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.token}` },
|
||
body: JSON.stringify({ password: pw })
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) {
|
||
this._justRevokedSessions = false;
|
||
status.textContent = data.error || t('settings.sessions_section.failed');
|
||
status.classList.add('error');
|
||
return;
|
||
}
|
||
this.token = data.token;
|
||
localStorage.setItem('haven_token', data.token);
|
||
this.socket.auth.token = data.token; // so the auto-reconnect authenticates
|
||
status.textContent = t('settings.sessions_section.done');
|
||
status.classList.add('success');
|
||
} catch {
|
||
this._justRevokedSessions = false;
|
||
status.textContent = t('settings.sessions_section.failed');
|
||
status.classList.add('error');
|
||
}
|
||
});
|
||
},
|
||
|
||
async _copyInviteCard(card) {
|
||
const input = card.querySelector('[data-role="invite-link"]');
|
||
const inviteUrl = input?.value || '';
|
||
if (!inviteUrl) return;
|
||
// The card is HTML pasted into mail: only an http(s) link goes in it.
|
||
let inviteHref;
|
||
try {
|
||
const parsed = new URL(inviteUrl);
|
||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return;
|
||
inviteHref = parsed.href;
|
||
} catch (err) {
|
||
console.warn('[invites] not copying a card for an unreadable link:', err.message);
|
||
return;
|
||
}
|
||
const esc = (v) => this._escapeHtml(String(v ?? ''));
|
||
|
||
// Find the invite code data for this card.
|
||
const id = parseInt(card.dataset.id, 10);
|
||
const invite = (this._inviteCodes || []).find(x => x.id === id);
|
||
|
||
// Server branding
|
||
const brandText = document.querySelector('.brand-text')?.textContent?.trim() || 'HAVEN';
|
||
const brandIcon = document.querySelector('.brand-icon');
|
||
const defaultLogo = document.querySelector('.logo-sm')?.textContent?.trim() || '⬡';
|
||
|
||
// Active theme
|
||
const themeElement = document.querySelector('[data-theme]') || document.documentElement;
|
||
const styles = getComputedStyle(themeElement);
|
||
const theme = name => styles.getPropertyValue(name).trim();
|
||
|
||
const bgCard = theme('--bg-card');
|
||
const accent = theme('--accent');
|
||
const accentText = theme('--accent-text') || '#fff';
|
||
const textPrimary = theme('--text-primary');
|
||
const textSecondary = theme('--text-secondary');
|
||
const textLink = theme('--text-link');
|
||
const border = theme('--border');
|
||
const radius = theme('--radius') || '8px';
|
||
const fontMain = theme('--font-main');
|
||
|
||
// Convert custom server icon to a self-contained data URL.
|
||
let iconSrc = '';
|
||
|
||
if (brandIcon?.src) {
|
||
try {
|
||
if (brandIcon.src.startsWith('data:')) {
|
||
iconSrc = brandIcon.src;
|
||
} else {
|
||
const response = await fetch(brandIcon.src);
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||
|
||
const blob = await response.blob();
|
||
iconSrc = await new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(reader.result);
|
||
reader.onerror = reject;
|
||
reader.readAsDataURL(blob);
|
||
});
|
||
}
|
||
} catch (err) {
|
||
console.warn('Failed to embed server icon:', err);
|
||
}
|
||
}
|
||
|
||
const iconHtml = iconSrc
|
||
? `<img src="${esc(iconSrc)}" alt="${esc(brandText)}" style="display:block;width:96px;height:96px;margin:0 auto 16px; border-radius:${radius};object-fit:contain">`
|
||
: `<div style="margin:0 auto 16px;font-size:84px;line-height:96px;color:${accent}">${esc(defaultLogo)}</div>`;
|
||
|
||
// Format invite expiration.
|
||
let expiryText = '';
|
||
|
||
if (invite?.expires_at) {
|
||
const expiryDate = new Date(invite.expires_at);
|
||
if (!Number.isNaN(expiryDate.getTime())) expiryText = this._fmtDateTime(expiryDate);
|
||
}
|
||
|
||
const invitedText = t('settings.admin.invite_links.card_invited', { server: brandText });
|
||
const registerText = t('settings.admin.invite_links.card_register');
|
||
const joinText = t('settings.admin.invite_links.card_join', { server: brandText });
|
||
const copyLinkText = t('settings.admin.invite_links.card_copy_link');
|
||
// The server name is set by an admin; in the HTML card it is text, not markup.
|
||
const invitedHtml = t('settings.admin.invite_links.card_invited', { server: esc(brandText) });
|
||
const joinHtml = t('settings.admin.invite_links.card_join', { server: esc(brandText) });
|
||
|
||
const expiryHtml = expiryText
|
||
? `<p style="margin:24px 0 0;padding-top:16px;border-top:1px solid ${border};color:${textSecondary}; font-size:13px">${t('settings.admin.invite_links.card_expires', { date: expiryText })}</p>`
|
||
: '';
|
||
|
||
const html = `
|
||
<div style="margin:0;padding:40px 20px;font-family:${fontMain};text-align:center">
|
||
<div style="max-width:600px;margin:0 auto;padding:32px 24px;box-sizing:border-box;background:${bgCard};
|
||
border:1px solid ${border};border-radius:${radius}">
|
||
${iconHtml}
|
||
<h2 style="margin:0 0 16px;font-family:${fontMain};font-size:24px;color:${textPrimary}">
|
||
${invitedHtml}
|
||
</h2>
|
||
<p style="margin:0 0 24px;color:${textSecondary};font-size:16px">
|
||
${registerText}
|
||
</p>
|
||
<a href="${esc(inviteHref)}" style="display:inline-block;padding:12px 24px;background:${accent};color:${accentText};
|
||
text-decoration:none;border-radius:${radius};font-size:16px;font-weight:bold">
|
||
${joinHtml}
|
||
</a>
|
||
<p style="margin:24px 0 8px;color:${textSecondary};font-size:14px">${copyLinkText}</p>
|
||
<p style="margin:0;word-break:break-all;font-size:14px">
|
||
<a href="${esc(inviteHref)}" style="color:${textLink};text-decoration:none">${esc(inviteUrl)}</a>
|
||
</p>
|
||
${expiryHtml}
|
||
</div>
|
||
</div>`;
|
||
|
||
const text = `${invitedText}
|
||
|
||
${registerText}
|
||
|
||
${joinText}:
|
||
${inviteUrl}${expiryText ? `
|
||
|
||
${t('settings.admin.invite_links.card_expires', { date: expiryText })}` : ''}`;
|
||
|
||
try {
|
||
if (!navigator.clipboard?.write) throw new Error('HTML clipboard API unavailable');
|
||
|
||
await navigator.clipboard.write([
|
||
new ClipboardItem({
|
||
'text/html': new Blob([html], { type: 'text/html' }),
|
||
'text/plain': new Blob([text], { type: 'text/plain' })
|
||
})
|
||
]);
|
||
|
||
this._showToast?.(t('settings.admin.invite_links.email_card_copied'), 'success');
|
||
} catch (err) {
|
||
console.warn('Failed to copy HTML email:', err);
|
||
this._showToast?.(t('settings.admin.invite_links.email_card_copy_failed'), 'info');
|
||
}
|
||
},
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// INVITE LINKS
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
// Run the configured STUN/TURN through a real ICE gathering and say, in words
|
||
// an admin can act on, what works and what does not.
|
||
//
|
||
// Every voice thread has run the same course: it fails for users, the admin has
|
||
// no way to see why, and it only gets solved when someone walks them through a
|
||
// third-party ICE test page and reads the candidate list back to them. The
|
||
// browser knows all of this the moment it gathers candidates. #5542 cost three
|
||
// people several days between them, and the answer in the end was a mistyped
|
||
// port that this would have named in ten seconds.
|
||
async _runConnectivityTest() {
|
||
const btn = document.getElementById('test-connectivity-btn');
|
||
const box = document.getElementById('connectivity-test-result');
|
||
if (!btn || !box || !this.voice) return;
|
||
|
||
const line = (icon, text, muted) =>
|
||
`<div style="display:flex;gap:6px;align-items:flex-start;margin:3px 0${muted ? ';opacity:0.75' : ''}">` +
|
||
`<span class="connectivity-test-icon" style="flex:none">${icon}</span><span>${text}</span></div>`;
|
||
|
||
btn.disabled = true;
|
||
box.style.display = '';
|
||
box.innerHTML = `<small class="settings-hint">${t('settings.admin.test_connectivity_running')}</small>`;
|
||
|
||
try {
|
||
// Deliberately the live endpoint rather than the values in the boxes, so
|
||
// this tests what users are actually handed, including unsaved edits being
|
||
// absent. Saying "save first" in the hint is cheaper than guessing here.
|
||
const res = await fetch('/api/ice-servers', {
|
||
headers: { Authorization: `Bearer ${localStorage.getItem('haven_token')}` }
|
||
});
|
||
if (!res.ok) throw new Error(t('settings.admin.test_server_list_failed'));
|
||
const cfg = await res.json();
|
||
const report = await this.voice.diagnoseConnectivity(cfg.iceServers || []);
|
||
|
||
const out = [];
|
||
|
||
if (!report.stunTotal && !report.turnTotal) {
|
||
out.push(line('⚠️', t('settings.admin.test_none_configured')));
|
||
}
|
||
|
||
// Headline first: can two people on different networks reach each other.
|
||
if (report.hasRelay) {
|
||
out.push(line('✅', t('settings.admin.test_ok_relay')));
|
||
} else if (report.canCrossNetworks) {
|
||
out.push(line('✅', t('settings.admin.test_ok_stun')));
|
||
} else if (report.stunTotal || report.turnTotal) {
|
||
out.push(line('❌', t('settings.admin.test_fail_nothing')));
|
||
}
|
||
|
||
// Then the specifics, naming each server, because "one of them is wrong"
|
||
// is the part that takes days to find by hand.
|
||
for (const r of report.results) {
|
||
const name = this._escapeHtml(r.urls);
|
||
const why = r.error ? ` <span style="opacity:0.8">(${this._escapeHtml(r.error)})</span>` : '';
|
||
if (r.isTurn) {
|
||
out.push(r.relay
|
||
? line('✅', t('settings.admin.test_turn_ok', { server: name }), true)
|
||
: line('❌', t('settings.admin.test_turn_dead', { server: name }) + why));
|
||
} else {
|
||
out.push(r.srflx
|
||
? line('✅', t('settings.admin.test_stun_ok', { server: name }), true)
|
||
: line('❌', t('settings.admin.test_stun_dead', { server: name }) + why));
|
||
}
|
||
}
|
||
|
||
// Advice, only where it applies.
|
||
if (report.deadTurn.length) {
|
||
out.push(line('💡', t('settings.admin.test_tip_turn')));
|
||
} else if (report.canCrossNetworks && !report.turnTotal) {
|
||
out.push(line('💡', t('settings.admin.test_tip_no_turn')));
|
||
}
|
||
if (report.deadStun.length && report.stunLive) {
|
||
out.push(line('💡', t('settings.admin.test_tip_partial')));
|
||
}
|
||
|
||
// The caveat that actually bit people: this ran from wherever the admin is
|
||
// sitting. A server reachable only on the LAN passes here and fails for
|
||
// everyone else, which is precisely how #5542 stayed hidden for days.
|
||
out.push(line('ℹ️', t('settings.admin.test_caveat'), true));
|
||
|
||
box.innerHTML = `<div style="font-size:0.8125rem;line-height:1.45">${out.join('')}</div>`;
|
||
} catch (err) {
|
||
box.innerHTML = `<small class="settings-hint">${this._escapeHtml(t('settings.admin.test_connectivity_failed', { error: err.message || t('settings.admin.unknown_error') }))}</small>`;
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
},
|
||
|
||
_openInviteLinksModal() {
|
||
const modal = document.getElementById('invite-links-modal');
|
||
if (!modal) return;
|
||
if (!this.user.isAdmin && !this._hasGlobalPerm('manage_server') && !this._hasGlobalPerm('invite_users')) return this._showToast(t('settings.admin.invite_links_no_permission'), 'error');
|
||
if (typeof this._renderInviteCreateChannels === 'function') {
|
||
try { this._renderInviteCreateChannels(true); } 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); } }
|
||
modal.style.display = 'flex';
|
||
|
||
// Setup max invite uses input limits.
|
||
// admin and manage_server roles exempt from limitation.
|
||
const parsedMaxInvtUses = parseInt(this.serverSettings?.max_invite_uses, 10);
|
||
const maxInvtUses = Number.isNaN(parsedMaxInvtUses) ? 0 : parsedMaxInvtUses;
|
||
const restrictUses = !this.user?.isAdmin && !this._hasPerm('manage_server') && maxInvtUses > 0;
|
||
const maxUsesInput = document.getElementById('invite-new-maxuses');
|
||
if (maxUsesInput) {
|
||
maxUsesInput.value = 1;
|
||
maxUsesInput.min = restrictUses ? 1 : 0;
|
||
maxUsesInput.max = restrictUses ? maxInvtUses : 100000;
|
||
}
|
||
},
|
||
|
||
};
|