diff --git a/handlers/users.php b/handlers/users.php index 7feef4c..2bc9d74 100644 --- a/handlers/users.php +++ b/handlers/users.php @@ -170,12 +170,87 @@ case 'POST': case 'PUT': case 'PATCH': + $body = read_json_body(); + $targetUserID = trim($body['userID'] ?? ''); + $newPassword = (string)($body['password'] ?? $body['newPassword'] ?? ''); + + if ($newPassword !== '') { + if ($targetUserID === '') { + json_error('MISSING_FIELDS', 'userID is required', 400); + } + if ($targetUserID === ($tokenRec['userID'] ?? '')) { + json_error('FORBIDDEN', 'Cannot reset your own password', 400); + } + if (strlen($newPassword) < 6) { + json_error('PASSWORD_TOO_SHORT', 'Password must be at least 6 characters', 400); + } + $mustChange = isset($body['mustChangePassword']) ? (int)(bool)$body['mustChangePassword'] : 1; + + try { + [$pdo, $tmpDb, $lockFp] = qdb_open(true); + + $row = $pdo->prepare( + "SELECT u.userID, u.username, u.role, u.entityID, c.supervisorID + FROM users u + LEFT JOIN coach c ON c.coachID = u.entityID AND u.role = 'coach' + WHERE u.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, ['supervisor', 'coach'], true)) { + qdb_discard($tmpDb, $lockFp); + json_error('FORBIDDEN', 'Admins may only reset passwords for supervisors and coaches', 403); + } + } elseif ($callerRole === 'supervisor') { + if ($targetRole !== 'coach') { + qdb_discard($tmpDb, $lockFp); + json_error('FORBIDDEN', 'Supervisors may only reset passwords 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 reset passwords', 403); + } + + $hash = password_hash($newPassword, PASSWORD_DEFAULT); + $pdo->prepare( + 'UPDATE users SET passwordHash = :h, mustChangePassword = :mcp WHERE userID = :uid' + )->execute([':h' => $hash, ':mcp' => $mustChange, ':uid' => $targetUserID]); + + qdb_save($tmpDb, $lockFp); + + json_success([ + 'userID' => $targetUserID, + 'username' => $target['username'], + 'mustChangePassword' => $mustChange, + ]); + } catch (Throwable $e) { + if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp); + error_log($e->getMessage()); + json_error('SERVER_ERROR', 'Server error', 500); + } + break; + } + if ($callerRole !== 'admin') { json_error('FORBIDDEN', 'Only admins can reassign coaches', 403); } - $body = read_json_body(); - $targetUserID = trim($body['userID'] ?? ''); $supervisorID = trim($body['supervisorID'] ?? ''); if ($targetUserID === '' || $supervisorID === '') { diff --git a/website/css/style.css b/website/css/style.css index 75b0589..1ae20e4 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -367,6 +367,22 @@ code { width: 20px; height: 20px; } + +.user-actions-cell { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.mobile-logout-btn { + display: none; + position: fixed; + top: 12px; + right: 12px; + z-index: 150; + box-shadow: var(--shadow); +} .coach-supervisor-select { max-width: 100%; min-width: 160px; @@ -873,6 +889,12 @@ th.sort-desc::after { content: ' \2193'; opacity: 1; } .sidebar.open { transform: translateX(0); } .main-content { margin-left: 0; padding: 16px; } .q-grid { grid-template-columns: 1fr; } + body.logged-in .mobile-logout-btn { + display: inline-flex; + } + body.logged-in .page-header { + padding-right: 88px; + } } @media (max-width: 520px) { @@ -1232,6 +1254,114 @@ table.data-table tbody tr.row-orphan-category td { cursor: pointer; } +/* Modal dialog */ +.modal-overlay { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background: color-mix(in srgb, var(--bg) 30%, rgba(0, 0, 0, 0.65)); +} +.modal-overlay[hidden] { + display: none; +} +body.modal-open { + overflow: hidden; +} +.modal-dialog { + width: 100%; + max-width: 460px; + max-height: calc(100vh - 32px); + overflow: auto; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + padding: 24px; +} +.modal-dialog h2 { + margin: 0 0 6px; + font-size: 1.15rem; +} +.modal-dialog .modal-subtitle { + margin: 0 0 16px; + font-size: 0.9rem; + color: var(--text-secondary); + line-height: 1.45; +} +.modal-dialog .modal-subtitle strong { + color: var(--text); +} +.modal-dialog .modal-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; + margin-top: 20px; +} +.password-mode-fieldset { + border: none; + margin: 0 0 16px; + padding: 0; +} +.password-mode-fieldset > legend { + font-size: 0.85rem; + font-weight: 600; + margin-bottom: 10px; + padding: 0; +} +.password-mode-options { + display: flex; + flex-direction: column; + gap: 8px; +} +.password-mode-option { + display: block; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} +.password-mode-option:hover { + background: var(--surface-hover); +} +.password-mode-option:has(input:checked) { + border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 8%, var(--surface)); +} +.password-mode-option input { + margin: 0 10px 0 0; + vertical-align: top; + accent-color: var(--primary); +} +.password-mode-option-title { + display: block; + font-weight: 600; + font-size: 0.9rem; + margin-bottom: 4px; +} +.password-mode-option-desc { + display: block; + font-size: 0.8rem; + color: var(--text-secondary); + line-height: 1.4; + margin-left: 26px; +} +.modal-hint { + font-size: 0.8rem; + color: var(--text-secondary); + margin: 0 0 14px; + line-height: 1.45; + padding: 10px 12px; + background: var(--surface-muted); + border-radius: 6px; + border: 1px solid var(--border); +} + /* Options builder (inside add-question form) */ .options-builder { border-top: 1px solid var(--border); diff --git a/website/index.html b/website/index.html index c9d20dc..3f5abfe 100644 --- a/website/index.html +++ b/website/index.html @@ -90,6 +90,7 @@
+