Implement user authentication and role-based access control
Three-tier user model: admin (all accounts, all actions), editor (assigned accounts, read/write), viewer (assigned accounts, read-only). Backend: - express-session with custom SQLite session store (no extra packages) - bcryptjs for password hashing - src/middleware/auth.js: requireAuth, requireAdmin, requireEditor, canAccessAccount helpers - src/routes/auth.js: login, logout, /me, setup-needed, change-password - src/routes/users.js: full CRUD + account assignments (admin only) - All API routes protected; /api/accounts filtered by user access; write routes gated by requireEditor; admin-only routes locked down Frontend: - Login overlay (full-page) with first-run admin-setup flow - Role-based UI: admin-only elements hidden for non-admins; edit/delete and PDF buttons hidden for viewers; account switcher shows only accessible accounts for non-admins - Users modal (admin only): user list with role badges, create/edit/delete users, set account access via checkboxes - Change-password section available to all logged-in users - apiFetch redirects to login on 401
This commit is contained in:
@@ -42,6 +42,8 @@ header {
|
||||
.header-info { font-size: 12px; color: rgba(255,255,255,0.7); }
|
||||
.header-info strong { color: #fff; }
|
||||
.header-left { display: flex; align-items: center; gap: 10px; }
|
||||
.header-right { display: flex; align-items: center; gap: 10px; }
|
||||
.header-username { font-size: 12px; color: rgba(255,255,255,0.7); }
|
||||
|
||||
.account-switcher {
|
||||
background: rgba(255,255,255,0.15);
|
||||
@@ -760,3 +762,46 @@ input[type="file"] {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.dep-pdf-btns { display: flex; gap: 6px; }
|
||||
|
||||
/* ── Login overlay ── */
|
||||
.login-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--header-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
.login-overlay.hidden { display: none; }
|
||||
.login-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 32px;
|
||||
width: 360px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.35);
|
||||
}
|
||||
.login-logo {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--header-bg);
|
||||
margin-bottom: 20px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.login-card h2 { font-size: 16px; font-weight: 600; margin-bottom: 4px; }
|
||||
.login-sub { font-size: 12px; color: var(--text-muted); margin-bottom: 16px; }
|
||||
|
||||
/* ── User management ── */
|
||||
.account-checkboxes { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
|
||||
.account-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.account-checkbox-label:hover { border-color: var(--primary); }
|
||||
|
||||
+110
-2
@@ -7,13 +7,58 @@
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Login overlay -->
|
||||
<div id="login-overlay" class="login-overlay">
|
||||
<div class="login-card" id="login-card">
|
||||
<div class="login-logo">ezcheck</div>
|
||||
<!-- First-run: create admin -->
|
||||
<div id="login-setup-section" hidden>
|
||||
<h2>Create Admin Account</h2>
|
||||
<p class="login-sub">No users exist yet. Set up the first admin account.</p>
|
||||
<div class="form-group">
|
||||
<label for="setup-username">Username</label>
|
||||
<input type="text" id="setup-username" autocomplete="username" autocapitalize="none">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="setup-password">Password <span class="field-hint">(min 8 characters)</span></label>
|
||||
<input type="password" id="setup-password" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="setup-password2">Confirm Password</label>
|
||||
<input type="password" id="setup-password2" autocomplete="new-password">
|
||||
</div>
|
||||
<div id="setup-error" class="wizard-error" hidden></div>
|
||||
<button id="btn-setup-submit" class="btn-primary" style="width:100%;margin-top:8px">Create Admin & Sign In</button>
|
||||
</div>
|
||||
<!-- Normal login -->
|
||||
<div id="login-form-section" hidden>
|
||||
<h2>Sign In</h2>
|
||||
<div class="form-group">
|
||||
<label for="login-username">Username</label>
|
||||
<input type="text" id="login-username" autocomplete="username" autocapitalize="none">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="login-password">Password</label>
|
||||
<input type="password" id="login-password" autocomplete="current-password">
|
||||
</div>
|
||||
<div id="login-error" class="wizard-error" hidden></div>
|
||||
<button id="btn-login-submit" class="btn-primary" style="width:100%;margin-top:8px">Sign In</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<span class="header-brand" id="company-name">ezcheck</span>
|
||||
<select id="account-switcher" class="account-switcher" title="Switch account"></select>
|
||||
<button id="btn-account-settings" class="btn-header-icon" title="Account settings">⚙</button>
|
||||
<button id="btn-account-settings" class="btn-header-icon" title="Account settings" data-admin-only>⚙</button>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="header-info">Next check: <strong id="current-check-no">—</strong><button id="btn-set-check-no" class="btn-header-inline" title="Set next check number" data-admin-only>✎</button></span>
|
||||
<button id="btn-users" class="btn-header-icon" title="Manage users" data-admin-only hidden>👥</button>
|
||||
<span id="header-username" class="header-username"></span>
|
||||
<button id="btn-logout" class="btn-header-icon" title="Sign out">↩</button>
|
||||
</div>
|
||||
<span class="header-info">Next check: <strong id="current-check-no">—</strong><button id="btn-set-check-no" class="btn-header-inline" title="Set next check number">✎</button></span>
|
||||
</header>
|
||||
|
||||
<!-- View nav tabs -->
|
||||
@@ -559,6 +604,69 @@
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- User management modal (admin only) -->
|
||||
<div id="users-overlay" class="modal-overlay"></div>
|
||||
<div id="users-modal" class="modal modal-wide" role="dialog" aria-labelledby="users-title">
|
||||
<div class="modal-header">
|
||||
<h2 id="users-title">Manage Users</h2>
|
||||
<button id="btn-close-users" class="btn-icon" title="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="users-list"></div>
|
||||
<div style="margin-top:16px;border-top:1px solid var(--border);padding-top:16px">
|
||||
<h3 style="font-size:13px;font-weight:600;margin-bottom:10px" id="user-form-title">Add User</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group required">
|
||||
<label for="uf-username">Username</label>
|
||||
<input type="text" id="uf-username" autocapitalize="none">
|
||||
</div>
|
||||
<div class="form-group required">
|
||||
<label for="uf-password">Password <span class="field-hint" id="uf-password-hint">(min 8 chars)</span></label>
|
||||
<input type="password" id="uf-password" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-group required">
|
||||
<label for="uf-role">Role</label>
|
||||
<select id="uf-role">
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="uf-accounts-group">
|
||||
<label>Account Access <span class="field-hint">(admins see all — no selection needed)</span></label>
|
||||
<div id="uf-accounts-checkboxes" class="account-checkboxes"></div>
|
||||
</div>
|
||||
<div id="user-form-error" class="wizard-error" hidden></div>
|
||||
<div style="display:flex;gap:8px;margin-top:8px">
|
||||
<button id="btn-save-user" class="btn-primary">Add User</button>
|
||||
<button id="btn-cancel-user-edit" class="btn-ghost" hidden>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Change own password -->
|
||||
<div style="margin-top:16px;border-top:1px solid var(--border);padding-top:16px">
|
||||
<h3 style="font-size:13px;font-weight:600;margin-bottom:10px">Change My Password</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="cp-current">Current Password</label>
|
||||
<input type="password" id="cp-current" autocomplete="current-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cp-new">New Password <span class="field-hint">(min 8 chars)</span></label>
|
||||
<input type="password" id="cp-new" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cp-confirm">Confirm New</label>
|
||||
<input type="password" id="cp-confirm" autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
<div id="cp-error" class="wizard-error" hidden></div>
|
||||
<div id="cp-success" class="import-result" hidden>Password changed.</div>
|
||||
<button id="btn-change-password" class="btn-secondary" style="margin-top:8px">Change Password</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+327
-8
@@ -5,7 +5,7 @@ const state = {
|
||||
account: null,
|
||||
accounts: [],
|
||||
activeAccountId: parseInt(localStorage.getItem('activeAccountId'), 10) || null,
|
||||
filterStatus: '', // '' = all, '0' = unprinted, '1' = printed
|
||||
filterStatus: '',
|
||||
filterPayee: '',
|
||||
filterDateFrom: '',
|
||||
filterDateTo: '',
|
||||
@@ -13,6 +13,7 @@ const state = {
|
||||
sortDir: 'desc',
|
||||
selected: new Set(),
|
||||
editingId: null,
|
||||
user: null, // { id, username, role }
|
||||
};
|
||||
|
||||
// ── API helpers ──────────────────────────────────────────────────────────────
|
||||
@@ -21,19 +22,315 @@ async function apiFetch(method, path, body) {
|
||||
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(path, opts);
|
||||
if (res.status === 401) { showLoginOverlay(); return null; }
|
||||
if (res.status === 204) return null;
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || res.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function showLoginOverlay() {
|
||||
document.getElementById('login-overlay').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideLoginOverlay() {
|
||||
document.getElementById('login-overlay').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function checkAuth() {
|
||||
// Is there already a session?
|
||||
const res = await fetch('/api/auth/me');
|
||||
if (res.ok) {
|
||||
state.user = await res.json();
|
||||
hideLoginOverlay();
|
||||
applyRoleUI();
|
||||
return true;
|
||||
}
|
||||
// No session — check if this is first-run (no users at all)
|
||||
const setup = await fetch('/api/auth/setup-needed');
|
||||
const { setupNeeded } = await setup.json();
|
||||
if (setupNeeded) {
|
||||
document.getElementById('login-setup-section').hidden = false;
|
||||
document.getElementById('login-form-section').hidden = true;
|
||||
} else {
|
||||
document.getElementById('login-setup-section').hidden = true;
|
||||
document.getElementById('login-form-section').hidden = false;
|
||||
}
|
||||
showLoginOverlay();
|
||||
return false;
|
||||
}
|
||||
|
||||
async function submitLogin() {
|
||||
const username = document.getElementById('login-username').value.trim();
|
||||
const password = document.getElementById('login-password').value;
|
||||
const errEl = document.getElementById('login-error');
|
||||
const btn = document.getElementById('btn-login-submit');
|
||||
errEl.hidden = true;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Signing in…';
|
||||
try {
|
||||
state.user = await apiFetch('POST', '/api/auth/login', { username, password });
|
||||
if (!state.user) return; // 401 already handled by apiFetch
|
||||
hideLoginOverlay();
|
||||
applyRoleUI();
|
||||
await loadAccounts();
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.hidden = false;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Sign In';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitSetup() {
|
||||
const username = document.getElementById('setup-username').value.trim();
|
||||
const password = document.getElementById('setup-password').value;
|
||||
const password2 = document.getElementById('setup-password2').value;
|
||||
const errEl = document.getElementById('setup-error');
|
||||
const btn = document.getElementById('btn-setup-submit');
|
||||
errEl.hidden = true;
|
||||
if (password !== password2) { errEl.textContent = 'Passwords do not match.'; errEl.hidden = false; return; }
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Creating…';
|
||||
try {
|
||||
state.user = await apiFetch('POST', '/api/auth/setup', { username, password });
|
||||
hideLoginOverlay();
|
||||
applyRoleUI();
|
||||
await loadAccounts();
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.hidden = false;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Create Admin & Sign In';
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
state.user = null;
|
||||
state.checks = [];
|
||||
state.accounts = [];
|
||||
state.account = null;
|
||||
state.activeAccountId = null;
|
||||
document.getElementById('login-username').value = '';
|
||||
document.getElementById('login-password').value = '';
|
||||
document.getElementById('login-error').hidden = true;
|
||||
document.getElementById('login-setup-section').hidden = true;
|
||||
document.getElementById('login-form-section').hidden = false;
|
||||
showLoginOverlay();
|
||||
}
|
||||
|
||||
// Hide/show elements based on role
|
||||
function applyRoleUI() {
|
||||
const role = state.user ? state.user.role : 'viewer';
|
||||
const isAdmin = role === 'admin';
|
||||
const isEditor = role === 'admin' || role === 'editor';
|
||||
|
||||
document.getElementById('header-username').textContent = state.user ? state.user.username : '';
|
||||
|
||||
// Admin-only elements
|
||||
document.querySelectorAll('[data-admin-only]').forEach(el => { el.hidden = !isAdmin; });
|
||||
|
||||
// Editor+ elements (hide for viewers)
|
||||
document.querySelectorAll('[data-editor-only]').forEach(el => { el.hidden = !isEditor; });
|
||||
|
||||
// Users button (admin only)
|
||||
document.getElementById('btn-users').hidden = !isAdmin;
|
||||
}
|
||||
|
||||
// ── User management ────────────────────────────────────────────────────────────
|
||||
|
||||
let usersState = { users: [], editingId: null };
|
||||
|
||||
function openUsersModal() {
|
||||
document.getElementById('user-form-error').hidden = true;
|
||||
document.getElementById('users-overlay').classList.add('open');
|
||||
document.getElementById('users-modal').classList.add('open');
|
||||
loadUsers();
|
||||
renderUfAccountCheckboxes();
|
||||
}
|
||||
|
||||
function closeUsersModal() {
|
||||
document.getElementById('users-overlay').classList.remove('open');
|
||||
document.getElementById('users-modal').classList.remove('open');
|
||||
cancelUserEdit();
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
usersState.users = await apiFetch('GET', '/api/users');
|
||||
renderUsersList();
|
||||
} catch (err) {
|
||||
document.getElementById('users-list').innerHTML =
|
||||
`<p style="color:var(--danger)">${escHtml(err.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function roleBadge(role) {
|
||||
const colors = { admin: '#2563eb', editor: '#16a34a', viewer: '#6b7280' };
|
||||
return `<span style="background:${colors[role]};color:#fff;font-size:10px;font-weight:600;padding:1px 6px;border-radius:3px;text-transform:uppercase">${role}</span>`;
|
||||
}
|
||||
|
||||
function renderUsersList() {
|
||||
const el = document.getElementById('users-list');
|
||||
const { users } = usersState;
|
||||
if (!users.length) { el.innerHTML = '<p style="color:var(--text-muted)">No users.</p>'; return; }
|
||||
|
||||
el.innerHTML = `<table class="qbo-preview-table" style="width:100%">
|
||||
<thead><tr><th>Username</th><th>Role</th><th>Account Access</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
${users.map(u => {
|
||||
const isSelf = u.id === state.user.id;
|
||||
const accountsLabel = u.role === 'admin'
|
||||
? '<em style="color:var(--text-muted)">All accounts</em>'
|
||||
: (u.accounts.length ? u.accounts.map(aid => {
|
||||
const a = state.accounts.find(x => x.id === aid);
|
||||
return escHtml(a ? (a.company1 || `Account ${a.id}`) : `#${aid}`);
|
||||
}).join(', ') : '<em style="color:var(--text-muted)">None</em>');
|
||||
return `<tr>
|
||||
<td><strong>${escHtml(u.username)}</strong>${isSelf ? ' <em style="color:var(--text-muted)">(you)</em>' : ''}</td>
|
||||
<td>${roleBadge(u.role)}</td>
|
||||
<td style="font-size:12px">${accountsLabel}</td>
|
||||
<td style="white-space:nowrap">
|
||||
<button class="btn-sm btn-secondary" onclick="startUserEdit(${u.id})">Edit</button>
|
||||
${!isSelf ? `<button class="btn-sm btn-danger" style="margin-left:4px" onclick="deleteUser(${u.id})">Delete</button>` : ''}
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('')}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
function renderUfAccountCheckboxes() {
|
||||
const role = document.getElementById('uf-role').value;
|
||||
const group = document.getElementById('uf-accounts-group');
|
||||
group.hidden = role === 'admin';
|
||||
const container = document.getElementById('uf-accounts-checkboxes');
|
||||
const currentAccounts = usersState.editingId
|
||||
? (usersState.users.find(u => u.id === usersState.editingId) || {}).accounts || []
|
||||
: [];
|
||||
container.innerHTML = state.accounts.map(a =>
|
||||
`<label class="account-checkbox-label">
|
||||
<input type="checkbox" name="uf-account" value="${a.id}"${currentAccounts.includes(a.id) ? ' checked' : ''}>
|
||||
${escHtml(a.company1 || a.bank_name || `Account ${a.id}`)}
|
||||
</label>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function startUserEdit(userId) {
|
||||
const u = usersState.users.find(x => x.id === userId);
|
||||
if (!u) return;
|
||||
usersState.editingId = userId;
|
||||
document.getElementById('user-form-title').textContent = `Edit User: ${u.username}`;
|
||||
document.getElementById('uf-username').value = u.username;
|
||||
document.getElementById('uf-password').value = '';
|
||||
document.getElementById('uf-password-hint').textContent = '(leave blank to keep)';
|
||||
document.getElementById('uf-role').value = u.role;
|
||||
document.getElementById('btn-save-user').textContent = 'Save Changes';
|
||||
document.getElementById('btn-cancel-user-edit').hidden = false;
|
||||
document.getElementById('user-form-error').hidden = true;
|
||||
renderUfAccountCheckboxes();
|
||||
document.getElementById('uf-username').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
function cancelUserEdit() {
|
||||
usersState.editingId = null;
|
||||
document.getElementById('user-form-title').textContent = 'Add User';
|
||||
document.getElementById('uf-username').value = '';
|
||||
document.getElementById('uf-password').value = '';
|
||||
document.getElementById('uf-password-hint').textContent = '(min 8 chars)';
|
||||
document.getElementById('uf-role').value = 'viewer';
|
||||
document.getElementById('btn-save-user').textContent = 'Add User';
|
||||
document.getElementById('btn-cancel-user-edit').hidden = true;
|
||||
document.getElementById('user-form-error').hidden = true;
|
||||
renderUfAccountCheckboxes();
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
const errEl = document.getElementById('user-form-error');
|
||||
const btn = document.getElementById('btn-save-user');
|
||||
errEl.hidden = true;
|
||||
const username = document.getElementById('uf-username').value.trim();
|
||||
const password = document.getElementById('uf-password').value;
|
||||
const role = document.getElementById('uf-role').value;
|
||||
const accounts = Array.from(document.querySelectorAll('input[name="uf-account"]:checked'))
|
||||
.map(cb => parseInt(cb.value, 10));
|
||||
|
||||
if (!username) { errEl.textContent = 'Username required.'; errEl.hidden = false; return; }
|
||||
if (!usersState.editingId && !password) { errEl.textContent = 'Password required.'; errEl.hidden = false; return; }
|
||||
|
||||
btn.disabled = true;
|
||||
const origText = btn.textContent;
|
||||
btn.textContent = 'Saving…';
|
||||
try {
|
||||
const body = { username, role, accounts };
|
||||
if (password) body.password = password;
|
||||
if (usersState.editingId) {
|
||||
await apiFetch('PUT', `/api/users/${usersState.editingId}`, body);
|
||||
} else {
|
||||
await apiFetch('POST', '/api/users', body);
|
||||
}
|
||||
cancelUserEdit();
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.hidden = false;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = origText;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(userId) {
|
||||
const u = usersState.users.find(x => x.id === userId);
|
||||
if (!u) return;
|
||||
if (!confirm(`Delete user "${u.username}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
await apiFetch('DELETE', `/api/users/${userId}`);
|
||||
if (usersState.editingId === userId) cancelUserEdit();
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
alert('Delete failed: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function changeOwnPassword() {
|
||||
const errEl = document.getElementById('cp-error');
|
||||
const successEl = document.getElementById('cp-success');
|
||||
const btn = document.getElementById('btn-change-password');
|
||||
errEl.hidden = true;
|
||||
successEl.hidden = true;
|
||||
const current = document.getElementById('cp-current').value;
|
||||
const next = document.getElementById('cp-new').value;
|
||||
const confirm2 = document.getElementById('cp-confirm').value;
|
||||
if (next !== confirm2) { errEl.textContent = 'New passwords do not match.'; errEl.hidden = false; return; }
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await apiFetch('POST', '/api/auth/change-password', { current_password: current, new_password: next });
|
||||
document.getElementById('cp-current').value = '';
|
||||
document.getElementById('cp-new').value = '';
|
||||
document.getElementById('cp-confirm').value = '';
|
||||
successEl.hidden = false;
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.hidden = false;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data loading ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadAccounts() {
|
||||
try {
|
||||
state.accounts = await apiFetch('GET', '/api/accounts');
|
||||
if (!state.accounts) return; // 401 redirect handled by apiFetch
|
||||
if (state.accounts.length === 0) {
|
||||
openWizard();
|
||||
// Only admins can create accounts; non-admins just see an empty state
|
||||
if (state.user && state.user.role === 'admin') openWizard();
|
||||
return;
|
||||
}
|
||||
// Use stored account or default to first
|
||||
@@ -133,14 +430,19 @@ function renderRow(c) {
|
||||
})
|
||||
: '—';
|
||||
|
||||
const checkbox = `<td class="col-select"><input type="checkbox" data-id="${c.id}"${selected ? ' checked' : ''}></td>`;
|
||||
const checkbox = isEditor
|
||||
? `<td class="col-select"><input type="checkbox" data-id="${c.id}"${selected ? ' checked' : ''}></td>`
|
||||
: `<td class="col-select"></td>`;
|
||||
|
||||
const statusBadge = printed
|
||||
? '<span class="status-badge status-printed">Printed</span>'
|
||||
: '<span class="status-badge status-unprinted">Unprinted</span>';
|
||||
|
||||
const actions = `<button class="btn-sm btn-edit" data-id="${c.id}">Edit</button>` +
|
||||
`<button class="btn-sm btn-delete" data-id="${c.id}">Delete</button>`;
|
||||
const isEditor = state.user && (state.user.role === 'admin' || state.user.role === 'editor');
|
||||
const actions = isEditor
|
||||
? `<button class="btn-sm btn-edit" data-id="${c.id}">Edit</button>` +
|
||||
`<button class="btn-sm btn-delete" data-id="${c.id}">Delete</button>`
|
||||
: '';
|
||||
|
||||
return `<tr class="${printed ? 'printed' : ''}">
|
||||
${checkbox}
|
||||
@@ -1180,7 +1482,7 @@ function escHtml(str) {
|
||||
|
||||
// ── Initialization ───────────────────────────────────────────────────────────
|
||||
|
||||
function init() {
|
||||
async function init() {
|
||||
// Column sort
|
||||
document.querySelectorAll('thead th.sortable').forEach(th => {
|
||||
th.addEventListener('click', () => {
|
||||
@@ -1354,8 +1656,25 @@ function init() {
|
||||
document.getElementById('btn-qbo-checks-cancel').addEventListener('click', closeQboImport);
|
||||
document.getElementById('btn-qbo-deposits-cancel').addEventListener('click', closeQboImport);
|
||||
|
||||
// Initial data load
|
||||
loadAccounts();
|
||||
// Auth event listeners
|
||||
document.getElementById('btn-login-submit').addEventListener('click', submitLogin);
|
||||
document.getElementById('btn-setup-submit').addEventListener('click', submitSetup);
|
||||
document.getElementById('btn-logout').addEventListener('click', logout);
|
||||
document.getElementById('login-password').addEventListener('keydown', e => { if (e.key === 'Enter') submitLogin(); });
|
||||
document.getElementById('setup-password2').addEventListener('keydown', e => { if (e.key === 'Enter') submitSetup(); });
|
||||
|
||||
// User management
|
||||
document.getElementById('btn-users').addEventListener('click', openUsersModal);
|
||||
document.getElementById('btn-close-users').addEventListener('click', closeUsersModal);
|
||||
document.getElementById('users-overlay').addEventListener('click', closeUsersModal);
|
||||
document.getElementById('btn-save-user').addEventListener('click', saveUser);
|
||||
document.getElementById('btn-cancel-user-edit').addEventListener('click', cancelUserEdit);
|
||||
document.getElementById('uf-role').addEventListener('change', renderUfAccountCheckboxes);
|
||||
document.getElementById('btn-change-password').addEventListener('click', changeOwnPassword);
|
||||
|
||||
// Initial auth check → loads app if already signed in
|
||||
const authed = await checkAuth();
|
||||
if (authed) await loadAccounts();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
Reference in New Issue
Block a user