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:
Generated
+64
@@ -8,8 +8,10 @@
|
|||||||
"name": "ezcheck",
|
"name": "ezcheck",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^9.4.3",
|
"better-sqlite3": "^9.4.3",
|
||||||
"express": "^4.18.3",
|
"express": "^4.18.3",
|
||||||
|
"express-session": "^1.19.0",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"pdfkit": "^0.15.0"
|
"pdfkit": "^0.15.0"
|
||||||
},
|
},
|
||||||
@@ -129,6 +131,15 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"bin": {
|
||||||
|
"bcrypt": "bin/bcrypt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/better-sqlite3": {
|
"node_modules/better-sqlite3": {
|
||||||
"version": "9.6.0",
|
"version": "9.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz",
|
||||||
@@ -717,6 +728,29 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-session": {
|
||||||
|
"version": "1.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
|
||||||
|
"integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "~0.7.2",
|
||||||
|
"cookie-signature": "~1.0.7",
|
||||||
|
"debug": "~2.6.9",
|
||||||
|
"depd": "~2.0.0",
|
||||||
|
"on-headers": "~1.1.0",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"safe-buffer": "~5.2.1",
|
||||||
|
"uid-safe": "~2.1.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/file-uri-to-path": {
|
"node_modules/file-uri-to-path": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||||
@@ -1679,6 +1713,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/on-headers": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/once": {
|
"node_modules/once": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
@@ -1821,6 +1864,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/random-bytes": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/range-parser": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
@@ -2345,6 +2397,18 @@
|
|||||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/uid-safe": {
|
||||||
|
"version": "2.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
|
||||||
|
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"random-bytes": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undefsafe": {
|
"node_modules/undefsafe": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||||
|
|||||||
@@ -9,8 +9,10 @@
|
|||||||
"migrate": "node migrations/import-mdb.js"
|
"migrate": "node migrations/import-mdb.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^9.4.3",
|
"better-sqlite3": "^9.4.3",
|
||||||
"express": "^4.18.3",
|
"express": "^4.18.3",
|
||||||
|
"express-session": "^1.19.0",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"pdfkit": "^0.15.0"
|
"pdfkit": "^0.15.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ header {
|
|||||||
.header-info { font-size: 12px; color: rgba(255,255,255,0.7); }
|
.header-info { font-size: 12px; color: rgba(255,255,255,0.7); }
|
||||||
.header-info strong { color: #fff; }
|
.header-info strong { color: #fff; }
|
||||||
.header-left { display: flex; align-items: center; gap: 10px; }
|
.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 {
|
.account-switcher {
|
||||||
background: rgba(255,255,255,0.15);
|
background: rgba(255,255,255,0.15);
|
||||||
@@ -760,3 +762,46 @@ input[type="file"] {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.dep-pdf-btns { display: flex; gap: 6px; }
|
.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">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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>
|
<header>
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<span class="header-brand" id="company-name">ezcheck</span>
|
<span class="header-brand" id="company-name">ezcheck</span>
|
||||||
<select id="account-switcher" class="account-switcher" title="Switch account"></select>
|
<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>
|
</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>
|
</header>
|
||||||
|
|
||||||
<!-- View nav tabs -->
|
<!-- View nav tabs -->
|
||||||
@@ -559,6 +604,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</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>
|
<script src="/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+327
-8
@@ -5,7 +5,7 @@ const state = {
|
|||||||
account: null,
|
account: null,
|
||||||
accounts: [],
|
accounts: [],
|
||||||
activeAccountId: parseInt(localStorage.getItem('activeAccountId'), 10) || null,
|
activeAccountId: parseInt(localStorage.getItem('activeAccountId'), 10) || null,
|
||||||
filterStatus: '', // '' = all, '0' = unprinted, '1' = printed
|
filterStatus: '',
|
||||||
filterPayee: '',
|
filterPayee: '',
|
||||||
filterDateFrom: '',
|
filterDateFrom: '',
|
||||||
filterDateTo: '',
|
filterDateTo: '',
|
||||||
@@ -13,6 +13,7 @@ const state = {
|
|||||||
sortDir: 'desc',
|
sortDir: 'desc',
|
||||||
selected: new Set(),
|
selected: new Set(),
|
||||||
editingId: null,
|
editingId: null,
|
||||||
|
user: null, // { id, username, role }
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── API helpers ──────────────────────────────────────────────────────────────
|
// ── API helpers ──────────────────────────────────────────────────────────────
|
||||||
@@ -21,19 +22,315 @@ async function apiFetch(method, path, body) {
|
|||||||
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||||
const res = await fetch(path, opts);
|
const res = await fetch(path, opts);
|
||||||
|
if (res.status === 401) { showLoginOverlay(); return null; }
|
||||||
if (res.status === 204) return null;
|
if (res.status === 204) return null;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.error || res.statusText);
|
if (!res.ok) throw new Error(data.error || res.statusText);
|
||||||
return data;
|
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 ─────────────────────────────────────────────────────────────
|
// ── Data loading ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function loadAccounts() {
|
async function loadAccounts() {
|
||||||
try {
|
try {
|
||||||
state.accounts = await apiFetch('GET', '/api/accounts');
|
state.accounts = await apiFetch('GET', '/api/accounts');
|
||||||
|
if (!state.accounts) return; // 401 redirect handled by apiFetch
|
||||||
if (state.accounts.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
// Use stored account or default to first
|
// 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
|
const statusBadge = printed
|
||||||
? '<span class="status-badge status-printed">Printed</span>'
|
? '<span class="status-badge status-printed">Printed</span>'
|
||||||
: '<span class="status-badge status-unprinted">Unprinted</span>';
|
: '<span class="status-badge status-unprinted">Unprinted</span>';
|
||||||
|
|
||||||
const actions = `<button class="btn-sm btn-edit" data-id="${c.id}">Edit</button>` +
|
const isEditor = state.user && (state.user.role === 'admin' || state.user.role === 'editor');
|
||||||
`<button class="btn-sm btn-delete" data-id="${c.id}">Delete</button>`;
|
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' : ''}">
|
return `<tr class="${printed ? 'printed' : ''}">
|
||||||
${checkbox}
|
${checkbox}
|
||||||
@@ -1180,7 +1482,7 @@ function escHtml(str) {
|
|||||||
|
|
||||||
// ── Initialization ───────────────────────────────────────────────────────────
|
// ── Initialization ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function init() {
|
async function init() {
|
||||||
// Column sort
|
// Column sort
|
||||||
document.querySelectorAll('thead th.sortable').forEach(th => {
|
document.querySelectorAll('thead th.sortable').forEach(th => {
|
||||||
th.addEventListener('click', () => {
|
th.addEventListener('click', () => {
|
||||||
@@ -1354,8 +1656,25 @@ function init() {
|
|||||||
document.getElementById('btn-qbo-checks-cancel').addEventListener('click', closeQboImport);
|
document.getElementById('btn-qbo-checks-cancel').addEventListener('click', closeQboImport);
|
||||||
document.getElementById('btn-qbo-deposits-cancel').addEventListener('click', closeQboImport);
|
document.getElementById('btn-qbo-deposits-cancel').addEventListener('click', closeQboImport);
|
||||||
|
|
||||||
// Initial data load
|
// Auth event listeners
|
||||||
loadAccounts();
|
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);
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
|||||||
+70
-36
@@ -4,33 +4,77 @@ const express = require('express');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
|
const crypto = require('crypto');
|
||||||
const { execFileSync } = require('child_process');
|
const { execFileSync } = require('child_process');
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
|
const session = require('express-session');
|
||||||
|
|
||||||
|
const db = require('./db/database');
|
||||||
|
const { requireAuth, requireAdmin, requireEditor, canAccessAccount } = require('./middleware/auth');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const upload = multer({ dest: os.tmpdir() });
|
const upload = multer({ dest: os.tmpdir() });
|
||||||
|
|
||||||
app.use(express.json());
|
// ── Session store (SQLite-backed, no extra packages) ──────────────────────────
|
||||||
|
const SessionStore = require('./lib/SessionStore');
|
||||||
|
|
||||||
|
const SESSION_SECRET = process.env.SESSION_SECRET ||
|
||||||
|
(() => { console.warn('[warn] SESSION_SECRET not set — using random secret (sessions reset on restart)'); return crypto.randomBytes(32).toString('hex'); })();
|
||||||
|
|
||||||
|
app.use(session({
|
||||||
|
store: new SessionStore(db),
|
||||||
|
secret: SESSION_SECRET,
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: { httpOnly: true, sameSite: 'lax', maxAge: 7 * 24 * 60 * 60 * 1000 }, // 7 days
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
app.use(express.static(path.join(__dirname, '../public')));
|
app.use(express.static(path.join(__dirname, '../public')));
|
||||||
|
|
||||||
// Routes
|
// ── Auth routes (public — no requireAuth) ─────────────────────────────────────
|
||||||
app.use('/api/checks', require('./routes/checks'));
|
app.use('/api/auth', require('./routes/auth'));
|
||||||
app.use('/api/pdf', require('./routes/pdf'));
|
|
||||||
app.use('/api/deposits', require('./routes/deposits'));
|
|
||||||
app.use('/api/deposit-pdf', require('./routes/deposit-pdf'));
|
|
||||||
|
|
||||||
// GET /api/accounts - list all accounts (id + display name)
|
// ── All routes below require authentication ───────────────────────────────────
|
||||||
|
app.use('/api', requireAuth);
|
||||||
|
|
||||||
|
// ── User management (admin only) ──────────────────────────────────────────────
|
||||||
|
app.use('/api/users', require('./routes/users'));
|
||||||
|
|
||||||
|
// ── Check routes ──────────────────────────────────────────────────────────────
|
||||||
|
app.use('/api/checks', require('./routes/checks'));
|
||||||
|
|
||||||
|
// ── PDF (editor+) ─────────────────────────────────────────────────────────────
|
||||||
|
app.use('/api/pdf', requireEditor, require('./routes/pdf'));
|
||||||
|
|
||||||
|
// ── Deposits ──────────────────────────────────────────────────────────────────
|
||||||
|
app.use('/api/deposits', require('./routes/deposits'));
|
||||||
|
app.use('/api/deposit-pdf', requireEditor, require('./routes/deposit-pdf'));
|
||||||
|
|
||||||
|
// ── QBO import (editor+) ──────────────────────────────────────────────────────
|
||||||
|
app.use('/api/qbo-import', requireEditor, require('./routes/qbo-import'));
|
||||||
|
|
||||||
|
// ── Accounts list — filtered by role ─────────────────────────────────────────
|
||||||
app.get('/api/accounts', (req, res) => {
|
app.get('/api/accounts', (req, res) => {
|
||||||
const db = require('./db/database');
|
let accounts;
|
||||||
const accounts = db.prepare(
|
if (req.session.role === 'admin') {
|
||||||
|
accounts = db.prepare(
|
||||||
'SELECT id, company1, bank_name, current_check_no FROM account ORDER BY id ASC'
|
'SELECT id, company1, bank_name, current_check_no FROM account ORDER BY id ASC'
|
||||||
).all();
|
).all();
|
||||||
|
} else {
|
||||||
|
accounts = db.prepare(`
|
||||||
|
SELECT a.id, a.company1, a.bank_name, a.current_check_no
|
||||||
|
FROM account a
|
||||||
|
JOIN user_accounts ua ON ua.account_id = a.id
|
||||||
|
WHERE ua.user_id = ?
|
||||||
|
ORDER BY a.id ASC
|
||||||
|
`).all(req.session.userId);
|
||||||
|
}
|
||||||
res.json(accounts);
|
res.json(accounts);
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT /api/account/:id - update account settings
|
// ── Account settings (admin only) ─────────────────────────────────────────────
|
||||||
app.put('/api/account/:id', (req, res) => {
|
app.put('/api/account/:id', requireAdmin, (req, res) => {
|
||||||
const db = require('./db/database');
|
|
||||||
const account = db.prepare('SELECT id FROM account WHERE id = ?').get(req.params.id);
|
const account = db.prepare('SELECT id FROM account WHERE id = ?').get(req.params.id);
|
||||||
if (!account) return res.status(404).json({ error: 'Account not found.' });
|
if (!account) return res.status(404).json({ error: 'Account not found.' });
|
||||||
|
|
||||||
@@ -74,9 +118,11 @@ app.put('/api/account/:id', (req, res) => {
|
|||||||
).get(req.params.id));
|
).get(req.params.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/account/:id - get full account by id
|
// GET /api/account/:id — any authenticated user with access
|
||||||
app.get('/api/account/:id', (req, res) => {
|
app.get('/api/account/:id', (req, res) => {
|
||||||
const db = require('./db/database');
|
if (!canAccessAccount(req.session, parseInt(req.params.id, 10))) {
|
||||||
|
return res.status(403).json({ error: 'Access denied.' });
|
||||||
|
}
|
||||||
const account = db.prepare(
|
const account = db.prepare(
|
||||||
'SELECT id, bank_name, bank_info1, bank_info2, bank_info3, transit_code, ' +
|
'SELECT id, bank_name, bank_info1, bank_info2, bank_info3, transit_code, ' +
|
||||||
'routing_number, account_number, current_check_no, ' +
|
'routing_number, account_number, current_check_no, ' +
|
||||||
@@ -86,9 +132,8 @@ app.get('/api/account/:id', (req, res) => {
|
|||||||
res.json(account);
|
res.json(account);
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT /api/account/:id/check-no - override the next check number
|
// PUT /api/account/:id/check-no (admin only)
|
||||||
app.put('/api/account/:id/check-no', (req, res) => {
|
app.put('/api/account/:id/check-no', requireAdmin, (req, res) => {
|
||||||
const db = require('./db/database');
|
|
||||||
const account = db.prepare('SELECT id FROM account WHERE id = ?').get(req.params.id);
|
const account = db.prepare('SELECT id FROM account WHERE id = ?').get(req.params.id);
|
||||||
if (!account) return res.status(404).json({ error: 'Account not found.' });
|
if (!account) return res.status(404).json({ error: 'Account not found.' });
|
||||||
|
|
||||||
@@ -97,33 +142,30 @@ app.put('/api/account/:id/check-no', (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Next check number must be a positive integer.' });
|
return res.status(400).json({ error: 'Next check number must be a positive integer.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// current_check_no is the last-used number; next check will be current_check_no + 1
|
|
||||||
db.prepare("UPDATE account SET current_check_no = ?, updated_at = datetime('now') WHERE id = ?")
|
db.prepare("UPDATE account SET current_check_no = ?, updated_at = datetime('now') WHERE id = ?")
|
||||||
.run(next - 1, req.params.id);
|
.run(next - 1, req.params.id);
|
||||||
|
|
||||||
res.json({ next_check_no: next });
|
res.json({ next_check_no: next });
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /api/account/:id - delete account and all associated data
|
// DELETE /api/account/:id (admin only)
|
||||||
app.delete('/api/account/:id', (req, res) => {
|
app.delete('/api/account/:id', requireAdmin, (req, res) => {
|
||||||
const db = require('./db/database');
|
|
||||||
const account = db.prepare('SELECT id FROM account WHERE id = ?').get(req.params.id);
|
const account = db.prepare('SELECT id FROM account WHERE id = ?').get(req.params.id);
|
||||||
if (!account) return res.status(404).json({ error: 'Account not found.' });
|
if (!account) return res.status(404).json({ error: 'Account not found.' });
|
||||||
|
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
// deposit_items deleted via ON DELETE CASCADE from deposits
|
|
||||||
db.prepare('DELETE FROM deposits WHERE account_id = ?').run(req.params.id);
|
db.prepare('DELETE FROM deposits WHERE account_id = ?').run(req.params.id);
|
||||||
db.prepare('DELETE FROM checks WHERE account_id = ?').run(req.params.id);
|
db.prepare('DELETE FROM checks WHERE account_id = ?').run(req.params.id);
|
||||||
db.prepare('DELETE FROM layout_fields WHERE account_id = ?').run(req.params.id);
|
db.prepare('DELETE FROM layout_fields WHERE account_id = ?').run(req.params.id);
|
||||||
|
db.prepare('DELETE FROM user_accounts WHERE account_id = ?').run(req.params.id);
|
||||||
db.prepare('DELETE FROM account WHERE id = ?').run(req.params.id);
|
db.prepare('DELETE FROM account WHERE id = ?').run(req.params.id);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/account/setup - create a new account (wizard)
|
// POST /api/account/setup (admin only — creates a new checking account)
|
||||||
app.post('/api/account/setup', (req, res) => {
|
app.post('/api/account/setup', requireAdmin, (req, res) => {
|
||||||
const db = require('./db/database');
|
|
||||||
const {
|
const {
|
||||||
company1, company2, company3, company4,
|
company1, company2, company3, company4,
|
||||||
bank_name, bank_info1, bank_info2, transit_code,
|
bank_name, bank_info1, bank_info2, transit_code,
|
||||||
@@ -167,16 +209,9 @@ app.post('/api/account/setup', (req, res) => {
|
|||||||
res.status(201).json({ success: true, accountId: result.lastInsertRowid });
|
res.status(201).json({ success: true, accountId: result.lastInsertRowid });
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Add basic auth or simple password gate for any network-exposed deployment
|
// .mdb import (admin only)
|
||||||
|
app.post('/api/import', requireAdmin, upload.single('mdbfile'), (req, res) => {
|
||||||
// TODO: Add deposit slip support -- deposits table, PDF generation, ledger, and slide-in entry form
|
|
||||||
|
|
||||||
app.use('/api/qbo-import', require('./routes/qbo-import'));
|
|
||||||
|
|
||||||
// .mdb import endpoint — always creates a new account
|
|
||||||
app.post('/api/import', upload.single('mdbfile'), (req, res) => {
|
|
||||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded.' });
|
if (!req.file) return res.status(400).json({ error: 'No file uploaded.' });
|
||||||
const db = require('./db/database');
|
|
||||||
const tmpPath = req.file.path;
|
const tmpPath = req.file.path;
|
||||||
try {
|
try {
|
||||||
const output = execFileSync(
|
const output = execFileSync(
|
||||||
@@ -184,7 +219,6 @@ app.post('/api/import', upload.single('mdbfile'), (req, res) => {
|
|||||||
[path.join(__dirname, '../migrations/import-mdb.js'), '--file', tmpPath],
|
[path.join(__dirname, '../migrations/import-mdb.js'), '--file', tmpPath],
|
||||||
{ encoding: 'utf8', timeout: 120000, env: process.env }
|
{ encoding: 'utf8', timeout: 120000, env: process.env }
|
||||||
);
|
);
|
||||||
// Grab the newly created account (highest id)
|
|
||||||
const newAccount = db.prepare('SELECT id, company1 FROM account ORDER BY id DESC LIMIT 1').get();
|
const newAccount = db.prepare('SELECT id, company1 FROM account ORDER BY id DESC LIMIT 1').get();
|
||||||
res.json({ success: true, log: output, newAccountId: newAccount ? newAccount.id : null });
|
res.json({ success: true, log: output, newAccountId: newAccount ? newAccount.id : null });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -197,7 +231,7 @@ app.post('/api/import', upload.single('mdbfile'), (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Catch-all: serve index.html for client-side routing
|
// Catch-all: serve index.html
|
||||||
app.get('*', (req, res) => {
|
app.get('*', (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, '../public/index.html'));
|
res.sendFile(path.join(__dirname, '../public/index.html'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -97,3 +97,25 @@ CREATE TABLE IF NOT EXISTS deposit_items (
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_deposits_account ON deposits(account_id);
|
CREATE INDEX IF NOT EXISTS idx_deposits_account ON deposits(account_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_deposit_items ON deposit_items(deposit_id);
|
CREATE INDEX IF NOT EXISTS idx_deposit_items ON deposit_items(deposit_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'viewer' CHECK(role IN ('admin','editor','viewer')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_accounts (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
account_id INTEGER NOT NULL REFERENCES account(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (user_id, account_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
sid TEXT PRIMARY KEY,
|
||||||
|
sess TEXT NOT NULL,
|
||||||
|
expired INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_expired ON sessions(expired);
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { Store } = require('express-session');
|
||||||
|
|
||||||
|
// SQLite-backed session store using the existing better-sqlite3 db instance.
|
||||||
|
// No additional npm packages required.
|
||||||
|
class SessionStore extends Store {
|
||||||
|
constructor(db) {
|
||||||
|
super();
|
||||||
|
this.db = db;
|
||||||
|
// Purge expired sessions every 10 minutes
|
||||||
|
setInterval(() => {
|
||||||
|
try { db.prepare('DELETE FROM sessions WHERE expired < ?').run(Date.now()); } catch (_) {}
|
||||||
|
}, 10 * 60 * 1000).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
get(sid, cb) {
|
||||||
|
try {
|
||||||
|
const row = this.db.prepare('SELECT sess, expired FROM sessions WHERE sid = ?').get(sid);
|
||||||
|
if (!row) return cb(null, null);
|
||||||
|
if (Date.now() > row.expired) {
|
||||||
|
this.destroy(sid, () => {});
|
||||||
|
return cb(null, null);
|
||||||
|
}
|
||||||
|
cb(null, JSON.parse(row.sess));
|
||||||
|
} catch (e) { cb(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
set(sid, sess, cb) {
|
||||||
|
try {
|
||||||
|
const maxAge = (sess.cookie && sess.cookie.maxAge)
|
||||||
|
? sess.cookie.maxAge * 1000
|
||||||
|
: 7 * 24 * 60 * 60 * 1000;
|
||||||
|
const expired = Date.now() + maxAge;
|
||||||
|
this.db.prepare(
|
||||||
|
'INSERT OR REPLACE INTO sessions (sid, sess, expired) VALUES (?, ?, ?)'
|
||||||
|
).run(sid, JSON.stringify(sess), expired);
|
||||||
|
cb(null);
|
||||||
|
} catch (e) { cb(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(sid, cb) {
|
||||||
|
try {
|
||||||
|
this.db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid);
|
||||||
|
cb(null);
|
||||||
|
} catch (e) { cb(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
touch(sid, sess, cb) {
|
||||||
|
this.set(sid, sess, cb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = SessionStore;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const db = require('../db/database');
|
||||||
|
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
if (!req.session || !req.session.userId) {
|
||||||
|
return res.status(401).json({ error: 'Not authenticated.' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAdmin(req, res, next) {
|
||||||
|
if (!req.session || req.session.role !== 'admin') {
|
||||||
|
return res.status(403).json({ error: 'Admin access required.' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blocks viewers; allows admin and editor
|
||||||
|
function requireEditor(req, res, next) {
|
||||||
|
if (!req.session || req.session.role === 'viewer') {
|
||||||
|
return res.status(403).json({ error: 'Write access required.' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if the current session user can access the given account
|
||||||
|
function canAccessAccount(session, accountId) {
|
||||||
|
if (!session || !session.userId) return false;
|
||||||
|
if (session.role === 'admin') return true;
|
||||||
|
const row = db.prepare(
|
||||||
|
'SELECT 1 FROM user_accounts WHERE user_id = ? AND account_id = ?'
|
||||||
|
).get(session.userId, accountId);
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware factory — resolves accountId via a callback on req, then checks access
|
||||||
|
function requireAccountAccess(getAccountId) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (!req.session || !req.session.userId) {
|
||||||
|
return res.status(401).json({ error: 'Not authenticated.' });
|
||||||
|
}
|
||||||
|
if (req.session.role === 'admin') return next();
|
||||||
|
const accountId = parseInt(getAccountId(req), 10);
|
||||||
|
if (!accountId) return next(); // route handler will deal with missing param
|
||||||
|
if (!canAccessAccount(req.session, accountId)) {
|
||||||
|
return res.status(403).json({ error: 'Access denied.' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { requireAuth, requireAdmin, requireEditor, requireAccountAccess, canAccessAccount };
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const db = require('../db/database');
|
||||||
|
|
||||||
|
// GET /api/auth/setup-needed — true when no users exist (first-run)
|
||||||
|
router.get('/setup-needed', (req, res) => {
|
||||||
|
const { n } = db.prepare('SELECT COUNT(*) AS n FROM users').get();
|
||||||
|
res.json({ setupNeeded: n === 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/setup — create the first admin (only works when no users exist)
|
||||||
|
router.post('/setup', async (req, res) => {
|
||||||
|
const { n } = db.prepare('SELECT COUNT(*) AS n FROM users').get();
|
||||||
|
if (n > 0) return res.status(409).json({ error: 'Setup already complete.' });
|
||||||
|
|
||||||
|
const { username, password } = req.body;
|
||||||
|
if (!username || !password) return res.status(400).json({ error: 'Username and password required.' });
|
||||||
|
if (password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters.' });
|
||||||
|
|
||||||
|
const hash = await bcrypt.hash(password, 12);
|
||||||
|
const result = db.prepare(
|
||||||
|
"INSERT INTO users (username, password_hash, role) VALUES (?, ?, 'admin')"
|
||||||
|
).run(username.trim(), hash);
|
||||||
|
|
||||||
|
req.session.userId = result.lastInsertRowid;
|
||||||
|
req.session.username = username.trim();
|
||||||
|
req.session.role = 'admin';
|
||||||
|
|
||||||
|
res.status(201).json({ id: result.lastInsertRowid, username: username.trim(), role: 'admin' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/login
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
if (!username || !password) return res.status(400).json({ error: 'Username and password required.' });
|
||||||
|
|
||||||
|
const user = db.prepare('SELECT * FROM users WHERE username = ? COLLATE NOCASE').get(username.trim());
|
||||||
|
if (!user) return res.status(401).json({ error: 'Invalid username or password.' });
|
||||||
|
|
||||||
|
const match = await bcrypt.compare(password, user.password_hash);
|
||||||
|
if (!match) return res.status(401).json({ error: 'Invalid username or password.' });
|
||||||
|
|
||||||
|
req.session.userId = user.id;
|
||||||
|
req.session.username = user.username;
|
||||||
|
req.session.role = user.role;
|
||||||
|
|
||||||
|
res.json({ id: user.id, username: user.username, role: user.role });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/logout
|
||||||
|
router.post('/logout', (req, res) => {
|
||||||
|
req.session.destroy(() => res.status(204).end());
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/auth/me
|
||||||
|
router.get('/me', (req, res) => {
|
||||||
|
if (!req.session || !req.session.userId) {
|
||||||
|
return res.status(401).json({ error: 'Not authenticated.' });
|
||||||
|
}
|
||||||
|
res.json({ id: req.session.userId, username: req.session.username, role: req.session.role });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/change-password — any logged-in user can change their own password
|
||||||
|
router.post('/change-password', async (req, res) => {
|
||||||
|
if (!req.session || !req.session.userId) return res.status(401).json({ error: 'Not authenticated.' });
|
||||||
|
|
||||||
|
const { current_password, new_password } = req.body;
|
||||||
|
if (!current_password || !new_password) return res.status(400).json({ error: 'Both fields required.' });
|
||||||
|
if (new_password.length < 8) return res.status(400).json({ error: 'New password must be at least 8 characters.' });
|
||||||
|
|
||||||
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.session.userId);
|
||||||
|
const match = await bcrypt.compare(current_password, user.password_hash);
|
||||||
|
if (!match) return res.status(401).json({ error: 'Current password is incorrect.' });
|
||||||
|
|
||||||
|
const hash = await bcrypt.hash(new_password, 12);
|
||||||
|
db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?")
|
||||||
|
.run(hash, req.session.userId);
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
+18
-8
@@ -3,11 +3,21 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const db = require('../db/database');
|
const db = require('../db/database');
|
||||||
|
const { requireEditor, canAccessAccount } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// Helper: resolve account_id from a check id (for edit/delete access checks)
|
||||||
|
function checkAccountId(checkId) {
|
||||||
|
const row = db.prepare('SELECT account_id FROM checks WHERE id = ?').get(checkId);
|
||||||
|
return row ? row.account_id : null;
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Add ledger reporting -- date range filter, payee search, total amount display, CSV export
|
// TODO: Add ledger reporting -- date range filter, payee search, total amount display, CSV export
|
||||||
|
|
||||||
// GET /api/checks?account_id=X - list checks for an account, newest first
|
// GET /api/checks?account_id=X - list checks for an account, newest first
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
|
if (!canAccessAccount(req.session, parseInt(req.query.account_id, 10))) {
|
||||||
|
return res.status(403).json({ error: 'Access denied.' });
|
||||||
|
}
|
||||||
const { after, printed, account_id } = req.query;
|
const { after, printed, account_id } = req.query;
|
||||||
if (!account_id) return res.status(400).json({ error: 'account_id query param required' });
|
if (!account_id) return res.status(400).json({ error: 'account_id query param required' });
|
||||||
|
|
||||||
@@ -36,8 +46,8 @@ router.get('/:id', (req, res) => {
|
|||||||
|
|
||||||
// TODO: Add payee address book -- store and recall payee name + address lines, autocomplete on new check form
|
// TODO: Add payee address book -- store and recall payee name + address lines, autocomplete on new check form
|
||||||
|
|
||||||
// POST /api/checks - create a new check
|
// POST /api/checks - create a new check (editor+)
|
||||||
router.post('/', (req, res) => {
|
router.post('/', requireEditor, (req, res) => {
|
||||||
const { account_id, payee, amount, check_date, memo, note1, note2,
|
const { account_id, payee, amount, check_date, memo, note1, note2,
|
||||||
payee_address1, payee_address2, payee_address3, payee_address4 } = req.body;
|
payee_address1, payee_address2, payee_address3, payee_address4 } = req.body;
|
||||||
|
|
||||||
@@ -75,8 +85,8 @@ router.post('/', (req, res) => {
|
|||||||
res.status(201).json(db.prepare('SELECT * FROM checks WHERE id = ?').get(newId));
|
res.status(201).json(db.prepare('SELECT * FROM checks WHERE id = ?').get(newId));
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT /api/checks/:id - update a check
|
// PUT /api/checks/:id - update a check (editor+)
|
||||||
router.put('/:id', (req, res) => {
|
router.put('/:id', requireEditor, (req, res) => {
|
||||||
const check = db.prepare('SELECT * FROM checks WHERE id = ?').get(req.params.id);
|
const check = db.prepare('SELECT * FROM checks WHERE id = ?').get(req.params.id);
|
||||||
if (!check) return res.status(404).json({ error: 'Check not found' });
|
if (!check) return res.status(404).json({ error: 'Check not found' });
|
||||||
|
|
||||||
@@ -105,16 +115,16 @@ router.put('/:id', (req, res) => {
|
|||||||
res.json(db.prepare('SELECT * FROM checks WHERE id = ?').get(req.params.id));
|
res.json(db.prepare('SELECT * FROM checks WHERE id = ?').get(req.params.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /api/checks/:id
|
// DELETE /api/checks/:id (editor+)
|
||||||
router.delete('/:id', (req, res) => {
|
router.delete('/:id', requireEditor, (req, res) => {
|
||||||
const check = db.prepare('SELECT * FROM checks WHERE id = ?').get(req.params.id);
|
const check = db.prepare('SELECT * FROM checks WHERE id = ?').get(req.params.id);
|
||||||
if (!check) return res.status(404).json({ error: 'Check not found' });
|
if (!check) return res.status(404).json({ error: 'Check not found' });
|
||||||
db.prepare('DELETE FROM checks WHERE id = ?').run(req.params.id);
|
db.prepare('DELETE FROM checks WHERE id = ?').run(req.params.id);
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/checks/mark-printed
|
// POST /api/checks/mark-printed (editor+)
|
||||||
router.post('/mark-printed', (req, res) => {
|
router.post('/mark-printed', requireEditor, (req, res) => {
|
||||||
const { ids } = req.body;
|
const { ids } = req.body;
|
||||||
if (!Array.isArray(ids) || ids.length === 0) {
|
if (!Array.isArray(ids) || ids.length === 0) {
|
||||||
return res.status(400).json({ error: 'ids array required' });
|
return res.status(400).json({ error: 'ids array required' });
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const db = require('../db/database');
|
const db = require('../db/database');
|
||||||
|
const { requireEditor, canAccessAccount } = require('../middleware/auth');
|
||||||
|
|
||||||
// Helper: fetch deposit with items
|
// Helper: fetch deposit with items
|
||||||
function getDepositWithItems(id) {
|
function getDepositWithItems(id) {
|
||||||
@@ -18,6 +19,7 @@ function getDepositWithItems(id) {
|
|||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
const { account_id } = req.query;
|
const { account_id } = req.query;
|
||||||
if (!account_id) return res.status(400).json({ error: 'account_id is required.' });
|
if (!account_id) return res.status(400).json({ error: 'account_id is required.' });
|
||||||
|
if (!canAccessAccount(req.session, parseInt(account_id, 10))) return res.status(403).json({ error: 'Access denied.' });
|
||||||
|
|
||||||
const deposits = db.prepare(`
|
const deposits = db.prepare(`
|
||||||
SELECT d.*, COUNT(di.id) AS item_count,
|
SELECT d.*, COUNT(di.id) AS item_count,
|
||||||
@@ -40,7 +42,7 @@ router.get('/:id', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/deposits
|
// POST /api/deposits
|
||||||
router.post('/', (req, res) => {
|
router.post('/', requireEditor, (req, res) => {
|
||||||
const { account_id, deposit_date, currency, coin, cash_back, items } = req.body;
|
const { account_id, deposit_date, currency, coin, cash_back, items } = req.body;
|
||||||
if (!account_id) return res.status(400).json({ error: 'account_id is required.' });
|
if (!account_id) return res.status(400).json({ error: 'account_id is required.' });
|
||||||
if (!deposit_date) return res.status(400).json({ error: 'deposit_date is required.' });
|
if (!deposit_date) return res.status(400).json({ error: 'deposit_date is required.' });
|
||||||
@@ -84,7 +86,7 @@ router.post('/', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// PUT /api/deposits/:id
|
// PUT /api/deposits/:id
|
||||||
router.put('/:id', (req, res) => {
|
router.put('/:id', requireEditor, (req, res) => {
|
||||||
const existing = db.prepare('SELECT id FROM deposits WHERE id = ?').get(req.params.id);
|
const existing = db.prepare('SELECT id FROM deposits WHERE id = ?').get(req.params.id);
|
||||||
if (!existing) return res.status(404).json({ error: 'Deposit not found.' });
|
if (!existing) return res.status(404).json({ error: 'Deposit not found.' });
|
||||||
|
|
||||||
@@ -127,7 +129,7 @@ router.put('/:id', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /api/deposits/:id
|
// DELETE /api/deposits/:id
|
||||||
router.delete('/:id', (req, res) => {
|
router.delete('/:id', requireEditor, (req, res) => {
|
||||||
const existing = db.prepare('SELECT id FROM deposits WHERE id = ?').get(req.params.id);
|
const existing = db.prepare('SELECT id FROM deposits WHERE id = ?').get(req.params.id);
|
||||||
if (!existing) return res.status(404).json({ error: 'Deposit not found.' });
|
if (!existing) return res.status(404).json({ error: 'Deposit not found.' });
|
||||||
// deposit_items deleted via ON DELETE CASCADE
|
// deposit_items deleted via ON DELETE CASCADE
|
||||||
@@ -136,7 +138,7 @@ router.delete('/:id', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /api/deposits/:id/mark-printed
|
// PATCH /api/deposits/:id/mark-printed
|
||||||
router.patch('/:id/mark-printed', (req, res) => {
|
router.patch('/:id/mark-printed', requireEditor, (req, res) => {
|
||||||
db.prepare('UPDATE deposits SET printed = 1 WHERE id = ?').run(req.params.id);
|
db.prepare('UPDATE deposits SET printed = 1 WHERE id = ?').run(req.params.id);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const db = require('../db/database');
|
||||||
|
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// All /api/users routes require admin
|
||||||
|
router.use(requireAuth, requireAdmin);
|
||||||
|
|
||||||
|
function userWithAccounts(id) {
|
||||||
|
const user = db.prepare('SELECT id, username, role, created_at FROM users WHERE id = ?').get(id);
|
||||||
|
if (!user) return null;
|
||||||
|
user.accounts = db.prepare('SELECT account_id FROM user_accounts WHERE user_id = ?')
|
||||||
|
.all(id).map(r => r.account_id);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/users
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
const users = db.prepare('SELECT id, username, role, created_at FROM users ORDER BY id ASC').all();
|
||||||
|
users.forEach(u => {
|
||||||
|
u.accounts = db.prepare('SELECT account_id FROM user_accounts WHERE user_id = ?')
|
||||||
|
.all(u.id).map(r => r.account_id);
|
||||||
|
});
|
||||||
|
res.json(users);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/users
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
const { username, password, role, accounts } = req.body;
|
||||||
|
if (!username || !password) return res.status(400).json({ error: 'Username and password required.' });
|
||||||
|
if (!['admin', 'editor', 'viewer'].includes(role)) return res.status(400).json({ error: 'Invalid role.' });
|
||||||
|
if (password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters.' });
|
||||||
|
|
||||||
|
const hash = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
let userId;
|
||||||
|
try {
|
||||||
|
const result = db.prepare(
|
||||||
|
'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)'
|
||||||
|
).run(username.trim(), hash, role);
|
||||||
|
userId = result.lastInsertRowid;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message.includes('UNIQUE')) return res.status(409).json({ error: 'Username already taken.' });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role !== 'admin' && Array.isArray(accounts) && accounts.length > 0) {
|
||||||
|
const stmt = db.prepare('INSERT OR IGNORE INTO user_accounts (user_id, account_id) VALUES (?, ?)');
|
||||||
|
accounts.forEach(aid => stmt.run(userId, aid));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json(userWithAccounts(userId));
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/users/:id
|
||||||
|
router.put('/:id', async (req, res) => {
|
||||||
|
const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(req.params.id);
|
||||||
|
if (!user) return res.status(404).json({ error: 'User not found.' });
|
||||||
|
|
||||||
|
const { username, password, role, accounts } = req.body;
|
||||||
|
|
||||||
|
if (role && !['admin', 'editor', 'viewer'].includes(role)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid role.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username && username.trim() !== '') {
|
||||||
|
try {
|
||||||
|
db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?")
|
||||||
|
.run(username.trim(), req.params.id);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message.includes('UNIQUE')) return res.status(409).json({ error: 'Username already taken.' });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role) {
|
||||||
|
db.prepare("UPDATE users SET role = ?, updated_at = datetime('now') WHERE id = ?")
|
||||||
|
.run(role, req.params.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
if (password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters.' });
|
||||||
|
const hash = await bcrypt.hash(password, 12);
|
||||||
|
db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?")
|
||||||
|
.run(hash, req.params.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(accounts)) {
|
||||||
|
db.prepare('DELETE FROM user_accounts WHERE user_id = ?').run(req.params.id);
|
||||||
|
const effectiveRole = role || user.role;
|
||||||
|
if (effectiveRole !== 'admin' && accounts.length > 0) {
|
||||||
|
const stmt = db.prepare('INSERT OR IGNORE INTO user_accounts (user_id, account_id) VALUES (?, ?)');
|
||||||
|
accounts.forEach(aid => stmt.run(req.params.id, aid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(userWithAccounts(req.params.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/users/:id
|
||||||
|
router.delete('/:id', (req, res) => {
|
||||||
|
if (parseInt(req.params.id, 10) === req.session.userId) {
|
||||||
|
return res.status(400).json({ error: 'Cannot delete your own account.' });
|
||||||
|
}
|
||||||
|
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
|
||||||
|
if (!user) return res.status(404).json({ error: 'User not found.' });
|
||||||
|
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
Reference in New Issue
Block a user