579 lines
22 KiB
JavaScript
579 lines
22 KiB
JavaScript
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
|
|
import { getRole, getUser, pageHeaderHTML, showToast } from '../app.js';
|
|
import { confirmAction } from '../confirm-modal.js';
|
|
import { openAdminResetPasswordModal } from '../password-modal.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<string, string>} */
|
|
let filterSearchByRole = { admin: '', supervisor: '', coach: '' };
|
|
|
|
function roleGroupsForCaller() {
|
|
return callerRole === 'supervisor'
|
|
? [{ label: 'Counselors', key: 'coach' }]
|
|
: [
|
|
{ label: 'Admins', key: 'admin' },
|
|
{ label: 'Supervisors', key: 'supervisor' },
|
|
{ label: 'Counselors', 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', `
|
|
<button class="btn" id="downloadUsersBtn" ${usersList.length ? '' : 'disabled'}>Download CSV</button>
|
|
<button class="btn btn-primary" id="showCreateFormBtn">+ Create User</button>
|
|
`)}
|
|
<div id="createUserWrapper" style="display:none"></div>
|
|
<div id="usersContent"><div class="spinner"></div></div>
|
|
`;
|
|
|
|
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();
|
|
}
|
|
|
|
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');
|
|
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 =
|
|
`<p class="error-text" style="padding:16px">${esc(e.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
// ── User table ────────────────────────────────────────────────────────────
|
|
|
|
function renderUsers() {
|
|
const container = document.getElementById('usersContent');
|
|
const myUsername = getUser();
|
|
|
|
if (!usersList.length) {
|
|
container.innerHTML = `
|
|
<div class="card">
|
|
<div class="empty-state">
|
|
<h3>No users found</h3>
|
|
<p>${callerRole === 'supervisor' ? 'You have no counselors yet.' : 'Create the first user above.'}</p>
|
|
</div>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
const byRole = usersByRoleMap();
|
|
const groups = roleGroupsForCaller();
|
|
|
|
container.innerHTML = groups.map(group => {
|
|
const allInRole = byRole[group.key] || [];
|
|
if (!allInRole.length) return '';
|
|
return `
|
|
<div class="card user-role-card" data-role="${group.key}" style="margin-bottom:16px">
|
|
<h3 class="user-role-title" style="margin-bottom:12px">
|
|
${group.label} (<span data-role-count="${group.key}">0</span>)
|
|
</h3>
|
|
<div class="filter-bar">
|
|
<input type="search" class="filter-search user-role-search" data-role="${group.key}"
|
|
placeholder="${searchPlaceholderForRole(group.key)}"
|
|
value="${esc(filterSearchByRole[group.key] || '')}">
|
|
</div>
|
|
<div class="table-wrapper">
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Username</th>
|
|
${group.key !== 'coach' ? '<th>Location</th>' : '<th>Supervisor</th>'}
|
|
<th>Must change password</th>
|
|
<th>Created</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="usersBody-${group.key}"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
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);
|
|
});
|
|
}
|
|
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 = `
|
|
<tr>
|
|
<td colspan="${colSpan}" style="text-align:center;color:var(--text-secondary);padding:24px">
|
|
${allInRole.length ? 'No users match' : 'No users'}
|
|
</td>
|
|
</tr>`;
|
|
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', () => openAdminResetPasswordModal({
|
|
userID: btn.dataset.id,
|
|
username: btn.dataset.name,
|
|
role: btn.dataset.role || '',
|
|
}));
|
|
});
|
|
tbody.querySelectorAll('.revoke-sessions-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => revokeUserSessions(btn.dataset.id, btn.dataset.name));
|
|
});
|
|
tbody.querySelectorAll('.coach-supervisor-select').forEach(sel => {
|
|
sel.addEventListener('change', () => reassignCoachSupervisor(sel));
|
|
});
|
|
}
|
|
|
|
function supervisorSelectHTML(u) {
|
|
if (!supervisorsList.length) {
|
|
return '<span class="data-toolbar-hint">No supervisors</span>';
|
|
}
|
|
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 `<option value="${esc(s.supervisorID)}"${selected}>${esc(label)}</option>`;
|
|
}).join('');
|
|
return `<select class="coach-supervisor-select" data-user-id="${esc(u.userID)}"
|
|
data-username="${esc(u.username)}" data-prev="${esc(cur)}" aria-label="Supervisor for ${esc(u.username)}">
|
|
${opts}
|
|
</select>`;
|
|
}
|
|
|
|
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
|
|
? '<span class="badge badge-pending" title="Must choose a new password on next sign-in">Temporary</span>'
|
|
: '<span style="color:var(--text-secondary);font-size:.8rem" title="Using a permanent password">Permanent</span>';
|
|
|
|
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 canRevokeSessions = !isSelf && (
|
|
callerRole === 'admin' ||
|
|
(callerRole === 'supervisor' && u.role === 'coach')
|
|
);
|
|
|
|
const actions = [];
|
|
if (canResetPassword) {
|
|
actions.push(
|
|
`<button type="button" class="btn btn-sm reset-password-btn" data-id="${u.userID}" data-name="${esc(u.username)}" data-role="${esc(u.role)}">Reset password</button>`
|
|
);
|
|
}
|
|
if (canRevokeSessions) {
|
|
actions.push(
|
|
`<button type="button" class="btn btn-sm revoke-sessions-btn" data-id="${u.userID}" data-name="${esc(u.username)}">Sign out everywhere</button>`
|
|
);
|
|
}
|
|
if (canDelete) {
|
|
actions.push(
|
|
`<button type="button" class="btn btn-sm btn-danger delete-user-btn" data-id="${u.userID}" data-name="${esc(u.username)}">Delete</button>`
|
|
);
|
|
}
|
|
|
|
return `
|
|
<tr>
|
|
<td>
|
|
<strong>${esc(u.username)}</strong>
|
|
${isSelf ? '<span class="badge badge-you">You</span>' : ''}
|
|
</td>
|
|
<td>${detail}</td>
|
|
<td>${pwBadge}</td>
|
|
<td>${created}</td>
|
|
<td class="user-actions-cell">${actions.join(' ')}</td>
|
|
</tr>`;
|
|
}
|
|
|
|
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(`Counselor "${username}" assigned to ${data.supervisorUsername || 'supervisor'}`, 'success');
|
|
} catch (e) {
|
|
selectEl.value = prev;
|
|
showToast(e.message, 'error');
|
|
} finally {
|
|
selectEl.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function revokeUserSessions(userID, username) {
|
|
if (!(await confirmAction({
|
|
title: 'Sign out everywhere',
|
|
message: `Sign out "${username}" on all devices?\n\nThey must log in again on the website and mobile app.`,
|
|
confirmLabel: 'Sign out',
|
|
variant: 'danger',
|
|
}))) {
|
|
return;
|
|
}
|
|
try {
|
|
const data = await apiPut('users.php', { action: 'revokeSessions', userID });
|
|
if (!data.success) throw new Error(data.error || 'Failed');
|
|
const n = data.revokedSessions ?? 0;
|
|
showToast(`Signed out "${username}" (${n} session${n === 1 ? '' : 's'} revoked)`, 'success');
|
|
} catch (e) {
|
|
showToast(e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function deleteUser(userID, username) {
|
|
if (!(await confirmAction({
|
|
title: 'Delete user',
|
|
message: `Delete user "${username}"? This cannot be undone.`,
|
|
confirmLabel: 'Delete',
|
|
variant: 'danger',
|
|
}))) 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 = `
|
|
<div class="inline-form-card" style="margin-bottom:20px">
|
|
<h4>Create New User</h4>
|
|
<div class="form-row">
|
|
<div class="form-group" style="flex:2">
|
|
<label>Username</label>
|
|
<input type="text" id="cu_username" autocomplete="off" placeholder="Enter username">
|
|
</div>
|
|
<div class="form-group" style="flex:2">
|
|
<label>Password</label>
|
|
<input type="password" id="cu_password" autocomplete="new-password" placeholder="Min 6 characters">
|
|
</div>
|
|
<div class="form-group" style="flex:1;min-width:160px">
|
|
<label>Role</label>
|
|
${isSupervisor
|
|
? `<input type="text" value="Counselor" disabled>`
|
|
: `<select id="cu_role">
|
|
${allowedRoles.map(r =>
|
|
`<option value="${r}">${r === 'coach' ? 'Counselor' : r.charAt(0).toUpperCase() + r.slice(1)}</option>`
|
|
).join('')}
|
|
</select>`
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div id="cu_location_row" class="form-group" ${isSupervisor ? 'style="display:none"' : ''}>
|
|
<label>Location <span style="font-weight:400;color:var(--text-secondary)">(for admin/supervisor)</span></label>
|
|
<input type="text" id="cu_location" placeholder="e.g. Stuttgart">
|
|
</div>
|
|
|
|
<div id="cu_supervisor_row" class="form-group" style="${isSupervisor ? '' : 'display:none'}">
|
|
<label>Supervisor</label>
|
|
${isSupervisor
|
|
? `<input type="text" value="You (auto-assigned)" disabled>`
|
|
: `<select id="cu_supervisor_select">
|
|
<option value="">— select supervisor —</option>
|
|
${supervisorsList.map(s =>
|
|
`<option value="${s.supervisorID}">${esc(s.username)}${s.location ? ` (${esc(s.location)})` : ''}</option>`
|
|
).join('')}
|
|
</select>`
|
|
}
|
|
</div>
|
|
|
|
<fieldset class="password-mode-fieldset" style="margin-bottom:14px">
|
|
<legend>Initial password</legend>
|
|
<div class="password-mode-options">
|
|
<label class="password-mode-option">
|
|
<input type="radio" name="cu_pw_mode" value="temporary" checked>
|
|
<span class="password-mode-option-title">Temporary password</span>
|
|
<span class="password-mode-option-desc">
|
|
They must choose their own password on first sign-in (recommended).
|
|
</span>
|
|
</label>
|
|
<label class="password-mode-option">
|
|
<input type="radio" name="cu_pw_mode" value="permanent">
|
|
<span class="password-mode-option-title">Permanent password</span>
|
|
<span class="password-mode-option-desc">
|
|
The password above stays in effect; no forced change on first sign-in.
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<div style="display:flex;gap:8px">
|
|
<button class="btn btn-primary btn-sm" id="cu_submit">Create User</button>
|
|
<button class="btn btn-sm" id="cu_cancel">Cancel</button>
|
|
</div>
|
|
<p id="cu_error" class="error-text" style="display:none;margin-top:8px"></p>
|
|
</div>
|
|
`;
|
|
|
|
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 counselor');
|
|
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 === 'coach' ? 'counselor' : 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;
|
|
}
|