added session revocation and settings menu for security measures
This commit is contained in:
@ -57,6 +57,7 @@ $routes = [
|
|||||||
'dev' => __DIR__ . '/../handlers/dev.php',
|
'dev' => __DIR__ . '/../handlers/dev.php',
|
||||||
'dev/import' => __DIR__ . '/../handlers/dev.php',
|
'dev/import' => __DIR__ . '/../handlers/dev.php',
|
||||||
'activity-log' => __DIR__ . '/../handlers/activity_log.php',
|
'activity-log' => __DIR__ . '/../handlers/activity_log.php',
|
||||||
|
'settings' => __DIR__ . '/../handlers/settings.php',
|
||||||
];
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
20
common.php
20
common.php
@ -223,6 +223,26 @@ function token_revoke_all_for_user(string $userID): int {
|
|||||||
return $removed;
|
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 ---
|
// --- Auth helpers for endpoints ---
|
||||||
function get_bearer_token(): ?string {
|
function get_bearer_token(): ?string {
|
||||||
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['Authorization'] ?? '');
|
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['Authorization'] ?? '');
|
||||||
|
|||||||
10
db_init.php
10
db_init.php
@ -109,6 +109,16 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
|
|||||||
$changed = true;
|
$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')) {
|
if (qdb_table_exists($pdo, 'questionnaire_submission')) {
|
||||||
require_once __DIR__ . '/lib/submissions.php';
|
require_once __DIR__ . '/lib/submissions.php';
|
||||||
if (qdb_backfill_submissions_from_live($pdo)) {
|
if (qdb_backfill_submissions_from_live($pdo)) {
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../lib/settings.php';
|
||||||
|
require_once __DIR__ . '/../lib/login_rate_limit.php';
|
||||||
|
|
||||||
switch ($route) {
|
switch ($route) {
|
||||||
|
|
||||||
case 'auth/login':
|
case 'auth/login':
|
||||||
@ -15,6 +18,12 @@ case 'auth/login':
|
|||||||
json_error('MISSING_FIELDS', 'Username and password are required', 400);
|
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 {
|
try {
|
||||||
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
|
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
@ -26,9 +35,15 @@ case 'auth/login':
|
|||||||
|
|
||||||
if (!$user || !password_verify($password, $user['passwordHash'])) {
|
if (!$user || !password_verify($password, $user['passwordHash'])) {
|
||||||
qdb_discard($tmpDb, $lockFp);
|
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);
|
json_error('INVALID_CREDENTIALS', 'Invalid username or password', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
qdb_login_rate_limit_clear($username);
|
||||||
|
|
||||||
$assignedClients = [];
|
$assignedClients = [];
|
||||||
if (($user['role'] ?? '') === 'coach' && ($user['entityID'] ?? '') !== '') {
|
if (($user['role'] ?? '') === 'coach' && ($user['entityID'] ?? '') !== '') {
|
||||||
$cStmt = $pdo->prepare(
|
$cStmt = $pdo->prepare(
|
||||||
@ -47,7 +62,7 @@ case 'auth/login':
|
|||||||
|
|
||||||
if ((int)$user['mustChangePassword'] === 1) {
|
if ((int)$user['mustChangePassword'] === 1) {
|
||||||
$tempToken = bin2hex(random_bytes(32));
|
$tempToken = bin2hex(random_bytes(32));
|
||||||
token_add($tempToken, 10 * 60, [
|
token_add($tempToken, qdb_session_ttl_seconds($securitySettings, true), [
|
||||||
'role' => $user['role'],
|
'role' => $user['role'],
|
||||||
'entityID' => $user['entityID'],
|
'entityID' => $user['entityID'],
|
||||||
'userID' => $user['userID'],
|
'userID' => $user['userID'],
|
||||||
@ -62,7 +77,7 @@ case 'auth/login':
|
|||||||
}
|
}
|
||||||
|
|
||||||
$token = bin2hex(random_bytes(32));
|
$token = bin2hex(random_bytes(32));
|
||||||
token_add($token, 30 * 24 * 60 * 60, [
|
token_add($token, qdb_session_ttl_seconds($securitySettings, false), [
|
||||||
'role' => $user['role'],
|
'role' => $user['role'],
|
||||||
'entityID' => $user['entityID'],
|
'entityID' => $user['entityID'],
|
||||||
'userID' => $user['userID'],
|
'userID' => $user['userID'],
|
||||||
@ -149,9 +164,11 @@ case 'auth/change-password':
|
|||||||
|
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
|
|
||||||
|
$securitySettings = qdb_settings_get();
|
||||||
|
|
||||||
// Issue a fresh session for this browser only
|
// Issue a fresh session for this browser only
|
||||||
$newToken = bin2hex(random_bytes(32));
|
$newToken = bin2hex(random_bytes(32));
|
||||||
token_add($newToken, 30 * 24 * 60 * 60, [
|
token_add($newToken, qdb_session_ttl_seconds($securitySettings, false), [
|
||||||
'role' => $user['role'],
|
'role' => $user['role'],
|
||||||
'entityID' => $user['entityID'],
|
'entityID' => $user['entityID'],
|
||||||
'userID' => $user['userID'],
|
'userID' => $user['userID'],
|
||||||
|
|||||||
71
handlers/settings.php
Normal file
71
handlers/settings.php
Normal 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);
|
||||||
|
}
|
||||||
@ -167,8 +167,67 @@ case 'PUT':
|
|||||||
case 'PATCH':
|
case 'PATCH':
|
||||||
$body = read_json_body();
|
$body = read_json_body();
|
||||||
$targetUserID = trim($body['userID'] ?? '');
|
$targetUserID = trim($body['userID'] ?? '');
|
||||||
|
$action = trim((string)($body['action'] ?? ''));
|
||||||
$newPassword = (string)($body['password'] ?? $body['newPassword'] ?? '');
|
$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 ($newPassword !== '') {
|
||||||
if ($targetUserID === '') {
|
if ($targetUserID === '') {
|
||||||
json_error('MISSING_FIELDS', 'userID is required', 400);
|
json_error('MISSING_FIELDS', 'userID is required', 400);
|
||||||
|
|||||||
176
lib/login_rate_limit.php
Normal file
176
lib/login_rate_limit.php
Normal 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
114
lib/settings.php
Normal 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'];
|
||||||
|
}
|
||||||
@ -328,6 +328,30 @@ code {
|
|||||||
.badge-activity-web_change { background: var(--badge-supervisor-bg); color: var(--badge-supervisor-fg); }
|
.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-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); }
|
.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 .table-wrapper { max-height: 420px; overflow: auto; }
|
||||||
.activity-log-card .filter-bar label {
|
.activity-log-card .filter-bar label {
|
||||||
margin: 0;
|
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 { getRole, pageHeaderHTML, showToast } from '../app.js';
|
||||||
import { navigate } from '../router.js';
|
import { navigate } from '../router.js';
|
||||||
|
|
||||||
|
const REVOKE_ALL_CONFIRM = 'REVOKE ALL SESSIONS';
|
||||||
|
|
||||||
const ACTIVITY_LABELS = {
|
const ACTIVITY_LABELS = {
|
||||||
app_sync: 'App sync',
|
app_sync: 'App sync',
|
||||||
app_change: 'App change',
|
app_change: 'App change',
|
||||||
@ -20,6 +22,32 @@ export function devPage() {
|
|||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
${pageHeaderHTML('Admin Settings')}
|
${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">
|
<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>
|
<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">
|
<p class="field-hint" style="margin:0 0 14px">
|
||||||
@ -216,6 +244,7 @@ export function devPage() {
|
|||||||
|
|
||||||
wireAdminZipExport();
|
wireAdminZipExport();
|
||||||
wireActivityLog();
|
wireActivityLog();
|
||||||
|
wireSecuritySettings();
|
||||||
|
|
||||||
document.getElementById('devImportBtn').addEventListener('click', async () => {
|
document.getElementById('devImportBtn').addEventListener('click', async () => {
|
||||||
if (!parsedFixture) return;
|
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();
|
const json = await res.json();
|
||||||
if (!json.ok) {
|
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 = '';
|
errEl.style.display = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -230,6 +230,9 @@ function updateRoleGroupTable(roleKey, myUsername) {
|
|||||||
role: btn.dataset.role || '',
|
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 => {
|
tbody.querySelectorAll('.coach-supervisor-select').forEach(sel => {
|
||||||
sel.addEventListener('change', () => reassignCoachSupervisor(sel));
|
sel.addEventListener('change', () => reassignCoachSupervisor(sel));
|
||||||
});
|
});
|
||||||
@ -276,6 +279,10 @@ function userRowHTML(u, myUsername) {
|
|||||||
(callerRole === 'admin' && (u.role === 'supervisor' || u.role === 'coach')) ||
|
(callerRole === 'admin' && (u.role === 'supervisor' || u.role === 'coach')) ||
|
||||||
(callerRole === 'supervisor' && u.role === 'coach')
|
(callerRole === 'supervisor' && u.role === 'coach')
|
||||||
);
|
);
|
||||||
|
const canRevokeSessions = !isSelf && (
|
||||||
|
callerRole === 'admin' ||
|
||||||
|
(callerRole === 'supervisor' && u.role === 'coach')
|
||||||
|
);
|
||||||
|
|
||||||
const actions = [];
|
const actions = [];
|
||||||
if (canResetPassword) {
|
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>`
|
`<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) {
|
if (canDelete) {
|
||||||
actions.push(
|
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>`
|
`<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) {
|
async function deleteUser(userID, username) {
|
||||||
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
|
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user