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:
tom.hempel
2026-06-01 23:11:00 +02:00
parent e4d55f4c90
commit 43c47bca6d
13 changed files with 472 additions and 200 deletions

View File

@ -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<string, string>} */
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 = `
<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) {
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
try {