import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from '../api.js'; import { getRole, getUser, pageHeaderHTML, showToast } from '../app.js'; import { isDevTestUsername, testDataRowClassForUser } from '../test-data.js'; // Roles an admin can create; supervisors can only create coaches const CREATEABLE_ROLES = { admin: ['admin', 'supervisor', 'coach'], supervisor: ['coach'], }; let usersList = []; let supervisorsList = []; 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' ? [{ label: 'Coaches', key: 'coach' }] : [ { label: 'Admins', key: 'admin' }, { label: 'Supervisors', key: 'supervisor' }, { label: 'Coaches', key: 'coach' }, ]; } function searchPlaceholderForRole(roleKey) { return roleKey === 'coach' ? 'Search username or supervisor…' : 'Search username or location…'; } function usersByRoleMap() { const byRole = { admin: [], supervisor: [], coach: [] }; usersList.forEach(u => { if (byRole[u.role]) byRole[u.role].push(u); else (byRole.other = byRole.other || []).push(u); }); return byRole; } export async function usersPage() { callerRole = getRole(); const app = document.getElementById('app'); app.innerHTML = ` ${pageHeaderHTML('Users', ` `)}
`; document.getElementById('showCreateFormBtn').addEventListener('click', () => { const wrapper = document.getElementById('createUserWrapper'); if (wrapper.style.display === 'none') { showCreateForm(); } else { hideCreateForm(); } }); document.getElementById('downloadUsersBtn').addEventListener('click', downloadUsersCSV); await loadUsers(); } async function loadUsers() { try { const data = await apiGet('users.php'); usersList = data.users || []; supervisorsList = data.supervisors || []; callerRole = data.callerRole || callerRole; renderUsers(); const dlBtn = document.getElementById('downloadUsersBtn'); if (dlBtn) dlBtn.disabled = !usersList.length; } catch (e) { showToast(e.message, 'error'); document.getElementById('usersContent').innerHTML = `

${esc(e.message)}

`; } } // ── User table ──────────────────────────────────────────────────────────── function renderUsers() { const container = document.getElementById('usersContent'); const myUsername = getUser(); if (!usersList.length) { container.innerHTML = `

No users found

${callerRole === 'supervisor' ? 'You have no coaches yet.' : 'Create the first user above.'}

`; return; } const byRole = usersByRoleMap(); const groups = roleGroupsForCaller(); const testFilterCard = `
`; container.innerHTML = testFilterCard + groups.map(group => { const allInRole = byRole[group.key] || []; if (!allInRole.length) return ''; return `

${group.label} (0)

${group.key !== 'coach' ? '' : ''}
UsernameLocationSupervisorMust change password Created Actions
`; }).join(''); document.getElementById('userTestFilter')?.addEventListener('change', (e) => { filterTestData = e.target.value; renderUsers(); }); container.addEventListener('input', (e) => { const input = e.target.closest('.user-role-search'); if (!input) return; const roleKey = input.dataset.role; if (!roleKey) return; filterSearchByRole[roleKey] = input.value; updateRoleGroupTable(roleKey, myUsername); }); groups.forEach(group => { if ((byRole[group.key] || []).length) { updateRoleGroupTable(group.key, myUsername); } }); } function filterUsers(users, roleKey) { let list = users; const q = (filterSearchByRole[roleKey] || '').trim().toLowerCase(); if (q) { list = list.filter(u => { const hay = [u.username, u.location, u.supervisorUsername].filter(Boolean).join(' ').toLowerCase(); return hay.includes(q); }); } if (filterTestData === 'test') { list = list.filter(u => isDevTestUsername(u.username)); } else if (filterTestData === 'hide-test') { list = list.filter(u => !isDevTestUsername(u.username)); } return list; } function updateRoleGroupTable(roleKey, myUsername) { const tbody = document.getElementById(`usersBody-${roleKey}`); const countEl = document.querySelector(`[data-role-count="${roleKey}"]`); if (!tbody) return; const byRole = usersByRoleMap(); const allInRole = byRole[roleKey] || []; const users = filterUsers(allInRole, roleKey); const colSpan = 5; if (countEl) { const q = (filterSearchByRole[roleKey] || '').trim(); countEl.textContent = q && users.length !== allInRole.length ? `${users.length} of ${allInRole.length}` : String(users.length); } if (!users.length) { tbody.innerHTML = ` ${allInRole.length ? 'No users match' : 'No users'} `; return; } tbody.innerHTML = users.map(u => userRowHTML(u, myUsername)).join(''); tbody.querySelectorAll('.delete-user-btn').forEach(btn => { btn.addEventListener('click', () => deleteUser(btn.dataset.id, btn.dataset.name)); }); tbody.querySelectorAll('.reset-password-btn').forEach(btn => { btn.addEventListener('click', () => openResetPasswordModal( btn.dataset.id, btn.dataset.name, btn.dataset.role || '' )); }); tbody.querySelectorAll('.coach-supervisor-select').forEach(sel => { sel.addEventListener('change', () => reassignCoachSupervisor(sel)); }); } function supervisorSelectHTML(u) { if (!supervisorsList.length) { return 'No supervisors'; } const cur = u.supervisorID || ''; const opts = supervisorsList.map(s => { const label = s.location ? `${s.username} (${s.location})` : s.username; const selected = s.supervisorID === cur ? ' selected' : ''; return ``; }).join(''); return ``; } function userRowHTML(u, myUsername) { const isSelf = u.username === myUsername; let detail; if (u.role === 'coach') { detail = callerRole === 'admin' ? supervisorSelectHTML(u) : esc(u.supervisorUsername || '—'); } else { detail = esc(u.location || '—'); } const created = u.createdAt ? new Date(parseInt(u.createdAt, 10) * 1000).toLocaleDateString() : '—'; const pwBadge = u.mustChangePassword == 1 ? 'Temporary' : 'Permanent'; const canDelete = !isSelf && ( callerRole === 'admin' || (callerRole === 'supervisor' && u.role === 'coach') ); const canResetPassword = !isSelf && ( (callerRole === 'admin' && (u.role === 'supervisor' || u.role === 'coach')) || (callerRole === 'supervisor' && u.role === 'coach') ); const actions = []; if (canResetPassword) { actions.push( `` ); } if (canDelete) { actions.push( `` ); } return ` ${esc(u.username)} ${isSelf ? 'You' : ''} ${detail} ${pwBadge} ${created} ${actions.join(' ')} `; } async function reassignCoachSupervisor(selectEl) { const userID = selectEl.dataset.userId; const username = selectEl.dataset.username || ''; const prev = selectEl.dataset.prev || ''; const supervisorID = selectEl.value; if (supervisorID === prev) { return; } selectEl.disabled = true; try { const data = await apiPut('users.php', { userID, supervisorID }); const u = usersList.find(x => x.userID === userID); if (u) { u.supervisorID = data.supervisorID ?? supervisorID; u.supervisorUsername = data.supervisorUsername ?? ''; } selectEl.dataset.prev = supervisorID; showToast(`Coach "${username}" assigned to ${data.supervisorUsername || 'supervisor'}`, 'success'); } catch (e) { selectEl.value = prev; showToast(e.message, 'error'); } finally { selectEl.disabled = false; } } 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 { const data = await apiDelete('users.php', { userID }); if (data.success) { usersList = usersList.filter(u => u.userID !== userID); showToast(`User "${username}" deleted`, 'success'); renderUsers(); } } catch (e) { showToast(e.message, 'error'); } } // ── Create user form ────────────────────────────────────────────────────── function showCreateForm() { const wrapper = document.getElementById('createUserWrapper'); const allowedRoles = CREATEABLE_ROLES[callerRole] || ['coach']; const isSupervisor = callerRole === 'supervisor'; wrapper.style.display = ''; wrapper.innerHTML = `

Create New User

${isSupervisor ? `` : `` }
${isSupervisor ? `` : `` }
Initial password
`; document.getElementById('cu_username').focus(); // Show/hide location vs supervisor field based on role selection (admin only) if (!isSupervisor) { document.getElementById('cu_role').addEventListener('change', (e) => { const r = e.target.value; document.getElementById('cu_location_row').style.display = r !== 'coach' ? '' : 'none'; document.getElementById('cu_supervisor_row').style.display = r === 'coach' ? '' : 'none'; }); // Set initial state based on default selection const initRole = document.getElementById('cu_role').value; document.getElementById('cu_location_row').style.display = initRole !== 'coach' ? '' : 'none'; document.getElementById('cu_supervisor_row').style.display = initRole === 'coach' ? '' : 'none'; } document.getElementById('cu_submit').addEventListener('click', submitCreateUser); document.getElementById('cu_cancel').addEventListener('click', hideCreateForm); // Enter key on last visible field submits wrapper.querySelectorAll('input').forEach(inp => { inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') submitCreateUser(); if (e.key === 'Escape') hideCreateForm(); }); }); } function hideCreateForm() { const wrapper = document.getElementById('createUserWrapper'); if (wrapper) { wrapper.style.display = 'none'; wrapper.innerHTML = ''; } } async function submitCreateUser() { const errEl = document.getElementById('cu_error'); errEl.style.display = 'none'; const isSupervisor = callerRole === 'supervisor'; const username = document.getElementById('cu_username').value.trim(); const password = document.getElementById('cu_password').value; const role = isSupervisor ? 'coach' : (document.getElementById('cu_role').value || 'coach'); const mustChange = document.querySelector('input[name="cu_pw_mode"]:checked')?.value === 'temporary' ? 1 : 0; let location = ''; let supervisorID = ''; if (!isSupervisor) { if (role !== 'coach') { location = document.getElementById('cu_location').value.trim(); } else { supervisorID = document.getElementById('cu_supervisor_select').value; } } if (!username) { showError(errEl, 'Username is required'); return; } if (password.length < 6) { showError(errEl, 'Password must be at least 6 characters'); return; } if (role === 'coach' && !isSupervisor && !supervisorID) { showError(errEl, 'Please select a supervisor for this coach'); return; } const btn = document.getElementById('cu_submit'); btn.disabled = true; btn.textContent = 'Creating...'; try { const data = await apiPost('users.php', { username, password, role, location, supervisorID, mustChangePassword: mustChange, }); if (!data.success) throw new Error(data.error || 'Failed to create user'); // Add to local list and re-render usersList.push({ userID: data.userID, username: data.username, role: data.role, entityID: data.entityID, location: data.location || '', supervisorID: data.supervisorID || null, supervisorUsername: data.supervisorUsername || null, mustChangePassword: data.mustChangePassword, createdAt: data.createdAt, }); showToast(`User "${data.username}" created`, 'success'); hideCreateForm(); renderUsers(); } catch (e) { showError(errEl, e.message); btn.disabled = false; btn.textContent = 'Create User'; } } function showError(el, msg) { el.textContent = msg; el.style.display = ''; } function downloadUsersCSV() { if (!usersList.length) { showToast('No users to export', 'error'); return; } const headers = ['username', 'role', 'location', 'supervisor', 'mustChangePassword', 'createdAt', 'userID']; const rows = usersList.map(u => [ u.username, u.role, u.role === 'coach' ? '' : (u.location || ''), u.role === 'coach' ? (u.supervisorUsername || u.supervisorID || '') : '', u.mustChangePassword == 1 ? 'yes' : 'no', u.createdAt ? new Date(parseInt(u.createdAt, 10) * 1000).toISOString() : '', u.userID, ]); let csv = '\uFEFF'; csv += headers.map(csvEsc).join(',') + '\n'; rows.forEach(r => { csv += r.map(csvEsc).join(',') + '\n'; }); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `users_${new Date().toISOString().slice(0, 10)}.csv`; a.click(); URL.revokeObjectURL(url); showToast('Users exported', 'success'); } function csvEsc(val) { const s = String(val ?? ''); if (s.includes(',') || s.includes('"') || s.includes('\n')) { return '"' + s.replace(/"/g, '""') + '"'; } return s; } function esc(s) { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }