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

@ -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 {

View File

@ -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'] ?? '');

View File

@ -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)) {

View File

@ -1,5 +1,8 @@
<?php
require_once __DIR__ . '/../lib/settings.php';
require_once __DIR__ . '/../lib/login_rate_limit.php';
switch ($route) {
case 'auth/login':
@ -15,6 +18,12 @@ case 'auth/login':
json_error('MISSING_FIELDS', 'Username and password are required', 400);
}
$securitySettings = qdb_settings_get();
[$allowed, $retryAfter] = qdb_login_rate_limit_check($username, $securitySettings);
if (!$allowed) {
qdb_login_rate_limit_deny($retryAfter);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
$stmt = $pdo->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'],

71
handlers/settings.php Normal file
View File

@ -0,0 +1,71 @@
<?php
require_once __DIR__ . '/../lib/settings.php';
$tokenRec = require_valid_token_web();
require_role(['admin'], $tokenRec);
const QDB_REVOKE_ALL_CONFIRM = 'REVOKE ALL SESSIONS';
switch ($method) {
case 'GET':
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
$settings = qdb_settings_get_on_pdo($pdo);
$sessionCount = (int)$pdo->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);
}

View File

@ -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);

176
lib/login_rate_limit.php Normal file
View File

@ -0,0 +1,176 @@
<?php
require_once __DIR__ . '/settings.php';
function qdb_login_rate_limit_path(): string
{
return dirname(QDB_PATH) . '/.login_rate_limit.json';
}
function qdb_client_ip(): string
{
$raw = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '';
if ($raw === '') {
return '0.0.0.0';
}
if (str_contains($raw, ',')) {
$raw = trim(explode(',', $raw)[0]);
}
return $raw;
}
function qdb_login_rate_limit_key(string $username): string
{
$ip = qdb_client_ip();
return hash('sha256', strtolower(trim($username)) . "\0" . $ip);
}
/**
* @return array{0: bool, 1: int} allowed, retry_after_seconds
*/
function qdb_login_rate_limit_check(string $username, ?array $settings = null): array
{
$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 [true, 0];
}
try {
if (!flock($fp, LOCK_EX)) {
return [true, 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];
$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
);
}

114
lib/settings.php Normal file
View File

@ -0,0 +1,114 @@
<?php
/**
* Persisted security settings (system_setting table in encrypted DB).
*/
function qdb_settings_defaults(): array
{
return [
'login_max_attempts' => 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<string, int> $partial
* @param array<string, int>|null $base existing settings; defaults used when null
* @return array<string, int> 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<string, int> $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'];
}

View File

@ -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;

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;');
}

View File

@ -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;
}

View File

@ -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 {