diff --git a/api/index.php b/api/index.php index 4fa8398..27c6af5 100644 --- a/api/index.php +++ b/api/index.php @@ -49,6 +49,7 @@ $routes = [ 'coaches' => __DIR__ . '/../handlers/coaches.php', 'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php', 'logout' => __DIR__ . '/../handlers/logout.php', + 'session' => __DIR__ . '/../handlers/session.php', 'auth/login' => __DIR__ . '/../handlers/auth.php', 'auth/change-password' => __DIR__ . '/../handlers/auth.php', 'backup' => __DIR__ . '/../handlers/backup.php', diff --git a/common.php b/common.php index 29a6964..09d8395 100644 --- a/common.php +++ b/common.php @@ -201,6 +201,28 @@ function token_revoke(string $token): void { qdb_save($tmpDb, $lockFp); } +/** Revoke all sessions for one user on an open writable connection. */ +function token_revoke_all_for_user_on_pdo(PDO $pdo, string $userID): int { + if ($userID === '') { + return 0; + } + $stmt = $pdo->prepare('DELETE FROM session WHERE userID = :uid'); + $stmt->execute([':uid' => $userID]); + return $stmt->rowCount(); +} + +/** Revoke every login session for a user (after password change or admin reset). */ +function token_revoke_all_for_user(string $userID): int { + if ($userID === '') { + return 0; + } + require_once __DIR__ . '/db_init.php'; + [$pdo, $tmpDb, $lockFp] = qdb_open(true); + $removed = token_revoke_all_for_user_on_pdo($pdo, $userID); + qdb_save($tmpDb, $lockFp); + return $removed; +} + // --- Auth helpers for endpoints --- function get_bearer_token(): ?string { $auth = $_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['Authorization'] ?? ''); diff --git a/handlers/auth.php b/handlers/auth.php index 6399d8e..c8cc92d 100644 --- a/handlers/auth.php +++ b/handlers/auth.php @@ -147,10 +147,11 @@ case 'auth/change-password': "UPDATE users SET passwordHash = :h, mustChangePassword = 0 WHERE userID = :uid" )->execute([':h' => $newHash, ':uid' => $user['userID']]); + token_revoke_all_for_user_on_pdo($pdo, $user['userID']); + qdb_save($tmpDb, $lockFp); - // Revoke the old (possibly temp) token and issue a fresh one - token_revoke($bearerToken); + // Issue a fresh session for this browser only $newToken = bin2hex(random_bytes(32)); token_add($newToken, 30 * 24 * 60 * 60, [ 'role' => $user['role'], diff --git a/handlers/session.php b/handlers/session.php new file mode 100644 index 0000000..8aceb9a --- /dev/null +++ b/handlers/session.php @@ -0,0 +1,46 @@ +prepare('SELECT username FROM users WHERE userID = :uid'); + $stmt->execute([':uid' => $rec['userID']]); + $row = $stmt->fetchColumn(); + $username = $row !== false ? (string)$row : ''; + qdb_discard($tmpDb, $lockFp); + } catch (Throwable $e) { + error_log('session username lookup: ' . $e->getMessage()); + } +} + +json_success([ + 'valid' => true, + 'user' => $username, + 'role' => $role, + 'userID' => $rec['userID'] ?? '', + 'mustChangePassword' => !empty($rec['temp']), +]); diff --git a/handlers/users.php b/handlers/users.php index 2bc9d74..558a1ee 100644 --- a/handlers/users.php +++ b/handlers/users.php @@ -232,6 +232,8 @@ case 'PATCH': 'UPDATE users SET passwordHash = :h, mustChangePassword = :mcp WHERE userID = :uid' )->execute([':h' => $hash, ':mcp' => $mustChange, ':uid' => $targetUserID]); + token_revoke_all_for_user_on_pdo($pdo, $targetUserID); + qdb_save($tmpDb, $lockFp); json_success([ diff --git a/lib/api_log.php b/lib/api_log.php index 7b19ad5..6d21c6b 100644 --- a/lib/api_log.php +++ b/lib/api_log.php @@ -150,7 +150,7 @@ function qdb_api_log_classify_activity(string $method, string $client, string $r $client = strtolower(trim($client)); $route = strtolower(trim($route)); - if ($method === 'OPTIONS' || $route === 'health' || $route === 'activity-log') { + if ($method === 'OPTIONS' || in_array($route, ['health', 'activity-log', 'session'], true)) { return null; } diff --git a/website/css/style.css b/website/css/style.css index ec937a8..41ec9fb 100644 --- a/website/css/style.css +++ b/website/css/style.css @@ -434,14 +434,20 @@ code { align-items: center; } -.mobile-logout-btn { +.mobile-logout-btn, +.mobile-change-password-btn { display: none; position: fixed; top: 12px; - right: 12px; z-index: 150; box-shadow: var(--shadow); } +.mobile-logout-btn { + right: 12px; +} +.mobile-change-password-btn { + right: 96px; +} .coach-supervisor-select { max-width: 100%; min-width: 160px; @@ -948,11 +954,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 { + body.logged-in .mobile-logout-btn, + body.logged-in .mobile-change-password-btn { display: inline-flex; } body.logged-in .page-header { - padding-right: 88px; + padding-right: 200px; } } diff --git a/website/index.html b/website/index.html index 3f5abfe..dff4b9c 100644 --- a/website/index.html +++ b/website/index.html @@ -80,6 +80,7 @@ @@ -90,6 +91,7 @@
+ diff --git a/website/js/api.js b/website/js/api.js index 4145c56..93fa0c9 100644 --- a/website/js/api.js +++ b/website/js/api.js @@ -4,6 +4,95 @@ function getToken() { return localStorage.getItem('qdb_token'); } +const WEBSITE_ROLES = ['admin', 'supervisor']; + +let sessionCheckPromise = null; +let sessionCheckToken = null; + +/** Clear cached session validation (after logout or token change). */ +export function invalidateSessionCheck() { + sessionCheckPromise = null; + sessionCheckToken = null; +} + +/** Clear stored session and open the login page. */ +export function redirectToLogin() { + invalidateSessionCheck(); + localStorage.removeItem('qdb_token'); + localStorage.removeItem('qdb_user'); + localStorage.removeItem('qdb_role'); + window.location.hash = '#/login'; +} + +/** @returns {boolean} true if the response means the session is no longer valid */ +function isSessionInvalidResponse(res, json) { + if (res.status === 401) return true; + const errMsg = json.error?.message || json.error || ''; + return res.status === 403 && ( + errMsg === 'Invalid or expired token' + || errMsg === 'Missing Bearer token' + || errMsg === 'Password change required before access' + || json.error?.code === 'UNAUTHORIZED' + || json.error?.code === 'PASSWORD_CHANGE_REQUIRED' + ); +} + +/** + * Verify the stored token is still valid on the server. Redirects to login when not. + * @returns {Promise} + */ +export async function ensureValidSession() { + const token = getToken(); + if (!token) { + redirectToLogin(); + return false; + } + + if (sessionCheckPromise && sessionCheckToken === token) { + return sessionCheckPromise; + } + + sessionCheckToken = token; + sessionCheckPromise = (async () => { + try { + const data = await apiGet('session'); + if (!data.success || !data.valid) { + redirectToLogin(); + return false; + } + if (data.mustChangePassword) { + redirectToLogin(); + return false; + } + if (!WEBSITE_ROLES.includes(data.role)) { + redirectToLogin(); + return false; + } + if (data.user) localStorage.setItem('qdb_user', data.user); + if (data.role) localStorage.setItem('qdb_role', data.role); + return true; + } catch (e) { + if (e.message !== 'Session expired') { + redirectToLogin(); + } + return false; + } + })(); + + return sessionCheckPromise; +} + +async function handleAuthResponse(res) { + if (res.status !== 401 && res.status !== 403) return; + const json = await res.json().catch(() => ({})); + if (isSessionInvalidResponse(res, json)) { + redirectToLogin(); + throw new Error('Session expired'); + } + const errMsg = json.error?.message || json.error || ''; + throw new Error(errMsg || 'Permission denied'); +} + async function apiFetch(endpoint, options = {}) { const token = getToken(); const headers = options.headers || {}; @@ -18,16 +107,7 @@ async function apiFetch(endpoint, options = {}) { const res = await fetch(`${API_BASE}/${cleanEndpoint}`, { ...options, headers }); if (res.status === 401 || res.status === 403) { - const json = await res.json().catch(() => ({})); - const errMsg = json.error?.message || json.error || ''; - if (res.status === 401 || errMsg === 'Invalid or expired token') { - localStorage.removeItem('qdb_token'); - localStorage.removeItem('qdb_user'); - localStorage.removeItem('qdb_role'); - window.location.hash = '#/login'; - throw new Error('Session expired'); - } - throw new Error(errMsg || 'Permission denied'); + await handleAuthResponse(res); } return res; @@ -98,5 +178,13 @@ export async function apiDownloadFetch(url, options = {}) { const token = getToken(); const headers = { ...(options.headers || {}), 'X-QDB-Client': 'web' }; if (token) headers['Authorization'] = `Bearer ${token}`; - return fetch(url, { ...options, headers }); + const res = await fetch(url, { ...options, headers }); + const ct = res.headers.get('Content-Type') || ''; + if (ct.includes('application/json')) { + await handleAuthResponse(res); + } else if (res.status === 401 || res.status === 403) { + redirectToLogin(); + throw new Error('Session expired'); + } + return res; } diff --git a/website/js/app.js b/website/js/app.js index a7f78c0..1fb213b 100644 --- a/website/js/app.js +++ b/website/js/app.js @@ -1,4 +1,5 @@ import { addRoute, startRouter, navigate } from './router.js'; +import { ensureValidSession, invalidateSessionCheck } from './api.js'; import { loginPage } from './pages/login.js'; import { homeDashboardPage } from './pages/home.js'; import { questionnairesPage } from './pages/questionnaires.js'; @@ -12,6 +13,7 @@ import { translationsPage } from './pages/translations.js'; import { devPage } from './pages/dev.js'; import { insightsPage } from './pages/insights.js'; import { coachesPage } from './pages/coaches.js'; +import { openChangeOwnPasswordModal } from './password-modal.js'; // Auth state export function isLoggedIn() { @@ -34,6 +36,7 @@ export function canEdit() { } function clearSession() { + invalidateSessionCheck(); localStorage.removeItem('qdb_token'); localStorage.removeItem('qdb_role'); localStorage.removeItem('qdb_user'); @@ -44,12 +47,6 @@ function logout() { navigate('#/login'); } -function rejectCoachSession() { - clearSession(); - showToast('Coach accounts must use the mobile app.', 'error'); - navigate('#/login'); -} - /** Link to home dashboard (#/). */ export const HOME_HREF = '#/'; @@ -97,9 +94,8 @@ export function showToast(message, type = 'info') { } function authGuard(handler) { - return (params) => { - if (!isLoggedIn()) { navigate('#/login'); return; } - if (!canAccessWebsite()) { rejectCoachSession(); return; } + return async (params) => { + if (!(await ensureValidSession())) return; return handler(params); }; } @@ -139,8 +135,8 @@ function updateNav() { } function roleGuard(roles, handler) { - return (params) => { - if (!isLoggedIn()) { navigate('#/login'); return; } + return async (params) => { + if (!(await ensureValidSession())) return; if (!roles.includes(getRole())) { navigate('#/'); return; } return handler(params); }; @@ -174,9 +170,12 @@ document.addEventListener('DOMContentLoaded', () => { bindLogout(document.getElementById('logoutBtn')); bindLogout(document.getElementById('mobileLogoutBtn')); - if (isLoggedIn() && !canAccessWebsite()) { - clearSession(); - } + const openOwnPassword = async () => { + if (!(await ensureValidSession())) return; + openChangeOwnPasswordModal(); + }; + document.getElementById('changePasswordBtn')?.addEventListener('click', openOwnPassword); + document.getElementById('mobileChangePasswordBtn')?.addEventListener('click', openOwnPassword); window.addEventListener('hashchange', updateNav); updateNav(); diff --git a/website/js/pages/login.js b/website/js/pages/login.js index b212021..5a87c54 100644 --- a/website/js/pages/login.js +++ b/website/js/pages/login.js @@ -1,8 +1,27 @@ import { navigate } from '../router.js'; import { isLoggedIn, showToast } from '../app.js'; +import { apiGet, invalidateSessionCheck } from '../api.js'; -export function loginPage() { - if (isLoggedIn()) { navigate('#/'); return; } +const WEBSITE_ROLES = ['admin', 'supervisor']; + +export async function loginPage() { + if (isLoggedIn()) { + try { + const data = await apiGet('session'); + if (data.success && data.valid && !data.mustChangePassword && WEBSITE_ROLES.includes(data.role)) { + if (data.user) localStorage.setItem('qdb_user', data.user); + if (data.role) localStorage.setItem('qdb_role', data.role); + navigate('#/'); + return; + } + } catch { + invalidateSessionCheck(); + } + localStorage.removeItem('qdb_token'); + localStorage.removeItem('qdb_user'); + localStorage.removeItem('qdb_role'); + invalidateSessionCheck(); + } const app = document.getElementById('app'); app.innerHTML = ` @@ -84,6 +103,7 @@ export function loginPage() { localStorage.setItem('qdb_token', d.token); localStorage.setItem('qdb_user', d.user); localStorage.setItem('qdb_role', d.role); + invalidateSessionCheck(); navigate('#/'); } catch (err) { errEl.textContent = err.message; @@ -131,6 +151,7 @@ export function loginPage() { localStorage.setItem('qdb_token', d.token); localStorage.setItem('qdb_user', d.user); localStorage.setItem('qdb_role', d.role); + invalidateSessionCheck(); showToast('Password changed successfully', 'success'); navigate('#/'); } catch (err) { diff --git a/website/js/pages/users.js b/website/js/pages/users.js index eda1b72..b2d6741 100644 --- a/website/js/pages/users.js +++ b/website/js/pages/users.js @@ -1,5 +1,6 @@ -import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from '../api.js'; +import { apiGet, apiPost, apiPut, apiDelete } from '../api.js'; import { getRole, getUser, pageHeaderHTML, showToast } from '../app.js'; +import { openAdminResetPasswordModal } from '../password-modal.js'; import { isDevTestUsername, testDataRowClassForUser } from '../test-data.js'; // Roles an admin can create; supervisors can only create coaches @@ -14,8 +15,6 @@ 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' @@ -68,6 +67,15 @@ export async function usersPage() { await loadUsers(); } +document.addEventListener('qdb:password-reset', (e) => { + const { userID, mustChangePassword } = e.detail || {}; + const u = usersList.find(x => x.userID === userID); + if (u) { + u.mustChangePassword = mustChangePassword; + renderUsers(); + } +}); + async function loadUsers() { try { const data = await apiGet('users.php'); @@ -216,11 +224,11 @@ function updateRoleGroupTable(roleKey, myUsername) { 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 || '' - )); + btn.addEventListener('click', () => openAdminResetPasswordModal({ + userID: btn.dataset.id, + username: btn.dataset.name, + role: btn.dataset.role || '', + })); }); tbody.querySelectorAll('.coach-supervisor-select').forEach(sel => { sel.addEventListener('change', () => reassignCoachSupervisor(sel)); @@ -322,164 +330,6 @@ 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 { diff --git a/website/js/password-modal.js b/website/js/password-modal.js new file mode 100644 index 0000000..94a4dc0 --- /dev/null +++ b/website/js/password-modal.js @@ -0,0 +1,233 @@ +import { apiPatch, apiPost, invalidateSessionCheck } from './api.js'; +import { getRole, getUser, showToast } from './app.js'; + +/** @typedef {'admin_reset' | 'self'} PasswordModalMode */ + +/** @type {{ mode: PasswordModalMode, userID?: string, username?: string, role?: string } | null} */ +let modalContext = null; + +function esc(s) { + const d = document.createElement('div'); + d.textContent = s ?? ''; + return d.innerHTML; +} + +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 ensurePasswordModal() { + let modal = document.getElementById('passwordModal'); + if (modal) return modal; + + modal = document.createElement('div'); + modal.id = 'passwordModal'; + modal.className = 'modal-overlay'; + modal.hidden = true; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + modal.setAttribute('aria-labelledby', 'passwordModalTitle'); + modal.innerHTML = ` + + `; + document.body.appendChild(modal); + + modal.addEventListener('click', (e) => { + if (e.target === modal) closePasswordModal(); + }); + modal.querySelector('[data-password-cancel]').addEventListener('click', closePasswordModal); + modal.querySelector('[data-password-submit]').addEventListener('click', submitPasswordModal); + modal.querySelector('#pw_new_password_confirm').addEventListener('keydown', (e) => { + if (e.key === 'Enter') submitPasswordModal(); + }); + + document.addEventListener('keydown', (e) => { + const el = document.getElementById('passwordModal'); + if (!el || el.hidden) return; + if (e.key === 'Escape') closePasswordModal(); + }); + + return modal; +} + +/** + * @param {{ mode: PasswordModalMode, userID?: string, username?: string, role?: string }} options + */ +function openPasswordModal(options) { + modalContext = options; + const modal = ensurePasswordModal(); + const isSelf = options.mode === 'self'; + const username = options.username || getUser(); + const role = options.role || getRole(); + + modal.querySelector('#passwordModalTitle').textContent = isSelf ? 'Change your password' : 'Reset password'; + modal.querySelector('#passwordModalSubtitle').innerHTML = isSelf + ? `Choose a new password for ${esc(username)} (${esc(roleLabel(role))}).` + : `Set a new password for ${esc(username)} (${esc(roleLabel(role))}).`; + modal.querySelector('#passwordModalHint').textContent = isSelf + ? 'You will stay signed in after saving. Use this password on your next login.' + : signInHintForRole(role); + + modal.querySelector('#passwordModalModeFieldset').style.display = isSelf ? 'none' : ''; + modal.querySelector('#passwordModalOldGroup').style.display = isSelf ? '' : 'none'; + + if (!isSelf) { + modal.querySelector('input[name="pw_mode"][value="temporary"]').checked = true; + } + + modal.querySelector('#pw_old_password').value = ''; + modal.querySelector('#pw_new_password').value = ''; + modal.querySelector('#pw_new_password_confirm').value = ''; + const errEl = modal.querySelector('#passwordModalError'); + errEl.style.display = 'none'; + errEl.textContent = ''; + + modal.querySelector('[data-password-submit]').textContent = isSelf ? 'Change password' : 'Set password'; + + modal.hidden = false; + document.body.classList.add('modal-open'); + (isSelf ? modal.querySelector('#pw_old_password') : modal.querySelector('#pw_new_password')).focus(); +} + +export function closePasswordModal() { + const modal = document.getElementById('passwordModal'); + if (modal) modal.hidden = true; + document.body.classList.remove('modal-open'); + modalContext = null; +} + +export function openAdminResetPasswordModal({ userID, username, role }) { + openPasswordModal({ mode: 'admin_reset', userID, username, role }); +} + +export function openChangeOwnPasswordModal() { + openPasswordModal({ mode: 'self' }); +} + +function showModalError(el, msg) { + el.textContent = msg; + el.style.display = ''; +} + +async function submitPasswordModal() { + if (!modalContext) return; + const modal = document.getElementById('passwordModal'); + const errEl = modal.querySelector('#passwordModalError'); + errEl.style.display = 'none'; + + const newPassword = modal.querySelector('#pw_new_password').value; + const confirm = modal.querySelector('#pw_new_password_confirm').value; + + if (newPassword.length < 6) { + showModalError(errEl, 'Password must be at least 6 characters'); + return; + } + if (newPassword !== confirm) { + showModalError(errEl, 'Passwords do not match'); + return; + } + + const submitBtn = modal.querySelector('[data-password-submit]'); + submitBtn.disabled = true; + const prevLabel = submitBtn.textContent; + submitBtn.textContent = 'Saving…'; + + try { + if (modalContext.mode === 'self') { + const oldPassword = modal.querySelector('#pw_old_password').value; + if (!oldPassword) { + showModalError(errEl, 'Current password is required'); + return; + } + const data = await apiPost('auth/change-password', { + username: getUser(), + old_password: oldPassword, + new_password: newPassword, + }); + if (!data.success) throw new Error(data.error || 'Failed to change password'); + if (data.token) localStorage.setItem('qdb_token', data.token); + if (data.user) localStorage.setItem('qdb_user', data.user); + if (data.role) localStorage.setItem('qdb_role', data.role); + invalidateSessionCheck(); + closePasswordModal(); + showToast('Your password was changed', 'success'); + return; + } + + const mode = modal.querySelector('input[name="pw_mode"]:checked')?.value || 'temporary'; + const temporary = mode === 'temporary'; + const { userID, username } = modalContext; + + const data = await apiPatch('users.php', { + userID, + password: newPassword, + mustChangePassword: temporary ? 1 : 0, + }); + if (!data.success) throw new Error(data.error || 'Failed to reset password'); + + closePasswordModal(); + showToast( + temporary + ? `Temporary password set for "${username}" — they must change it on next sign-in` + : `Permanent password set for "${username}"`, + 'success' + ); + + document.dispatchEvent(new CustomEvent('qdb:password-reset', { + detail: { userID, mustChangePassword: data.mustChangePassword ?? (temporary ? 1 : 0) }, + })); + } catch (e) { + showModalError(errEl, e.message); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = prevLabel; + } +}