Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2b2722c56 | |||
| d1b27837e3 | |||
| 5d66d1f575 | |||
| 34540e410c | |||
| 2504766be7 | |||
| b4824655dd | |||
| 674506bd2d | |||
| 3fd3285c13 | |||
| 427b064af1 | |||
| 7d105bce21 | |||
| 0b21f4ea3c | |||
| f91fc7bd8a | |||
| bb935acfa9 | |||
| 3957cf5518 | |||
| 4a47394923 | |||
| 189ae53d34 | |||
| 657de9e61a | |||
| 0ee95dbb09 | |||
| a2de7e2d9d | |||
| c4e4a8c246 | |||
| b692791436 | |||
| 37d70b4d82 | |||
| 7d854d4e01 |
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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:-}
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
|
|||||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ezcheck",
|
"name": "ezcheck",
|
||||||
"version": "0.4.1",
|
"version": "0.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ezcheck",
|
"name": "ezcheck",
|
||||||
"version": "0.4.1",
|
"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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ezcheck",
|
"name": "ezcheck",
|
||||||
"version": "0.4.1",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-7
@@ -679,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);
|
||||||
@@ -687,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); }
|
||||||
|
|
||||||
@@ -697,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 {
|
||||||
@@ -867,19 +869,49 @@ 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) {
|
||||||
|
|||||||
+6
-2
@@ -149,7 +149,9 @@
|
|||||||
<label>Account Access <span class="field-hint">(admins see all — no selection needed)</span></label>
|
<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 id="uf-accounts-checkboxes" class="account-checkboxes"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="uf-oidc-group" class="form-row" hidden>
|
<div id="uf-oidc-group" class="uf-oidc-section" hidden>
|
||||||
|
<h4>Single Sign-On Identity</h4>
|
||||||
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="uf-oidc-sub">OIDC Subject <span class="field-hint">(sub claim from provider)</span></label>
|
<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">
|
<input type="text" id="uf-oidc-sub" autocomplete="off">
|
||||||
@@ -159,6 +161,7 @@
|
|||||||
<input type="text" id="uf-oidc-issuer" autocomplete="off">
|
<input type="text" id="uf-oidc-issuer" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div id="user-form-error" class="wizard-error" hidden></div>
|
<div id="user-form-error" class="wizard-error" hidden></div>
|
||||||
<div style="display:flex;gap:8px;margin-top:12px">
|
<div style="display:flex;gap:8px;margin-top:12px">
|
||||||
<button id="btn-save-user" class="btn-primary">Add User</button>
|
<button id="btn-save-user" class="btn-primary">Add User</button>
|
||||||
@@ -883,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>
|
||||||
|
|||||||
+101
-14
@@ -316,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>
|
||||||
@@ -780,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;
|
||||||
@@ -801,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;
|
||||||
@@ -1430,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();
|
||||||
|
|
||||||
@@ -1463,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}">
|
||||||
@@ -1485,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();
|
||||||
});
|
});
|
||||||
@@ -1595,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);
|
||||||
@@ -1888,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'));
|
||||||
@@ -1982,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();
|
||||||
@@ -2082,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);
|
||||||
@@ -2104,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);
|
||||||
@@ -2333,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');
|
||||||
@@ -2364,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();
|
||||||
@@ -2395,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
@@ -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
@@ -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); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+69
-70
@@ -15,44 +15,56 @@ 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
|
||||||
|
setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const entry = loginAttempts.get(ip);
|
for (const [key, entry] of attempts) {
|
||||||
if (!entry || now > entry.resetAt) {
|
if (now > entry.resetAt) attempts.delete(key);
|
||||||
loginAttempts.set(ip, { count: 0, resetAt: now + RATE_WINDOW_MS });
|
|
||||||
return true; // allow
|
|
||||||
}
|
}
|
||||||
return entry.count < RATE_MAX_FAILS;
|
}, 30 * 60 * 1000).unref();
|
||||||
}
|
|
||||||
|
|
||||||
function recordLoginFailure(ip) {
|
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 now = Date.now();
|
||||||
const entry = loginAttempts.get(ip);
|
const entry = attempts.get(key);
|
||||||
if (!entry || now > entry.resetAt) {
|
if (!entry || now > entry.resetAt) {
|
||||||
loginAttempts.set(ip, { count: 1, resetAt: now + RATE_WINDOW_MS });
|
attempts.set(key, { count: 1, resetAt: now + windowMs });
|
||||||
} else {
|
} else {
|
||||||
entry.count++;
|
entry.count++;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
clear(key) {
|
||||||
|
attempts.delete(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearLoginFailures(ip) {
|
// 10 failed logins per IP per 15 minutes
|
||||||
loginAttempts.delete(ip);
|
const loginLimiter = makeRateLimiter(10, 15 * 60 * 1000);
|
||||||
}
|
// 5 reset emails per IP per 15 minutes (counts every request, success or not)
|
||||||
|
const resetLimiter = makeRateLimiter(5, 15 * 60 * 1000);
|
||||||
|
|
||||||
// Purge stale entries every 30 minutes to prevent unbounded memory growth
|
// Regenerates the session ID before establishing a login (prevents session fixation)
|
||||||
setInterval(() => {
|
function establishSession(req, user, cb) {
|
||||||
const now = Date.now();
|
req.session.regenerate(err => {
|
||||||
for (const [ip, entry] of loginAttempts) {
|
if (err) return cb(err);
|
||||||
if (now > entry.resetAt) loginAttempts.delete(ip);
|
req.session.userId = user.id;
|
||||||
}
|
req.session.username = user.username;
|
||||||
}, 30 * 60 * 1000).unref();
|
req.session.role = user.role;
|
||||||
|
cb(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 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) => {
|
||||||
@@ -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)
|
// Load account access into session (mirrors login behavior)
|
||||||
if (user.role !== 'admin') {
|
if (user.role !== 'admin') {
|
||||||
const accts = db.prepare('SELECT account_id, role FROM user_accounts WHERE user_id = ?').all(user.id);
|
req.session.accounts = db.prepare('SELECT account_id, role FROM user_accounts WHERE user_id = ?').all(user.id);
|
||||||
req.session.accounts = accts;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.redirect('/');
|
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
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+46
-37
@@ -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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const passwordHash = password ? await bcrypt.hash(password, 12) : null;
|
||||||
|
|
||||||
|
// ── Apply all changes atomically ──
|
||||||
|
try {
|
||||||
|
db.transaction(() => {
|
||||||
|
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)) {
|
if (Array.isArray(accounts)) {
|
||||||
db.prepare('DELETE FROM user_accounts WHERE user_id = ?').run(req.params.id);
|
db.prepare('DELETE FROM user_accounts WHERE user_id = ?').run(userId);
|
||||||
const effectiveRole = role || user.role;
|
const effectiveRole = role || user.role;
|
||||||
if (effectiveRole !== 'admin' && accounts.length > 0) {
|
if (effectiveRole !== 'admin' && accounts.length > 0) {
|
||||||
const stmt = db.prepare('INSERT OR IGNORE INTO user_accounts (user_id, account_id, role) VALUES (?, ?, ?)');
|
const stmt = db.prepare('INSERT OR IGNORE INTO user_accounts (user_id, account_id, role) VALUES (?, ?, ?)');
|
||||||
accounts.forEach(a => stmt.run(req.params.id, a.id, a.role === 'editor' ? 'editor' : 'viewer'));
|
accounts.forEach(a => stmt.run(userId, a.id, a.role === 'editor' ? 'editor' : 'viewer'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// If role or account assignments changed, invalidate all active sessions for
|
||||||
// If role or account assignments changed, invalidate all active sessions for this user
|
// this user so the new permissions take effect immediately.
|
||||||
// so the new permissions take effect immediately rather than at session expiry.
|
|
||||||
if (role || Array.isArray(accounts)) {
|
if (role || Array.isArray(accounts)) {
|
||||||
db.prepare("DELETE FROM sessions WHERE CAST(json_extract(sess, '$.userId') AS INTEGER) = ?")
|
db.prepare("DELETE FROM sessions WHERE CAST(json_extract(sess, '$.userId') AS INTEGER) = ?")
|
||||||
.run(parseInt(req.params.id, 10));
|
.run(userId);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message.includes('UNIQUE')) return res.status(409).json({ error: 'Username already taken.' });
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(userWithAccounts(req.params.id));
|
res.json(userWithAccounts(userId));
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /api/users/:id
|
// DELETE /api/users/:id
|
||||||
|
|||||||
@@ -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: 31–60) ────────────────────────────
|
||||||
|
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 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user