added session revocation and settings menu for security measures

This commit is contained in:
2026-06-03 23:10:26 +02:00
parent ffe6d04d75
commit d80a8de559
12 changed files with 682 additions and 5 deletions

View File

@ -1,7 +1,9 @@
import { apiGet, apiPost, apiDownloadFetch } from '../api.js';
import { apiGet, apiPost, apiPut, apiDownloadFetch, redirectToLogin } from '../api.js';
import { getRole, pageHeaderHTML, showToast } from '../app.js';
import { navigate } from '../router.js';
const REVOKE_ALL_CONFIRM = 'REVOKE ALL SESSIONS';
const ACTIVITY_LABELS = {
app_sync: 'App sync',
app_change: 'App change',
@ -20,6 +22,32 @@ export function devPage() {
const today = new Date().toISOString().slice(0, 10);
app.innerHTML = `
${pageHeaderHTML('Admin Settings')}
<div class="card security-settings-card" style="max-width:720px;margin-bottom:16px">
<h2 style="margin:0 0 8px;font-size:1.1rem">Security &amp; sessions</h2>
<p class="field-hint" style="margin:0 0 14px">
Login rate limits apply per username and IP. Session length applies to new logins
(website and mobile). Password-change tokens use the temporary session length.
</p>
<div id="securitySettingsForm" class="security-settings-form">
<div class="spinner" style="margin:12px 0"></div>
</div>
<p id="securitySettingsMeta" class="field-hint" style="margin:12px 0 0"></p>
<div class="security-revoke-all" style="margin-top:20px;padding-top:16px;border-top:1px solid var(--border)">
<h3 style="margin:0 0 8px;font-size:1rem">Revoke all sessions</h3>
<p class="field-hint callout-danger" style="margin:0 0 12px">
<strong>Signs out everyone</strong> — all admins, supervisors, and coaches on every device.
You will be logged out too and must sign in again. Use after a suspected compromise or policy change.
</p>
<label for="revokeAllConfirm" style="font-size:.85rem;font-weight:500;display:block;margin-bottom:6px">
Type <code>${REVOKE_ALL_CONFIRM}</code> to confirm
</label>
<input type="text" id="revokeAllConfirm" class="revoke-confirm-input" autocomplete="off" spellcheck="false"
placeholder="${REVOKE_ALL_CONFIRM}">
<button type="button" class="btn btn-danger" id="revokeAllSessionsBtn" style="margin-top:10px" disabled>
Revoke all sessions
</button>
</div>
</div>
<div class="card" style="max-width:720px;margin-bottom:16px">
<h2 style="margin:0 0 8px;font-size:1.1rem">Export all response data (ZIP)</h2>
<p class="field-hint" style="margin:0 0 14px">
@ -216,6 +244,7 @@ export function devPage() {
wireAdminZipExport();
wireActivityLog();
wireSecuritySettings();
document.getElementById('devImportBtn').addEventListener('click', async () => {
if (!parsedFixture) return;
@ -488,3 +517,122 @@ function wireAdminZipExport() {
}
});
}
function wireSecuritySettings() {
const formEl = document.getElementById('securitySettingsForm');
const metaEl = document.getElementById('securitySettingsMeta');
const revokeInput = document.getElementById('revokeAllConfirm');
const revokeBtn = document.getElementById('revokeAllSessionsBtn');
if (!formEl) return;
revokeInput?.addEventListener('input', () => {
if (revokeBtn) {
revokeBtn.disabled = revokeInput.value.trim() !== REVOKE_ALL_CONFIRM;
}
});
revokeBtn?.addEventListener('click', async () => {
if (revokeInput.value.trim() !== REVOKE_ALL_CONFIRM) return;
if (!confirm(
'Revoke every active session?\n\n'
+ 'All users (including you) will be signed out on website and mobile.'
)) {
return;
}
revokeBtn.disabled = true;
try {
const data = await apiPost('settings', {
action: 'revokeAllSessions',
confirmPhrase: REVOKE_ALL_CONFIRM,
});
if (!data.success) throw new Error(data.error || 'Failed');
showToast(`Revoked ${data.revokedSessions ?? 0} session(s). Signing you out…`, 'success');
revokeInput.value = '';
setTimeout(() => redirectToLogin(), 800);
} catch (err) {
showToast(err.message, 'error');
revokeBtn.disabled = revokeInput.value.trim() !== REVOKE_ALL_CONFIRM;
}
});
loadSecuritySettings(formEl, metaEl);
}
async function loadSecuritySettings(formEl, metaEl) {
try {
const data = await apiGet('settings');
if (!data.success) throw new Error(data.error || 'Failed to load settings');
const s = data.settings || {};
const active = data.activeSessions ?? 0;
if (metaEl) {
metaEl.textContent = `${active} active session(s) in the database.`;
}
formEl.innerHTML = `
<div class="security-settings-grid">
<div class="form-group">
<label for="setLoginMax">Max failed logins</label>
<input type="number" id="setLoginMax" min="1" max="100" step="1"
value="${escAttr(s.login_max_attempts)}">
<span class="field-hint">Before lockout (per username + IP)</span>
</div>
<div class="form-group">
<label for="setLoginWindow">Login attempt window (minutes)</label>
<input type="number" id="setLoginWindow" min="1" max="1440" step="1"
value="${escAttr(Math.round((s.login_window_seconds || 900) / 60))}">
</div>
<div class="form-group">
<label for="setLoginLockout">Lockout duration (minutes)</label>
<input type="number" id="setLoginLockout" min="1" max="1440" step="1"
value="${escAttr(Math.round((s.login_lockout_seconds || 900) / 60))}">
</div>
<div class="form-group">
<label for="setSessionDays">Session length (days)</label>
<input type="number" id="setSessionDays" min="1" max="365" step="1"
value="${escAttr(Math.max(1, Math.round((s.session_ttl_seconds || 2592000) / 86400)))}">
<span class="field-hint">Website &amp; app after normal login</span>
</div>
<div class="form-group">
<label for="setTempSession">Temporary session (minutes)</label>
<input type="number" id="setTempSession" min="5" max="1440" step="1"
value="${escAttr(Math.round((s.temp_session_ttl_seconds || 600) / 60))}">
<span class="field-hint">Forced password change flow</span>
</div>
</div>
<button type="button" class="btn btn-primary" id="saveSecuritySettingsBtn">Save security settings</button>
`;
document.getElementById('saveSecuritySettingsBtn')?.addEventListener('click', () => {
saveSecuritySettings(formEl, metaEl);
});
} catch (err) {
formEl.innerHTML = `<p class="error-text">${escHtml(err.message)}</p>`;
}
}
async function saveSecuritySettings(formEl, metaEl) {
const btn = document.getElementById('saveSecuritySettingsBtn');
const payload = {
login_max_attempts: parseInt(document.getElementById('setLoginMax')?.value || '10', 10),
login_window_seconds: parseInt(document.getElementById('setLoginWindow')?.value || '15', 10) * 60,
login_lockout_seconds: parseInt(document.getElementById('setLoginLockout')?.value || '15', 10) * 60,
session_ttl_seconds: parseInt(document.getElementById('setSessionDays')?.value || '30', 10) * 86400,
temp_session_ttl_seconds: parseInt(document.getElementById('setTempSession')?.value || '10', 10) * 60,
};
if (btn) btn.disabled = true;
try {
const data = await apiPut('settings', payload);
if (!data.success) throw new Error(data.error || 'Save failed');
showToast('Security settings saved', 'success');
await loadSecuritySettings(formEl, metaEl);
} catch (err) {
showToast(err.message, 'error');
} finally {
if (btn) btn.disabled = false;
}
}
function escAttr(v) {
return String(v ?? '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;');
}