added session revocation and settings menu for security measures
This commit is contained in:
@ -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 & 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 & 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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user