Add session management features: implement user session revocation on password change, enhance session validation, and update UI for password change functionality
This commit is contained in:
@ -49,6 +49,7 @@ $routes = [
|
|||||||
'coaches' => __DIR__ . '/../handlers/coaches.php',
|
'coaches' => __DIR__ . '/../handlers/coaches.php',
|
||||||
'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php',
|
'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php',
|
||||||
'logout' => __DIR__ . '/../handlers/logout.php',
|
'logout' => __DIR__ . '/../handlers/logout.php',
|
||||||
|
'session' => __DIR__ . '/../handlers/session.php',
|
||||||
'auth/login' => __DIR__ . '/../handlers/auth.php',
|
'auth/login' => __DIR__ . '/../handlers/auth.php',
|
||||||
'auth/change-password' => __DIR__ . '/../handlers/auth.php',
|
'auth/change-password' => __DIR__ . '/../handlers/auth.php',
|
||||||
'backup' => __DIR__ . '/../handlers/backup.php',
|
'backup' => __DIR__ . '/../handlers/backup.php',
|
||||||
|
|||||||
22
common.php
22
common.php
@ -201,6 +201,28 @@ function token_revoke(string $token): void {
|
|||||||
qdb_save($tmpDb, $lockFp);
|
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 ---
|
// --- 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'] ?? '');
|
||||||
|
|||||||
@ -147,10 +147,11 @@ case 'auth/change-password':
|
|||||||
"UPDATE users SET passwordHash = :h, mustChangePassword = 0 WHERE userID = :uid"
|
"UPDATE users SET passwordHash = :h, mustChangePassword = 0 WHERE userID = :uid"
|
||||||
)->execute([':h' => $newHash, ':uid' => $user['userID']]);
|
)->execute([':h' => $newHash, ':uid' => $user['userID']]);
|
||||||
|
|
||||||
|
token_revoke_all_for_user_on_pdo($pdo, $user['userID']);
|
||||||
|
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
|
|
||||||
// Revoke the old (possibly temp) token and issue a fresh one
|
// Issue a fresh session for this browser only
|
||||||
token_revoke($bearerToken);
|
|
||||||
$newToken = bin2hex(random_bytes(32));
|
$newToken = bin2hex(random_bytes(32));
|
||||||
token_add($newToken, 30 * 24 * 60 * 60, [
|
token_add($newToken, 30 * 24 * 60 * 60, [
|
||||||
'role' => $user['role'],
|
'role' => $user['role'],
|
||||||
|
|||||||
46
handlers/session.php
Normal file
46
handlers/session.php
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/session — verify Bearer token is still active (website session check).
|
||||||
|
*/
|
||||||
|
|
||||||
|
if ($method !== 'GET') {
|
||||||
|
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = get_bearer_token();
|
||||||
|
if (!$token) {
|
||||||
|
json_error('UNAUTHORIZED', 'Missing Bearer token', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rec = token_get_record($token);
|
||||||
|
if (!$rec) {
|
||||||
|
json_error('UNAUTHORIZED', 'Invalid or expired token', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$role = (string)($rec['role'] ?? '');
|
||||||
|
if ($role === 'coach') {
|
||||||
|
json_error('FORBIDDEN', 'Coach accounts can only sign in through the mobile app.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$username = '';
|
||||||
|
if (($rec['userID'] ?? '') !== '') {
|
||||||
|
try {
|
||||||
|
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||||
|
$stmt = $pdo->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']),
|
||||||
|
]);
|
||||||
@ -232,6 +232,8 @@ case 'PATCH':
|
|||||||
'UPDATE users SET passwordHash = :h, mustChangePassword = :mcp WHERE userID = :uid'
|
'UPDATE users SET passwordHash = :h, mustChangePassword = :mcp WHERE userID = :uid'
|
||||||
)->execute([':h' => $hash, ':mcp' => $mustChange, ':uid' => $targetUserID]);
|
)->execute([':h' => $hash, ':mcp' => $mustChange, ':uid' => $targetUserID]);
|
||||||
|
|
||||||
|
token_revoke_all_for_user_on_pdo($pdo, $targetUserID);
|
||||||
|
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
|
|
||||||
json_success([
|
json_success([
|
||||||
|
|||||||
@ -150,7 +150,7 @@ function qdb_api_log_classify_activity(string $method, string $client, string $r
|
|||||||
$client = strtolower(trim($client));
|
$client = strtolower(trim($client));
|
||||||
$route = strtolower(trim($route));
|
$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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -434,14 +434,20 @@ code {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-logout-btn {
|
.mobile-logout-btn,
|
||||||
|
.mobile-change-password-btn {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 12px;
|
top: 12px;
|
||||||
right: 12px;
|
|
||||||
z-index: 150;
|
z-index: 150;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
|
.mobile-logout-btn {
|
||||||
|
right: 12px;
|
||||||
|
}
|
||||||
|
.mobile-change-password-btn {
|
||||||
|
right: 96px;
|
||||||
|
}
|
||||||
.coach-supervisor-select {
|
.coach-supervisor-select {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
@ -948,11 +954,12 @@ th.sort-desc::after { content: ' \2193'; opacity: 1; }
|
|||||||
.sidebar.open { transform: translateX(0); }
|
.sidebar.open { transform: translateX(0); }
|
||||||
.main-content { margin-left: 0; padding: 16px; }
|
.main-content { margin-left: 0; padding: 16px; }
|
||||||
.q-grid { grid-template-columns: 1fr; }
|
.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;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
body.logged-in .page-header {
|
body.logged-in .page-header {
|
||||||
padding-right: 88px;
|
padding-right: 200px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -80,6 +80,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<div class="user-info" id="userInfo"></div>
|
<div class="user-info" id="userInfo"></div>
|
||||||
|
<button type="button" id="changePasswordBtn" class="btn btn-sm" style="width:100%;justify-content:center;margin-bottom:8px;">Change password</button>
|
||||||
<a href="#" id="logoutBtn" class="btn btn-sm" style="width:100%;justify-content:center;">Log out</a>
|
<a href="#" id="logoutBtn" class="btn btn-sm" style="width:100%;justify-content:center;">Log out</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@ -90,6 +91,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="toastContainer"></div>
|
<div id="toastContainer"></div>
|
||||||
|
<button type="button" id="mobileChangePasswordBtn" class="btn btn-sm mobile-change-password-btn" aria-label="Change password">Password</button>
|
||||||
<button type="button" id="mobileLogoutBtn" class="btn btn-sm mobile-logout-btn" aria-label="Log out">Log out</button>
|
<button type="button" id="mobileLogoutBtn" class="btn btn-sm mobile-logout-btn" aria-label="Log out">Log out</button>
|
||||||
|
|
||||||
<script type="module" src="js/app.js"></script>
|
<script type="module" src="js/app.js"></script>
|
||||||
|
|||||||
@ -4,6 +4,95 @@ function getToken() {
|
|||||||
return localStorage.getItem('qdb_token');
|
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<boolean>}
|
||||||
|
*/
|
||||||
|
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 = {}) {
|
async function apiFetch(endpoint, options = {}) {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const headers = options.headers || {};
|
const headers = options.headers || {};
|
||||||
@ -18,16 +107,7 @@ async function apiFetch(endpoint, options = {}) {
|
|||||||
const res = await fetch(`${API_BASE}/${cleanEndpoint}`, { ...options, headers });
|
const res = await fetch(`${API_BASE}/${cleanEndpoint}`, { ...options, headers });
|
||||||
|
|
||||||
if (res.status === 401 || res.status === 403) {
|
if (res.status === 401 || res.status === 403) {
|
||||||
const json = await res.json().catch(() => ({}));
|
await handleAuthResponse(res);
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
@ -98,5 +178,13 @@ export async function apiDownloadFetch(url, options = {}) {
|
|||||||
const token = getToken();
|
const token = getToken();
|
||||||
const headers = { ...(options.headers || {}), 'X-QDB-Client': 'web' };
|
const headers = { ...(options.headers || {}), 'X-QDB-Client': 'web' };
|
||||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { addRoute, startRouter, navigate } from './router.js';
|
import { addRoute, startRouter, navigate } from './router.js';
|
||||||
|
import { ensureValidSession, invalidateSessionCheck } from './api.js';
|
||||||
import { loginPage } from './pages/login.js';
|
import { loginPage } from './pages/login.js';
|
||||||
import { homeDashboardPage } from './pages/home.js';
|
import { homeDashboardPage } from './pages/home.js';
|
||||||
import { questionnairesPage } from './pages/questionnaires.js';
|
import { questionnairesPage } from './pages/questionnaires.js';
|
||||||
@ -12,6 +13,7 @@ import { translationsPage } from './pages/translations.js';
|
|||||||
import { devPage } from './pages/dev.js';
|
import { devPage } from './pages/dev.js';
|
||||||
import { insightsPage } from './pages/insights.js';
|
import { insightsPage } from './pages/insights.js';
|
||||||
import { coachesPage } from './pages/coaches.js';
|
import { coachesPage } from './pages/coaches.js';
|
||||||
|
import { openChangeOwnPasswordModal } from './password-modal.js';
|
||||||
|
|
||||||
// Auth state
|
// Auth state
|
||||||
export function isLoggedIn() {
|
export function isLoggedIn() {
|
||||||
@ -34,6 +36,7 @@ export function canEdit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearSession() {
|
function clearSession() {
|
||||||
|
invalidateSessionCheck();
|
||||||
localStorage.removeItem('qdb_token');
|
localStorage.removeItem('qdb_token');
|
||||||
localStorage.removeItem('qdb_role');
|
localStorage.removeItem('qdb_role');
|
||||||
localStorage.removeItem('qdb_user');
|
localStorage.removeItem('qdb_user');
|
||||||
@ -44,12 +47,6 @@ function logout() {
|
|||||||
navigate('#/login');
|
navigate('#/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
function rejectCoachSession() {
|
|
||||||
clearSession();
|
|
||||||
showToast('Coach accounts must use the mobile app.', 'error');
|
|
||||||
navigate('#/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Link to home dashboard (#/). */
|
/** Link to home dashboard (#/). */
|
||||||
export const HOME_HREF = '#/';
|
export const HOME_HREF = '#/';
|
||||||
|
|
||||||
@ -97,9 +94,8 @@ export function showToast(message, type = 'info') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function authGuard(handler) {
|
function authGuard(handler) {
|
||||||
return (params) => {
|
return async (params) => {
|
||||||
if (!isLoggedIn()) { navigate('#/login'); return; }
|
if (!(await ensureValidSession())) return;
|
||||||
if (!canAccessWebsite()) { rejectCoachSession(); return; }
|
|
||||||
return handler(params);
|
return handler(params);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -139,8 +135,8 @@ function updateNav() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function roleGuard(roles, handler) {
|
function roleGuard(roles, handler) {
|
||||||
return (params) => {
|
return async (params) => {
|
||||||
if (!isLoggedIn()) { navigate('#/login'); return; }
|
if (!(await ensureValidSession())) return;
|
||||||
if (!roles.includes(getRole())) { navigate('#/'); return; }
|
if (!roles.includes(getRole())) { navigate('#/'); return; }
|
||||||
return handler(params);
|
return handler(params);
|
||||||
};
|
};
|
||||||
@ -174,9 +170,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
bindLogout(document.getElementById('logoutBtn'));
|
bindLogout(document.getElementById('logoutBtn'));
|
||||||
bindLogout(document.getElementById('mobileLogoutBtn'));
|
bindLogout(document.getElementById('mobileLogoutBtn'));
|
||||||
|
|
||||||
if (isLoggedIn() && !canAccessWebsite()) {
|
const openOwnPassword = async () => {
|
||||||
clearSession();
|
if (!(await ensureValidSession())) return;
|
||||||
}
|
openChangeOwnPasswordModal();
|
||||||
|
};
|
||||||
|
document.getElementById('changePasswordBtn')?.addEventListener('click', openOwnPassword);
|
||||||
|
document.getElementById('mobileChangePasswordBtn')?.addEventListener('click', openOwnPassword);
|
||||||
|
|
||||||
window.addEventListener('hashchange', updateNav);
|
window.addEventListener('hashchange', updateNav);
|
||||||
updateNav();
|
updateNav();
|
||||||
|
|||||||
@ -1,8 +1,27 @@
|
|||||||
import { navigate } from '../router.js';
|
import { navigate } from '../router.js';
|
||||||
import { isLoggedIn, showToast } from '../app.js';
|
import { isLoggedIn, showToast } from '../app.js';
|
||||||
|
import { apiGet, invalidateSessionCheck } from '../api.js';
|
||||||
|
|
||||||
export function loginPage() {
|
const WEBSITE_ROLES = ['admin', 'supervisor'];
|
||||||
if (isLoggedIn()) { navigate('#/'); return; }
|
|
||||||
|
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');
|
const app = document.getElementById('app');
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
@ -84,6 +103,7 @@ export function loginPage() {
|
|||||||
localStorage.setItem('qdb_token', d.token);
|
localStorage.setItem('qdb_token', d.token);
|
||||||
localStorage.setItem('qdb_user', d.user);
|
localStorage.setItem('qdb_user', d.user);
|
||||||
localStorage.setItem('qdb_role', d.role);
|
localStorage.setItem('qdb_role', d.role);
|
||||||
|
invalidateSessionCheck();
|
||||||
navigate('#/');
|
navigate('#/');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errEl.textContent = err.message;
|
errEl.textContent = err.message;
|
||||||
@ -131,6 +151,7 @@ export function loginPage() {
|
|||||||
localStorage.setItem('qdb_token', d.token);
|
localStorage.setItem('qdb_token', d.token);
|
||||||
localStorage.setItem('qdb_user', d.user);
|
localStorage.setItem('qdb_user', d.user);
|
||||||
localStorage.setItem('qdb_role', d.role);
|
localStorage.setItem('qdb_role', d.role);
|
||||||
|
invalidateSessionCheck();
|
||||||
showToast('Password changed successfully', 'success');
|
showToast('Password changed successfully', 'success');
|
||||||
navigate('#/');
|
navigate('#/');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -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 { getRole, getUser, pageHeaderHTML, showToast } from '../app.js';
|
||||||
|
import { openAdminResetPasswordModal } from '../password-modal.js';
|
||||||
import { isDevTestUsername, testDataRowClassForUser } from '../test-data.js';
|
import { isDevTestUsername, testDataRowClassForUser } from '../test-data.js';
|
||||||
|
|
||||||
// Roles an admin can create; supervisors can only create coaches
|
// Roles an admin can create; supervisors can only create coaches
|
||||||
@ -14,8 +15,6 @@ let callerRole = '';
|
|||||||
/** @type {Record<string, string>} */
|
/** @type {Record<string, string>} */
|
||||||
let filterSearchByRole = { admin: '', supervisor: '', coach: '' };
|
let filterSearchByRole = { admin: '', supervisor: '', coach: '' };
|
||||||
let filterTestData = '';
|
let filterTestData = '';
|
||||||
/** @type {{ userID: string, username: string, role: string } | null} */
|
|
||||||
let resetPasswordTarget = null;
|
|
||||||
|
|
||||||
function roleGroupsForCaller() {
|
function roleGroupsForCaller() {
|
||||||
return callerRole === 'supervisor'
|
return callerRole === 'supervisor'
|
||||||
@ -68,6 +67,15 @@ export async function usersPage() {
|
|||||||
await loadUsers();
|
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() {
|
async function loadUsers() {
|
||||||
try {
|
try {
|
||||||
const data = await apiGet('users.php');
|
const data = await apiGet('users.php');
|
||||||
@ -216,11 +224,11 @@ function updateRoleGroupTable(roleKey, myUsername) {
|
|||||||
btn.addEventListener('click', () => deleteUser(btn.dataset.id, btn.dataset.name));
|
btn.addEventListener('click', () => deleteUser(btn.dataset.id, btn.dataset.name));
|
||||||
});
|
});
|
||||||
tbody.querySelectorAll('.reset-password-btn').forEach(btn => {
|
tbody.querySelectorAll('.reset-password-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => openResetPasswordModal(
|
btn.addEventListener('click', () => openAdminResetPasswordModal({
|
||||||
btn.dataset.id,
|
userID: btn.dataset.id,
|
||||||
btn.dataset.name,
|
username: btn.dataset.name,
|
||||||
btn.dataset.role || ''
|
role: btn.dataset.role || '',
|
||||||
));
|
}));
|
||||||
});
|
});
|
||||||
tbody.querySelectorAll('.coach-supervisor-select').forEach(sel => {
|
tbody.querySelectorAll('.coach-supervisor-select').forEach(sel => {
|
||||||
sel.addEventListener('change', () => reassignCoachSupervisor(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 = `
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<h2 id="resetPwTitle">Reset password</h2>
|
|
||||||
<p class="modal-subtitle" id="resetPwSubtitle"></p>
|
|
||||||
<p class="modal-hint" id="resetPwSignInHint"></p>
|
|
||||||
<fieldset class="password-mode-fieldset">
|
|
||||||
<legend>Password type</legend>
|
|
||||||
<div class="password-mode-options">
|
|
||||||
<label class="password-mode-option">
|
|
||||||
<input type="radio" name="rp_mode" value="temporary" checked>
|
|
||||||
<span class="password-mode-option-title">Temporary password</span>
|
|
||||||
<span class="password-mode-option-desc">
|
|
||||||
They sign in with the password below, then must choose their own password before they can continue. Use this when you share the password by message or in person.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label class="password-mode-option">
|
|
||||||
<input type="radio" name="rp_mode" value="permanent">
|
|
||||||
<span class="password-mode-option-title">Permanent password</span>
|
|
||||||
<span class="password-mode-option-desc">
|
|
||||||
This becomes their regular password. They are not asked to change it when signing in, until you reset it again.
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="rp_password">New password</label>
|
|
||||||
<input type="password" id="rp_password" autocomplete="new-password" placeholder="At least 6 characters">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="rp_password_confirm">Confirm new password</label>
|
|
||||||
<input type="password" id="rp_password_confirm" autocomplete="new-password" placeholder="Repeat password">
|
|
||||||
</div>
|
|
||||||
<p id="rp_error" class="error-text" style="display:none"></p>
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button type="button" class="btn btn-sm" data-reset-cancel>Cancel</button>
|
|
||||||
<button type="button" class="btn btn-primary btn-sm" data-reset-submit>Set password</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
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 <strong>${esc(username)}</strong> (${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) {
|
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 {
|
||||||
|
|||||||
233
website/js/password-modal.js
Normal file
233
website/js/password-modal.js
Normal file
@ -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 = `
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<h2 id="passwordModalTitle">Password</h2>
|
||||||
|
<p class="modal-subtitle" id="passwordModalSubtitle"></p>
|
||||||
|
<p class="modal-hint" id="passwordModalHint"></p>
|
||||||
|
<fieldset class="password-mode-fieldset" id="passwordModalModeFieldset">
|
||||||
|
<legend>Password type</legend>
|
||||||
|
<div class="password-mode-options">
|
||||||
|
<label class="password-mode-option">
|
||||||
|
<input type="radio" name="pw_mode" value="temporary" checked>
|
||||||
|
<span class="password-mode-option-title">Temporary password</span>
|
||||||
|
<span class="password-mode-option-desc">
|
||||||
|
They sign in with the password below, then must choose their own password before they can continue. Use this when you share the password by message or in person.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="password-mode-option">
|
||||||
|
<input type="radio" name="pw_mode" value="permanent">
|
||||||
|
<span class="password-mode-option-title">Permanent password</span>
|
||||||
|
<span class="password-mode-option-desc">
|
||||||
|
This becomes their regular password. They are not asked to change it when signing in, until you reset it again.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<div class="form-group" id="passwordModalOldGroup" style="display:none">
|
||||||
|
<label for="pw_old_password">Current password</label>
|
||||||
|
<input type="password" id="pw_old_password" autocomplete="current-password" placeholder="Your current password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="pw_new_password">New password</label>
|
||||||
|
<input type="password" id="pw_new_password" autocomplete="new-password" placeholder="At least 6 characters">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="pw_new_password_confirm">Confirm new password</label>
|
||||||
|
<input type="password" id="pw_new_password_confirm" autocomplete="new-password" placeholder="Repeat password">
|
||||||
|
</div>
|
||||||
|
<p id="passwordModalError" class="error-text" style="display:none"></p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-sm" data-password-cancel>Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" data-password-submit>Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 <strong>${esc(username)}</strong> (${esc(roleLabel(role))}).`
|
||||||
|
: `Set a new password for <strong>${esc(username)}</strong> (${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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user