25 Commits

Author SHA1 Message Date
steve c2b2722c56 chore: bump version to 0.5.0
TODO to Issues / todo (push) Has been cancelled
Build and push Docker image / build-push (push) Has been cancelled
2026-06-11 22:56:20 -06:00
steve d1b27837e3 Merge pull request #18 from snachodog/fix/qs-dos-advisory
Build and push Docker image / build-push (push) Has been cancelled
TODO to Issues / todo (push) Has been cancelled
fix(deps): force qs >= 6.15.2 to resolve DoS advisory
2026-06-11 22:30:50 -06:00
steve 5d66d1f575 fix(deps): force qs >= 6.15.2 to resolve DoS advisory
express 4 pins qs to ~6.14.0, which falls in the vulnerable range of
GHSA-q8mj-m7cp-5q26 (remotely triggerable TypeError in qs.stringify).
Add an npm override so the transitive dependency resolves to the patched
6.15.2.
2026-06-11 22:29:52 -06:00
steve 34540e410c Merge pull request #17 from snachodog/refactor/security-hardening
Build and push Docker image / build-push (push) Has been cancelled
TODO to Issues / todo (push) Has been cancelled
Security hardening and hot-path performance fixes
2026-06-11 22:25:30 -06:00
steve 2504766be7 perf(db): reuse prepared statements on hot paths
- Prepare the user_accounts role lookup once in the auth middleware; it
  runs on nearly every authenticated request
- Prepare session store get/set/destroy/purge statements once in the
  constructor instead of per request
- Prepare the per-check SELECT once per PDF job instead of once per check
2026-06-11 22:03:56 -06:00
steve b4824655dd fix(docker): run container as non-root and exclude local files from image
- Add .dockerignore: a local .env, the live SQLite database in data/, .git,
  and node_modules were previously copied into the published image by COPY
- Run the app as the unprivileged node user; pre-create /app/data with
  matching ownership so named volumes inherit it
- Set NODE_ENV=production in the image
- Document the one-time volume chown needed when upgrading existing
  deployments
2026-06-11 21:57:39 -06:00
steve 674506bd2d fix(api): close authz gap and tighten input validation
- Require account access on POST /api/pdf/preview: any authenticated user
  could previously render any account's MICR line (routing and account
  number) regardless of role
- Re-validate QBO import records server-side on confirm (date format,
  positive amounts, integer check numbers) instead of trusting client JSON
- Make PUT /api/users/:id atomic: validate all fields up front, then apply
  every change in a single transaction (a validation failure could
  previously leave a half-applied update)
- Block demoting the last remaining admin
- Validate routing numbers as exactly 9 digits on account setup and update
- Cap upload sizes (50 MB .mdb, 10 MB CSV) and add a JSON error handler so
  oversized uploads and bad JSON return clean 4xx errors instead of HTML
  stack traces
- Disable the X-Powered-By header
2026-06-11 21:56:53 -06:00
steve 3fd3285c13 fix(auth): harden session lifecycle, reset links, and OIDC logging
- Fix session store expiry: cookie.maxAge is already in milliseconds, so
  stored sessions outlived the cookie by 1000x
- Regenerate the session ID on login, first-run setup, and OIDC login to
  prevent session fixation
- Mark session cookies Secure on TLS connections (secure: 'auto') and add
  TRUST_PROXY support for reverse-proxy deployments
- Build password reset links from APP_BASE_URL instead of the Host header
  to prevent reset-link poisoning
- Rate-limit forgot-password requests (5 per IP per 15 minutes)
- Strip OIDC debug logging that leaked authorization codes, subject IDs,
  and emails to logs
2026-06-11 21:54:35 -06:00
steve 427b064af1 chore: add GitHub issue templates 2026-05-16 08:36:55 -06:00
steve 7d105bce21 chore: bump version to 0.4.6
TODO to Issues / todo (push) Has been cancelled
Build and push Docker image / build-push (push) Has been cancelled
2026-05-02 17:22:31 -06:00
steve 0b21f4ea3c feat(layout): add preview PDF button to layout editor
Generates a 3-up PDF with dummy check data (no real checks touched) so the
layout can be proofed without printing live checks.

Closes #12
2026-05-02 17:21:41 -06:00
steve f91fc7bd8a fix(deposits): bold check numbers and amounts for better print visibility 2026-04-28 10:28:08 -06:00
steve bb935acfa9 fix(deposits): check number spacing, darker text, header/title repositioning
- Shift check numbers right (0.16->0.28") so they clear the row number labels
- Darken label text (#444->##111) and disclaimer text (#666->#333)
- Move 'DEPOSIT TICKET' header and depositor/bank block down 0.12" on front page
- Vertically center the check grid on the back page
- Reposition 'ADDITIONAL CHECK LISTING' title relative to centered grid
2026-04-28 09:59:28 -06:00
steve 3957cf5518 feat(deposits): add back page rows all at once via single button 2026-04-28 09:28:49 -06:00
steve 4a47394923 fix(deposits): darken deposit slip grid lines for scanner readability 2026-04-28 09:14:52 -06:00
steve 189ae53d34 feat(deposits): pre-fill 30 slots and add back-page overflow for 31-60 checks
- Deposit panel now pre-fills all 30 check slots on open (new and existing)
- Remove button maintains 30-slot minimum by appending a blank row
- Add Row button hidden at <30, visible at 30-59, disabled at 60
- Deposit slip PDF splits items: first 30 on front, up to 30 more on back
- Front page gains a FROM REVERSE row carrying back-page subtotal when needed
- Back page renders with same column positions/width as front, titled
  'ADDITIONAL CHECK LISTING', numbered rows 31-60, and
  'Forward to other side / TOTAL $' footer

Closes #10, closes #11
2026-04-28 09:04:44 -06:00
steve 657de9e61a fix(ui): resolve deposit panel scroll and PDF open on iOS
- Switch #deposit-panel to 100dvh so iOS browser chrome doesn't clip the Save button
- Move overflow-y:auto to #deposit-panel-body so the actions footer stays pinned
- Replace window.open(blob:) with <a download> click to fix PDF opening in Firefox iOS
  (Firefox iOS blocks blob: URL navigation in new tabs)

Closes #7, closes #9
2026-04-28 08:32:07 -06:00
steve 0ee95dbb09 chore: bump version to 0.4.5
Build and push Docker image / build-push (push) Has been cancelled
2026-04-13 08:28:46 -06:00
steve a2de7e2d9d feat(layout): add grid, safe zone, and MICR anchor alignment
- Draw 1/8" grid overlay on layout editor canvas
- Anchor MICR second transit symbol at 2 59/64" from left
- Clamp draggable fields to printing safe zone (11/64" sides, 13/64" top, 0.5" bottom)
- Render dashed safe-zone outline on layout canvas
2026-04-13 08:06:23 -06:00
steve c4e4a8c246 chore: bump version to 0.4.3
Build and push Docker image / build-push (push) Has been cancelled
2026-04-11 10:23:17 -06:00
steve b692791436 fix: align account access checkboxes with fixed grid layout
Use a 3-column inner grid (checkbox | label | dropdown) with fixed
64px row height for consistent alignment across all account entries.
2026-04-11 10:23:15 -06:00
steve 37d70b4d82 chore: bump version to 0.4.2
Build and push Docker image / build-push (push) Has been cancelled
2026-04-11 09:51:20 -06:00
steve 7d854d4e01 fix: clean up account access grid and separate OIDC fields in user form
Use CSS grid for uniform account checkbox layout and add a bordered
subsection with heading for the OIDC identity fields.
2026-04-11 09:51:18 -06:00
steve f9f6a4cd9a chore: bump version to 0.4.1
Build and push Docker image / build-push (push) Has been cancelled
2026-04-11 09:44:22 -06:00
steve fd36c25636 feat: replace settings modal with full-page sidebar layout
Convert the users/admin modal into a dedicated settings page with
left sidebar navigation and spacious content panels. Hash-based
routing (#settings/users, #settings/smtp, etc.) enables browser
back-button support and direct URL access. Admin-only tabs are
hidden for non-admin users.
2026-04-11 09:43:24 -06:00
24 changed files with 1133 additions and 345 deletions
+15
View File
@@ -0,0 +1,15 @@
.git
.github
node_modules
data
*.db
*.db-shm
*.db-wal
.env
.env.*
!.env.example
*.log
.claude
CLAUDE.md
TODO.md
docker-compose.yml
+8
View File
@@ -6,6 +6,14 @@ SESSION_MAX_AGE_HOURS=168 # default: 168 (7 days)
PORT=3000 PORT=3000
DB_PATH=/app/data/check-printing.db DB_PATH=/app/data/check-printing.db
# Public base URL of the app — used to build password reset links.
# Strongly recommended in production (prevents host-header link poisoning).
APP_BASE_URL=https://checks.example.com
# Set to 1 when running behind a reverse proxy (TLS termination) so client IPs
# and HTTPS detection work correctly. Leave unset for direct LAN access.
TRUST_PROXY=
# OIDC / SSO (optional — omit or leave blank to disable) # OIDC / SSO (optional — omit or leave blank to disable)
OIDC_ENABLED=false OIDC_ENABLED=false
OIDC_DISCOVERY_URL=https://auth.example.com/.well-known/openid-configuration OIDC_DISCOVERY_URL=https://auth.example.com/.well-known/openid-configuration
+35
View File
@@ -0,0 +1,35 @@
---
name: Bug Report
about: Report a bug in the system
title: "[Bug]: "
labels: bug
assignees: ''
---
### Description
A clear and concise description of the bug.
### Steps to Reproduce
1. Go to '...'
2. Click on '...'
3. See the error.
### Expected Behavior
Explain what you expected to happen.
### Screenshots
Add screenshots if applicable.
### Environment
- OS: [e.g., Windows, macOS, Linux]
- Browser: [e.g., Chrome, Firefox]
- Version: [e.g., 1.0.0]
### Additional Context
Add any other context about the problem here.
+22
View File
@@ -0,0 +1,22 @@
blank_issues_enabled: false
issue_templates:
- name: "Bug Report"
description: "Report a bug in the system."
title: "[Bug]: "
labels: ["bug"]
body: "./ISSUE_TEMPLATE/bug_report.md"
- name: "Feature Request"
description: "Propose a new feature or improvement."
title: "[Feature]: "
labels: ["enhancement"]
body: "./ISSUE_TEMPLATE/feature_request.md"
- name: "Documentation"
description: "Suggest updates or additions to the documentation."
title: "[Docs]: "
labels: ["documentation"]
body: "./ISSUE_TEMPLATE/documentation.md"
- name: "General Report"
description: "Provide general feedback or inquiries."
title: "[General]: "
labels: ["general"]
body: "./ISSUE_TEMPLATE/general_report.md"
+23
View File
@@ -0,0 +1,23 @@
--
name: Documentation
about: Suggest updates or additions to documentation
title: "[Docs]: "
labels: documentation
assignees: ''
---
### Documentation Update
What part of the documentation needs to be updated or added?
### Why Is This Needed?
Explain the importance of this update.
### Suggested Changes
Provide a detailed description of the changes.
### Additional Context
Include any related resources.
+23
View File
@@ -0,0 +1,23 @@
---
name: Feature Request
about: Suggest a new feature or improvement
title: "[Feature]: "
labels: enhancement
assignees: ''
---
### Feature Description
What feature would you like to see?
### Why Is This Needed?
Explain the problem or need for this feature.
### Suggested Solutions
Describe how this feature could be implemented.
### Additional Context
Add any relevant screenshots, links, or resources.
+15
View File
@@ -0,0 +1,15 @@
---
name: General Report
about: Provide general feedback or inquiries
title: "[General]: "
labels: general
assignees: ''
---
### Feedback or Inquiry
Provide your feedback or inquiry.
### Additional Information
Add any other relevant details here.
+16
View File
@@ -46,6 +46,17 @@ docker compose up -d
4. Use the setup wizard to configure your first checking account (organization info, bank info, routing/account numbers), or import an existing ezCheckPrinting `.mdb` file. 4. Use the setup wizard to configure your first checking account (organization info, bank info, routing/account numbers), or import an existing ezCheckPrinting `.mdb` file.
#### Upgrading from images before v0.5
The container now runs as the unprivileged `node` user (UID 1000). Existing data
volumes were written as root, so fix ownership once before upgrading:
```bash
docker compose down
docker run --rm -v check-printing-data:/data alpine chown -R 1000:1000 /data
docker compose up -d
```
### Development (local) ### Development (local)
```bash ```bash
@@ -162,6 +173,8 @@ docker exec -it check-printing node migrations/import-mdb.js \
| `SESSION_MAX_AGE_HOURS` | `168` | Session lifetime in hours (default 7 days) | | `SESSION_MAX_AGE_HOURS` | `168` | Session lifetime in hours (default 7 days) |
| `PORT` | `3000` | HTTP listen port | | `PORT` | `3000` | HTTP listen port |
| `DB_PATH` | `/app/data/check-printing.db` | SQLite database file path | | `DB_PATH` | `/app/data/check-printing.db` | SQLite database file path |
| `APP_BASE_URL` | *(empty)* | Public base URL used in password reset links, e.g. `https://checks.example.com`. Recommended in production |
| `TRUST_PROXY` | *(empty)* | Set to `1` when running behind a reverse proxy so client IPs and HTTPS detection work correctly |
| `OIDC_ENABLED` | *(empty)* | Set to `true` or `1` to enable OIDC login | | `OIDC_ENABLED` | *(empty)* | Set to `true` or `1` to enable OIDC login |
| `OIDC_DISCOVERY_URL` | *(empty)* | Provider's `.well-known/openid-configuration` URL | | `OIDC_DISCOVERY_URL` | *(empty)* | Provider's `.well-known/openid-configuration` URL |
| `OIDC_CLIENT_ID` | *(empty)* | OIDC client ID | | `OIDC_CLIENT_ID` | *(empty)* | OIDC client ID |
@@ -185,6 +198,9 @@ services:
- check-printing-data:/app/data - check-printing-data:/app/data
environment: environment:
- SESSION_SECRET=${SESSION_SECRET} - SESSION_SECRET=${SESSION_SECRET}
# Optional: public base URL for reset links, reverse-proxy support
- APP_BASE_URL=${APP_BASE_URL:-}
- TRUST_PROXY=${TRUST_PROXY:-}
# Optional: OIDC / SSO # Optional: OIDC / SSO
- OIDC_ENABLED=${OIDC_ENABLED:-} - OIDC_ENABLED=${OIDC_ENABLED:-}
- OIDC_DISCOVERY_URL=${OIDC_DISCOVERY_URL:-} - OIDC_DISCOVERY_URL=${OIDC_DISCOVERY_URL:-}
+4
View File
@@ -14,6 +14,10 @@ services:
- DB_PATH=/app/data/check-printing.db - DB_PATH=/app/data/check-printing.db
# Required in production — generate with: openssl rand -hex 32 # Required in production — generate with: openssl rand -hex 32
- SESSION_SECRET=${SESSION_SECRET} - SESSION_SECRET=${SESSION_SECRET}
# Public base URL for password reset links (recommended)
- APP_BASE_URL=${APP_BASE_URL:-}
# Set to 1 when behind a reverse proxy / TLS termination
- TRUST_PROXY=${TRUST_PROXY:-}
# OIDC / SSO (optional — omit or leave blank to disable) # OIDC / SSO (optional — omit or leave blank to disable)
- OIDC_ENABLED=${OIDC_ENABLED:-} - OIDC_ENABLED=${OIDC_ENABLED:-}
- OIDC_DISCOVERY_URL=${OIDC_DISCOVERY_URL:-} - OIDC_DISCOVERY_URL=${OIDC_DISCOVERY_URL:-}
+7 -1
View File
@@ -1,5 +1,7 @@
FROM node:20-slim FROM node:20-slim
ENV NODE_ENV=production
# mdbtools for migration script (only needed on first run, stays in image for convenience) # mdbtools for migration script (only needed on first run, stays in image for convenience)
RUN apt-get update && apt-get install -y --no-install-recommends mdbtools && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends mdbtools && rm -rf /var/lib/apt/lists/*
@@ -10,9 +12,13 @@ RUN npm ci --omit=dev
COPY . . COPY . .
# Data volume: SQLite database and any runtime uploads # Data volume: SQLite database and any runtime uploads.
# Pre-create it owned by the unprivileged user so named volumes inherit ownership.
RUN mkdir -p /app/data && chown -R node:node /app
VOLUME ["/app/data"] VOLUME ["/app/data"]
USER node
EXPOSE 3000 EXPOSE 3000
CMD ["node", "src/app.js"] CMD ["node", "src/app.js"]
+5 -5
View File
@@ -1,12 +1,12 @@
{ {
"name": "ezcheck", "name": "ezcheck",
"version": "0.4.0", "version": "0.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ezcheck", "name": "ezcheck",
"version": "0.4.0", "version": "0.5.0",
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^9.4.3", "better-sqlite3": "^9.4.3",
@@ -1915,9 +1915,9 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.2", "version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.1.0" "side-channel": "^1.1.0"
+4 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ezcheck", "name": "ezcheck",
"version": "0.4.0", "version": "0.5.0",
"description": "Self-hosted check printing web app", "description": "Self-hosted check printing web app",
"main": "src/app.js", "main": "src/app.js",
"scripts": { "scripts": {
@@ -21,6 +21,9 @@
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.0" "nodemon": "^3.1.0"
}, },
"overrides": {
"qs": "^6.15.2"
},
"engines": { "engines": {
"node": ">=20" "node": ">=20"
} }
+161 -9
View File
@@ -22,11 +22,17 @@ body {
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
height: 100vh; height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden; overflow: hidden;
} }
#main-app {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
#main-app[hidden] { display: none; }
/* ── Header ── */ /* ── Header ── */
header { header {
background: var(--header-bg); background: var(--header-bg);
@@ -673,7 +679,7 @@ input[type="file"] {
right: 0; right: 0;
width: 560px; width: 560px;
max-width: 98vw; max-width: 98vw;
height: 100vh; height: 100dvh;
background: var(--surface); background: var(--surface);
z-index: 101; z-index: 101;
box-shadow: -4px 0 24px rgba(0,0,0,0.15); box-shadow: -4px 0 24px rgba(0,0,0,0.15);
@@ -681,7 +687,7 @@ input[type="file"] {
transition: transform 0.2s ease; transition: transform 0.2s ease;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: auto; overflow: hidden;
} }
#deposit-panel.open { transform: translateX(0); } #deposit-panel.open { transform: translateX(0); }
@@ -691,6 +697,8 @@ input[type="file"] {
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
flex: 1; flex: 1;
overflow-y: auto;
min-height: 0;
} }
.dep-summary { .dep-summary {
@@ -861,21 +869,165 @@ input[type="file"] {
} }
/* ── User management ── */ /* ── User management ── */
.account-checkboxes { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; } .account-checkboxes {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 6px;
margin-top: 4px;
}
.account-checkbox-label { .account-checkbox-label {
display: flex; display: grid;
grid-template-columns: 20px 1fr auto;
align-items: center; align-items: center;
gap: 5px; gap: 8px;
font-size: 12px; font-size: 12px;
background: var(--bg); background: var(--bg);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 4px; border-radius: 6px;
padding: 3px 8px; padding: 6px 10px;
cursor: pointer; cursor: pointer;
height: 64px;
} }
.account-checkbox-label:hover { border-color: var(--primary); } .account-checkbox-label:hover { border-color: var(--primary); }
.account-checkbox-label input[type="checkbox"] {
justify-self: center;
}
.account-checkbox-label span {
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.account-checkbox-label select {
flex-shrink: 0;
}
.uf-oidc-section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.uf-oidc-section h4 {
font-size: 13px;
font-weight: 600;
margin-bottom: 10px;
color: var(--text-muted);
}
/* Hide layout editor button on portrait/mobile — canvas needs landscape space */ /* Hide layout editor button on portrait/mobile — canvas needs landscape space */
@media (max-width: 768px), (orientation: portrait) { @media (max-width: 768px), (orientation: portrait) {
#btn-layout-editor { display: none !important; } #btn-layout-editor { display: none !important; }
} }
/* ── Settings Page ── */
.settings-page {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.settings-page[hidden] { display: none; }
.settings-header {
background: var(--header-bg);
color: var(--header-fg);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.5rem;
height: 44px;
flex-shrink: 0;
}
.settings-header-left { display: flex; align-items: center; gap: 12px; }
.settings-header-right { display: flex; align-items: center; gap: 10px; }
.settings-back-link {
color: rgba(255,255,255,0.7);
text-decoration: none;
font-size: 13px;
font-weight: 500;
transition: color 0.15s;
}
.settings-back-link:hover { color: #fff; }
.settings-layout {
display: flex;
flex: 1;
overflow: hidden;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.settings-sidebar {
width: 220px;
flex-shrink: 0;
padding: 20px 0;
border-right: 1px solid var(--border);
overflow-y: auto;
background: var(--surface);
}
.settings-sidebar-title {
font-size: 15px;
font-weight: 700;
padding: 4px 20px 16px;
color: var(--text);
}
.settings-nav-group { margin-bottom: 8px; }
.settings-nav-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
padding: 12px 20px 6px;
}
.settings-nav-item {
display: block;
padding: 7px 20px;
font-size: 13px;
color: var(--text);
text-decoration: none;
border-radius: 6px;
margin: 1px 8px;
transition: background 0.12s;
}
.settings-nav-item:hover { background: var(--bg); }
.settings-nav-item.active {
background: var(--primary);
color: #fff;
font-weight: 500;
}
.settings-content {
flex: 1;
padding: 32px 48px;
overflow-y: auto;
max-width: 780px;
}
.settings-panel h2 {
font-size: 20px;
font-weight: 600;
margin-bottom: 4px;
color: var(--text);
}
.settings-panel h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
color: var(--text);
}
.settings-desc {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 20px;
line-height: 1.5;
}
.settings-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 24px;
margin-bottom: 20px;
}
.settings-section .form-row { gap: 16px; }
.settings-section .form-group { margin-bottom: 4px; }
+169 -122
View File
@@ -87,6 +87,172 @@
</div> </div>
</div> </div>
<!-- Settings page (full-page, hidden by default) -->
<div id="settings-page" class="settings-page" hidden>
<header class="settings-header">
<div class="settings-header-left">
<a href="#" id="settings-back-link" class="settings-back-link">&larr; Back</a>
</div>
<div class="settings-header-right">
<span id="settings-username" class="header-username"></span>
<button id="btn-settings-logout" class="btn-header-icon" title="Sign out"></button>
</div>
</header>
<div class="settings-layout">
<nav class="settings-sidebar">
<div class="settings-sidebar-title">Settings</div>
<div class="settings-nav-group" data-admin-only>
<div class="settings-nav-label">Administration</div>
<a href="#settings/users" class="settings-nav-item" data-settings-tab="users">Users</a>
<a href="#settings/smtp" class="settings-nav-item" data-settings-tab="smtp">Email (SMTP)</a>
</div>
<div class="settings-nav-group">
<div class="settings-nav-label">Account</div>
<a href="#settings/password" class="settings-nav-item" data-settings-tab="password">Password</a>
<a href="#settings/sso" class="settings-nav-item" data-settings-tab="sso">Single Sign-On</a>
</div>
</nav>
<main class="settings-content">
<!-- Users panel (admin only) -->
<div id="settings-panel-users" class="settings-panel" hidden>
<h2>Users</h2>
<p class="settings-desc">Manage user accounts and access permissions.</p>
<div class="settings-section">
<div id="users-list"></div>
</div>
<div class="settings-section" id="user-form-section">
<h3 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">
<label for="uf-email">Email <span class="field-hint">(for password reset)</span></label>
<input type="email" id="uf-email" autocomplete="email">
</div>
<div class="form-group required">
<label for="uf-password">Password <span class="field-hint" id="uf-password-hint">(min 10 chars, include a digit or symbol)</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="uf-oidc-group" class="uf-oidc-section" hidden>
<h4>Single Sign-On Identity</h4>
<div class="form-row">
<div class="form-group">
<label for="uf-oidc-sub">OIDC Subject <span class="field-hint">(sub claim from provider)</span></label>
<input type="text" id="uf-oidc-sub" autocomplete="off">
</div>
<div class="form-group">
<label for="uf-oidc-issuer">OIDC Issuer <span class="field-hint">(provider URL)</span></label>
<input type="text" id="uf-oidc-issuer" autocomplete="off">
</div>
</div>
</div>
<div id="user-form-error" class="wizard-error" hidden></div>
<div style="display:flex;gap:8px;margin-top:12px">
<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>
</div>
<!-- SMTP panel (admin only) -->
<div id="settings-panel-smtp" class="settings-panel" hidden>
<h2>Email Settings</h2>
<p class="settings-desc">Configure SMTP for password reset emails.</p>
<div class="settings-section" id="smtp-settings-section">
<div class="form-row">
<div class="form-group">
<label for="smtp-host">SMTP Host</label>
<input type="text" id="smtp-host" placeholder="smtp.example.com">
</div>
<div class="form-group" style="max-width:100px">
<label for="smtp-port">Port</label>
<input type="number" id="smtp-port" value="587" min="1" max="65535">
</div>
<div class="form-group" style="max-width:160px">
<label for="smtp-secure">Encryption</label>
<select id="smtp-secure">
<option value="0">STARTTLS (587)</option>
<option value="1">SSL/TLS (465)</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="smtp-user">Username</label>
<input type="text" id="smtp-user" autocomplete="off">
</div>
<div class="form-group">
<label for="smtp-pass">Password <span class="field-hint" id="smtp-pass-hint"></span></label>
<input type="password" id="smtp-pass" autocomplete="new-password">
</div>
<div class="form-group">
<label for="smtp-from">From Address</label>
<input type="email" id="smtp-from" placeholder="ezcheck@example.com">
</div>
</div>
<div id="smtp-error" class="wizard-error" hidden></div>
<div id="smtp-success" class="import-result" hidden></div>
<button id="btn-save-smtp" class="btn-secondary" style="margin-top:12px">Save Email Settings</button>
</div>
</div>
<!-- Password panel (all users) -->
<div id="settings-panel-password" class="settings-panel" hidden>
<h2>Change Password</h2>
<p class="settings-desc">Update your login password.</p>
<div class="settings-section">
<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 10 chars, include a digit or symbol)</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:12px">Change Password</button>
</div>
</div>
<!-- SSO panel (all users, shown when OIDC enabled) -->
<div id="settings-panel-sso" class="settings-panel" hidden>
<h2>Single Sign-On</h2>
<p class="settings-desc">Link your account to an external identity provider.</p>
<div class="settings-section" id="oidc-link-section">
<p id="oidc-link-status" class="settings-desc" style="margin-bottom:12px"></p>
<a id="btn-oidc-link" href="/api/auth/oidc/link" class="btn-secondary" style="display:inline-block;text-decoration:none">Link My Account</a>
<button id="btn-oidc-unlink" class="btn-ghost" style="margin-left:8px" hidden>Unlink</button>
</div>
</div>
</main>
</div>
</div>
<div id="main-app">
<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>
@@ -657,128 +823,8 @@
</div> </div>
</div> </div>
</aside> </aside>
</div><!-- /main-app -->
<!-- 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 id="user-form-section" 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">
<label for="uf-email">Email <span class="field-hint">(for password reset)</span></label>
<input type="email" id="uf-email" autocomplete="email">
</div>
<div class="form-group required">
<label for="uf-password">Password <span class="field-hint" id="uf-password-hint">(min 10 chars, include a digit or symbol)</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="uf-oidc-group" class="form-row" hidden>
<div class="form-group">
<label for="uf-oidc-sub">OIDC Subject <span class="field-hint">(sub claim from provider)</span></label>
<input type="text" id="uf-oidc-sub" autocomplete="off">
</div>
<div class="form-group">
<label for="uf-oidc-issuer">OIDC Issuer <span class="field-hint">(provider URL)</span></label>
<input type="text" id="uf-oidc-issuer" autocomplete="off">
</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>
<!-- SMTP settings (admin only) -->
<div id="smtp-settings-section" style="margin-top:16px;border-top:1px solid var(--border);padding-top:16px">
<h3 style="font-size:13px;font-weight:600;margin-bottom:10px">Email Settings (SMTP)</h3>
<div class="form-row">
<div class="form-group">
<label for="smtp-host">SMTP Host</label>
<input type="text" id="smtp-host" placeholder="smtp.example.com">
</div>
<div class="form-group" style="max-width:90px">
<label for="smtp-port">Port</label>
<input type="number" id="smtp-port" value="587" min="1" max="65535">
</div>
<div class="form-group" style="max-width:140px">
<label for="smtp-secure">Encryption</label>
<select id="smtp-secure">
<option value="0">STARTTLS (587)</option>
<option value="1">SSL/TLS (465)</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="smtp-user">Username</label>
<input type="text" id="smtp-user" autocomplete="off">
</div>
<div class="form-group">
<label for="smtp-pass">Password <span class="field-hint" id="smtp-pass-hint"></span></label>
<input type="password" id="smtp-pass" autocomplete="new-password">
</div>
<div class="form-group">
<label for="smtp-from">From Address</label>
<input type="email" id="smtp-from" placeholder="ezcheck@example.com">
</div>
</div>
<div id="smtp-error" class="wizard-error" hidden></div>
<div id="smtp-success" class="import-result" hidden></div>
<button id="btn-save-smtp" class="btn-secondary" style="margin-top:8px">Save Email Settings</button>
</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 10 chars, include a digit or symbol)</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>
<!-- Link my OIDC identity (self-service, shown when OIDC is enabled) -->
<div id="oidc-link-section" style="margin-top:16px;border-top:1px solid var(--border);padding-top:16px" hidden>
<h3 style="font-size:13px;font-weight:600;margin-bottom:10px">Single Sign-On</h3>
<p id="oidc-link-status" style="font-size:12px;color:var(--text-muted);margin-bottom:8px"></p>
<a id="btn-oidc-link" href="/api/auth/oidc/link" class="btn-secondary" style="display:inline-block;text-decoration:none">Link My Account</a>
<button id="btn-oidc-unlink" class="btn-ghost" style="margin-left:8px" hidden>Unlink</button>
</div>
</div>
</div>
<!-- Layout Editor Modal --> <!-- Layout Editor Modal -->
<div id="layout-editor-overlay" class="modal-overlay"></div> <div id="layout-editor-overlay" class="modal-overlay"></div>
@@ -840,7 +886,8 @@
</div> </div>
</div> </div>
<div id="layout-save-status" style="font-size:11px;color:var(--text-muted);min-width:56px"></div> <div id="layout-save-status" style="font-size:11px;color:var(--text-muted);min-width:56px"></div>
<div style="margin-left:auto"> <div style="margin-left:auto;display:flex;gap:8px;align-items:center">
<button id="btn-layout-preview" class="btn-secondary btn-sm">⎙ Preview PDF</button>
<button id="btn-layout-reset" class="btn-secondary btn-sm" data-admin-only>↺ Reset to Default</button> <button id="btn-layout-reset" class="btn-secondary btn-sm" data-admin-only>↺ Reset to Default</button>
</div> </div>
</div> </div>
+188 -41
View File
@@ -65,7 +65,8 @@ async function checkAuth() {
return false; return false;
} }
if (location.hash === '#oidc-linked') { if (location.hash === '#oidc-linked') {
history.replaceState(null, '', location.pathname); // After OIDC link callback, navigate to SSO settings panel
location.hash = '#settings/sso';
// Fall through to normal auth check — user is still logged in // Fall through to normal auth check — user is still logged in
} }
@@ -75,6 +76,8 @@ async function checkAuth() {
state.user = await res.json(); state.user = await res.json();
hideLoginOverlay(); hideLoginOverlay();
applyRoleUI(); applyRoleUI();
// Route to settings page if hash says so
if (location.hash.startsWith('#settings')) handleHashRoute();
return true; return true;
} }
// No session — check if this is first-run (no users at all) // No session — check if this is first-run (no users at all)
@@ -165,6 +168,9 @@ async function logout() {
document.getElementById('login-error').hidden = true; document.getElementById('login-error').hidden = true;
document.getElementById('login-setup-section').hidden = true; document.getElementById('login-setup-section').hidden = true;
document.getElementById('login-form-section').hidden = false; document.getElementById('login-form-section').hidden = false;
// Ensure settings page is hidden and main app restored
document.getElementById('settings-page').hidden = true;
document.getElementById('main-app').hidden = false;
showLoginOverlay(); showLoginOverlay();
} }
@@ -176,8 +182,9 @@ function applyRoleUI() {
const isEditor = state.accountRole === 'editor' || (!state.accountRole && (role === 'admin' || role === 'editor')); const isEditor = state.accountRole === 'editor' || (!state.accountRole && (role === 'admin' || role === 'editor'));
document.getElementById('header-username').textContent = state.user ? state.user.username : ''; document.getElementById('header-username').textContent = state.user ? state.user.username : '';
document.getElementById('settings-username').textContent = state.user ? state.user.username : '';
// Admin-only elements // Admin-only elements (main app + settings sidebar)
document.querySelectorAll('[data-admin-only]').forEach(el => { el.hidden = !isAdmin; }); document.querySelectorAll('[data-admin-only]').forEach(el => { el.hidden = !isAdmin; });
// Editor+ elements (hide for viewers) // Editor+ elements (hide for viewers)
@@ -191,28 +198,62 @@ function applyRoleUI() {
let usersState = { users: [], editingId: null }; let usersState = { users: [], editingId: null };
function openUsersModal() { // ── Settings page navigation ────────────────────────────────────────────────
function navigateToSettings(tab) {
const isAdmin = state.user && state.user.role === 'admin'; const isAdmin = state.user && state.user.role === 'admin';
document.getElementById('user-form-error').hidden = true; const defaultTab = isAdmin ? 'users' : 'password';
document.getElementById('users-title').textContent = isAdmin ? 'Manage Users' : 'My Account'; const resolved = tab || defaultTab;
document.getElementById('users-overlay').classList.add('open');
document.getElementById('users-modal').classList.add('open'); // Guard non-admin from admin tabs
// Admin-only sections if (!isAdmin && (resolved === 'users' || resolved === 'smtp')) {
document.getElementById('users-list').hidden = !isAdmin; location.hash = '#settings/password';
document.getElementById('user-form-section').hidden = !isAdmin; return;
document.getElementById('smtp-settings-section').hidden = !isAdmin;
if (isAdmin) {
loadUsers();
renderUfAccountCheckboxes();
loadSmtpSettings();
} }
document.getElementById('main-app').hidden = true;
const sp = document.getElementById('settings-page');
sp.hidden = false;
document.getElementById('settings-username').textContent = state.user ? state.user.username : '';
// Check OIDC status to show/hide SSO tab
loadOidcLinkStatus(); loadOidcLinkStatus();
activateSettingsTab(resolved);
} }
function closeUsersModal() { function activateSettingsTab(tab) {
document.getElementById('users-overlay').classList.remove('open'); // Hide all panels, show the target
document.getElementById('users-modal').classList.remove('open'); document.querySelectorAll('.settings-panel').forEach(p => { p.hidden = true; });
cancelUserEdit(); const panel = document.getElementById('settings-panel-' + tab);
if (panel) panel.hidden = false;
// Update sidebar active state
document.querySelectorAll('.settings-nav-item').forEach(a => {
a.classList.toggle('active', a.dataset.settingsTab === tab);
});
// Load data for the activated tab
if (tab === 'users') { loadUsers(); renderUfAccountCheckboxes(); }
if (tab === 'smtp') { loadSmtpSettings(); }
if (tab === 'sso') { loadOidcLinkStatus(); }
}
function showMainApp() {
document.getElementById('settings-page').hidden = true;
document.getElementById('main-app').hidden = false;
}
function handleHashRoute() {
const hash = location.hash;
if (hash.startsWith('#settings/')) {
const tab = hash.split('/')[1];
navigateToSettings(tab);
} else if (hash.startsWith('#settings')) {
navigateToSettings();
} else {
showMainApp();
}
} }
async function loadUsers() { async function loadUsers() {
@@ -275,8 +316,8 @@ function renderUfAccountCheckboxes() {
const acctRole = assignment ? assignment.role : 'viewer'; const acctRole = assignment ? assignment.role : 'viewer';
return `<label class="account-checkbox-label"> return `<label class="account-checkbox-label">
<input type="checkbox" name="uf-account" value="${a.id}"${checked ? ' checked' : ''}> <input type="checkbox" name="uf-account" value="${a.id}"${checked ? ' checked' : ''}>
${escHtml(a.company1 || a.bank_name || `Account ${a.id}`)} <span>${escHtml(a.company1 || a.bank_name || `Account ${a.id}`)}</span>
<select name="uf-account-role" data-account-id="${a.id}" style="margin-left:6px;font-size:12px"> <select name="uf-account-role" data-account-id="${a.id}" style="font-size:12px">
<option value="editor"${acctRole === 'editor' ? ' selected' : ''}>Editor</option> <option value="editor"${acctRole === 'editor' ? ' selected' : ''}>Editor</option>
<option value="viewer"${acctRole === 'viewer' ? ' selected' : ''}>Viewer</option> <option value="viewer"${acctRole === 'viewer' ? ' selected' : ''}>Viewer</option>
</select> </select>
@@ -739,6 +780,18 @@ async function deleteCheck(id) {
} }
} }
// Firefox on iOS blocks window.open(blob:) in a new tab; use a temporary <a download> instead.
function openPdfBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 10000);
}
async function generatePdf() { async function generatePdf() {
const ids = [...state.selected]; const ids = [...state.selected];
if (ids.length === 0) return; if (ids.length === 0) return;
@@ -760,7 +813,7 @@ async function generatePdf() {
throw new Error(err.error || res.statusText); throw new Error(err.error || res.statusText);
} }
const blob = await res.blob(); const blob = await res.blob();
window.open(URL.createObjectURL(blob), '_blank'); openPdfBlob(blob, 'checks.pdf');
await loadChecks(); // refresh to show printed status await loadChecks(); // refresh to show printed status
} catch (err) { } catch (err) {
countSpan.textContent = savedCount; countSpan.textContent = savedCount;
@@ -1389,14 +1442,18 @@ async function openDepositPanel(id = null) {
document.getElementById('dep-coin').value = dep.coin || ''; document.getElementById('dep-coin').value = dep.coin || '';
document.getElementById('dep-cashback').value = dep.cash_back || ''; document.getElementById('dep-cashback').value = dep.cash_back || '';
depState.items = (dep.items || []).map(it => ({ ...it })); depState.items = (dep.items || []).map(it => ({ ...it }));
while (depState.items.length < 30) depState.items.push(newDepItem());
} catch (err) { } catch (err) {
alert('Error loading deposit: ' + err.message); alert('Error loading deposit: ' + err.message);
return; return;
} }
} else { } else {
depState.items = [newDepItem()]; depState.items = [];
} }
// Always start with at least 30 slots (one full deposit slip page)
while (depState.items.length < 30) depState.items.push(newDepItem());
renderDepItems(); renderDepItems();
recalcDepTotals(); recalcDepTotals();
@@ -1422,6 +1479,13 @@ function newDepItem() {
} }
function renderDepItems() { function renderDepItems() {
const addBtn = document.getElementById('btn-add-dep-item');
if (addBtn) {
const count = depState.items.length;
addBtn.hidden = count >= 60; // hide once back page rows are added
addBtn.disabled = count >= 60;
addBtn.textContent = 'Add Back Page Rows';
}
const tbody = document.getElementById('dep-items-tbody'); const tbody = document.getElementById('dep-items-tbody');
tbody.innerHTML = depState.items.map((item, i) => ` tbody.innerHTML = depState.items.map((item, i) => `
<tr data-idx="${i}"> <tr data-idx="${i}">
@@ -1444,6 +1508,8 @@ function renderDepItems() {
tbody.querySelectorAll('.dep-item-remove').forEach(btn => { tbody.querySelectorAll('.dep-item-remove').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
depState.items.splice(parseInt(btn.dataset.idx, 10), 1); depState.items.splice(parseInt(btn.dataset.idx, 10), 1);
// Maintain 30-slot minimum (one full slip page)
while (depState.items.length < 30) depState.items.push(newDepItem());
renderDepItems(); renderDepItems();
recalcDepTotals(); recalcDepTotals();
}); });
@@ -1554,7 +1620,7 @@ async function generateDepositPdf(type) {
throw new Error(err.error || res.statusText); throw new Error(err.error || res.statusText);
} }
const blob = await res.blob(); const blob = await res.blob();
window.open(URL.createObjectURL(blob), '_blank'); openPdfBlob(blob, type === 'slip' ? 'deposit-slip.pdf' : 'deposit-report.pdf');
if (type === 'slip') await loadDeposits(); if (type === 'slip') await loadDeposits();
} catch (err) { } catch (err) {
alert('PDF error: ' + err.message); alert('PDF error: ' + err.message);
@@ -1664,9 +1730,12 @@ async function saveSmtpSettings() {
async function loadOidcLinkStatus() { async function loadOidcLinkStatus() {
try { try {
const cfg = await fetch('/api/auth/oidc/config').then(r => r.json()); const cfg = await fetch('/api/auth/oidc/config').then(r => r.json());
const section = document.getElementById('oidc-link-section'); const ssoNavItem = document.querySelector('[data-settings-tab="sso"]');
if (!cfg.enabled) { section.hidden = true; return; } if (!cfg.enabled) {
section.hidden = false; if (ssoNavItem) ssoNavItem.hidden = true;
return;
}
if (ssoNavItem) ssoNavItem.hidden = false;
const me = await apiFetch('GET', '/api/auth/me'); const me = await apiFetch('GET', '/api/auth/me');
const statusEl = document.getElementById('oidc-link-status'); const statusEl = document.getElementById('oidc-link-status');
@@ -1844,7 +1913,7 @@ async function init() {
document.getElementById('dep-panel-overlay').addEventListener('click', closeDepositPanel); document.getElementById('dep-panel-overlay').addEventListener('click', closeDepositPanel);
document.getElementById('btn-save-deposit').addEventListener('click', saveDeposit); document.getElementById('btn-save-deposit').addEventListener('click', saveDeposit);
document.getElementById('btn-add-dep-item').addEventListener('click', () => { document.getElementById('btn-add-dep-item').addEventListener('click', () => {
depState.items.push(newDepItem()); while (depState.items.length < 60) depState.items.push(newDepItem());
renderDepItems(); renderDepItems();
}); });
document.getElementById('btn-dep-slip').addEventListener('click', () => generateDepositPdf('slip')); document.getElementById('btn-dep-slip').addEventListener('click', () => generateDepositPdf('slip'));
@@ -1886,11 +1955,27 @@ async function init() {
document.getElementById('btn-reset-submit').addEventListener('click', submitResetPassword); document.getElementById('btn-reset-submit').addEventListener('click', submitResetPassword);
document.getElementById('reset-password2').addEventListener('keydown', e => { if (e.key === 'Enter') submitResetPassword(); }); document.getElementById('reset-password2').addEventListener('keydown', e => { if (e.key === 'Enter') submitResetPassword(); });
// User management // User management / settings page
document.getElementById('btn-users').addEventListener('click', openUsersModal); document.getElementById('btn-users').addEventListener('click', () => { location.hash = '#settings/users'; });
document.getElementById('header-username').addEventListener('click', openUsersModal); document.getElementById('header-username').addEventListener('click', () => {
document.getElementById('btn-close-users').addEventListener('click', closeUsersModal); const isAdmin = state.user && state.user.role === 'admin';
document.getElementById('users-overlay').addEventListener('click', closeUsersModal); location.hash = isAdmin ? '#settings/users' : '#settings/password';
});
document.getElementById('settings-back-link').addEventListener('click', e => {
e.preventDefault();
location.hash = '';
});
document.getElementById('btn-settings-logout').addEventListener('click', () => {
location.hash = '';
logout();
});
// Sidebar tab navigation
document.querySelectorAll('.settings-nav-item').forEach(a => {
a.addEventListener('click', e => { e.preventDefault(); location.hash = a.getAttribute('href'); });
});
window.addEventListener('hashchange', () => {
if (state.user) handleHashRoute();
});
document.getElementById('users-list').addEventListener('click', e => { document.getElementById('users-list').addEventListener('click', e => {
const editBtn = e.target.closest('.user-btn-edit'); const editBtn = e.target.closest('.user-btn-edit');
const deleteBtn = e.target.closest('.user-btn-delete'); const deleteBtn = e.target.closest('.user-btn-delete');
@@ -1922,6 +2007,7 @@ async function init() {
document.getElementById('nudge-left').addEventListener('click', () => nudgeLayoutField(-1, 0)); document.getElementById('nudge-left').addEventListener('click', () => nudgeLayoutField(-1, 0));
document.getElementById('nudge-right').addEventListener('click', () => nudgeLayoutField( 1, 0)); document.getElementById('nudge-right').addEventListener('click', () => nudgeLayoutField( 1, 0));
document.getElementById('btn-layout-reset').addEventListener('click', resetLayoutToDefault); document.getElementById('btn-layout-reset').addEventListener('click', resetLayoutToDefault);
document.getElementById('btn-layout-preview').addEventListener('click', previewLayoutPdf);
// Initial auth check → loads app if already signed in // Initial auth check → loads app if already signed in
const authed = await checkAuth(); const authed = await checkAuth();
@@ -2022,6 +2108,12 @@ function populateLayoutDropdown() {
).join(''); ).join('');
} }
// Printing safe zone for user-adjustable fields (inches). MICR is exempt.
const SAFE_LEFT = 11 / 64;
const SAFE_RIGHT = 8.5 - 11 / 64;
const SAFE_TOP = 13 / 64;
const SAFE_BOTTOM = 3.5 - 0.5;
const SVG_NS = 'http://www.w3.org/2000/svg'; const SVG_NS = 'http://www.w3.org/2000/svg';
function svgEl(tag, attrs, text) { function svgEl(tag, attrs, text) {
const el = document.createElementNS(SVG_NS, tag); const el = document.createElementNS(SVG_NS, tag);
@@ -2044,11 +2136,41 @@ function renderLayoutCanvas() {
// White check background // White check background
svg.appendChild(svgEl('rect', { x:0, y:0, width:W, height:H, fill:'#fff', stroke:'#bbb', 'stroke-width':1 })); svg.appendChild(svgEl('rect', { x:0, y:0, width:W, height:H, fill:'#fff', stroke:'#bbb', 'stroke-width':1 }));
// Grid at 1/8" increments (darker every 1/4", darkest on whole inches)
for (let n8 = 1; n8 < Math.ceil(8.5 * 8); n8++) {
const x = (n8 / 8) * SCALE;
if (x >= W) break;
const isInch = n8 % 8 === 0;
const isQtr = n8 % 2 === 0;
const stroke = isInch ? '#d0d7de' : isQtr ? '#e4e8ed' : '#f0f2f5';
svg.appendChild(svgEl('line', { x1:x, y1:0, x2:x, y2:H, stroke, 'stroke-width':1 }));
}
for (let n8 = 1; n8 < Math.ceil(3.5 * 8); n8++) {
const y = (n8 / 8) * SCALE;
if (y >= H) break;
const isInch = n8 % 8 === 0;
const isQtr = n8 % 2 === 0;
const stroke = isInch ? '#d0d7de' : isQtr ? '#e4e8ed' : '#f0f2f5';
svg.appendChild(svgEl('line', { x1:0, y1:y, x2:W, y2:y, stroke, 'stroke-width':1 }));
}
// MICR reference line // MICR reference line
const micrY = (3.5 - 0.267) * SCALE; const micrY = (3.5 - 0.267) * SCALE;
svg.appendChild(svgEl('line', { x1:0, y1:micrY, x2:W, y2:micrY, stroke:'#ccc', 'stroke-width':1, 'stroke-dasharray':'4,4' })); svg.appendChild(svgEl('line', { x1:0, y1:micrY, x2:W, y2:micrY, stroke:'#ccc', 'stroke-width':1, 'stroke-dasharray':'4,4' }));
svg.appendChild(svgEl('text', { x:4, y:micrY - 3, 'font-size':9, fill:'#bbb', 'font-family':'sans-serif' }, 'MICR')); svg.appendChild(svgEl('text', { x:4, y:micrY - 3, 'font-size':9, fill:'#bbb', 'font-family':'sans-serif' }, 'MICR'));
// Safe zone outline for user-adjustable fields
svg.appendChild(svgEl('rect', {
x: SAFE_LEFT * SCALE,
y: SAFE_TOP * SCALE,
width: (SAFE_RIGHT - SAFE_LEFT) * SCALE,
height: (SAFE_BOTTOM - SAFE_TOP) * SCALE,
fill: 'none',
stroke: '#60a5fa',
'stroke-width': 1,
'stroke-dasharray': '3,3',
}));
for (const f of layoutState.fields) { for (const f of layoutState.fields) {
const g = createFieldSvgElement(f, SCALE, layoutState.selectedId === f.id); const g = createFieldSvgElement(f, SCALE, layoutState.selectedId === f.id);
svg.appendChild(g); svg.appendChild(g);
@@ -2273,11 +2395,11 @@ function onLayoutDragMove(e) {
const dy = (e.clientY - layoutDrag.mouseY) / layoutState.scale; const dy = (e.clientY - layoutDrag.mouseY) / layoutState.scale;
const f = layoutState.fields.find(x => x.id === layoutDrag.fieldId); const f = layoutState.fields.find(x => x.id === layoutDrag.fieldId);
if (!f) return; if (!f) return;
f.x_pos = clampIn(round16(layoutDrag.origX + dx), 0, 8.5); f.x_pos = clampIn(round16(layoutDrag.origX + dx), SAFE_LEFT, SAFE_RIGHT);
f.y_pos = clampIn(round16(layoutDrag.origY + dy), 0, 3.5); f.y_pos = clampIn(round16(layoutDrag.origY + dy), SAFE_TOP, SAFE_BOTTOM);
if (layoutDrag.moveEnd) { if (layoutDrag.moveEnd) {
f.x_end_pos = clampIn(round16(layoutDrag.origX2 + dx), 0, 8.5); f.x_end_pos = clampIn(round16(layoutDrag.origX2 + dx), SAFE_LEFT, SAFE_RIGHT);
f.y_end_pos = clampIn(round16(layoutDrag.origY2 + dy), 0, 3.5); f.y_end_pos = clampIn(round16(layoutDrag.origY2 + dy), SAFE_TOP, SAFE_BOTTOM);
} }
// Update just the dragged element for smooth performance // Update just the dragged element for smooth performance
const svg = document.querySelector('#layout-canvas-container svg'); const svg = document.querySelector('#layout-canvas-container svg');
@@ -2304,11 +2426,11 @@ function nudgeLayoutField(dx, dy) {
const f = layoutState.fields.find(x => x.id === layoutState.selectedId); const f = layoutState.fields.find(x => x.id === layoutState.selectedId);
if (!f) return; if (!f) return;
const S = 1 / 16; const S = 1 / 16;
f.x_pos = clampIn(round16(f.x_pos + dx * S), 0, 8.5); f.x_pos = clampIn(round16(f.x_pos + dx * S), SAFE_LEFT, SAFE_RIGHT);
f.y_pos = clampIn(round16(f.y_pos + dy * S), 0, 3.5); f.y_pos = clampIn(round16(f.y_pos + dy * S), SAFE_TOP, SAFE_BOTTOM);
if (f.field_type === 'Line' || f.field_type === 'Graph') { if (f.field_type === 'Line' || f.field_type === 'Graph') {
f.x_end_pos = clampIn(round16(f.x_end_pos + dx * S), 0, 8.5); f.x_end_pos = clampIn(round16(f.x_end_pos + dx * S), SAFE_LEFT, SAFE_RIGHT);
f.y_end_pos = clampIn(round16(f.y_end_pos + dy * S), 0, 3.5); f.y_end_pos = clampIn(round16(f.y_end_pos + dy * S), SAFE_TOP, SAFE_BOTTOM);
} }
updateLayoutSidebar(f); updateLayoutSidebar(f);
renderLayoutCanvas(); renderLayoutCanvas();
@@ -2335,6 +2457,31 @@ async function saveLayoutField(f) {
} }
} }
async function previewLayoutPdf() {
const btn = document.getElementById('btn-layout-preview');
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = '…';
try {
const res = await fetch('/api/pdf/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account_id: state.activeAccountId }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
const blob = await res.blob();
openPdfBlob(blob, 'layout-preview.pdf');
} catch (err) {
alert('Preview error: ' + err.message);
} finally {
btn.disabled = false;
btn.textContent = orig;
}
}
async function resetLayoutToDefault() { async function resetLayoutToDefault() {
if (!confirm('Reset all layout fields to default positions? This cannot be undone.')) return; if (!confirm('Reset all layout fields to default positions? This cannot be undone.')) return;
try { try {
+43 -5
View File
@@ -4,7 +4,6 @@ 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 session = require('express-session');
@@ -14,7 +13,14 @@ const { seedLayoutFields } = require('./db/database');
const { requireAuth, requireAdmin, canAccessAccount, isEditorForAccount } = require('./middleware/auth'); const { requireAuth, requireAdmin, canAccessAccount, isEditorForAccount } = require('./middleware/auth');
const app = express(); const app = express();
const upload = multer({ dest: os.tmpdir() }); app.disable('x-powered-by');
const upload = multer({ dest: os.tmpdir(), limits: { fileSize: 50 * 1024 * 1024 } });
// US ABA routing numbers are exactly 9 digits (spaces/dashes tolerated on input)
function normalizeRoutingNumber(value) {
const digits = String(value || '').replace(/[\s-]/g, '');
return /^\d{9}$/.test(digits) ? digits : null;
}
// ── Session store (SQLite-backed, no extra packages) ────────────────────────── // ── Session store (SQLite-backed, no extra packages) ──────────────────────────
const SessionStore = require('./lib/SessionStore'); const SessionStore = require('./lib/SessionStore');
@@ -27,12 +33,20 @@ const SESSION_SECRET = process.env.SESSION_SECRET;
const SESSION_MAX_AGE_MS = (parseInt(process.env.SESSION_MAX_AGE_HOURS, 10) || 168) * 60 * 60 * 1000; const SESSION_MAX_AGE_MS = (parseInt(process.env.SESSION_MAX_AGE_HOURS, 10) || 168) * 60 * 60 * 1000;
// Behind a reverse proxy (TLS termination), set TRUST_PROXY=1 so req.ip and
// req.protocol reflect the original client instead of the proxy.
if (process.env.TRUST_PROXY === '1' || process.env.TRUST_PROXY === 'true') {
app.set('trust proxy', 1);
}
app.use(session({ app.use(session({
store: new SessionStore(db), store: new SessionStore(db),
secret: SESSION_SECRET, secret: SESSION_SECRET,
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { httpOnly: true, sameSite: 'strict', maxAge: SESSION_MAX_AGE_MS }, // secure: 'auto' marks the cookie Secure only on TLS connections, so plain-HTTP
// LAN deployments keep working while proxied HTTPS deployments get Secure cookies
cookie: { httpOnly: true, sameSite: 'strict', secure: 'auto', maxAge: SESSION_MAX_AGE_MS },
})); }));
// Security headers // Security headers
@@ -112,6 +126,10 @@ app.put('/api/account/:id', requireAdmin, (req, res) => {
if (!company1 || !routing_number || !account_number) { if (!company1 || !routing_number || !account_number) {
return res.status(400).json({ error: 'Organization name, routing number, and account number are required.' }); return res.status(400).json({ error: 'Organization name, routing number, and account number are required.' });
} }
const normalizedRouting = normalizeRoutingNumber(routing_number);
if (!normalizedRouting) {
return res.status(400).json({ error: 'Routing number must be exactly 9 digits.' });
}
const MAX_IMAGE_BYTES = 512 * 1024; // 512 KB base64 limit const MAX_IMAGE_BYTES = 512 * 1024; // 512 KB base64 limit
if (logo_data && Buffer.byteLength(logo_data, 'utf8') > MAX_IMAGE_BYTES) { if (logo_data && Buffer.byteLength(logo_data, 'utf8') > MAX_IMAGE_BYTES) {
return res.status(400).json({ error: 'Logo image must be smaller than 512 KB.' }); return res.status(400).json({ error: 'Logo image must be smaller than 512 KB.' });
@@ -133,7 +151,7 @@ app.put('/api/account/:id', requireAdmin, (req, res) => {
`).run( `).run(
company1 || null, company2 || null, company3 || null, company4 || null, company1 || null, company2 || null, company3 || null, company4 || null,
bank_name || '', bank_info1 || null, bank_info2 || null, bank_info3 || null, transit_code || null, bank_name || '', bank_info1 || null, bank_info2 || null, bank_info3 || null, transit_code || null,
routing_number, account_number, normalizedRouting, account_number,
parseFloat(offset_left) || 0, parseFloat(offset_right) || 0, parseFloat(offset_left) || 0, parseFloat(offset_right) || 0,
parseFloat(offset_up) || 0, parseFloat(offset_down) || 0, parseFloat(offset_up) || 0, parseFloat(offset_down) || 0,
second_signature ? 1 : 0, resolvedPosition, second_signature ? 1 : 0, resolvedPosition,
@@ -206,6 +224,10 @@ app.post('/api/account/setup', requireAdmin, (req, res) => {
if (!company1 || !routing_number || !account_number || !start_check_no) { if (!company1 || !routing_number || !account_number || !start_check_no) {
return res.status(400).json({ error: 'Organization name, routing number, account number, and starting check number are required.' }); return res.status(400).json({ error: 'Organization name, routing number, account number, and starting check number are required.' });
} }
const normalizedRouting = normalizeRoutingNumber(routing_number);
if (!normalizedRouting) {
return res.status(400).json({ error: 'Routing number must be exactly 9 digits.' });
}
const checkNo = parseInt(start_check_no, 10); const checkNo = parseInt(start_check_no, 10);
if (isNaN(checkNo) || checkNo < 1) { if (isNaN(checkNo) || checkNo < 1) {
return res.status(400).json({ error: 'Starting check number must be a positive integer.' }); return res.status(400).json({ error: 'Starting check number must be a positive integer.' });
@@ -226,7 +248,7 @@ app.post('/api/account/setup', requireAdmin, (req, res) => {
bank_info1: bank_info1 || null, bank_info1: bank_info1 || null,
bank_info2: bank_info2 || null, bank_info2: bank_info2 || null,
transit_code: transit_code || null, transit_code: transit_code || null,
routing_number, routing_number: normalizedRouting,
account_number, account_number,
start_check_no: checkNo, start_check_no: checkNo,
current_check_no: checkNo, current_check_no: checkNo,
@@ -304,6 +326,22 @@ app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../public/index.html')); res.sendFile(path.join(__dirname, '../public/index.html'));
}); });
// JSON error handler — keeps stack traces out of responses
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'Uploaded file is too large.' });
}
if (err.type === 'entity.too.large') {
return res.status(413).json({ error: 'Request body is too large.' });
}
if (err.type === 'entity.parse.failed') {
return res.status(400).json({ error: 'Invalid JSON in request body.' });
}
console.error('[error]', err);
res.status(500).json({ error: 'Internal server error.' });
});
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`ezcheck running on http://localhost:${PORT}`); console.log(`ezcheck running on http://localhost:${PORT}`);
+11 -8
View File
@@ -7,16 +7,20 @@ const { Store } = require('express-session');
class SessionStore extends Store { class SessionStore extends Store {
constructor(db) { constructor(db) {
super(); super();
this.db = db; // Prepared once — get/set run on every request
this.getStmt = db.prepare('SELECT sess, expired FROM sessions WHERE sid = ?');
this.setStmt = db.prepare('INSERT OR REPLACE INTO sessions (sid, sess, expired) VALUES (?, ?, ?)');
this.delStmt = db.prepare('DELETE FROM sessions WHERE sid = ?');
this.purgeStmt = db.prepare('DELETE FROM sessions WHERE expired < ?');
// Purge expired sessions every 10 minutes // Purge expired sessions every 10 minutes
setInterval(() => { setInterval(() => {
try { db.prepare('DELETE FROM sessions WHERE expired < ?').run(Date.now()); } catch (_) {} try { this.purgeStmt.run(Date.now()); } catch (_) {}
}, 10 * 60 * 1000).unref(); }, 10 * 60 * 1000).unref();
} }
get(sid, cb) { get(sid, cb) {
try { try {
const row = this.db.prepare('SELECT sess, expired FROM sessions WHERE sid = ?').get(sid); const row = this.getStmt.get(sid);
if (!row) return cb(null, null); if (!row) return cb(null, null);
if (Date.now() > row.expired) { if (Date.now() > row.expired) {
this.destroy(sid, () => {}); this.destroy(sid, () => {});
@@ -28,20 +32,19 @@ class SessionStore extends Store {
set(sid, sess, cb) { set(sid, sess, cb) {
try { try {
// cookie.maxAge is already in milliseconds
const maxAge = (sess.cookie && sess.cookie.maxAge) const maxAge = (sess.cookie && sess.cookie.maxAge)
? sess.cookie.maxAge * 1000 ? sess.cookie.maxAge
: 7 * 24 * 60 * 60 * 1000; : 7 * 24 * 60 * 60 * 1000;
const expired = Date.now() + maxAge; const expired = Date.now() + maxAge;
this.db.prepare( this.setStmt.run(sid, JSON.stringify(sess), expired);
'INSERT OR REPLACE INTO sessions (sid, sess, expired) VALUES (?, ?, ?)'
).run(sid, JSON.stringify(sess), expired);
cb(null); cb(null);
} catch (e) { cb(e); } } catch (e) { cb(e); }
} }
destroy(sid, cb) { destroy(sid, cb) {
try { try {
this.db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid); this.delStmt.run(sid);
cb(null); cb(null);
} catch (e) { cb(e); } } catch (e) { cb(e); }
} }
+7 -7
View File
@@ -2,6 +2,11 @@
const db = require('../db/database'); const db = require('../db/database');
// Prepared once — this lookup runs on nearly every authenticated request
const accountRoleStmt = db.prepare(
'SELECT role FROM user_accounts WHERE user_id = ? AND account_id = ?'
);
function requireAuth(req, res, next) { function requireAuth(req, res, next) {
if (!req.session || !req.session.userId) { if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Not authenticated.' }); return res.status(401).json({ error: 'Not authenticated.' });
@@ -28,10 +33,7 @@ function requireEditor(req, res, next) {
function canAccessAccount(session, accountId) { function canAccessAccount(session, accountId) {
if (!session || !session.userId) return false; if (!session || !session.userId) return false;
if (session.role === 'admin') return true; if (session.role === 'admin') return true;
const row = db.prepare( return !!accountRoleStmt.get(session.userId, accountId);
'SELECT 1 FROM user_accounts WHERE user_id = ? AND account_id = ?'
).get(session.userId, accountId);
return !!row;
} }
// Returns true if the user has editor (write) access to the given account. // Returns true if the user has editor (write) access to the given account.
@@ -39,9 +41,7 @@ function canAccessAccount(session, accountId) {
function isEditorForAccount(session, accountId) { function isEditorForAccount(session, accountId) {
if (!session || !session.userId) return false; if (!session || !session.userId) return false;
if (session.role === 'admin') return true; if (session.role === 'admin') return true;
const row = db.prepare( const row = accountRoleStmt.get(session.userId, accountId);
"SELECT role FROM user_accounts WHERE user_id = ? AND account_id = ?"
).get(session.userId, accountId);
return !!(row && row.role === 'editor'); return !!(row && row.role === 'editor');
} }
+81 -82
View File
@@ -15,45 +15,57 @@ function validatePassword(password) {
return null; return null;
} }
// ── Login rate limiter ──────────────────────────────────────────────────────── // ── Rate limiting ─────────────────────────────────────────────────────────────
// Tracks failed login attempts per IP. After 10 failures within 15 minutes, // Sliding-window counter per key (IP). After `max` hits within `windowMs`,
// further attempts are blocked until the window resets. // further attempts are blocked until the window resets.
const loginAttempts = new Map(); // ip -> { count, resetAt } function makeRateLimiter(max, windowMs) {
const RATE_WINDOW_MS = 15 * 60 * 1000; // 15 minutes const attempts = new Map(); // key -> { count, resetAt }
const RATE_MAX_FAILS = 10;
function checkLoginRate(ip) { // Purge stale entries every 30 minutes to prevent unbounded memory growth
const now = Date.now(); setInterval(() => {
const entry = loginAttempts.get(ip); const now = Date.now();
if (!entry || now > entry.resetAt) { for (const [key, entry] of attempts) {
loginAttempts.set(ip, { count: 0, resetAt: now + RATE_WINDOW_MS }); if (now > entry.resetAt) attempts.delete(key);
return true; // allow }
} }, 30 * 60 * 1000).unref();
return entry.count < RATE_MAX_FAILS;
return {
allowed(key) {
const entry = attempts.get(key);
if (!entry || Date.now() > entry.resetAt) return true;
return entry.count < max;
},
record(key) {
const now = Date.now();
const entry = attempts.get(key);
if (!entry || now > entry.resetAt) {
attempts.set(key, { count: 1, resetAt: now + windowMs });
} else {
entry.count++;
}
},
clear(key) {
attempts.delete(key);
},
};
} }
function recordLoginFailure(ip) { // 10 failed logins per IP per 15 minutes
const now = Date.now(); const loginLimiter = makeRateLimiter(10, 15 * 60 * 1000);
const entry = loginAttempts.get(ip); // 5 reset emails per IP per 15 minutes (counts every request, success or not)
if (!entry || now > entry.resetAt) { const resetLimiter = makeRateLimiter(5, 15 * 60 * 1000);
loginAttempts.set(ip, { count: 1, resetAt: now + RATE_WINDOW_MS });
} else {
entry.count++;
}
}
function clearLoginFailures(ip) { // Regenerates the session ID before establishing a login (prevents session fixation)
loginAttempts.delete(ip); function establishSession(req, user, cb) {
req.session.regenerate(err => {
if (err) return cb(err);
req.session.userId = user.id;
req.session.username = user.username;
req.session.role = user.role;
cb(null);
});
} }
// Purge stale entries every 30 minutes to prevent unbounded memory growth
setInterval(() => {
const now = Date.now();
for (const [ip, entry] of loginAttempts) {
if (now > entry.resetAt) loginAttempts.delete(ip);
}
}, 30 * 60 * 1000).unref();
// GET /api/auth/setup-needed — true when no users exist (first-run) // GET /api/auth/setup-needed — true when no users exist (first-run)
router.get('/setup-needed', (req, res) => { router.get('/setup-needed', (req, res) => {
const { n } = db.prepare('SELECT COUNT(*) AS n FROM users').get(); const { n } = db.prepare('SELECT COUNT(*) AS n FROM users').get();
@@ -75,18 +87,18 @@ router.post('/setup', async (req, res) => {
"INSERT INTO users (username, password_hash, role) VALUES (?, ?, 'admin')" "INSERT INTO users (username, password_hash, role) VALUES (?, ?, 'admin')"
).run(username.trim(), hash); ).run(username.trim(), hash);
req.session.userId = result.lastInsertRowid; const user = { id: result.lastInsertRowid, username: username.trim(), role: 'admin' };
req.session.username = username.trim(); establishSession(req, user, err => {
req.session.role = 'admin'; if (err) return res.status(500).json({ error: 'Failed to create session.' });
res.status(201).json(user);
res.status(201).json({ id: result.lastInsertRowid, username: username.trim(), role: 'admin' }); });
}); });
// POST /api/auth/login // POST /api/auth/login
router.post('/login', async (req, res) => { router.post('/login', async (req, res) => {
const ip = req.ip || req.socket.remoteAddress || 'unknown'; const ip = req.ip || req.socket.remoteAddress || 'unknown';
if (!checkLoginRate(ip)) { if (!loginLimiter.allowed(ip)) {
return res.status(429).json({ error: 'Too many failed login attempts. Please try again later.' }); return res.status(429).json({ error: 'Too many failed login attempts. Please try again later.' });
} }
@@ -95,22 +107,21 @@ router.post('/login', async (req, res) => {
const user = db.prepare('SELECT * FROM users WHERE username = ? COLLATE NOCASE').get(username.trim()); const user = db.prepare('SELECT * FROM users WHERE username = ? COLLATE NOCASE').get(username.trim());
if (!user) { if (!user) {
recordLoginFailure(ip); loginLimiter.record(ip);
return res.status(401).json({ error: 'Invalid username or password.' }); return res.status(401).json({ error: 'Invalid username or password.' });
} }
const match = await bcrypt.compare(password, user.password_hash); const match = await bcrypt.compare(password, user.password_hash);
if (!match) { if (!match) {
recordLoginFailure(ip); loginLimiter.record(ip);
return res.status(401).json({ error: 'Invalid username or password.' }); return res.status(401).json({ error: 'Invalid username or password.' });
} }
clearLoginFailures(ip); loginLimiter.clear(ip);
req.session.userId = user.id; establishSession(req, user, err => {
req.session.username = user.username; if (err) return res.status(500).json({ error: 'Failed to create session.' });
req.session.role = user.role; res.json({ id: user.id, username: user.username, role: user.role });
});
res.json({ id: user.id, username: user.username, role: user.role });
}); });
// POST /api/auth/logout // POST /api/auth/logout
@@ -154,6 +165,12 @@ router.post('/change-password', async (req, res) => {
// POST /api/auth/forgot-password — always 200 to avoid user enumeration // POST /api/auth/forgot-password — always 200 to avoid user enumeration
router.post('/forgot-password', async (req, res) => { router.post('/forgot-password', async (req, res) => {
const ip = req.ip || req.socket.remoteAddress || 'unknown';
if (!resetLimiter.allowed(ip)) {
return res.status(429).json({ error: 'Too many reset requests. Please try again later.' });
}
resetLimiter.record(ip);
const { email } = req.body; const { email } = req.body;
if (!email) return res.status(400).json({ error: 'Email is required.' }); if (!email) return res.status(400).json({ error: 'Email is required.' });
@@ -168,7 +185,9 @@ router.post('/forgot-password', async (req, res) => {
db.prepare('INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)').run(user.id, tokenHash, expiresAt); db.prepare('INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)').run(user.id, tokenHash, expiresAt);
})(); })();
const baseUrl = `${req.protocol}://${req.get('host')}`; // Prefer the configured base URL; deriving it from the Host header lets an
// attacker poison reset links (host header injection)
const baseUrl = (process.env.APP_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
const resetLink = `${baseUrl}/#reset?token=${token}`; const resetLink = `${baseUrl}/#reset?token=${token}`;
try { try {
@@ -221,17 +240,13 @@ function getOidcSettings() {
async function getOidcClient(settings) { async function getOidcClient(settings) {
const { Issuer } = require('openid-client'); const { Issuer } = require('openid-client');
console.log('[oidc] discovering issuer from:', settings.discovery_url);
const issuer = await Issuer.discover(settings.discovery_url); const issuer = await Issuer.discover(settings.discovery_url);
console.log('[oidc] discovered issuer:', issuer.issuer); return new issuer.Client({
const client = new issuer.Client({
client_id: settings.client_id, client_id: settings.client_id,
client_secret: settings.client_secret, client_secret: settings.client_secret,
redirect_uris: [settings.redirect_uri], redirect_uris: [settings.redirect_uri],
response_types: ['code'], response_types: ['code'],
}); });
console.log('[oidc] client created, redirect_uri:', settings.redirect_uri);
return client;
} }
// GET /api/auth/oidc/config — public, returns whether OIDC is enabled + button label // GET /api/auth/oidc/config — public, returns whether OIDC is enabled + button label
@@ -244,8 +259,6 @@ router.get('/oidc/config', (req, res) => {
router.get('/oidc/authorize', async (req, res) => { router.get('/oidc/authorize', async (req, res) => {
try { try {
const settings = getOidcSettings(); const settings = getOidcSettings();
console.log('[oidc] authorize: enabled=%s, discovery_url=%s, client_id=%s, redirect_uri=%s',
settings.enabled, settings.discovery_url, settings.client_id, settings.redirect_uri);
if (!settings.enabled) return res.status(400).json({ error: 'OIDC is not enabled.' }); if (!settings.enabled) return res.status(400).json({ error: 'OIDC is not enabled.' });
const { generators } = require('openid-client'); const { generators } = require('openid-client');
@@ -266,11 +279,10 @@ router.get('/oidc/authorize', async (req, res) => {
code_challenge_method: 'S256', code_challenge_method: 'S256',
}); });
console.log('[oidc] authorize: redirecting to:', authUrl.substring(0, 200) + '...');
// Ensure session is persisted before redirecting (saveUninitialized is false) // Ensure session is persisted before redirecting (saveUninitialized is false)
req.session.save(() => res.redirect(authUrl)); req.session.save(() => res.redirect(authUrl));
} catch (err) { } catch (err) {
console.error('[oidc] authorize error:', err.message, err.stack); console.error('[oidc] authorize error:', err.message);
res.redirect('/#oidc-error=' + encodeURIComponent('Failed to initiate SSO login.')); res.redirect('/#oidc-error=' + encodeURIComponent('Failed to initiate SSO login.'));
} }
}); });
@@ -278,7 +290,6 @@ router.get('/oidc/authorize', async (req, res) => {
// GET /api/auth/oidc/callback — handles the provider redirect // GET /api/auth/oidc/callback — handles the provider redirect
router.get('/oidc/callback', async (req, res) => { router.get('/oidc/callback', async (req, res) => {
try { try {
console.log('[oidc] callback: query params:', JSON.stringify(req.query));
const settings = getOidcSettings(); const settings = getOidcSettings();
if (!settings.enabled) return res.redirect('/#oidc-error=' + encodeURIComponent('OIDC is not enabled.')); if (!settings.enabled) return res.redirect('/#oidc-error=' + encodeURIComponent('OIDC is not enabled.'));
@@ -287,12 +298,9 @@ router.get('/oidc/callback', async (req, res) => {
console.error('[oidc] callback: no oidc session data found — session may have expired or cookie lost'); console.error('[oidc] callback: no oidc session data found — session may have expired or cookie lost');
return res.redirect('/#oidc-error=' + encodeURIComponent('Session expired. Please try again.')); return res.redirect('/#oidc-error=' + encodeURIComponent('Session expired. Please try again.'));
} }
console.log('[oidc] callback: session has oidc data, linking=%s, linkUserId=%s',
!!oidcSession.linking, oidcSession.linkUserId || 'n/a');
const client = await getOidcClient(settings); const client = await getOidcClient(settings);
const params = client.callbackParams(req); const params = client.callbackParams(req);
console.log('[oidc] callback: exchanging code for tokens...');
const tokenSet = await client.callback(settings.redirect_uri, params, { const tokenSet = await client.callback(settings.redirect_uri, params, {
code_verifier: oidcSession.code_verifier, code_verifier: oidcSession.code_verifier,
@@ -303,25 +311,21 @@ router.get('/oidc/callback', async (req, res) => {
const claims = tokenSet.claims(); const claims = tokenSet.claims();
const sub = claims.sub; const sub = claims.sub;
const issuer = claims.iss; const issuer = claims.iss;
console.log('[oidc] callback: token exchange OK, sub=%s, iss=%s, email=%s, name=%s',
sub, issuer, claims.email || 'n/a', claims.name || 'n/a');
delete req.session.oidc; delete req.session.oidc;
// Self-service linking flow // Self-service linking flow
if (oidcSession.linking && oidcSession.linkUserId) { if (oidcSession.linking && oidcSession.linkUserId) {
console.log('[oidc] callback: linking flow for userId=%s', oidcSession.linkUserId);
const existing = db.prepare( const existing = db.prepare(
'SELECT id FROM users WHERE oidc_issuer = ? AND oidc_sub = ? AND id != ?' 'SELECT id FROM users WHERE oidc_issuer = ? AND oidc_sub = ? AND id != ?'
).get(issuer, sub, oidcSession.linkUserId); ).get(issuer, sub, oidcSession.linkUserId);
if (existing) { if (existing) {
console.warn('[oidc] callback: identity already linked to userId=%s', existing.id);
return res.redirect('/#oidc-error=' + encodeURIComponent('This identity is already linked to another account.')); return res.redirect('/#oidc-error=' + encodeURIComponent('This identity is already linked to another account.'));
} }
db.prepare("UPDATE users SET oidc_sub = ?, oidc_issuer = ?, updated_at = datetime('now') WHERE id = ?") db.prepare("UPDATE users SET oidc_sub = ?, oidc_issuer = ?, updated_at = datetime('now') WHERE id = ?")
.run(sub, issuer, oidcSession.linkUserId); .run(sub, issuer, oidcSession.linkUserId);
console.log('[oidc] callback: linked sub=%s to userId=%s', sub, oidcSession.linkUserId); console.log('[oidc] linked identity to userId=%s', oidcSession.linkUserId);
return res.redirect('/#oidc-linked'); return res.redirect('/#oidc-linked');
} }
@@ -331,26 +335,23 @@ router.get('/oidc/callback', async (req, res) => {
).get(issuer, sub); ).get(issuer, sub);
if (!user) { if (!user) {
console.warn('[oidc] callback: no user found for iss=%s sub=%s — not linked', issuer, sub); console.warn('[oidc] callback: identity not linked to any user');
return res.redirect('/#oidc-error=' + encodeURIComponent( return res.redirect('/#oidc-error=' + encodeURIComponent(
'No account is linked to this identity. Ask an admin to link your account, or sign in with your password and link it yourself.' 'No account is linked to this identity. Ask an admin to link your account, or sign in with your password and link it yourself.'
)); ));
} }
console.log('[oidc] callback: login success, userId=%s, username=%s, role=%s', user.id, user.username, user.role); console.log('[oidc] login success for userId=%s', user.id);
req.session.userId = user.id; establishSession(req, user, err => {
req.session.username = user.username; if (err) return res.redirect('/#oidc-error=' + encodeURIComponent('SSO login failed. Please try again.'));
req.session.role = user.role; // Load account access into session (mirrors login behavior)
if (user.role !== 'admin') {
// Load account access into session (mirrors login behavior) req.session.accounts = db.prepare('SELECT account_id, role FROM user_accounts WHERE user_id = ?').all(user.id);
if (user.role !== 'admin') { }
const accts = db.prepare('SELECT account_id, role FROM user_accounts WHERE user_id = ?').all(user.id); res.redirect('/');
req.session.accounts = accts; });
}
res.redirect('/');
} catch (err) { } catch (err) {
console.error('[oidc] callback error:', err.message, err.stack); console.error('[oidc] callback error:', err.message);
res.redirect('/#oidc-error=' + encodeURIComponent('SSO login failed. Please try again.')); res.redirect('/#oidc-error=' + encodeURIComponent('SSO login failed. Please try again.'));
} }
}); });
@@ -362,7 +363,6 @@ router.get('/oidc/link', async (req, res) => {
} }
try { try {
console.log('[oidc] link: userId=%s initiating linking flow', req.session.userId);
const settings = getOidcSettings(); const settings = getOidcSettings();
if (!settings.enabled) return res.redirect('/#oidc-error=' + encodeURIComponent('OIDC is not enabled.')); if (!settings.enabled) return res.redirect('/#oidc-error=' + encodeURIComponent('OIDC is not enabled.'));
@@ -384,10 +384,9 @@ router.get('/oidc/link', async (req, res) => {
code_challenge_method: 'S256', code_challenge_method: 'S256',
}); });
console.log('[oidc] link: redirecting to provider');
req.session.save(() => res.redirect(authUrl)); req.session.save(() => res.redirect(authUrl));
} catch (err) { } catch (err) {
console.error('[oidc] link error:', err.message, err.stack); console.error('[oidc] link error:', err.message);
res.redirect('/#oidc-error=' + encodeURIComponent('Failed to initiate SSO linking.')); res.redirect('/#oidc-error=' + encodeURIComponent('Failed to initiate SSO linking.'));
} }
}); });
+59 -2
View File
@@ -4,7 +4,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const db = require('../db/database'); const db = require('../db/database');
const { generateCheckPdf } = require('../services/pdfService'); const { generateCheckPdf } = require('../services/pdfService');
const { isEditorForAccount } = require('../middleware/auth'); const { canAccessAccount, isEditorForAccount } = require('../middleware/auth');
/** /**
* POST /api/pdf * POST /api/pdf
@@ -30,10 +30,11 @@ router.post('/', async (req, res) => {
} }
// Fetch checks in the order provided; verify each belongs to the declared account // Fetch checks in the order provided; verify each belongs to the declared account
const checkStmt = db.prepare('SELECT * FROM checks WHERE id = ?');
let checks; let checks;
try { try {
checks = checkIds.map(id => { checks = checkIds.map(id => {
const check = db.prepare('SELECT * FROM checks WHERE id = ?').get(id); const check = checkStmt.get(id);
if (!check) throw new Error(`Check ID ${id} not found`); if (!check) throw new Error(`Check ID ${id} not found`);
if (check.account_id !== resolvedAccountId) throw new Error(`Check ID ${id} does not belong to this account`); if (check.account_id !== resolvedAccountId) throw new Error(`Check ID ${id} does not belong to this account`);
return check; return check;
@@ -70,4 +71,60 @@ router.post('/', async (req, res) => {
} }
}); });
/**
* POST /api/pdf/preview
* Body: { account_id: X }
*
* Generates a layout preview PDF using dummy check data — no real checks touched.
* Shows all three slots filled with sample data so every visible field is visible.
*/
router.post('/preview', async (req, res) => {
const resolvedAccountId = parseInt(req.body.account_id, 10);
if (!resolvedAccountId) return res.status(400).json({ error: 'account_id required' });
// The preview renders the MICR line (routing + account number) — same access
// rules as reading the account itself
if (!canAccessAccount(req.session, resolvedAccountId)) {
return res.status(403).json({ error: 'Access denied.' });
}
const account = db.prepare('SELECT * FROM account WHERE id = ?').get(resolvedAccountId);
if (!account) return res.status(404).json({ error: 'Account not found.' });
const fields = db.prepare('SELECT * FROM layout_fields WHERE account_id = ?').all(resolvedAccountId);
const DUMMY_CHECK = {
id: 0,
check_no: 1001,
payee: 'Sample Payee Name',
amount: 1234.56,
check_date: new Date().toISOString().slice(0, 10),
memo: 'Sample Memo',
payee_address1: '123 Sample Street',
payee_address2: 'City, ST 12345',
payee_address3: null,
payee_address4: null,
printed: 0,
account_id: resolvedAccountId,
};
const checks = [
{ ...DUMMY_CHECK, check_no: 1001 },
{ ...DUMMY_CHECK, check_no: 1002 },
{ ...DUMMY_CHECK, check_no: 1003 },
];
try {
const pdfBuffer = await generateCheckPdf(account, checks, fields);
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': 'inline; filename="layout-preview.pdf"',
'Content-Length': pdfBuffer.length,
});
res.send(pdfBuffer);
} catch (err) {
console.error('Preview PDF error:', err);
res.status(500).json({ error: 'Preview generation failed.' });
}
});
module.exports = router; module.exports = router;
+26 -1
View File
@@ -6,7 +6,7 @@ const multer = require('multer');
const os = require('os'); const os = require('os');
const fs = require('fs'); const fs = require('fs');
const upload = multer({ dest: os.tmpdir() }); const upload = multer({ dest: os.tmpdir(), limits: { fileSize: 10 * 1024 * 1024 } });
const { isEditorForAccount } = require('../middleware/auth'); const { isEditorForAccount } = require('../middleware/auth');
// ── CSV helpers ─────────────────────────────────────────────────────────────── // ── CSV helpers ───────────────────────────────────────────────────────────────
@@ -165,6 +165,29 @@ function extractRows(text, type) {
// ── Confirm helpers ─────────────────────────────────────────────────────────── // ── Confirm helpers ───────────────────────────────────────────────────────────
// Records come back from the client as JSON, not from the parsed file —
// re-validate them server-side. Normalizes amount/check_no in place.
// Returns an error string, or null if all records are valid.
function validateRecords(records, type) {
for (const rec of records) {
if (!rec || typeof rec !== 'object') return 'Invalid record.';
if (!/^\d{4}-\d{2}-\d{2}$/.test(rec.date || '')) {
return 'Each record must have a date in YYYY-MM-DD format.';
}
const amount = Number(rec.amount);
if (!isFinite(amount) || amount <= 0) {
return 'Each record amount must be a positive number.';
}
rec.amount = Math.round(amount * 100) / 100;
if (type === 'checks' && rec.check_no !== null && rec.check_no !== undefined) {
const n = parseInt(rec.check_no, 10);
if (!Number.isInteger(n) || n < 1) return 'Check numbers must be positive integers.';
rec.check_no = n;
}
}
return null;
}
function confirmChecks(db, records, account_id) { function confirmChecks(db, records, account_id) {
const existing = new Set( const existing = new Set(
db.prepare('SELECT check_no FROM checks WHERE account_id = ?').all(account_id).map(r => r.check_no) db.prepare('SELECT check_no FROM checks WHERE account_id = ?').all(account_id).map(r => r.check_no)
@@ -312,6 +335,8 @@ router.post('/confirm', express.json(), (req, res) => {
if (records.length > 1000) { if (records.length > 1000) {
return res.status(400).json({ error: 'Cannot import more than 1000 records at a time.' }); return res.status(400).json({ error: 'Cannot import more than 1000 records at a time.' });
} }
const validationError = validateRecords(records, type);
if (validationError) return res.status(400).json({ error: validationError });
const db = require('../db/database'); const db = require('../db/database');
try { try {
+54 -45
View File
@@ -57,77 +57,86 @@ router.post('/', async (req, res) => {
// PUT /api/users/:id // PUT /api/users/:id
router.put('/:id', async (req, res) => { router.put('/:id', async (req, res) => {
const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(req.params.id); const userId = parseInt(req.params.id, 10);
const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(userId);
if (!user) return res.status(404).json({ error: 'User not found.' }); if (!user) return res.status(404).json({ error: 'User not found.' });
const { username, password, role, accounts, email, oidc_sub, oidc_issuer } = req.body; const { username, password, role, accounts, email, oidc_sub, oidc_issuer } = req.body;
// ── Validate everything before writing anything ──
if (role && !['admin', 'editor', 'viewer'].includes(role)) { if (role && !['admin', 'editor', 'viewer'].includes(role)) {
return res.status(400).json({ error: 'Invalid role.' }); return res.status(400).json({ error: 'Invalid role.' });
} }
if (role && role !== 'admin' && user.role === 'admin') {
if (username && username.trim() !== '') { const { n } = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND id != ?").get(userId);
try { if (n === 0) return res.status(400).json({ error: 'Cannot demote the last admin.' });
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 (email !== undefined) {
db.prepare("UPDATE users SET email = ?, updated_at = datetime('now') WHERE id = ?")
.run(email ? email.trim() : null, req.params.id);
}
if (password) { if (password) {
const pwErr = validatePassword(password); const pwErr = validatePassword(password);
if (pwErr) return res.status(400).json({ error: pwErr }); if (pwErr) return res.status(400).json({ error: pwErr });
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);
} }
// OIDC linking — admin can set or clear oidc_sub/oidc_issuer let newSub, newIssuer;
if (oidc_sub !== undefined) { if (oidc_sub !== undefined) {
const newSub = oidc_sub ? oidc_sub.trim() : null; newSub = oidc_sub ? oidc_sub.trim() : null;
const newIssuer = oidc_issuer ? oidc_issuer.trim() : null; newIssuer = oidc_issuer ? oidc_issuer.trim() : null;
if (newSub && !newIssuer) { if (newSub && !newIssuer) {
return res.status(400).json({ error: 'OIDC issuer is required when setting OIDC subject.' }); return res.status(400).json({ error: 'OIDC issuer is required when setting OIDC subject.' });
} }
if (newSub) { if (newSub) {
const existing = db.prepare( const existing = db.prepare(
'SELECT id FROM users WHERE oidc_issuer = ? AND oidc_sub = ? AND id != ?' 'SELECT id FROM users WHERE oidc_issuer = ? AND oidc_sub = ? AND id != ?'
).get(newIssuer, newSub, req.params.id); ).get(newIssuer, newSub, userId);
if (existing) return res.status(409).json({ error: 'This OIDC identity is already linked to another user.' }); if (existing) return res.status(409).json({ error: 'This OIDC identity is already linked to another user.' });
} }
db.prepare("UPDATE users SET oidc_sub = ?, oidc_issuer = ?, updated_at = datetime('now') WHERE id = ?")
.run(newSub, newSub ? newIssuer : null, req.params.id);
} }
if (Array.isArray(accounts)) { const passwordHash = password ? await bcrypt.hash(password, 12) : null;
db.prepare('DELETE FROM user_accounts WHERE user_id = ?').run(req.params.id);
const effectiveRole = role || user.role; // ── Apply all changes atomically ──
if (effectiveRole !== 'admin' && accounts.length > 0) { try {
const stmt = db.prepare('INSERT OR IGNORE INTO user_accounts (user_id, account_id, role) VALUES (?, ?, ?)'); db.transaction(() => {
accounts.forEach(a => stmt.run(req.params.id, a.id, a.role === 'editor' ? 'editor' : 'viewer')); if (username && username.trim() !== '') {
} db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?")
.run(username.trim(), userId);
}
if (role) {
db.prepare("UPDATE users SET role = ?, updated_at = datetime('now') WHERE id = ?")
.run(role, userId);
}
if (email !== undefined) {
db.prepare("UPDATE users SET email = ?, updated_at = datetime('now') WHERE id = ?")
.run(email ? email.trim() : null, userId);
}
if (passwordHash) {
db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?")
.run(passwordHash, userId);
}
if (oidc_sub !== undefined) {
db.prepare("UPDATE users SET oidc_sub = ?, oidc_issuer = ?, updated_at = datetime('now') WHERE id = ?")
.run(newSub, newSub ? newIssuer : null, userId);
}
if (Array.isArray(accounts)) {
db.prepare('DELETE FROM user_accounts WHERE user_id = ?').run(userId);
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, role) VALUES (?, ?, ?)');
accounts.forEach(a => stmt.run(userId, a.id, a.role === 'editor' ? 'editor' : 'viewer'));
}
}
// If role or account assignments changed, invalidate all active sessions for
// this user so the new permissions take effect immediately.
if (role || Array.isArray(accounts)) {
db.prepare("DELETE FROM sessions WHERE CAST(json_extract(sess, '$.userId') AS INTEGER) = ?")
.run(userId);
}
})();
} catch (err) {
if (err.message.includes('UNIQUE')) return res.status(409).json({ error: 'Username already taken.' });
throw err;
} }
// If role or account assignments changed, invalidate all active sessions for this user res.json(userWithAccounts(userId));
// so the new permissions take effect immediately rather than at session expiry.
if (role || Array.isArray(accounts)) {
db.prepare("DELETE FROM sessions WHERE CAST(json_extract(sess, '$.userId') AS INTEGER) = ?")
.run(parseInt(req.params.id, 10));
}
res.json(userWithAccounts(req.params.id));
}); });
// DELETE /api/users/:id // DELETE /api/users/:id
+142 -11
View File
@@ -44,7 +44,7 @@ const SL = {
cX: 0.65, cX: 0.65,
// ── Depositor block ─────────────────────────────────────────────────────── // ── Depositor block ───────────────────────────────────────────────────────
depositorY: 0.28, // Y of company name (first depositor line) depositorY: 0.42, // Y of company name (first depositor line)
// ── Date ───────────────────────────────────────────────────────────────── // ── Date ─────────────────────────────────────────────────────────────────
dateY: 1.38, // Y of DATE label dateY: 1.38, // Y of DATE label
@@ -87,8 +87,8 @@ const SL = {
checkCountValY: 6.1, // check count value start checkCountValY: 6.1, // check count value start
// ── Colours ─────────────────────────────────────────────────────────────── // ── Colours ───────────────────────────────────────────────────────────────
bgLineColor: '#888888', bgLineColor: '#333333',
bgLabelColor: '#444444', bgLabelColor: '#111111',
bgHeaderColor: '#000000', bgHeaderColor: '#000000',
}; };
@@ -238,7 +238,17 @@ function generateDepositSlip(account, deposit, items) {
const depositTotal = subTotal - (deposit.cash_back || 0); const depositTotal = subTotal - (deposit.cash_back || 0);
const checkCount = items.length; const checkCount = items.length;
const totalRows = SL.firstCheckRow + SL.maxChecks; // Split items: first 30 on front, up to 30 more on back
const frontItems = items.slice(0, SL.maxChecks);
const backItems = items.slice(SL.maxChecks, SL.maxChecks * 2);
const hasBackPage = backItems.some(it => (it.amount || 0) > 0 || it.check_no || it.payee);
const backTotal = hasBackPage ? backItems.reduce((s, i) => s + (i.amount || 0), 0) : 0;
// When back page exists, add one extra row on front for "FROM REVERSE"
const fromReverseRow = hasBackPage ? SL.firstCheckRow + SL.maxChecks : null;
const totalRows = fromReverseRow != null
? SL.firstCheckRow + SL.maxChecks + 1
: SL.firstCheckRow + SL.maxChecks;
const totalRowY_ = rowTopY(totalRows); const totalRowY_ = rowTopY(totalRows);
const gridBottom = totalRowY_ + SL.rowH; const gridBottom = totalRowY_ + SL.rowH;
@@ -304,7 +314,7 @@ function generateDepositSlip(account, deposit, items) {
doc.text('TOTAL $', SL.cX * PT, rowY(totalRows) * PT - 5, { lineBreak: false }); doc.text('TOTAL $', SL.cX * PT, rowY(totalRows) * PT - 5, { lineBreak: false });
// Top disclaimer (above grid) // Top disclaimer (above grid)
doc.font('Helvetica').fontSize(5).fillColor('#666666') doc.font('Helvetica').fontSize(5).fillColor('#333333')
.text( .text(
'DEPOSITS MAY NOT BE AVAILABLE FOR IMMEDIATE WITHDRAWAL', 'DEPOSITS MAY NOT BE AVAILABLE FOR IMMEDIATE WITHDRAWAL',
SL.cX * PT, SL.disclaimerY * PT, SL.cX * PT, SL.disclaimerY * PT,
@@ -312,7 +322,7 @@ function generateDepositSlip(account, deposit, items) {
); );
// Bottom disclaimer (below grid) // Bottom disclaimer (below grid)
doc.font('Helvetica').fontSize(5).fillColor('#666666') doc.font('Helvetica').fontSize(5).fillColor('#333333')
.text( .text(
'Checks and other items are received for deposit subject to the provisions of the Uniform Commercial Code or any applicable collection agreements.', 'Checks and other items are received for deposit subject to the provisions of the Uniform Commercial Code or any applicable collection agreements.',
SL.cX * PT, (gridBottom + 0.05) * PT, SL.cX * PT, (gridBottom + 0.05) * PT,
@@ -321,7 +331,7 @@ function generateDepositSlip(account, deposit, items) {
// DEPOSIT TICKET header // DEPOSIT TICKET header
doc.font('Helvetica-Bold').fontSize(9).fillColor(SL.bgHeaderColor) doc.font('Helvetica-Bold').fontSize(9).fillColor(SL.bgHeaderColor)
.text('D E P O S I T T I C K E T', SL.cX * PT, 0.08 * PT, .text('D E P O S I T T I C K E T', SL.cX * PT, 0.20 * PT,
{ width: (SL.W - SL.cX - 0.05) * PT, align: 'center', lineBreak: false }); { width: (SL.W - SL.cX - 0.05) * PT, align: 'center', lineBreak: false });
// ── Depositor block — account info, then bank info stacked below ──────── // ── Depositor block — account info, then bank info stacked below ────────
@@ -368,25 +378,32 @@ function generateDepositSlip(account, deposit, items) {
function drawAmountRow(amount, rowIdx) { function drawAmountRow(amount, rowIdx) {
const y = (rowY(rowIdx) - 0.015) * PT; const y = (rowY(rowIdx) - 0.015) * PT;
doc.font('Courier').fontSize(8).fillColor('#000000'); doc.font('Courier-Bold').fontSize(8).fillColor('#000000');
drawDigitAmount(doc, amount, dollarsRightX, y); drawDigitAmount(doc, amount, dollarsRightX, y);
} }
drawAmountRow(deposit.currency || 0, SL.currencyRow); drawAmountRow(deposit.currency || 0, SL.currencyRow);
drawAmountRow(deposit.coin || 0, SL.coinRow); drawAmountRow(deposit.coin || 0, SL.coinRow);
items.slice(0, SL.maxChecks).forEach((item, i) => { frontItems.forEach((item, i) => {
const r = SL.firstCheckRow + i; const r = SL.firstCheckRow + i;
const y = (rowY(r) - 0.015) * PT; const y = (rowY(r) - 0.015) * PT;
if (item.check_no) { if (item.check_no) {
doc.font('Courier').fontSize(7).fillColor('#000000') doc.font('Courier-Bold').fontSize(7).fillColor('#000000')
.text(String(item.check_no).slice(0, 8), .text(String(item.check_no).slice(0, 8),
(SL.cX + 0.16) * PT, y, (SL.cX + 0.28) * PT, y,
{ width: SL.checkNoW * PT, lineBreak: false }); { width: SL.checkNoW * PT, lineBreak: false });
} }
drawAmountRow(item.amount || 0, r); drawAmountRow(item.amount || 0, r);
}); });
// "FROM REVERSE" row carries back-page subtotal onto the front
if (fromReverseRow != null) {
doc.font('Courier').fontSize(6).fillColor(SL.bgLabelColor)
.text('FROM REVERSE', SL.cX * PT, rowY(fromReverseRow) * PT - 4, { lineBreak: false });
drawAmountRow(backTotal, fromReverseRow);
}
drawAmountRow(depositTotal, totalRows); drawAmountRow(depositTotal, totalRows);
// ── Rotated left strip elements ───────────────────────────────────────── // ── Rotated left strip elements ─────────────────────────────────────────
@@ -440,10 +457,124 @@ function generateDepositSlip(account, deposit, items) {
doc.restore(); // end slip position translate doc.restore(); // end slip position translate
if (hasBackPage) {
doc.addPage();
renderDepositBackPage(doc, backItems, backTotal);
}
doc.end(); doc.end();
}); });
} }
// ── Back page renderer ────────────────────────────────────────────────────────
function renderDepositBackPage(doc, backItems, backTotal) {
// Same slip position and width as front (slipX=0, W=3.375").
// No left strip elements; grid starts near the top.
// Vertically center the grid on the 8.5" page.
// Grid height = (checksRow + maxChecks + 1 TOTAL row + 1 border) * rowH = 33 * 0.175 = 5.775"
// Allow ~0.45" above grid for title + column headers; remainder splits top/bottom.
const BK_GRID_HEIGHT = (1 + SL.maxChecks + 1 + 1) * SL.rowH; // 33 rows
const BK_TITLE_AREA = 0.45;
const BK_GRID_TOP = (SL.H - BK_GRID_HEIGHT - BK_TITLE_AREA) / 2 + BK_TITLE_AREA;
const BK_TITLE_Y = (SL.H - BK_GRID_HEIGHT - BK_TITLE_AREA) / 2;
const BK = {
gridTop: BK_GRID_TOP,
titleY: BK_TITLE_Y,
checksRow: 0,
firstRow: 1,
maxChecks: SL.maxChecks, // 30
};
const totalRows = BK.firstRow + BK.maxChecks; // "TOTAL $" row index
const bkRowTopY = r => BK.gridTop + r * SL.rowH;
const bkRowY = r => BK.gridTop + r * SL.rowH + SL.rowH * 0.7;
const gridTopPt = bkRowTopY(0) * PT;
const gridBotPt = (bkRowTopY(totalRows) + SL.rowH) * PT;
doc.save();
doc.translate(SL.slipX * PT, 0);
// ── Title ─────────────────────────────────────────────────────────────────
doc.font('Helvetica-Bold').fontSize(9).fillColor(SL.bgHeaderColor)
.text('A D D I T I O N A L C H E C K L I S T I N G',
SL.cX * PT, BK.titleY * PT,
{ width: (SL.W - SL.cX - 0.05) * PT, align: 'center', lineBreak: false });
// ── Grid verticals (same column positions as front) ───────────────────────
const dollarsRightX = SL.colCentsR - SL.colCentsW - SL.colDollarSep;
const dividerX = (dollarsRightX - 7 * SL.digitW) * PT;
const dollarsCentsX = dollarsRightX * PT;
doc.moveTo(dividerX, gridTopPt).lineTo(dividerX, gridBotPt).lineWidth(0.5).stroke(SL.bgLineColor);
doc.moveTo(dollarsCentsX, gridTopPt).lineTo(dollarsCentsX, gridBotPt).lineWidth(0.5).stroke(SL.bgLineColor);
doc.moveTo(SL.colCentsR * PT, gridTopPt).lineTo(SL.colCentsR * PT, gridBotPt).lineWidth(0.5).stroke(SL.bgLineColor);
// Column headers
doc.font('Helvetica').fontSize(6).fillColor(SL.bgLabelColor);
const hdrY = (BK.gridTop - 0.10) * PT;
doc.text('DOLLARS', dollarsCentsX - 7 * SL.digitW * PT, hdrY,
{ width: 7 * SL.digitW * PT, align: 'center', lineBreak: false });
doc.text('CENTS', (SL.colCentsR - SL.colCentsW) * PT, hdrY,
{ width: SL.colCentsW * PT, align: 'center', lineBreak: false });
// "CHECKS:" header label
doc.font('Courier').fontSize(7).fillColor(SL.bgLabelColor)
.text('CHECKS:', SL.cX * PT, bkRowY(BK.checksRow) * PT - 5, { lineBreak: false });
// ── Horizontal grid lines ─────────────────────────────────────────────────
for (let r = 0; r <= totalRows + 1; r++) {
const y = bkRowTopY(r) * PT;
const isOuter = r === 0 || r === totalRows + 1;
doc.moveTo(SL.stripX * PT, y).lineTo(SL.colCentsR * PT, y)
.lineWidth(isOuter ? 0.75 : 0.3).stroke(SL.bgLineColor);
}
// ── Row numbers (continuing from front: 3160) ────────────────────────────
doc.font('Courier').fontSize(6).fillColor(SL.bgLabelColor);
for (let i = 0; i < BK.maxChecks; i++) {
const r = BK.firstRow + i;
doc.text(String(SL.maxChecks + i + 1), SL.cX * PT, bkRowY(r) * PT - 4,
{ width: 14, align: 'right', lineBreak: false });
}
// ── "TOTAL $" footer label ────────────────────────────────────────────────
doc.font('Courier-Bold').fontSize(7).fillColor('#000000')
.text('T O T A L $', SL.cX * PT, bkRowY(totalRows) * PT - 5, { lineBreak: false });
// ── "Forward to other side" in left strip (rotated) ───────────────────────
const fwdY = bkRowTopY(totalRows) + SL.rowH * 0.5;
doc.save();
doc.translate(SL.stripCenterX * PT, fwdY * PT);
doc.rotate(90);
doc.font('Helvetica').fontSize(6).fillColor(SL.bgLabelColor)
.text('Forward to other side', 0, 0, { lineBreak: false });
doc.restore();
// ── Amount data ───────────────────────────────────────────────────────────
backItems.forEach((item, i) => {
const r = BK.firstRow + i;
const y = (bkRowY(r) - 0.015) * PT;
if (item.check_no) {
doc.font('Courier-Bold').fontSize(7).fillColor('#000000')
.text(String(item.check_no).slice(0, 8),
(SL.cX + 0.28) * PT, y,
{ width: SL.checkNoW * PT, lineBreak: false });
}
if ((item.amount || 0) > 0) {
doc.font('Courier-Bold').fontSize(8).fillColor('#000000');
drawDigitAmount(doc, item.amount, dollarsRightX, y);
}
});
// Back page total
doc.font('Courier-Bold').fontSize(8).fillColor('#000000');
drawDigitAmount(doc, backTotal, dollarsRightX, (bkRowY(totalRows) - 0.015) * PT);
doc.restore();
}
// ── Amount rendering helpers ────────────────────────────────────────────────── // ── Amount rendering helpers ──────────────────────────────────────────────────
/** /**
+15 -5
View File
@@ -235,17 +235,27 @@ function generateCheckPdf(account, checks, fields) {
} }
// --- MICR line --- // --- MICR line ---
// Anchor the second transit symbol (the 'A' after routing) at 2 59/64" from left.
// Routing extends left from that anchor; account + check extend right.
const micrLine = formatMicrLine(account.routing_number, account.account_number, check.check_no); const micrLine = formatMicrLine(account.routing_number, account.account_number, check.check_no);
const micrPos = pt(0.3, MICR_Y_IN); const ANCHOR_IN = 2 + 59 / 64;
if (hasMicrFont) { if (hasMicrFont) {
doc.font('MICR').fontSize(12).fillColor('#000000') doc.font('MICR').fontSize(12).fillColor('#000000');
.text(micrLine, micrPos.x, micrPos.y, { lineBreak: false });
} else { } else {
doc.font('Courier').fontSize(10).fillColor('#000000') doc.font('Courier').fontSize(10).fillColor('#000000');
.text(micrLine, micrPos.x, micrPos.y, { lineBreak: false });
} }
// Prefix = everything up to and including the second 'A' (first A + routing + second A).
const secondA = micrLine.indexOf('A', 1) + 1;
const prefix = micrLine.slice(0, secondA);
const prefixWidthPts = doc.widthOfString(prefix);
const anchorXPts = (ANCHOR_IN + offX) * POINTS_PER_INCH;
const micrXPts = anchorXPts - prefixWidthPts;
const micrYPts = (slotOriginY + MICR_Y_IN + offY) * POINTS_PER_INCH;
doc.text(micrLine, micrXPts, micrYPts, { lineBreak: false });
} // end slot loop } // end slot loop
} // end page loop } // end page loop