added session revocation and settings menu for security measures
This commit is contained in:
@ -328,6 +328,30 @@ code {
|
||||
.badge-activity-web_change { background: var(--badge-supervisor-bg); color: var(--badge-supervisor-fg); }
|
||||
.badge-activity-web_export { background: var(--badge-admin-bg); color: var(--badge-admin-fg); }
|
||||
.badge-activity-api_error { background: var(--badge-archived-bg); color: var(--badge-archived-fg); }
|
||||
.security-settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 14px 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.security-settings-grid .form-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.revoke-confirm-input {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
}
|
||||
.user-actions-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.user-actions-cell .btn + .btn {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.activity-log-card .table-wrapper { max-height: 420px; overflow: auto; }
|
||||
.activity-log-card .filter-bar label {
|
||||
margin: 0;
|
||||
|
||||
@ -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, '<');
|
||||
}
|
||||
|
||||
@ -83,7 +83,15 @@ export async function loginPage() {
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.ok) {
|
||||
errEl.textContent = json.error?.message || 'Login failed';
|
||||
const code = json.error?.code || '';
|
||||
let msg = json.error?.message || 'Login failed';
|
||||
if (res.status === 429 || code === 'RATE_LIMITED') {
|
||||
const retry = res.headers.get('Retry-After');
|
||||
if (retry) {
|
||||
msg += ` Try again in about ${retry} seconds.`;
|
||||
}
|
||||
}
|
||||
errEl.textContent = msg;
|
||||
errEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
@ -230,6 +230,9 @@ function updateRoleGroupTable(roleKey, myUsername) {
|
||||
role: btn.dataset.role || '',
|
||||
}));
|
||||
});
|
||||
tbody.querySelectorAll('.revoke-sessions-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => revokeUserSessions(btn.dataset.id, btn.dataset.name));
|
||||
});
|
||||
tbody.querySelectorAll('.coach-supervisor-select').forEach(sel => {
|
||||
sel.addEventListener('change', () => reassignCoachSupervisor(sel));
|
||||
});
|
||||
@ -276,6 +279,10 @@ function userRowHTML(u, myUsername) {
|
||||
(callerRole === 'admin' && (u.role === 'supervisor' || u.role === 'coach')) ||
|
||||
(callerRole === 'supervisor' && u.role === 'coach')
|
||||
);
|
||||
const canRevokeSessions = !isSelf && (
|
||||
callerRole === 'admin' ||
|
||||
(callerRole === 'supervisor' && u.role === 'coach')
|
||||
);
|
||||
|
||||
const actions = [];
|
||||
if (canResetPassword) {
|
||||
@ -283,6 +290,11 @@ function userRowHTML(u, myUsername) {
|
||||
`<button type="button" class="btn btn-sm reset-password-btn" data-id="${u.userID}" data-name="${esc(u.username)}" data-role="${esc(u.role)}">Reset password</button>`
|
||||
);
|
||||
}
|
||||
if (canRevokeSessions) {
|
||||
actions.push(
|
||||
`<button type="button" class="btn btn-sm revoke-sessions-btn" data-id="${u.userID}" data-name="${esc(u.username)}">Sign out everywhere</button>`
|
||||
);
|
||||
}
|
||||
if (canDelete) {
|
||||
actions.push(
|
||||
`<button type="button" class="btn btn-sm btn-danger delete-user-btn" data-id="${u.userID}" data-name="${esc(u.username)}">Delete</button>`
|
||||
@ -330,6 +342,23 @@ async function reassignCoachSupervisor(selectEl) {
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeUserSessions(userID, username) {
|
||||
if (!confirm(
|
||||
`Sign out "${username}" on all devices?\n\n`
|
||||
+ 'They must log in again on the website and mobile app.'
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await apiPut('users.php', { action: 'revokeSessions', userID });
|
||||
if (!data.success) throw new Error(data.error || 'Failed');
|
||||
const n = data.revokedSessions ?? 0;
|
||||
showToast(`Signed out "${username}" (${n} session${n === 1 ? '' : 's'} revoked)`, 'success');
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(userID, username) {
|
||||
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user