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 = `
-
-
Reset password
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- `;
- 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 = `
+
+
Password
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ 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;
+ }
+}