From 890232f58f1208bf7d610416ffd1ed5ad83165cb Mon Sep 17 00:00:00 2001 From: "tom.hempel" Date: Mon, 1 Jun 2026 21:23:33 +0200 Subject: [PATCH] added password reset --- handlers/users.php | 79 +++++++++++++- website/css/style.css | 130 ++++++++++++++++++++++ website/index.html | 1 + website/js/app.js | 22 ++-- website/js/pages/users.js | 221 +++++++++++++++++++++++++++++++++++--- 5 files changed, 432 insertions(+), 21 deletions(-) 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 @@
+ diff --git a/website/js/app.js b/website/js/app.js index 1190506..a7f78c0 100644 --- a/website/js/app.js +++ b/website/js/app.js @@ -35,8 +35,13 @@ export function canEdit() { function clearSession() { localStorage.removeItem('qdb_token'); - localStorage.removeItem('qdb_user'); localStorage.removeItem('qdb_role'); + localStorage.removeItem('qdb_user'); +} + +function logout() { + clearSession(); + navigate('#/login'); } function rejectCoachSession() { @@ -102,6 +107,7 @@ function authGuard(handler) { function updateNav() { const nav = document.getElementById('mainNav'); const userInfo = document.getElementById('userInfo'); + document.body.classList.toggle('logged-in', isLoggedIn()); if (isLoggedIn()) { nav.style.display = ''; const role = getRole(); @@ -158,11 +164,15 @@ addRoute('/dev', roleGuard(['admin'], devPage)); // Nav link handling & logout document.addEventListener('DOMContentLoaded', () => { - document.getElementById('logoutBtn').addEventListener('click', (e) => { - e.preventDefault(); - clearSession(); - navigate('#/login'); - }); + const bindLogout = (el) => { + if (!el) return; + el.addEventListener('click', (e) => { + e.preventDefault(); + logout(); + }); + }; + bindLogout(document.getElementById('logoutBtn')); + bindLogout(document.getElementById('mobileLogoutBtn')); if (isLoggedIn() && !canAccessWebsite()) { clearSession(); diff --git a/website/js/pages/users.js b/website/js/pages/users.js index 7fc10fb..eda1b72 100644 --- a/website/js/pages/users.js +++ b/website/js/pages/users.js @@ -1,4 +1,4 @@ -import { apiGet, apiPost, apiPut, apiDelete } from '../api.js'; +import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from '../api.js'; import { getRole, getUser, pageHeaderHTML, showToast } from '../app.js'; import { isDevTestUsername, testDataRowClassForUser } from '../test-data.js'; @@ -14,6 +14,8 @@ let callerRole = ''; /** @type {Record} */ let filterSearchByRole = { admin: '', supervisor: '', coach: '' }; let filterTestData = ''; +/** @type {{ userID: string, username: string, role: string } | null} */ +let resetPasswordTarget = null; function roleGroupsForCaller() { return callerRole === 'supervisor' @@ -133,7 +135,7 @@ function renderUsers() { Username ${group.key !== 'coach' ? 'Location' : 'Supervisor'} - Password Change Required + Must change password Created Actions @@ -213,6 +215,13 @@ function updateRoleGroupTable(roleKey, myUsername) { tbody.querySelectorAll('.delete-user-btn').forEach(btn => { btn.addEventListener('click', () => deleteUser(btn.dataset.id, btn.dataset.name)); }); + tbody.querySelectorAll('.reset-password-btn').forEach(btn => { + btn.addEventListener('click', () => openResetPasswordModal( + btn.dataset.id, + btn.dataset.name, + btn.dataset.role || '' + )); + }); tbody.querySelectorAll('.coach-supervisor-select').forEach(sel => { sel.addEventListener('change', () => reassignCoachSupervisor(sel)); }); @@ -248,13 +257,29 @@ function userRowHTML(u, myUsername) { ? new Date(parseInt(u.createdAt, 10) * 1000).toLocaleDateString() : '—'; const pwBadge = u.mustChangePassword == 1 - ? 'Pending' - : 'No'; + ? 'Temporary' + : 'Permanent'; const canDelete = !isSelf && ( callerRole === 'admin' || (callerRole === 'supervisor' && u.role === 'coach') ); + const canResetPassword = !isSelf && ( + (callerRole === 'admin' && (u.role === 'supervisor' || u.role === 'coach')) || + (callerRole === 'supervisor' && u.role === 'coach') + ); + + const actions = []; + if (canResetPassword) { + actions.push( + `` + ); + } + if (canDelete) { + actions.push( + `` + ); + } return ` @@ -265,11 +290,7 @@ function userRowHTML(u, myUsername) { ${detail} ${pwBadge} ${created} - - ${canDelete - ? `` - : ''} - + ${actions.join(' ')} `; } @@ -301,6 +322,164 @@ async function reassignCoachSupervisor(selectEl) { } } +function roleLabel(role) { + if (!role) return ''; + return role.charAt(0).toUpperCase() + role.slice(1); +} + +function signInHintForRole(role) { + if (role === 'coach') { + return 'Coaches sign in through the mobile app and will be prompted to choose a new password there.'; + } + return 'Supervisors sign in through this website and will be prompted to choose a new password at login.'; +} + +function ensureResetPasswordModal() { + let modal = document.getElementById('resetPasswordModal'); + if (modal) return modal; + + modal = document.createElement('div'); + modal.id = 'resetPasswordModal'; + modal.className = 'modal-overlay'; + modal.hidden = true; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + modal.setAttribute('aria-labelledby', 'resetPwTitle'); + modal.innerHTML = ` + + `; + document.body.appendChild(modal); + + modal.addEventListener('click', (e) => { + if (e.target === modal) closeResetPasswordModal(); + }); + modal.querySelector('[data-reset-cancel]').addEventListener('click', closeResetPasswordModal); + modal.querySelector('[data-reset-submit]').addEventListener('click', submitResetPassword); + modal.querySelector('#rp_password_confirm').addEventListener('keydown', (e) => { + if (e.key === 'Enter') submitResetPassword(); + }); + + document.addEventListener('keydown', (e) => { + const el = document.getElementById('resetPasswordModal'); + if (!el || el.hidden) return; + if (e.key === 'Escape') closeResetPasswordModal(); + }); + + return modal; +} + +function openResetPasswordModal(userID, username, role) { + resetPasswordTarget = { userID, username, role }; + const modal = ensureResetPasswordModal(); + modal.querySelector('#resetPwSubtitle').innerHTML = + `Set a new password for ${esc(username)} (${esc(roleLabel(role))}).`; + modal.querySelector('#resetPwSignInHint').textContent = signInHintForRole(role); + modal.querySelector('input[name="rp_mode"][value="temporary"]').checked = true; + modal.querySelector('#rp_password').value = ''; + modal.querySelector('#rp_password_confirm').value = ''; + const errEl = modal.querySelector('#rp_error'); + errEl.style.display = 'none'; + errEl.textContent = ''; + modal.hidden = false; + document.body.classList.add('modal-open'); + modal.querySelector('#rp_password').focus(); +} + +function closeResetPasswordModal() { + const modal = document.getElementById('resetPasswordModal'); + if (modal) modal.hidden = true; + document.body.classList.remove('modal-open'); + resetPasswordTarget = null; +} + +async function submitResetPassword() { + if (!resetPasswordTarget) return; + const modal = document.getElementById('resetPasswordModal'); + const errEl = modal.querySelector('#rp_error'); + errEl.style.display = 'none'; + + const mode = modal.querySelector('input[name="rp_mode"]:checked')?.value || 'temporary'; + const password = modal.querySelector('#rp_password').value; + const confirm = modal.querySelector('#rp_password_confirm').value; + const temporary = mode === 'temporary'; + + if (password.length < 6) { + showError(errEl, 'Password must be at least 6 characters'); + return; + } + if (password !== confirm) { + showError(errEl, 'Passwords do not match'); + return; + } + + const submitBtn = modal.querySelector('[data-reset-submit]'); + submitBtn.disabled = true; + submitBtn.textContent = 'Saving…'; + + const { userID, username } = resetPasswordTarget; + try { + const data = await apiPatch('users.php', { + userID, + password, + mustChangePassword: temporary ? 1 : 0, + }); + if (!data.success) throw new Error(data.error || 'Failed to reset password'); + + const u = usersList.find(x => x.userID === userID); + if (u) u.mustChangePassword = data.mustChangePassword ?? (temporary ? 1 : 0); + + closeResetPasswordModal(); + renderUsers(); + showToast( + temporary + ? `Temporary password set for "${username}" — they must change it on next sign-in` + : `Permanent password set for "${username}"`, + 'success' + ); + } catch (e) { + showError(errEl, e.message); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = 'Set password'; + } +} + async function deleteUser(userID, username) { if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return; try { @@ -366,9 +545,25 @@ function showCreateForm() { } - +
+ Initial password +
+ + +
+
@@ -418,7 +613,7 @@ async function submitCreateUser() { const username = document.getElementById('cu_username').value.trim(); const password = document.getElementById('cu_password').value; const role = isSupervisor ? 'coach' : (document.getElementById('cu_role').value || 'coach'); - const mustChange = document.getElementById('cu_mustchange').checked ? 1 : 0; + const mustChange = document.querySelector('input[name="cu_pw_mode"]:checked')?.value === 'temporary' ? 1 : 0; let location = ''; let supervisorID = '';