From d80a8de559832a4b5d56c1b432c895c8641983a8 Mon Sep 17 00:00:00 2001 From: Tom Hempel Date: Wed, 3 Jun 2026 23:10:26 +0200 Subject: [PATCH] added session revocation and settings menu for security measures --- api/index.php | 1 + common.php | 20 +++++ db_init.php | 10 +++ handlers/auth.php | 23 ++++- handlers/settings.php | 71 +++++++++++++++ handlers/users.php | 59 +++++++++++++ lib/login_rate_limit.php | 176 ++++++++++++++++++++++++++++++++++++++ lib/settings.php | 114 ++++++++++++++++++++++++ website/css/style.css | 24 ++++++ website/js/pages/dev.js | 150 +++++++++++++++++++++++++++++++- website/js/pages/login.js | 10 ++- website/js/pages/users.js | 29 +++++++ 12 files changed, 682 insertions(+), 5 deletions(-) create mode 100644 handlers/settings.php create mode 100644 lib/login_rate_limit.php create mode 100644 lib/settings.php diff --git a/api/index.php b/api/index.php index 9a6a554..6deeb51 100644 --- a/api/index.php +++ b/api/index.php @@ -57,6 +57,7 @@ $routes = [ 'dev' => __DIR__ . '/../handlers/dev.php', 'dev/import' => __DIR__ . '/../handlers/dev.php', 'activity-log' => __DIR__ . '/../handlers/activity_log.php', + 'settings' => __DIR__ . '/../handlers/settings.php', ]; try { diff --git a/common.php b/common.php index 7657791..2978646 100644 --- a/common.php +++ b/common.php @@ -223,6 +223,26 @@ function token_revoke_all_for_user(string $userID): int { return $removed; } +/** Revoke every stored session (all users and devices). */ +function token_revoke_all_on_pdo(PDO $pdo): int +{ + if (!qdb_table_exists($pdo, 'session')) { + return 0; + } + $count = (int)$pdo->query('SELECT COUNT(*) FROM session')->fetchColumn(); + $pdo->exec('DELETE FROM session'); + return $count; +} + +function token_revoke_all(): int +{ + require_once __DIR__ . '/db_init.php'; + [$pdo, $tmpDb, $lockFp] = qdb_open(true); + $removed = token_revoke_all_on_pdo($pdo); + qdb_save($tmpDb, $lockFp); + return $removed; +} + // --- Auth helpers for endpoints --- function get_bearer_token(): ?string { $auth = $_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['Authorization'] ?? ''); diff --git a/db_init.php b/db_init.php index 5fdd831..7444729 100644 --- a/db_init.php +++ b/db_init.php @@ -109,6 +109,16 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool { $changed = true; } + if (!qdb_table_exists($pdo, 'system_setting')) { + $pdo->exec(" + CREATE TABLE system_setting ( + settingKey TEXT NOT NULL PRIMARY KEY, + settingValue TEXT NOT NULL DEFAULT '' + ) + "); + $changed = true; + } + if (qdb_table_exists($pdo, 'questionnaire_submission')) { require_once __DIR__ . '/lib/submissions.php'; if (qdb_backfill_submissions_from_live($pdo)) { diff --git a/handlers/auth.php b/handlers/auth.php index 431f009..9bcb302 100644 --- a/handlers/auth.php +++ b/handlers/auth.php @@ -1,5 +1,8 @@ prepare( @@ -26,9 +35,15 @@ case 'auth/login': if (!$user || !password_verify($password, $user['passwordHash'])) { qdb_discard($tmpDb, $lockFp); + $retryAfter = qdb_login_rate_limit_record_failure($username, $securitySettings); + if ($retryAfter > 0) { + qdb_login_rate_limit_deny($retryAfter); + } json_error('INVALID_CREDENTIALS', 'Invalid username or password', 401); } + qdb_login_rate_limit_clear($username); + $assignedClients = []; if (($user['role'] ?? '') === 'coach' && ($user['entityID'] ?? '') !== '') { $cStmt = $pdo->prepare( @@ -47,7 +62,7 @@ case 'auth/login': if ((int)$user['mustChangePassword'] === 1) { $tempToken = bin2hex(random_bytes(32)); - token_add($tempToken, 10 * 60, [ + token_add($tempToken, qdb_session_ttl_seconds($securitySettings, true), [ 'role' => $user['role'], 'entityID' => $user['entityID'], 'userID' => $user['userID'], @@ -62,7 +77,7 @@ case 'auth/login': } $token = bin2hex(random_bytes(32)); - token_add($token, 30 * 24 * 60 * 60, [ + token_add($token, qdb_session_ttl_seconds($securitySettings, false), [ 'role' => $user['role'], 'entityID' => $user['entityID'], 'userID' => $user['userID'], @@ -149,9 +164,11 @@ case 'auth/change-password': qdb_save($tmpDb, $lockFp); + $securitySettings = qdb_settings_get(); + // Issue a fresh session for this browser only $newToken = bin2hex(random_bytes(32)); - token_add($newToken, 30 * 24 * 60 * 60, [ + token_add($newToken, qdb_session_ttl_seconds($securitySettings, false), [ 'role' => $user['role'], 'entityID' => $user['entityID'], 'userID' => $user['userID'], diff --git a/handlers/settings.php b/handlers/settings.php new file mode 100644 index 0000000..a489adc --- /dev/null +++ b/handlers/settings.php @@ -0,0 +1,71 @@ +query('SELECT COUNT(*) FROM session')->fetchColumn(); + qdb_discard($tmpDb, $lockFp); + json_success([ + 'settings' => $settings, + 'activeSessions' => $sessionCount, + 'defaults' => qdb_settings_defaults(), + ]); + } catch (Throwable $e) { + qdb_handler_fail($e, 'Load settings', null, $tmpDb ?? null, $lockFp ?? null); + } + break; + +case 'PUT': +case 'PATCH': + $body = read_json_body(); + try { + [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); + $current = qdb_settings_get_on_pdo($pdo); + $merged = qdb_settings_validate_and_merge($body, $current); + qdb_settings_save_on_pdo($pdo, $merged); + qdb_save($tmpDb, $lockFp); + json_success(['settings' => $merged]); + } catch (InvalidArgumentException $e) { + json_error('INVALID_FIELD', $e->getMessage(), 400); + } catch (Throwable $e) { + qdb_handler_fail($e, 'Save settings', null, $tmpDb ?? null, $lockFp ?? null); + } + break; + +case 'POST': + $body = read_json_body(); + $action = trim((string)($body['action'] ?? '')); + if ($action !== 'revokeAllSessions') { + json_error('BAD_REQUEST', 'Unknown action', 400); + } + $confirm = trim((string)($body['confirmPhrase'] ?? '')); + if ($confirm !== QDB_REVOKE_ALL_CONFIRM) { + json_error( + 'CONFIRMATION_REQUIRED', + 'Type exactly "' . QDB_REVOKE_ALL_CONFIRM . '" to revoke every session', + 400 + ); + } + try { + [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); + $removed = token_revoke_all_on_pdo($pdo); + qdb_save($tmpDb, $lockFp); + json_success(['revokedSessions' => $removed]); + } catch (Throwable $e) { + qdb_handler_fail($e, 'Revoke all sessions', null, $tmpDb ?? null, $lockFp ?? null); + } + break; + +default: + json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); +} diff --git a/handlers/users.php b/handlers/users.php index 72e33f8..18b153f 100644 --- a/handlers/users.php +++ b/handlers/users.php @@ -167,8 +167,67 @@ case 'PUT': case 'PATCH': $body = read_json_body(); $targetUserID = trim($body['userID'] ?? ''); + $action = trim((string)($body['action'] ?? '')); $newPassword = (string)($body['password'] ?? $body['newPassword'] ?? ''); + if ($action === 'revokeSessions') { + if ($targetUserID === '') { + json_error('MISSING_FIELDS', 'userID is required', 400); + } + if ($targetUserID === ($tokenRec['userID'] ?? '')) { + json_error('FORBIDDEN', 'Use Admin settings to revoke your own other sessions, or sign out', 400); + } + + try { + [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); + + $row = $pdo->prepare('SELECT userID, username, role, entityID FROM users WHERE userID = :uid'); + $row->execute([':uid' => $targetUserID]); + $target = $row->fetch(PDO::FETCH_ASSOC); + + if (!$target) { + qdb_discard($tmpDb, $lockFp); + json_error('NOT_FOUND', 'User not found', 404); + } + + $targetRole = $target['role'] ?? ''; + if ($callerRole === 'admin') { + if (!in_array($targetRole, ['admin', 'supervisor', 'coach'], true)) { + qdb_discard($tmpDb, $lockFp); + json_error('FORBIDDEN', 'Cannot revoke sessions for this user', 403); + } + } elseif ($callerRole === 'supervisor') { + if ($targetRole !== 'coach') { + qdb_discard($tmpDb, $lockFp); + json_error('FORBIDDEN', 'Supervisors may only revoke sessions for their coaches', 403); + } + $chk = $pdo->prepare( + 'SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :svid' + ); + $chk->execute([':cid' => $target['entityID'], ':svid' => $callerEntityID]); + if (!$chk->fetch()) { + qdb_discard($tmpDb, $lockFp); + json_error('FORBIDDEN', 'Coach is not under your supervision', 403); + } + } else { + qdb_discard($tmpDb, $lockFp); + json_error('FORBIDDEN', 'Not allowed to revoke sessions', 403); + } + + $removed = token_revoke_all_for_user_on_pdo($pdo, $targetUserID); + qdb_save($tmpDb, $lockFp); + + json_success([ + 'userID' => $targetUserID, + 'username' => $target['username'], + 'revokedSessions' => $removed, + ]); + } catch (Throwable $e) { + qdb_handler_fail($e, 'Revoke user sessions', null, $tmpDb ?? null, $lockFp ?? null); + } + break; + } + if ($newPassword !== '') { if ($targetUserID === '') { json_error('MISSING_FIELDS', 'userID is required', 400); diff --git a/lib/login_rate_limit.php b/lib/login_rate_limit.php new file mode 100644 index 0000000..6533e56 --- /dev/null +++ b/lib/login_rate_limit.php @@ -0,0 +1,176 @@ + [], 'locked_until' => 0]; + $lockedUntil = (int)($entry['locked_until'] ?? 0); + if ($lockedUntil > $now) { + return [false, $lockedUntil - $now]; + } + $attempts = array_values(array_filter( + (array)($entry['attempts'] ?? []), + static fn($t) => is_int($t) && $t > $now - $window + )); + if (count($attempts) >= $max) { + $entry['locked_until'] = $now + $lockout; + $entry['attempts'] = $attempts; + $data[$key] = $entry; + ftruncate($fp, 0); + rewind($fp); + fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE)); + fflush($fp); + return [false, $lockout]; + } + return [true, 0]; + } finally { + flock($fp, LOCK_UN); + fclose($fp); + } +} + +function qdb_login_rate_limit_record_failure(string $username, ?array $settings = null): int +{ + $settings ??= qdb_settings_get(); + $max = (int)$settings['login_max_attempts']; + $window = (int)$settings['login_window_seconds']; + $lockout = (int)$settings['login_lockout_seconds']; + $now = time(); + $key = qdb_login_rate_limit_key($username); + $path = qdb_login_rate_limit_path(); + $dir = dirname($path); + if (!is_dir($dir)) { + @mkdir($dir, 0755, true); + } + + $fp = @fopen($path, 'c+'); + if ($fp === false) { + return 0; + } + try { + if (!flock($fp, LOCK_EX)) { + return 0; + } + $raw = stream_get_contents($fp); + $data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : []; + if (!is_array($data)) { + $data = []; + } + $entry = $data[$key] ?? ['attempts' => [], 'locked_until' => 0]; + $attempts = array_values(array_filter( + (array)($entry['attempts'] ?? []), + static fn($t) => is_int($t) && $t > $now - $window + )); + $attempts[] = $now; + $retry = 0; + if (count($attempts) >= $max) { + $entry['locked_until'] = $now + $lockout; + $retry = $lockout; + } + $entry['attempts'] = $attempts; + $data[$key] = $entry; + ftruncate($fp, 0); + rewind($fp); + fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE)); + fflush($fp); + return $retry; + } finally { + flock($fp, LOCK_UN); + fclose($fp); + } +} + +function qdb_login_rate_limit_clear(string $username): void +{ + $key = qdb_login_rate_limit_key($username); + $path = qdb_login_rate_limit_path(); + if (!is_file($path)) { + return; + } + $fp = @fopen($path, 'c+'); + if ($fp === false) { + return; + } + try { + if (!flock($fp, LOCK_EX)) { + return; + } + $raw = stream_get_contents($fp); + $data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : []; + if (!is_array($data) || !isset($data[$key])) { + return; + } + unset($data[$key]); + ftruncate($fp, 0); + rewind($fp); + fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE)); + fflush($fp); + } finally { + flock($fp, LOCK_UN); + fclose($fp); + } +} + +function qdb_login_rate_limit_deny(int $retryAfterSeconds): never +{ + if ($retryAfterSeconds > 0) { + header('Retry-After: ' . (string)$retryAfterSeconds); + } + json_error( + 'RATE_LIMITED', + 'Too many login attempts. Please wait before trying again.', + 429 + ); +} diff --git a/lib/settings.php b/lib/settings.php new file mode 100644 index 0000000..6d644d6 --- /dev/null +++ b/lib/settings.php @@ -0,0 +1,114 @@ + 10, + 'login_window_seconds' => 900, + 'login_lockout_seconds' => 900, + 'session_ttl_seconds' => 30 * 24 * 60 * 60, + 'temp_session_ttl_seconds' => 10 * 60, + ]; +} + +function qdb_settings_keys(): array +{ + return array_keys(qdb_settings_defaults()); +} + +function qdb_ensure_system_setting_table(PDO $pdo): void +{ + if (!qdb_table_exists($pdo, 'system_setting')) { + $pdo->exec(" + CREATE TABLE system_setting ( + settingKey TEXT NOT NULL PRIMARY KEY, + settingValue TEXT NOT NULL DEFAULT '' + ) + "); + } +} + +function qdb_settings_get_on_pdo(PDO $pdo): array +{ + qdb_ensure_system_setting_table($pdo); + $out = qdb_settings_defaults(); + $rows = $pdo->query('SELECT settingKey, settingValue FROM system_setting')->fetchAll(PDO::FETCH_ASSOC); + foreach ($rows as $row) { + $key = (string)($row['settingKey'] ?? ''); + if ($key === '' || !array_key_exists($key, $out)) { + continue; + } + $out[$key] = (int)$row['settingValue']; + } + return $out; +} + +function qdb_settings_get(): array +{ + require_once __DIR__ . '/../db_init.php'; + [$pdo, $tmpDb, $lockFp] = qdb_open(false); + try { + return qdb_settings_get_on_pdo($pdo); + } finally { + qdb_discard($tmpDb, $lockFp); + } +} + +/** + * @param array $partial + * @param array|null $base existing settings; defaults used when null + * @return array saved settings + */ +function qdb_settings_validate_and_merge(array $partial, ?array $base = null): array +{ + $merged = $base ?? qdb_settings_defaults(); + $limits = [ + 'login_max_attempts' => [1, 100], + 'login_window_seconds' => [60, 86400], + 'login_lockout_seconds' => [60, 86400], + 'session_ttl_seconds' => [3600, 365 * 24 * 60 * 60], + 'temp_session_ttl_seconds' => [300, 24 * 60 * 60], + ]; + foreach (qdb_settings_keys() as $key) { + if (!array_key_exists($key, $partial)) { + continue; + } + $val = (int)$partial[$key]; + [$min, $max] = $limits[$key]; + if ($val < $min || $val > $max) { + throw new InvalidArgumentException( + "$key must be between $min and $max" + ); + } + $merged[$key] = $val; + } + return $merged; +} + +/** + * @param array $settings full merged settings to persist + */ +function qdb_settings_save_on_pdo(PDO $pdo, array $settings): void +{ + qdb_ensure_system_setting_table($pdo); + $stmt = $pdo->prepare( + 'INSERT INTO system_setting (settingKey, settingValue) + VALUES (:k, :v) + ON CONFLICT(settingKey) DO UPDATE SET settingValue = excluded.settingValue' + ); + foreach (qdb_settings_keys() as $key) { + $stmt->execute([':k' => $key, ':v' => (string)(int)($settings[$key] ?? 0)]); + } +} + +function qdb_session_ttl_seconds(?array $settings = null, bool $temp = false): int +{ + $settings ??= qdb_settings_get(); + return $temp + ? (int)$settings['temp_session_ttl_seconds'] + : (int)$settings['session_ttl_seconds']; +} diff --git a/website/css/style.css b/website/css/style.css index 2fec998..a5bc5e9 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -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; diff --git a/website/js/pages/dev.js b/website/js/pages/dev.js index 83c597c..6cc4f53 100644 --- a/website/js/pages/dev.js +++ b/website/js/pages/dev.js @@ -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')} +
+

Security & sessions

+

+ 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. +

+
+
+
+

+
+

Revoke all sessions

+

+ Signs out everyone — 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. +

+ + + +
+

Export all response data (ZIP)

@@ -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 = ` +

+
+ + + Before lockout (per username + IP) +
+
+ + +
+
+ + +
+
+ + + Website & app after normal login +
+
+ + + Forced password change flow +
+
+ + `; + document.getElementById('saveSecuritySettingsBtn')?.addEventListener('click', () => { + saveSecuritySettings(formEl, metaEl); + }); + } catch (err) { + formEl.innerHTML = `

${escHtml(err.message)}

`; + } +} + +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(/ { + 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) { `` ); } + if (canRevokeSessions) { + actions.push( + `` + ); + } if (canDelete) { actions.push( `` @@ -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 {