Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d105bce21 | |||
| 0b21f4ea3c | |||
| f91fc7bd8a | |||
| bb935acfa9 | |||
| 3957cf5518 | |||
| 4a47394923 | |||
| 189ae53d34 | |||
| 657de9e61a | |||
| 0ee95dbb09 | |||
| a2de7e2d9d | |||
| c4e4a8c246 | |||
| b692791436 | |||
| 37d70b4d82 | |||
| 7d854d4e01 | |||
| f9f6a4cd9a | |||
| fd36c25636 | |||
| a4be7c4cff | |||
| deb31d248f | |||
| 66374196c5 | |||
| ec54373848 | |||
| caf75fbb3f | |||
| da5d436432 | |||
| dff5fd4156 | |||
| 0c4e190952 | |||
| 01ca9a08d1 | |||
| 3b1a35b7f2 | |||
| 8587fb9378 | |||
| 572b4d8c37 | |||
| cac97f1d9c | |||
| c2d61c96cd | |||
| d70081159d | |||
| 8a944d1d20 |
@@ -5,3 +5,11 @@ SESSION_SECRET=replace-with-a-random-64-character-hex-string
|
|||||||
SESSION_MAX_AGE_HOURS=168 # default: 168 (7 days)
|
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
|
||||||
|
|
||||||
|
# OIDC / SSO (optional — omit or leave blank to disable)
|
||||||
|
OIDC_ENABLED=false
|
||||||
|
OIDC_DISCOVERY_URL=https://auth.example.com/.well-known/openid-configuration
|
||||||
|
OIDC_CLIENT_ID=
|
||||||
|
OIDC_CLIENT_SECRET=
|
||||||
|
OIDC_REDIRECT_URI=https://checks.example.com/api/auth/oidc/callback
|
||||||
|
OIDC_BUTTON_LABEL=Sign in with SSO
|
||||||
|
|||||||
@@ -1,124 +1,198 @@
|
|||||||
# check-printing
|
# check-printing
|
||||||
|
|
||||||
Self-hosted web app for printing checks and bank deposit slips. A containerized Node.js web app accessible on the local network.
|
Self-hosted web app for printing checks and bank deposit slips on blank check stock. Runs as a containerized Node.js app accessible on your local network or behind a reverse proxy.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Check ledger with search, filtering, and sorting
|
||||||
|
- Precise 3-up check PDFs (three checks per 8.5" x 11" letter page, multi-page supported)
|
||||||
|
- MICR E-13B encoding for routing/account lines (GnuMICR font, GPL-2.0)
|
||||||
|
- Bank deposit slips with digit-column formatting and MICR line
|
||||||
|
- Deposit reports for filing
|
||||||
|
- Visual drag-and-drop check layout editor
|
||||||
|
- Multi-account support with per-user access control
|
||||||
|
- OIDC / SSO login (OpenID Connect)
|
||||||
|
- Password reset via email (SMTP)
|
||||||
|
- QBO CSV import for checks and deposits
|
||||||
|
- ezCheckPrinting .mdb import
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Runtime:** Node.js 20
|
- **Runtime:** Node.js 20
|
||||||
- **Framework:** Express 4
|
- **Framework:** Express 4
|
||||||
- **Database:** SQLite via `better-sqlite3`
|
- **Database:** SQLite via `better-sqlite3`
|
||||||
- **PDF generation:** PDFKit with embedded GnuMICR E-13B font (GPL-2.0)
|
- **PDF generation:** PDFKit with embedded GnuMICR E-13B font
|
||||||
- **Frontend:** Vanilla JS, no framework
|
- **Frontend:** Vanilla JS, no framework
|
||||||
- **Container:** Docker Compose pulling from Docker Hub
|
- **Container:** Docker Compose pulling from Docker Hub (`dogiakos/check-printing`)
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
### Production (Docker)
|
### Production (Docker)
|
||||||
|
|
||||||
|
1. Create a `.env` file (see `.env.example`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SESSION_SECRET=$(openssl rand -hex 32)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start the container:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose pull
|
docker compose pull
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
On first launch, the app detects no users are configured and opens a **setup wizard** in the browser. Create the first admin account, then configure checkwriter info, bank info, and account/routing numbers. If you have an existing ezCheckPrinting `.mdb` file, click **Import .mdb** instead.
|
3. Open the app in your browser. On first launch, create the initial admin account when prompted.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
### Development (local)
|
### Development (local)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev # nodemon src/app.js
|
cp .env.example .env # edit .env with your values
|
||||||
|
npm run dev # nodemon with --env-file=.env
|
||||||
```
|
```
|
||||||
|
|
||||||
## Authentication and user roles
|
## Authentication
|
||||||
|
|
||||||
All access requires login. The first run prompts you to create an admin account.
|
All access requires login. The first run prompts you to create an admin account.
|
||||||
|
|
||||||
Three roles are available:
|
### Roles
|
||||||
|
|
||||||
| Role | Access |
|
| Role | Access |
|
||||||
|------|--------|
|
| --- | --- |
|
||||||
| **admin** | Full access to all accounts; create/edit/delete users and accounts |
|
| **Admin** | Full access to all accounts; manage users, accounts, and app settings |
|
||||||
| **editor** | Read and write access to assigned accounts |
|
| **Editor** | Read and write access to assigned accounts |
|
||||||
| **viewer** | Read-only access to assigned accounts |
|
| **Viewer** | Read-only access to assigned accounts |
|
||||||
|
|
||||||
Admins have editor access to all accounts automatically. Non-admin users are assigned per-account roles individually. User management is available in the **Users** panel (admin only). Any user can change their own password from the account menu.
|
Admins have editor access to all accounts automatically. Non-admin users are assigned per-account roles individually.
|
||||||
|
|
||||||
|
### User management
|
||||||
|
|
||||||
|
Available in the **Manage Users** panel (admin only):
|
||||||
|
|
||||||
|
- Create, edit, and delete users
|
||||||
|
- Assign per-account roles
|
||||||
|
- Configure SMTP for password reset emails
|
||||||
|
- Link/unlink OIDC identities
|
||||||
|
|
||||||
|
Any user can change their own password and link/unlink their OIDC identity from the account menu (click your username in the header).
|
||||||
|
|
||||||
|
### OIDC / SSO
|
||||||
|
|
||||||
|
OIDC login is configured via environment variables (see below). When enabled, a **Sign in with SSO** button appears on the login page.
|
||||||
|
|
||||||
|
Users must link their local account to their OIDC identity before SSO login will work. Two ways to link:
|
||||||
|
|
||||||
|
1. **Self-service:** Sign in with your password, click your username, then click **Link My Account** in the Single Sign-On section
|
||||||
|
2. **Admin:** Edit a user in the Manage Users panel and set the OIDC Subject and Issuer fields
|
||||||
|
|
||||||
|
OIDC uses the authorization code flow with PKCE. The provider must have the redirect URI registered: `https://your-app.example.com/api/auth/oidc/callback`
|
||||||
|
|
||||||
## Multi-account support
|
## Multi-account support
|
||||||
|
|
||||||
The app supports multiple checking accounts in a single instance. Each account has its own check ledger, deposit records, and layout configuration. Admins can create, edit, and delete accounts. Deleting an account removes all associated checks, deposits, and layout data.
|
The app supports multiple checking accounts in a single instance. Each account has its own check ledger, deposit records, and layout configuration. Use the account switcher in the header to switch between accounts. Admins can create, edit, and delete accounts.
|
||||||
|
|
||||||
## Printing
|
## Checks
|
||||||
|
|
||||||
1. Select 1–3 checks from the ledger (checkbox column)
|
1. Select 1 or more checks from the ledger (checkbox column)
|
||||||
2. Click **Generate PDF**
|
2. Click **Generate PDF**
|
||||||
3. A 3-up 8.5"×11" PDF opens in a new tab — three 3.5" check slots per page
|
3. A 3-up 8.5" x 11" PDF opens in a new tab
|
||||||
4. Print from the browser; checks are marked as printed in the ledger
|
4. Print from the browser; checks are marked as printed
|
||||||
|
|
||||||
Use the **Reprint** button on printed checks to regenerate without re-marking them.
|
Use **Reprint** on printed checks to regenerate without re-marking.
|
||||||
|
|
||||||
Multi-page PDFs are supported when more than 3 checks are selected.
|
### Check layout
|
||||||
|
|
||||||
|
- Page: 8.5" x 11", zero margins
|
||||||
|
- Three check slots of 3.5" each
|
||||||
|
- MICR line at 0.267" from bottom of each slot
|
||||||
|
- MICR format: `A{routing}A {account}C {checkNo}A` (GnuMICR E-13B encoding)
|
||||||
|
|
||||||
## Deposit slips
|
## Deposit slips
|
||||||
|
|
||||||
Switch to the **Deposits** tab in the toolbar to manage bank deposits.
|
1. Switch to the **Deposits** tab
|
||||||
|
2. Click **+ New Deposit**
|
||||||
|
3. Enter deposit date, currency, coin, and cash back amounts
|
||||||
|
4. Add each check (check number, payee, memo, amount) -- totals update live
|
||||||
|
5. **Save Deposit**, then click **Deposit Slip** or **Report** to generate a PDF
|
||||||
|
|
||||||
1. Click **+ New Deposit** to open the deposit entry panel
|
**Deposit Slip** generates a 3.375" x 8.5" PDF matching physical bank deposit slip stock with digit-column formatting, MICR line, and rotated totals.
|
||||||
2. Enter the deposit date, currency, coin, and cash back amounts
|
|
||||||
3. Add each check being deposited (check number, payee, memo, amount) — totals update live
|
|
||||||
4. Click **Save Deposit**, then **Deposit Slip** or **Report** to generate a PDF
|
|
||||||
|
|
||||||
**Deposit Slip** generates a precisely positioned 3.375" × 8.5" PDF matching physical bank deposit slip stock, including:
|
**Deposit Report** generates a plain formatted ledger document for filing.
|
||||||
|
|
||||||
- Style A background (form lines and labels drawn server-side — no preprinted stock required)
|
## Visual layout editor
|
||||||
- Digit-column amount formatting
|
|
||||||
- Routing/account line in E-13B magnetic ink character recognition font, rotated 90°
|
|
||||||
- Rotated deposit total and check count in the left margin
|
|
||||||
|
|
||||||
**Deposit Report** generates a plain formatted ledger document listing all checks, cash totals, and the final deposit amount — suitable for filing.
|
Click the **layout** button in the header (editors and above) to open the layout editor.
|
||||||
|
|
||||||
Generating a deposit slip marks the deposit as printed in the ledger.
|
- Full-screen canvas with inch rulers
|
||||||
|
- Drag any check element to reposition it
|
||||||
|
- Position readout in inches and fractions with nudge buttons
|
||||||
|
- Visibility toggle to hide fields from PDFs
|
||||||
|
- Auto-saves on change
|
||||||
|
- **Reset to Defaults** restores the built-in layout
|
||||||
|
|
||||||
## Importing from QuickBooks Online (QBO CSV)
|
## Importing
|
||||||
|
|
||||||
Checks and deposits can be imported from a QuickBooks Online CSV export. Click **Import QBO CSV** in the toolbar, select the file, choose whether to import checks or deposits, and review the parsed records before confirming.
|
### QuickBooks Online (QBO CSV)
|
||||||
|
|
||||||
The importer handles:
|
Click **Import QBO** in the toolbar. Supports standard QBO export columns, automatic type filtering (checks vs. deposits), duplicate detection, and auto-numbering.
|
||||||
|
|
||||||
- Standard QBO export column layouts (`Date`, `Transaction Type`, `Num`, `Name`, `Memo/Description`, `Amount`, `Debit`, `Credit`)
|
### ezCheckPrinting (.mdb)
|
||||||
- Automatic type filtering — checks are matched by transaction type `Check`, deposits by `Deposit`
|
|
||||||
- Duplicate detection — existing check numbers are skipped
|
|
||||||
- Auto-assignment of check numbers when the source CSV has no `Num` value
|
|
||||||
- Grouping of deposit rows by date into individual deposit records
|
|
||||||
|
|
||||||
## Importing from ezCheckPrinting (.mdb)
|
**Via the UI:** Click **Import .mdb** in the toolbar, select the file, and click Import.
|
||||||
|
|
||||||
Two ways to import:
|
**Via CLI** (inside the container):
|
||||||
|
|
||||||
**Via the UI (recommended):** Click **Import .mdb** in the toolbar, select the file, and click Import. The server runs the migration and shows the log output.
|
|
||||||
|
|
||||||
**Via CLI** (inside the container or locally with `mdbtools` installed):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec -it check-printing node migrations/import-mdb.js \
|
docker exec -it check-printing node migrations/import-mdb.js \
|
||||||
--file "/app/data/YourAccount.mdb"
|
--file "/app/data/YourAccount.mdb"
|
||||||
|
|
||||||
# Preview without writing:
|
# Preview without writing:
|
||||||
node migrations/import-mdb.js --file YourAccount.mdb --dry-run
|
docker exec -it check-printing node migrations/import-mdb.js \
|
||||||
|
--file "/app/data/YourAccount.mdb" --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
The script imports account config (T100), logo (Settings), check layout (T200), and check history (T104).
|
|
||||||
|
|
||||||
## Check layout
|
|
||||||
|
|
||||||
- Page: 8.5" × 11", zero margins
|
|
||||||
- Three check slots of 3.5" each; remaining ~0.5" is tear-off strip
|
|
||||||
- MICR line at 0.267" from bottom of each slot
|
|
||||||
- MICR format: `A{routing}A {account}C {checkNo}A` (GnuMICR E-13B encoding)
|
|
||||||
|
|
||||||
## Environment variables
|
## Environment variables
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
| --- | --- | --- |
|
||||||
| `PORT` | `3000` | HTTP port |
|
| `SESSION_SECRET` | *(required)* | Secret for signing session cookies. Generate with `openssl rand -hex 32` |
|
||||||
| `DB_PATH` | `/app/data/check-printing.db` | SQLite database path |
|
| `SESSION_MAX_AGE_HOURS` | `168` | Session lifetime in hours (default 7 days) |
|
||||||
| `SESSION_SECRET` | *(random)* | Secret for signing session cookies — set explicitly in production |
|
| `PORT` | `3000` | HTTP listen port |
|
||||||
|
| `DB_PATH` | `/app/data/check-printing.db` | SQLite database file path |
|
||||||
|
| `OIDC_ENABLED` | *(empty)* | Set to `true` or `1` to enable OIDC login |
|
||||||
|
| `OIDC_DISCOVERY_URL` | *(empty)* | Provider's `.well-known/openid-configuration` URL |
|
||||||
|
| `OIDC_CLIENT_ID` | *(empty)* | OIDC client ID |
|
||||||
|
| `OIDC_CLIENT_SECRET` | *(empty)* | OIDC client secret |
|
||||||
|
| `OIDC_REDIRECT_URI` | *(empty)* | Full callback URL, e.g. `https://checks.example.com/api/auth/oidc/callback` |
|
||||||
|
| `OIDC_BUTTON_LABEL` | `Sign in with SSO` | Text shown on the SSO login button |
|
||||||
|
|
||||||
|
SMTP settings for password reset emails are configured in the admin UI (Manage Users > Email Settings).
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
check-printing:
|
||||||
|
image: dogiakos/check-printing:latest
|
||||||
|
container_name: check-printing
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3003:3000"
|
||||||
|
volumes:
|
||||||
|
- check-printing-data:/app/data
|
||||||
|
environment:
|
||||||
|
- SESSION_SECRET=${SESSION_SECRET}
|
||||||
|
# Optional: OIDC / SSO
|
||||||
|
- OIDC_ENABLED=${OIDC_ENABLED:-}
|
||||||
|
- OIDC_DISCOVERY_URL=${OIDC_DISCOVERY_URL:-}
|
||||||
|
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-}
|
||||||
|
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-}
|
||||||
|
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-}
|
||||||
|
- OIDC_BUTTON_LABEL=${OIDC_BUTTON_LABEL:-Sign in with SSO}
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
check-printing-data:
|
||||||
|
```
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ 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}
|
||||||
|
# OIDC / SSO (optional — omit or leave blank to disable)
|
||||||
|
- OIDC_ENABLED=${OIDC_ENABLED:-}
|
||||||
|
- OIDC_DISCOVERY_URL=${OIDC_DISCOVERY_URL:-}
|
||||||
|
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-}
|
||||||
|
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-}
|
||||||
|
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-}
|
||||||
|
- OIDC_BUTTON_LABEL=${OIDC_BUTTON_LABEL:-Sign in with SSO}
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
check-printing-data:
|
check-printing-data:
|
||||||
|
|||||||
Generated
+67
-6
@@ -1,19 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "ezcheck",
|
"name": "ezcheck",
|
||||||
"version": "0.1.0",
|
"version": "0.4.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ezcheck",
|
"name": "ezcheck",
|
||||||
"version": "0.1.0",
|
"version": "0.4.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^9.4.3",
|
"better-sqlite3": "^9.4.3",
|
||||||
"express": "^4.18.3",
|
"express": "^4.18.3",
|
||||||
"express-session": "^1.19.0",
|
"express-session": "^1.19.0",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"nodemailer": "^8.0.4",
|
"nodemailer": "^8.0.5",
|
||||||
|
"openid-client": "^5.7.1",
|
||||||
"pdfkit": "^0.15.0"
|
"pdfkit": "^0.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1391,6 +1392,15 @@
|
|||||||
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "4.15.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||||
|
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jpeg-exif": {
|
"node_modules/jpeg-exif": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
|
||||||
@@ -1417,6 +1427,18 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lru-cache": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"yallist": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -1582,9 +1604,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemailer": {
|
"node_modules/nodemailer": {
|
||||||
"version": "8.0.4",
|
"version": "8.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
|
||||||
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
|
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
|
||||||
"license": "MIT-0",
|
"license": "MIT-0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
@@ -1654,6 +1676,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-hash": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-inspect": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
@@ -1711,6 +1742,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/oidc-token-hash": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^10.13.0 || >=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
@@ -1741,6 +1781,21 @@
|
|||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/openid-client": {
|
||||||
|
"version": "5.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||||
|
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jose": "^4.15.9",
|
||||||
|
"lru-cache": "^6.0.0",
|
||||||
|
"object-hash": "^2.2.0",
|
||||||
|
"oidc-token-hash": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pako": {
|
"node_modules/pako": {
|
||||||
"version": "0.2.9",
|
"version": "0.2.9",
|
||||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||||
@@ -2542,6 +2597,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/yallist": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||||
|
"license": "ISC"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ezcheck",
|
"name": "ezcheck",
|
||||||
"version": "0.2.0",
|
"version": "0.4.6",
|
||||||
"description": "Self-hosted check printing web app",
|
"description": "Self-hosted check printing web app",
|
||||||
"main": "src/app.js",
|
"main": "src/app.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
"express": "^4.18.3",
|
"express": "^4.18.3",
|
||||||
"express-session": "^1.19.0",
|
"express-session": "^1.19.0",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"nodemailer": "^8.0.4",
|
"nodemailer": "^8.0.5",
|
||||||
|
"openid-client": "^5.7.1",
|
||||||
"pdfkit": "^0.15.0"
|
"pdfkit": "^0.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+229
-10
@@ -22,11 +22,17 @@ body {
|
|||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#main-app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#main-app[hidden] { display: none; }
|
||||||
|
|
||||||
/* ── Header ── */
|
/* ── Header ── */
|
||||||
header {
|
header {
|
||||||
background: var(--header-bg);
|
background: var(--header-bg);
|
||||||
@@ -88,6 +94,18 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-wide { width: min(720px, 96vw); }
|
.modal-wide { width: min(720px, 96vw); }
|
||||||
|
.modal.modal-layout-editor { width: min(1400px, 96vw); height: 92vh; max-height: 92vh; }
|
||||||
|
.layout-editor-body { flex: 1; min-height: 0; flex-direction: column !important; padding: 0 !important; gap: 0; overflow: hidden; }
|
||||||
|
.layout-canvas-area { flex: 1; min-height: 0; display: grid; grid-template-columns: 24px 1fr; grid-template-rows: 24px 1fr; }
|
||||||
|
.layout-ruler-corner { background: var(--surface); border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
||||||
|
#layout-ruler-top { background: var(--surface); border-bottom: 1px solid var(--border); overflow: hidden; }
|
||||||
|
#layout-ruler-left { background: var(--surface); border-right: 1px solid var(--border); overflow: hidden; }
|
||||||
|
#layout-canvas-container { overflow: hidden; background: #d4d4d4; }
|
||||||
|
.layout-controls { flex-shrink: 0; display: flex; align-items: center; gap: 14px; padding: 8px 16px; border-top: 1px solid var(--border); background: var(--surface); flex-wrap: wrap; }
|
||||||
|
.layout-coord { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.layout-coord label { font-size: 11px; font-weight: 600; text-transform: uppercase; color: var(--text-muted); white-space: nowrap; margin: 0; }
|
||||||
|
.layout-coord input { width: 68px; }
|
||||||
|
.layout-coord .frac { font-size: 11px; color: var(--text-muted); min-width: 28px; }
|
||||||
|
|
||||||
.qbo-tabs {
|
.qbo-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -661,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);
|
||||||
@@ -669,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); }
|
||||||
|
|
||||||
@@ -679,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 {
|
||||||
@@ -798,17 +818,216 @@ input[type="file"] {
|
|||||||
.login-card h2 { font-size: 16px; font-weight: 600; margin-bottom: 4px; }
|
.login-card h2 { font-size: 16px; font-weight: 600; margin-bottom: 4px; }
|
||||||
.login-sub { font-size: 12px; color: var(--text-muted); margin-bottom: 16px; }
|
.login-sub { font-size: 12px; color: var(--text-muted); margin-bottom: 16px; }
|
||||||
|
|
||||||
/* ── User management ── */
|
.login-divider {
|
||||||
.account-checkboxes { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
|
|
||||||
.account-checkbox-label {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
margin: 16px 0 12px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.login-divider::before,
|
||||||
|
.login-divider::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
.login-divider span {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-oidc {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.btn-oidc:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
border-color: #b0b0b0;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
.btn-oidc:active {
|
||||||
|
background: #e8e8e8;
|
||||||
|
}
|
||||||
|
.btn-oidc svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── User management ── */
|
||||||
|
.account-checkboxes {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.account-checkbox-label {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 20px 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
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 */
|
||||||
|
@media (max-width: 768px), (orientation: portrait) {
|
||||||
|
#btn-layout-editor { display: none !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Settings Page ── */
|
||||||
|
.settings-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.settings-page[hidden] { display: none; }
|
||||||
|
|
||||||
|
.settings-header {
|
||||||
|
background: var(--header-bg);
|
||||||
|
color: var(--header-fg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
height: 44px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.settings-header-left { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.settings-header-right { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.settings-back-link {
|
||||||
|
color: rgba(255,255,255,0.7);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.settings-back-link:hover { color: #fff; }
|
||||||
|
|
||||||
|
.settings-layout {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar {
|
||||||
|
width: 220px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 20px 0;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.settings-sidebar-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 4px 20px 16px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.settings-nav-group { margin-bottom: 8px; }
|
||||||
|
.settings-nav-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 12px 20px 6px;
|
||||||
|
}
|
||||||
|
.settings-nav-item {
|
||||||
|
display: block;
|
||||||
|
padding: 7px 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 1px 8px;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.settings-nav-item:hover { background: var(--bg); }
|
||||||
|
.settings-nav-item.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 32px 48px;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-width: 780px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.settings-panel h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.settings-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.settings-section {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.settings-section .form-row { gap: 16px; }
|
||||||
|
.settings-section .form-group { margin-bottom: 4px; }
|
||||||
|
|||||||
+243
-92
@@ -43,6 +43,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="login-error" class="wizard-error" hidden></div>
|
<div id="login-error" class="wizard-error" hidden></div>
|
||||||
<button id="btn-login-submit" class="btn-primary" style="width:100%;margin-top:8px">Sign In</button>
|
<button id="btn-login-submit" class="btn-primary" style="width:100%;margin-top:8px">Sign In</button>
|
||||||
|
<div id="oidc-login-section" hidden>
|
||||||
|
<div class="login-divider"><span>or</span></div>
|
||||||
|
<a id="btn-oidc-login" href="/api/auth/oidc/authorize" class="btn-oidc">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
|
||||||
|
<span id="btn-oidc-login-label">Sign in with SSO</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<div style="text-align:center;margin-top:8px">
|
<div style="text-align:center;margin-top:8px">
|
||||||
<a href="#" id="link-forgot-password" style="font-size:12px;color:var(--text-muted)">Forgot password?</a>
|
<a href="#" id="link-forgot-password" style="font-size:12px;color:var(--text-muted)">Forgot password?</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,17 +87,184 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings page (full-page, hidden by default) -->
|
||||||
|
<div id="settings-page" class="settings-page" hidden>
|
||||||
|
<header class="settings-header">
|
||||||
|
<div class="settings-header-left">
|
||||||
|
<a href="#" id="settings-back-link" class="settings-back-link">← Back</a>
|
||||||
|
</div>
|
||||||
|
<div class="settings-header-right">
|
||||||
|
<span id="settings-username" class="header-username"></span>
|
||||||
|
<button id="btn-settings-logout" class="btn-header-icon" title="Sign out">↩</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="settings-layout">
|
||||||
|
<nav class="settings-sidebar">
|
||||||
|
<div class="settings-sidebar-title">Settings</div>
|
||||||
|
<div class="settings-nav-group" data-admin-only>
|
||||||
|
<div class="settings-nav-label">Administration</div>
|
||||||
|
<a href="#settings/users" class="settings-nav-item" data-settings-tab="users">Users</a>
|
||||||
|
<a href="#settings/smtp" class="settings-nav-item" data-settings-tab="smtp">Email (SMTP)</a>
|
||||||
|
</div>
|
||||||
|
<div class="settings-nav-group">
|
||||||
|
<div class="settings-nav-label">Account</div>
|
||||||
|
<a href="#settings/password" class="settings-nav-item" data-settings-tab="password">Password</a>
|
||||||
|
<a href="#settings/sso" class="settings-nav-item" data-settings-tab="sso">Single Sign-On</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main class="settings-content">
|
||||||
|
|
||||||
|
<!-- Users panel (admin only) -->
|
||||||
|
<div id="settings-panel-users" class="settings-panel" hidden>
|
||||||
|
<h2>Users</h2>
|
||||||
|
<p class="settings-desc">Manage user accounts and access permissions.</p>
|
||||||
|
<div class="settings-section">
|
||||||
|
<div id="users-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-section" id="user-form-section">
|
||||||
|
<h3 id="user-form-title">Add User</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group required">
|
||||||
|
<label for="uf-username">Username</label>
|
||||||
|
<input type="text" id="uf-username" autocapitalize="none">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="uf-email">Email <span class="field-hint">(for password reset)</span></label>
|
||||||
|
<input type="email" id="uf-email" autocomplete="email">
|
||||||
|
</div>
|
||||||
|
<div class="form-group required">
|
||||||
|
<label for="uf-password">Password <span class="field-hint" id="uf-password-hint">(min 10 chars, include a digit or symbol)</span></label>
|
||||||
|
<input type="password" id="uf-password" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group required">
|
||||||
|
<label for="uf-role">Role</label>
|
||||||
|
<select id="uf-role">
|
||||||
|
<option value="viewer">Viewer</option>
|
||||||
|
<option value="editor">Editor</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="uf-accounts-group">
|
||||||
|
<label>Account Access <span class="field-hint">(admins see all — no selection needed)</span></label>
|
||||||
|
<div id="uf-accounts-checkboxes" class="account-checkboxes"></div>
|
||||||
|
</div>
|
||||||
|
<div id="uf-oidc-group" class="uf-oidc-section" hidden>
|
||||||
|
<h4>Single Sign-On Identity</h4>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="uf-oidc-sub">OIDC Subject <span class="field-hint">(sub claim from provider)</span></label>
|
||||||
|
<input type="text" id="uf-oidc-sub" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="uf-oidc-issuer">OIDC Issuer <span class="field-hint">(provider URL)</span></label>
|
||||||
|
<input type="text" id="uf-oidc-issuer" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="user-form-error" class="wizard-error" hidden></div>
|
||||||
|
<div style="display:flex;gap:8px;margin-top:12px">
|
||||||
|
<button id="btn-save-user" class="btn-primary">Add User</button>
|
||||||
|
<button id="btn-cancel-user-edit" class="btn-ghost" hidden>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SMTP panel (admin only) -->
|
||||||
|
<div id="settings-panel-smtp" class="settings-panel" hidden>
|
||||||
|
<h2>Email Settings</h2>
|
||||||
|
<p class="settings-desc">Configure SMTP for password reset emails.</p>
|
||||||
|
<div class="settings-section" id="smtp-settings-section">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="smtp-host">SMTP Host</label>
|
||||||
|
<input type="text" id="smtp-host" placeholder="smtp.example.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="max-width:100px">
|
||||||
|
<label for="smtp-port">Port</label>
|
||||||
|
<input type="number" id="smtp-port" value="587" min="1" max="65535">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="max-width:160px">
|
||||||
|
<label for="smtp-secure">Encryption</label>
|
||||||
|
<select id="smtp-secure">
|
||||||
|
<option value="0">STARTTLS (587)</option>
|
||||||
|
<option value="1">SSL/TLS (465)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="smtp-user">Username</label>
|
||||||
|
<input type="text" id="smtp-user" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="smtp-pass">Password <span class="field-hint" id="smtp-pass-hint"></span></label>
|
||||||
|
<input type="password" id="smtp-pass" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="smtp-from">From Address</label>
|
||||||
|
<input type="email" id="smtp-from" placeholder="ezcheck@example.com">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="smtp-error" class="wizard-error" hidden></div>
|
||||||
|
<div id="smtp-success" class="import-result" hidden></div>
|
||||||
|
<button id="btn-save-smtp" class="btn-secondary" style="margin-top:12px">Save Email Settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Password panel (all users) -->
|
||||||
|
<div id="settings-panel-password" class="settings-panel" hidden>
|
||||||
|
<h2>Change Password</h2>
|
||||||
|
<p class="settings-desc">Update your login password.</p>
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cp-current">Current Password</label>
|
||||||
|
<input type="password" id="cp-current" autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cp-new">New Password <span class="field-hint">(min 10 chars, include a digit or symbol)</span></label>
|
||||||
|
<input type="password" id="cp-new" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cp-confirm">Confirm New</label>
|
||||||
|
<input type="password" id="cp-confirm" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="cp-error" class="wizard-error" hidden></div>
|
||||||
|
<div id="cp-success" class="import-result" hidden>Password changed.</div>
|
||||||
|
<button id="btn-change-password" class="btn-secondary" style="margin-top:12px">Change Password</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SSO panel (all users, shown when OIDC enabled) -->
|
||||||
|
<div id="settings-panel-sso" class="settings-panel" hidden>
|
||||||
|
<h2>Single Sign-On</h2>
|
||||||
|
<p class="settings-desc">Link your account to an external identity provider.</p>
|
||||||
|
<div class="settings-section" id="oidc-link-section">
|
||||||
|
<p id="oidc-link-status" class="settings-desc" style="margin-bottom:12px"></p>
|
||||||
|
<a id="btn-oidc-link" href="/api/auth/oidc/link" class="btn-secondary" style="display:inline-block;text-decoration:none">Link My Account</a>
|
||||||
|
<button id="btn-oidc-unlink" class="btn-ghost" style="margin-left:8px" hidden>Unlink</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="main-app">
|
||||||
<header>
|
<header>
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<span class="header-brand" id="company-name">ezcheck</span>
|
<span class="header-brand" id="company-name">ezcheck</span>
|
||||||
<select id="account-switcher" class="account-switcher" title="Switch account"></select>
|
<select id="account-switcher" class="account-switcher" title="Switch account"></select>
|
||||||
<button id="btn-account-settings" class="btn-header-icon" title="Account settings" data-admin-only>⚙</button>
|
<button id="btn-account-settings" class="btn-header-icon" title="Account settings" data-admin-only>⚙</button>
|
||||||
|
<button id="btn-layout-editor" class="btn-header-icon" title="Edit check layout" data-editor-only>⊞</button>
|
||||||
<button id="btn-add-account" class="btn-header-icon" title="Add checking account" data-admin-only>+</button>
|
<button id="btn-add-account" class="btn-header-icon" title="Add checking account" data-admin-only>+</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<span class="header-info">Next check: <strong id="current-check-no">—</strong><button id="btn-set-check-no" class="btn-header-inline" title="Set next check number" data-admin-only>✎</button></span>
|
<span class="header-info">Next check: <strong id="current-check-no">—</strong><button id="btn-set-check-no" class="btn-header-inline" title="Set next check number" data-admin-only>✎</button></span>
|
||||||
<button id="btn-users" class="btn-header-icon" title="Manage users" data-admin-only hidden>👥</button>
|
<button id="btn-users" class="btn-header-icon" title="Manage users" data-admin-only hidden>👥</button>
|
||||||
<span id="header-username" class="header-username"></span>
|
<span id="header-username" class="header-username" style="cursor:pointer" title="Account settings"></span>
|
||||||
<button id="btn-logout" class="btn-header-icon" title="Sign out">↩</button>
|
<button id="btn-logout" class="btn-header-icon" title="Sign out">↩</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -464,6 +638,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p class="settings-section-label">Check Position</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="as-check-position">Print checks in</label>
|
||||||
|
<select id="as-check-position" name="check_position">
|
||||||
|
<option value="3-per-page">All 3 slots (3 per page)</option>
|
||||||
|
<option value="top">Top slot only</option>
|
||||||
|
<option value="middle">Middle slot only</option>
|
||||||
|
<option value="bottom">Bottom slot only</option>
|
||||||
|
</select>
|
||||||
|
<span class="field-hint">Choose which slot(s) on the page to print checks in.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="acct-settings-error" class="wizard-error" hidden></div>
|
<div id="acct-settings-error" class="wizard-error" hidden></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -637,108 +823,73 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
</div><!-- /main-app -->
|
||||||
|
|
||||||
<!-- User management modal (admin only) -->
|
|
||||||
<div id="users-overlay" class="modal-overlay"></div>
|
<!-- Layout Editor Modal -->
|
||||||
<div id="users-modal" class="modal modal-wide" role="dialog" aria-labelledby="users-title">
|
<div id="layout-editor-overlay" class="modal-overlay"></div>
|
||||||
|
<div id="layout-editor-modal" class="modal modal-layout-editor" role="dialog" aria-labelledby="layout-editor-title">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 id="users-title">Manage Users</h2>
|
<h2 id="layout-editor-title">Layout Editor</h2>
|
||||||
<button id="btn-close-users" class="btn-icon" title="Close">×</button>
|
<button id="btn-close-layout-editor" class="btn-icon" title="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body layout-editor-body">
|
||||||
<div id="users-list"></div>
|
<!-- Canvas with rulers -->
|
||||||
<div style="margin-top:16px;border-top:1px solid var(--border);padding-top:16px">
|
<div class="layout-canvas-area">
|
||||||
<h3 style="font-size:13px;font-weight:600;margin-bottom:10px" id="user-form-title">Add User</h3>
|
<div class="layout-ruler-corner"></div>
|
||||||
<div class="form-row">
|
<div id="layout-ruler-top"></div>
|
||||||
<div class="form-group required">
|
<div id="layout-ruler-left"></div>
|
||||||
<label for="uf-username">Username</label>
|
<div id="layout-canvas-container"><!-- SVG rendered by JS --></div>
|
||||||
<input type="text" id="uf-username" autocapitalize="none">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<!-- Controls strip -->
|
||||||
<label for="uf-email">Email <span class="field-hint">(for password reset)</span></label>
|
<div class="layout-controls">
|
||||||
<input type="email" id="uf-email" autocomplete="email">
|
<div class="layout-coord" style="gap:6px">
|
||||||
|
<label for="layout-field-select" style="font-size:11px;font-weight:600;text-transform:uppercase;color:var(--text-muted)">Field</label>
|
||||||
|
<select id="layout-field-select" style="font-size:12px;max-width:200px"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group required">
|
<label style="display:flex;align-items:center;gap:5px;font-size:12px;cursor:pointer;white-space:nowrap">
|
||||||
<label for="uf-password">Password <span class="field-hint" id="uf-password-hint">(min 10 chars, include a digit or symbol)</span></label>
|
<input type="checkbox" id="layout-field-visible"> Visible
|
||||||
<input type="password" id="uf-password" autocomplete="new-password">
|
</label>
|
||||||
|
<div style="width:1px;height:24px;background:var(--border)"></div>
|
||||||
|
<div class="layout-coord">
|
||||||
|
<label for="layout-field-x">X</label>
|
||||||
|
<input type="number" id="layout-field-x" step="0.0625" min="0" max="8.5">
|
||||||
|
<span class="frac" id="layout-field-x-frac"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group required">
|
<div class="layout-coord">
|
||||||
<label for="uf-role">Role</label>
|
<label for="layout-field-y">Y</label>
|
||||||
<select id="uf-role">
|
<input type="number" id="layout-field-y" step="0.0625" min="0" max="3.5">
|
||||||
<option value="viewer">Viewer</option>
|
<span class="frac" id="layout-field-y-frac"></span>
|
||||||
<option value="editor">Editor</option>
|
</div>
|
||||||
<option value="admin">Admin</option>
|
<div id="layout-end-pos-group" style="display:contents" hidden>
|
||||||
</select>
|
<div class="layout-coord">
|
||||||
|
<label for="layout-field-x2">End X</label>
|
||||||
|
<input type="number" id="layout-field-x2" step="0.0625" min="0" max="8.5">
|
||||||
|
<span class="frac" id="layout-field-x2-frac"></span>
|
||||||
|
</div>
|
||||||
|
<div class="layout-coord">
|
||||||
|
<label for="layout-field-y2">End Y</label>
|
||||||
|
<input type="number" id="layout-field-y2" step="0.0625" min="0" max="3.5">
|
||||||
|
<span class="frac" id="layout-field-y2-frac"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" id="uf-accounts-group">
|
<div style="width:1px;height:24px;background:var(--border)"></div>
|
||||||
<label>Account Access <span class="field-hint">(admins see all — no selection needed)</span></label>
|
<!-- Nudge cross -->
|
||||||
<div id="uf-accounts-checkboxes" class="account-checkboxes"></div>
|
<div style="display:flex;flex-direction:column;align-items:center;gap:2px">
|
||||||
</div>
|
<div style="font-size:10px;text-transform:uppercase;color:var(--text-muted);letter-spacing:.04em">¹⁄₁₆"</div>
|
||||||
<div id="user-form-error" class="wizard-error" hidden></div>
|
<div style="display:grid;grid-template-columns:repeat(3,22px);grid-template-rows:repeat(3,22px);gap:2px">
|
||||||
<div style="display:flex;gap:8px;margin-top:8px">
|
<span></span><button id="nudge-up" class="btn-sm btn-secondary" style="padding:0;display:flex;align-items:center;justify-content:center">↑</button><span></span>
|
||||||
<button id="btn-save-user" class="btn-primary">Add User</button>
|
<button id="nudge-left" class="btn-sm btn-secondary" style="padding:0;display:flex;align-items:center;justify-content:center">←</button>
|
||||||
<button id="btn-cancel-user-edit" class="btn-ghost" hidden>Cancel</button>
|
<span></span>
|
||||||
|
<button id="nudge-right" class="btn-sm btn-secondary" style="padding:0;display:flex;align-items:center;justify-content:center">→</button>
|
||||||
|
<span></span><button id="nudge-down" class="btn-sm btn-secondary" style="padding:0;display:flex;align-items:center;justify-content:center">↓</button><span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- SMTP settings (admin only) -->
|
<div id="layout-save-status" style="font-size:11px;color:var(--text-muted);min-width:56px"></div>
|
||||||
<div id="smtp-settings-section" style="margin-top:16px;border-top:1px solid var(--border);padding-top:16px">
|
<div style="margin-left:auto;display:flex;gap:8px;align-items:center">
|
||||||
<h3 style="font-size:13px;font-weight:600;margin-bottom:10px">Email Settings (SMTP)</h3>
|
<button id="btn-layout-preview" class="btn-secondary btn-sm">⎙ Preview PDF</button>
|
||||||
<div class="form-row">
|
<button id="btn-layout-reset" class="btn-secondary btn-sm" data-admin-only>↺ Reset to Default</button>
|
||||||
<div class="form-group">
|
|
||||||
<label for="smtp-host">SMTP Host</label>
|
|
||||||
<input type="text" id="smtp-host" placeholder="smtp.example.com">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="max-width:90px">
|
|
||||||
<label for="smtp-port">Port</label>
|
|
||||||
<input type="number" id="smtp-port" value="587" min="1" max="65535">
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="max-width:140px">
|
|
||||||
<label for="smtp-secure">Encryption</label>
|
|
||||||
<select id="smtp-secure">
|
|
||||||
<option value="0">STARTTLS (587)</option>
|
|
||||||
<option value="1">SSL/TLS (465)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="smtp-user">Username</label>
|
|
||||||
<input type="text" id="smtp-user" autocomplete="off">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="smtp-pass">Password <span class="field-hint" id="smtp-pass-hint"></span></label>
|
|
||||||
<input type="password" id="smtp-pass" autocomplete="new-password">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="smtp-from">From Address</label>
|
|
||||||
<input type="email" id="smtp-from" placeholder="ezcheck@example.com">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="smtp-error" class="wizard-error" hidden></div>
|
|
||||||
<div id="smtp-success" class="import-result" hidden></div>
|
|
||||||
<button id="btn-save-smtp" class="btn-secondary" style="margin-top:8px">Save Email Settings</button>
|
|
||||||
</div>
|
|
||||||
<!-- Change own password -->
|
|
||||||
<div style="margin-top:16px;border-top:1px solid var(--border);padding-top:16px">
|
|
||||||
<h3 style="font-size:13px;font-weight:600;margin-bottom:10px">Change My Password</h3>
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="cp-current">Current Password</label>
|
|
||||||
<input type="password" id="cp-current" autocomplete="current-password">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="cp-new">New Password <span class="field-hint">(min 10 chars, include a digit or symbol)</span></label>
|
|
||||||
<input type="password" id="cp-new" autocomplete="new-password">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="cp-confirm">Confirm New</label>
|
|
||||||
<input type="password" id="cp-confirm" autocomplete="new-password">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="cp-error" class="wizard-error" hidden></div>
|
|
||||||
<div id="cp-success" class="import-result" hidden>Password changed.</div>
|
|
||||||
<button id="btn-change-password" class="btn-secondary" style="margin-top:8px">Change Password</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+697
-23
@@ -52,12 +52,32 @@ async function checkAuth() {
|
|||||||
showLoginOverlay();
|
showLoginOverlay();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OIDC callback error/success detection
|
||||||
|
if (location.hash.startsWith('#oidc-error=')) {
|
||||||
|
const msg = decodeURIComponent(location.hash.slice('#oidc-error='.length));
|
||||||
|
history.replaceState(null, '', location.pathname);
|
||||||
|
showLoginSection('login-form-section');
|
||||||
|
const errEl = document.getElementById('login-error');
|
||||||
|
errEl.textContent = msg;
|
||||||
|
errEl.hidden = false;
|
||||||
|
showLoginOverlay();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (location.hash === '#oidc-linked') {
|
||||||
|
// After OIDC link callback, navigate to SSO settings panel
|
||||||
|
location.hash = '#settings/sso';
|
||||||
|
// Fall through to normal auth check — user is still logged in
|
||||||
|
}
|
||||||
|
|
||||||
// Is there already a session?
|
// Is there already a session?
|
||||||
const res = await fetch('/api/auth/me');
|
const res = await fetch('/api/auth/me');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
state.user = await res.json();
|
state.user = await res.json();
|
||||||
hideLoginOverlay();
|
hideLoginOverlay();
|
||||||
applyRoleUI();
|
applyRoleUI();
|
||||||
|
// Route to settings page if hash says so
|
||||||
|
if (location.hash.startsWith('#settings')) handleHashRoute();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// No session — check if this is first-run (no users at all)
|
// No session — check if this is first-run (no users at all)
|
||||||
@@ -68,10 +88,27 @@ async function checkAuth() {
|
|||||||
} else {
|
} else {
|
||||||
showLoginSection('login-form-section');
|
showLoginSection('login-form-section');
|
||||||
}
|
}
|
||||||
|
// Show SSO button if OIDC is enabled
|
||||||
|
loadOidcLoginButton();
|
||||||
showLoginOverlay();
|
showLoginOverlay();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadOidcLoginButton() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/oidc/config');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const cfg = await res.json();
|
||||||
|
const section = document.getElementById('oidc-login-section');
|
||||||
|
if (cfg.enabled) {
|
||||||
|
document.getElementById('btn-oidc-login-label').textContent = cfg.button_label || 'Sign in with SSO';
|
||||||
|
section.hidden = false;
|
||||||
|
} else {
|
||||||
|
section.hidden = true;
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
async function submitLogin() {
|
async function submitLogin() {
|
||||||
const username = document.getElementById('login-username').value.trim();
|
const username = document.getElementById('login-username').value.trim();
|
||||||
const password = document.getElementById('login-password').value;
|
const password = document.getElementById('login-password').value;
|
||||||
@@ -131,6 +168,9 @@ async function logout() {
|
|||||||
document.getElementById('login-error').hidden = true;
|
document.getElementById('login-error').hidden = true;
|
||||||
document.getElementById('login-setup-section').hidden = true;
|
document.getElementById('login-setup-section').hidden = true;
|
||||||
document.getElementById('login-form-section').hidden = false;
|
document.getElementById('login-form-section').hidden = false;
|
||||||
|
// Ensure settings page is hidden and main app restored
|
||||||
|
document.getElementById('settings-page').hidden = true;
|
||||||
|
document.getElementById('main-app').hidden = false;
|
||||||
showLoginOverlay();
|
showLoginOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,8 +182,9 @@ function applyRoleUI() {
|
|||||||
const isEditor = state.accountRole === 'editor' || (!state.accountRole && (role === 'admin' || role === 'editor'));
|
const isEditor = state.accountRole === 'editor' || (!state.accountRole && (role === 'admin' || role === 'editor'));
|
||||||
|
|
||||||
document.getElementById('header-username').textContent = state.user ? state.user.username : '';
|
document.getElementById('header-username').textContent = state.user ? state.user.username : '';
|
||||||
|
document.getElementById('settings-username').textContent = state.user ? state.user.username : '';
|
||||||
|
|
||||||
// Admin-only elements
|
// Admin-only elements (main app + settings sidebar)
|
||||||
document.querySelectorAll('[data-admin-only]').forEach(el => { el.hidden = !isAdmin; });
|
document.querySelectorAll('[data-admin-only]').forEach(el => { el.hidden = !isAdmin; });
|
||||||
|
|
||||||
// Editor+ elements (hide for viewers)
|
// Editor+ elements (hide for viewers)
|
||||||
@@ -157,19 +198,62 @@ function applyRoleUI() {
|
|||||||
|
|
||||||
let usersState = { users: [], editingId: null };
|
let usersState = { users: [], editingId: null };
|
||||||
|
|
||||||
function openUsersModal() {
|
// ── Settings page navigation ────────────────────────────────────────────────
|
||||||
document.getElementById('user-form-error').hidden = true;
|
|
||||||
document.getElementById('users-overlay').classList.add('open');
|
function navigateToSettings(tab) {
|
||||||
document.getElementById('users-modal').classList.add('open');
|
const isAdmin = state.user && state.user.role === 'admin';
|
||||||
loadUsers();
|
const defaultTab = isAdmin ? 'users' : 'password';
|
||||||
renderUfAccountCheckboxes();
|
const resolved = tab || defaultTab;
|
||||||
if (state.user && state.user.role === 'admin') loadSmtpSettings();
|
|
||||||
|
// Guard non-admin from admin tabs
|
||||||
|
if (!isAdmin && (resolved === 'users' || resolved === 'smtp')) {
|
||||||
|
location.hash = '#settings/password';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('main-app').hidden = true;
|
||||||
|
const sp = document.getElementById('settings-page');
|
||||||
|
sp.hidden = false;
|
||||||
|
document.getElementById('settings-username').textContent = state.user ? state.user.username : '';
|
||||||
|
|
||||||
|
// Check OIDC status to show/hide SSO tab
|
||||||
|
loadOidcLinkStatus();
|
||||||
|
|
||||||
|
activateSettingsTab(resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeUsersModal() {
|
function activateSettingsTab(tab) {
|
||||||
document.getElementById('users-overlay').classList.remove('open');
|
// Hide all panels, show the target
|
||||||
document.getElementById('users-modal').classList.remove('open');
|
document.querySelectorAll('.settings-panel').forEach(p => { p.hidden = true; });
|
||||||
cancelUserEdit();
|
const panel = document.getElementById('settings-panel-' + tab);
|
||||||
|
if (panel) panel.hidden = false;
|
||||||
|
|
||||||
|
// Update sidebar active state
|
||||||
|
document.querySelectorAll('.settings-nav-item').forEach(a => {
|
||||||
|
a.classList.toggle('active', a.dataset.settingsTab === tab);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load data for the activated tab
|
||||||
|
if (tab === 'users') { loadUsers(); renderUfAccountCheckboxes(); }
|
||||||
|
if (tab === 'smtp') { loadSmtpSettings(); }
|
||||||
|
if (tab === 'sso') { loadOidcLinkStatus(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMainApp() {
|
||||||
|
document.getElementById('settings-page').hidden = true;
|
||||||
|
document.getElementById('main-app').hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHashRoute() {
|
||||||
|
const hash = location.hash;
|
||||||
|
if (hash.startsWith('#settings/')) {
|
||||||
|
const tab = hash.split('/')[1];
|
||||||
|
navigateToSettings(tab);
|
||||||
|
} else if (hash.startsWith('#settings')) {
|
||||||
|
navigateToSettings();
|
||||||
|
} else {
|
||||||
|
showMainApp();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
@@ -204,8 +288,9 @@ function renderUsersList() {
|
|||||||
const name = escHtml(a ? (a.company1 || `Account ${a.account_id}`) : `#${ua.account_id}`);
|
const name = escHtml(a ? (a.company1 || `Account ${a.account_id}`) : `#${ua.account_id}`);
|
||||||
return `${name} <span style="font-size:10px;color:${ua.role === 'editor' ? '#16a34a' : '#6b7280'};font-weight:600;text-transform:uppercase">${ua.role}</span>`;
|
return `${name} <span style="font-size:10px;color:${ua.role === 'editor' ? '#16a34a' : '#6b7280'};font-weight:600;text-transform:uppercase">${ua.role}</span>`;
|
||||||
}).join(', ') : '<em style="color:var(--text-muted)">None</em>');
|
}).join(', ') : '<em style="color:var(--text-muted)">None</em>');
|
||||||
|
const oidcTag = u.oidc_sub ? ' <span style="font-size:10px;color:#2563eb;font-weight:600" title="OIDC linked">SSO</span>' : '';
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td><strong>${escHtml(u.username)}</strong>${isSelf ? ' <em style="color:var(--text-muted)">(you)</em>' : ''}</td>
|
<td><strong>${escHtml(u.username)}</strong>${isSelf ? ' <em style="color:var(--text-muted)">(you)</em>' : ''}${oidcTag}</td>
|
||||||
<td>${roleBadge(u.role)}</td>
|
<td>${roleBadge(u.role)}</td>
|
||||||
<td style="font-size:12px">${accountsLabel}</td>
|
<td style="font-size:12px">${accountsLabel}</td>
|
||||||
<td style="white-space:nowrap">
|
<td style="white-space:nowrap">
|
||||||
@@ -231,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>
|
||||||
@@ -253,6 +338,10 @@ function startUserEdit(userId) {
|
|||||||
document.getElementById('btn-save-user').textContent = 'Save Changes';
|
document.getElementById('btn-save-user').textContent = 'Save Changes';
|
||||||
document.getElementById('btn-cancel-user-edit').hidden = false;
|
document.getElementById('btn-cancel-user-edit').hidden = false;
|
||||||
document.getElementById('user-form-error').hidden = true;
|
document.getElementById('user-form-error').hidden = true;
|
||||||
|
// OIDC fields
|
||||||
|
document.getElementById('uf-oidc-sub').value = u.oidc_sub || '';
|
||||||
|
document.getElementById('uf-oidc-issuer').value = u.oidc_issuer || '';
|
||||||
|
document.getElementById('uf-oidc-group').hidden = false;
|
||||||
renderUfAccountCheckboxes();
|
renderUfAccountCheckboxes();
|
||||||
document.getElementById('uf-username').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
document.getElementById('uf-username').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
}
|
}
|
||||||
@@ -268,6 +357,10 @@ function cancelUserEdit() {
|
|||||||
document.getElementById('btn-save-user').textContent = 'Add User';
|
document.getElementById('btn-save-user').textContent = 'Add User';
|
||||||
document.getElementById('btn-cancel-user-edit').hidden = true;
|
document.getElementById('btn-cancel-user-edit').hidden = true;
|
||||||
document.getElementById('user-form-error').hidden = true;
|
document.getElementById('user-form-error').hidden = true;
|
||||||
|
// OIDC fields
|
||||||
|
document.getElementById('uf-oidc-sub').value = '';
|
||||||
|
document.getElementById('uf-oidc-issuer').value = '';
|
||||||
|
document.getElementById('uf-oidc-group').hidden = true;
|
||||||
renderUfAccountCheckboxes();
|
renderUfAccountCheckboxes();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,6 +389,8 @@ async function saveUser() {
|
|||||||
const body = { username, email, role, accounts };
|
const body = { username, email, role, accounts };
|
||||||
if (password) body.password = password;
|
if (password) body.password = password;
|
||||||
if (usersState.editingId) {
|
if (usersState.editingId) {
|
||||||
|
body.oidc_sub = document.getElementById('uf-oidc-sub').value.trim();
|
||||||
|
body.oidc_issuer = document.getElementById('uf-oidc-issuer').value.trim();
|
||||||
await apiFetch('PUT', `/api/users/${usersState.editingId}`, body);
|
await apiFetch('PUT', `/api/users/${usersState.editingId}`, body);
|
||||||
} else {
|
} else {
|
||||||
await apiFetch('POST', '/api/users', body);
|
await apiFetch('POST', '/api/users', body);
|
||||||
@@ -685,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;
|
||||||
@@ -706,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;
|
||||||
@@ -909,6 +1016,7 @@ function openAccountSettings() {
|
|||||||
f.elements.offset_up.value = a.offset_up || 0;
|
f.elements.offset_up.value = a.offset_up || 0;
|
||||||
f.elements.offset_down.value = a.offset_down || 0;
|
f.elements.offset_down.value = a.offset_down || 0;
|
||||||
document.getElementById('as-second-sig').checked = !!a.second_signature;
|
document.getElementById('as-second-sig').checked = !!a.second_signature;
|
||||||
|
document.getElementById('as-check-position').value = a.check_position || '3-per-page';
|
||||||
|
|
||||||
document.getElementById('as-logo').value = '';
|
document.getElementById('as-logo').value = '';
|
||||||
document.getElementById('as-logo-preview').hidden = true;
|
document.getElementById('as-logo-preview').hidden = true;
|
||||||
@@ -947,6 +1055,7 @@ async function saveAccountSettings() {
|
|||||||
offset_up: parseFloat(f.elements.offset_up.value) || 0,
|
offset_up: parseFloat(f.elements.offset_up.value) || 0,
|
||||||
offset_down: parseFloat(f.elements.offset_down.value) || 0,
|
offset_down: parseFloat(f.elements.offset_down.value) || 0,
|
||||||
second_signature: document.getElementById('as-second-sig').checked ? 1 : 0,
|
second_signature: document.getElementById('as-second-sig').checked ? 1 : 0,
|
||||||
|
check_position: document.getElementById('as-check-position').value,
|
||||||
logo_data: acctSettings.logoData || null,
|
logo_data: acctSettings.logoData || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1333,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();
|
||||||
|
|
||||||
@@ -1366,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}">
|
||||||
@@ -1388,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();
|
||||||
});
|
});
|
||||||
@@ -1498,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);
|
||||||
@@ -1603,6 +1725,45 @@ async function saveSmtpSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── OIDC self-service linking ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadOidcLinkStatus() {
|
||||||
|
try {
|
||||||
|
const cfg = await fetch('/api/auth/oidc/config').then(r => r.json());
|
||||||
|
const ssoNavItem = document.querySelector('[data-settings-tab="sso"]');
|
||||||
|
if (!cfg.enabled) {
|
||||||
|
if (ssoNavItem) ssoNavItem.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ssoNavItem) ssoNavItem.hidden = false;
|
||||||
|
|
||||||
|
const me = await apiFetch('GET', '/api/auth/me');
|
||||||
|
const statusEl = document.getElementById('oidc-link-status');
|
||||||
|
const linkBtn = document.getElementById('btn-oidc-link');
|
||||||
|
const unlinkBtn = document.getElementById('btn-oidc-unlink');
|
||||||
|
|
||||||
|
if (me.oidc_linked) {
|
||||||
|
statusEl.textContent = 'Your account is linked to SSO.';
|
||||||
|
linkBtn.hidden = true;
|
||||||
|
unlinkBtn.hidden = false;
|
||||||
|
} else {
|
||||||
|
statusEl.textContent = 'Link your account to sign in with SSO.';
|
||||||
|
linkBtn.hidden = false;
|
||||||
|
unlinkBtn.hidden = true;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlinkOidc() {
|
||||||
|
if (!confirm('Unlink your SSO identity? You will need to use your password to sign in.')) return;
|
||||||
|
try {
|
||||||
|
await apiFetch('POST', '/api/auth/oidc/unlink');
|
||||||
|
await loadOidcLinkStatus();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Initialization ───────────────────────────────────────────────────────────
|
// ── Initialization ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
@@ -1752,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'));
|
||||||
@@ -1794,10 +1955,27 @@ async function init() {
|
|||||||
document.getElementById('btn-reset-submit').addEventListener('click', submitResetPassword);
|
document.getElementById('btn-reset-submit').addEventListener('click', submitResetPassword);
|
||||||
document.getElementById('reset-password2').addEventListener('keydown', e => { if (e.key === 'Enter') submitResetPassword(); });
|
document.getElementById('reset-password2').addEventListener('keydown', e => { if (e.key === 'Enter') submitResetPassword(); });
|
||||||
|
|
||||||
// User management
|
// User management / settings page
|
||||||
document.getElementById('btn-users').addEventListener('click', openUsersModal);
|
document.getElementById('btn-users').addEventListener('click', () => { location.hash = '#settings/users'; });
|
||||||
document.getElementById('btn-close-users').addEventListener('click', closeUsersModal);
|
document.getElementById('header-username').addEventListener('click', () => {
|
||||||
document.getElementById('users-overlay').addEventListener('click', closeUsersModal);
|
const isAdmin = state.user && state.user.role === 'admin';
|
||||||
|
location.hash = isAdmin ? '#settings/users' : '#settings/password';
|
||||||
|
});
|
||||||
|
document.getElementById('settings-back-link').addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
location.hash = '';
|
||||||
|
});
|
||||||
|
document.getElementById('btn-settings-logout').addEventListener('click', () => {
|
||||||
|
location.hash = '';
|
||||||
|
logout();
|
||||||
|
});
|
||||||
|
// Sidebar tab navigation
|
||||||
|
document.querySelectorAll('.settings-nav-item').forEach(a => {
|
||||||
|
a.addEventListener('click', e => { e.preventDefault(); location.hash = a.getAttribute('href'); });
|
||||||
|
});
|
||||||
|
window.addEventListener('hashchange', () => {
|
||||||
|
if (state.user) handleHashRoute();
|
||||||
|
});
|
||||||
document.getElementById('users-list').addEventListener('click', e => {
|
document.getElementById('users-list').addEventListener('click', e => {
|
||||||
const editBtn = e.target.closest('.user-btn-edit');
|
const editBtn = e.target.closest('.user-btn-edit');
|
||||||
const deleteBtn = e.target.closest('.user-btn-delete');
|
const deleteBtn = e.target.closest('.user-btn-delete');
|
||||||
@@ -1809,13 +1987,509 @@ async function init() {
|
|||||||
document.getElementById('uf-role').addEventListener('change', renderUfAccountCheckboxes);
|
document.getElementById('uf-role').addEventListener('change', renderUfAccountCheckboxes);
|
||||||
document.getElementById('btn-change-password').addEventListener('click', changeOwnPassword);
|
document.getElementById('btn-change-password').addEventListener('click', changeOwnPassword);
|
||||||
document.getElementById('btn-save-smtp').addEventListener('click', saveSmtpSettings);
|
document.getElementById('btn-save-smtp').addEventListener('click', saveSmtpSettings);
|
||||||
|
document.getElementById('btn-oidc-unlink').addEventListener('click', unlinkOidc);
|
||||||
|
|
||||||
// Add checking account
|
// Add checking account
|
||||||
document.getElementById('btn-add-account').addEventListener('click', openWizard);
|
document.getElementById('btn-add-account').addEventListener('click', openWizard);
|
||||||
|
|
||||||
|
// Layout editor
|
||||||
|
document.getElementById('btn-layout-editor').addEventListener('click', openLayoutEditor);
|
||||||
|
document.getElementById('btn-close-layout-editor').addEventListener('click', closeLayoutEditor);
|
||||||
|
document.getElementById('layout-editor-overlay').addEventListener('click', closeLayoutEditor);
|
||||||
|
document.getElementById('layout-field-select').addEventListener('change', e => selectLayoutField(parseInt(e.target.value, 10)));
|
||||||
|
document.getElementById('layout-field-x').addEventListener('input', onLayoutSidebarChange);
|
||||||
|
document.getElementById('layout-field-y').addEventListener('input', onLayoutSidebarChange);
|
||||||
|
document.getElementById('layout-field-x2').addEventListener('input', onLayoutSidebarChange);
|
||||||
|
document.getElementById('layout-field-y2').addEventListener('input', onLayoutSidebarChange);
|
||||||
|
document.getElementById('layout-field-visible').addEventListener('change', onLayoutSidebarChange);
|
||||||
|
document.getElementById('nudge-up').addEventListener('click', () => nudgeLayoutField( 0, -1));
|
||||||
|
document.getElementById('nudge-down').addEventListener('click', () => nudgeLayoutField( 0, 1));
|
||||||
|
document.getElementById('nudge-left').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-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();
|
||||||
if (authed) await loadAccounts();
|
if (authed) await loadAccounts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Layout Editor ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let layoutState = { fields: [], selectedId: null, scale: 80 };
|
||||||
|
let layoutDrag = null;
|
||||||
|
let layoutSaveTimer = null;
|
||||||
|
|
||||||
|
const FIELD_LABELS = {
|
||||||
|
'Company Name': 'Account Name (line 1)',
|
||||||
|
'Company Name2': 'Account Address (line 2)',
|
||||||
|
'Company Name3': 'Account City/State (line 3)',
|
||||||
|
'Company Name4': 'Account Phone/Web (line 4)',
|
||||||
|
'Check Number': 'Check Number',
|
||||||
|
'Date Label': 'Date Label',
|
||||||
|
'Date': 'Date',
|
||||||
|
'Pay To Label': '"Pay To" Label',
|
||||||
|
'Payee Name': 'Payee Name',
|
||||||
|
'Dollar Sign': 'Dollar Sign ($)',
|
||||||
|
'Amount': 'Amount (numeric)',
|
||||||
|
'Text Amount': 'Amount (written)',
|
||||||
|
'Dollars Label': '"Dollars" Label',
|
||||||
|
'Bank Information': 'Bank Information',
|
||||||
|
'Bank Transit Code': 'Transit Code',
|
||||||
|
'Payee Address': 'Payee Address',
|
||||||
|
'Memo Label': 'Memo Label',
|
||||||
|
'Memo': 'Memo',
|
||||||
|
'Auth Signature Label': '"Authorized Signature" Label',
|
||||||
|
'Payee Line': 'Line: Payee',
|
||||||
|
'Amount Box Top': 'Line: Amount Box (top)',
|
||||||
|
'Amount Box Left': 'Line: Amount Box (left)',
|
||||||
|
'Amount Box Bottom': 'Line: Amount Box (bottom)',
|
||||||
|
'Text Amount Line': 'Line: Written Amount',
|
||||||
|
'Memo Line': 'Line: Memo',
|
||||||
|
'Signature Line': 'Line: Signature',
|
||||||
|
};
|
||||||
|
const FIELD_COLORS = { Regular: '#2563eb', Text: '#16a34a', Line: '#b45309', Graph: '#7c3aed' };
|
||||||
|
|
||||||
|
function fieldLabel(f) { return FIELD_LABELS[f.field_name] || f.field_name; }
|
||||||
|
function round16(v) { return Math.round(v * 16) / 16; }
|
||||||
|
function clampIn(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
||||||
|
|
||||||
|
const FRAC_MAP = [
|
||||||
|
[0,''], [1/16,'¹⁄₁₆'], [1/8,'⅛'], [3/16,'³⁄₁₆'],
|
||||||
|
[1/4,'¼'], [5/16,'⁵⁄₁₆'], [3/8,'⅜'], [7/16,'⁷⁄₁₆'],
|
||||||
|
[1/2,'½'], [9/16,'⁹⁄₁₆'], [5/8,'⅝'], [11/16,'¹¹⁄₁₆'],
|
||||||
|
[3/4,'¾'], [13/16,'¹³⁄₁₆'], [7/8,'⅞'], [15/16,'¹⁵⁄₁₆'],
|
||||||
|
];
|
||||||
|
function toFracStr(val) {
|
||||||
|
const w = Math.floor(val);
|
||||||
|
const dec = val - w;
|
||||||
|
const fr = FRAC_MAP.reduce((a, b) => Math.abs(b[0] - dec) < Math.abs(a[0] - dec) ? b : a);
|
||||||
|
const parts = [];
|
||||||
|
if (w) parts.push(w);
|
||||||
|
if (fr[1]) parts.push(fr[1]);
|
||||||
|
return (parts.length ? parts.join(' ') : '0') + '"';
|
||||||
|
}
|
||||||
|
function setFracEl(id, val) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = toFracStr(val || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLayoutEditor() {
|
||||||
|
if (!state.activeAccountId) return;
|
||||||
|
document.getElementById('layout-editor-overlay').classList.add('open');
|
||||||
|
document.getElementById('layout-editor-modal').classList.add('open');
|
||||||
|
loadLayoutFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLayoutEditor() {
|
||||||
|
document.getElementById('layout-editor-overlay').classList.remove('open');
|
||||||
|
document.getElementById('layout-editor-modal').classList.remove('open');
|
||||||
|
layoutState = { fields: [], selectedId: null, scale: 80 };
|
||||||
|
clearTimeout(layoutSaveTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLayoutFields() {
|
||||||
|
try {
|
||||||
|
layoutState.fields = await apiFetch('GET', `/api/layout/${state.activeAccountId}`);
|
||||||
|
populateLayoutDropdown();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
renderLayoutCanvas();
|
||||||
|
if (layoutState.fields.length > 0) selectLayoutField(layoutState.fields[0].id);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load layout fields:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateLayoutDropdown() {
|
||||||
|
const sel = document.getElementById('layout-field-select');
|
||||||
|
sel.innerHTML = layoutState.fields.map(f =>
|
||||||
|
`<option value="${f.id}">${escHtml(fieldLabel(f))}</option>`
|
||||||
|
).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';
|
||||||
|
function svgEl(tag, attrs, text) {
|
||||||
|
const el = document.createElementNS(SVG_NS, tag);
|
||||||
|
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
|
||||||
|
if (text != null) el.textContent = text;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLayoutCanvas() {
|
||||||
|
const container = document.getElementById('layout-canvas-container');
|
||||||
|
const W = container.offsetWidth;
|
||||||
|
if (W <= 0) return;
|
||||||
|
const SCALE = W / 8.5;
|
||||||
|
layoutState.scale = SCALE;
|
||||||
|
const H = 3.5 * SCALE;
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
const svg = svgEl('svg', { width: W, height: H, style: 'display:block;user-select:none' });
|
||||||
|
|
||||||
|
// White check background
|
||||||
|
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
|
||||||
|
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('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) {
|
||||||
|
const g = createFieldSvgElement(f, SCALE, layoutState.selectedId === f.id);
|
||||||
|
svg.appendChild(g);
|
||||||
|
attachFieldEvents(g, f);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(svg);
|
||||||
|
renderRulers(W, H, SCALE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRulers(W, H, scale) {
|
||||||
|
const RULER = 24;
|
||||||
|
const topEl = document.getElementById('layout-ruler-top');
|
||||||
|
const leftEl = document.getElementById('layout-ruler-left');
|
||||||
|
if (!topEl || !leftEl) return;
|
||||||
|
|
||||||
|
// ── Horizontal ruler (top) ──────────────────────────────────────
|
||||||
|
const topSvg = svgEl('svg', { width: W, height: RULER, style: 'display:block' });
|
||||||
|
topSvg.appendChild(svgEl('rect', { x:0, y:0, width:W, height:RULER, fill:'var(--surface)' }));
|
||||||
|
|
||||||
|
for (let n8 = 0; n8 <= Math.ceil(8.5 * 8); n8++) {
|
||||||
|
const inches = n8 / 8;
|
||||||
|
if (inches > 8.5) break;
|
||||||
|
const x = inches * scale;
|
||||||
|
const isInch = n8 % 8 === 0;
|
||||||
|
const isHalf = n8 % 4 === 0;
|
||||||
|
const isQtr = n8 % 2 === 0;
|
||||||
|
const tickH = isInch ? RULER - 2 : isHalf ? 14 : isQtr ? 9 : 5;
|
||||||
|
topSvg.appendChild(svgEl('line', { x1:x, y1:RULER, x2:x, y2:RULER - tickH, stroke:'#999', 'stroke-width': isInch ? 1 : 0.5 }));
|
||||||
|
if (isInch && inches > 0) {
|
||||||
|
topSvg.appendChild(svgEl('text', { x:x + 2, y:RULER - tickH - 1, 'font-size':8, fill:'#666', 'font-family':'sans-serif' }, inches + '"'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
topEl.innerHTML = '';
|
||||||
|
topEl.appendChild(topSvg);
|
||||||
|
|
||||||
|
// ── Vertical ruler (left) ───────────────────────────────────────
|
||||||
|
const leftSvg = svgEl('svg', { width: RULER, height: H, style: 'display:block' });
|
||||||
|
leftSvg.appendChild(svgEl('rect', { x:0, y:0, width:RULER, height:H, fill:'var(--surface)' }));
|
||||||
|
|
||||||
|
for (let n8 = 0; n8 <= Math.ceil(3.5 * 8); n8++) {
|
||||||
|
const inches = n8 / 8;
|
||||||
|
if (inches > 3.5) break;
|
||||||
|
const y = inches * scale;
|
||||||
|
const isInch = n8 % 8 === 0;
|
||||||
|
const isHalf = n8 % 4 === 0;
|
||||||
|
const isQtr = n8 % 2 === 0;
|
||||||
|
const tickW = isInch ? RULER - 2 : isHalf ? 14 : isQtr ? 9 : 5;
|
||||||
|
leftSvg.appendChild(svgEl('line', { x1:RULER, y1:y, x2:RULER - tickW, y2:y, stroke:'#999', 'stroke-width': isInch ? 1 : 0.5 }));
|
||||||
|
if (isInch && inches > 0) {
|
||||||
|
const t = svgEl('text', { 'font-size':8, fill:'#666', 'font-family':'sans-serif',
|
||||||
|
'text-anchor':'end', x: RULER - tickW - 2, y: y + 3 }, inches + '"');
|
||||||
|
leftSvg.appendChild(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
leftEl.innerHTML = '';
|
||||||
|
leftEl.appendChild(leftSvg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFieldDisplayValue(f) {
|
||||||
|
const a = state.account || {};
|
||||||
|
switch (f.field_name) {
|
||||||
|
case 'Company Name': return a.company1 || 'Company Name';
|
||||||
|
case 'Company Name2': return a.company2 || '';
|
||||||
|
case 'Company Name3': return a.company3 || '';
|
||||||
|
case 'Company Name4': return a.company4 || '';
|
||||||
|
case 'Check Number': return '1001';
|
||||||
|
case 'Date': return '01/01/2025';
|
||||||
|
case 'Payee Name': return 'Sample Payee';
|
||||||
|
case 'Amount': return '1,234.56';
|
||||||
|
case 'Text Amount': return 'One Thousand Two Hundred Thirty Four and 56/100---';
|
||||||
|
case 'Bank Information': return [a.bank_name, a.bank_info1, a.bank_info2, a.bank_info3].filter(Boolean);
|
||||||
|
case 'Bank Transit Code': return a.transit_code || '';
|
||||||
|
case 'Payee Address': return ['123 Sample St', 'City, ST 12345'];
|
||||||
|
case 'Memo': return 'Sample Memo';
|
||||||
|
default:
|
||||||
|
if (f.field_type === 'Text') return f.field_text || '';
|
||||||
|
return f.field_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFieldSvgElement(f, scale, selected) {
|
||||||
|
const g = svgEl('g', { 'data-field-id': f.id, style: `cursor:grab;opacity:${f.visible ? 1 : 0.35}` });
|
||||||
|
const x = f.x_pos * scale;
|
||||||
|
const y = f.y_pos * scale;
|
||||||
|
|
||||||
|
if (f.field_type === 'Line') {
|
||||||
|
const x1 = f.x_pos * scale, y1 = f.y_pos * scale;
|
||||||
|
const x2 = f.x_end_pos * scale, y2 = f.y_end_pos * scale;
|
||||||
|
g.appendChild(svgEl('line', { x1, y1, x2, y2, stroke:'transparent', 'stroke-width':10 }));
|
||||||
|
g.appendChild(svgEl('line', { x1, y1, x2, y2, stroke: selected ? '#2563eb' : '#333', 'stroke-width': selected ? 2 : 1.5 }));
|
||||||
|
if (selected) {
|
||||||
|
g.appendChild(svgEl('circle', { cx:x1, cy:y1, r:3, fill:'#2563eb' }));
|
||||||
|
g.appendChild(svgEl('circle', { cx:x2, cy:y2, r:3, fill:'#2563eb' }));
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (f.field_type === 'Graph') {
|
||||||
|
const w = Math.max(4, (f.x_end_pos - f.x_pos) * scale);
|
||||||
|
const h = Math.max(4, (f.y_end_pos - f.y_pos) * scale);
|
||||||
|
g.appendChild(svgEl('rect', { x, y, width:w, height:h, fill:'#f0f0f0', stroke: selected ? '#2563eb' : '#aaa', 'stroke-width':1, 'stroke-dasharray':'4,3' }));
|
||||||
|
g.appendChild(svgEl('text', { x:x+2, y:y+10, 'font-size':7, fill:'#999', 'font-family':'sans-serif' }, '[image]'));
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular and Text fields — render actual content at proportional size
|
||||||
|
const fontSize = Math.max(6, (f.font_size || 10) / 72 * scale);
|
||||||
|
const fontWeight = f.font_bold ? 'bold' : 'normal';
|
||||||
|
const displayVal = getFieldDisplayValue(f);
|
||||||
|
const lines = Array.isArray(displayVal) ? displayVal : [String(displayVal)];
|
||||||
|
const lineHeight = fontSize * 1.3;
|
||||||
|
|
||||||
|
// Invisible hit area so the element is always draggable
|
||||||
|
const approxCharW = fontSize * 0.58;
|
||||||
|
const hitW = Math.max(20, lines.reduce((m, l) => Math.max(m, l.length * approxCharW), 0));
|
||||||
|
const hitH = lines.length * lineHeight + 2;
|
||||||
|
g.appendChild(svgEl('rect', { x, y: y - fontSize, width: hitW, height: hitH, fill: 'transparent' }));
|
||||||
|
|
||||||
|
// Selection highlight
|
||||||
|
if (selected) {
|
||||||
|
g.appendChild(svgEl('rect', {
|
||||||
|
x: x - 2, y: y - fontSize - 2,
|
||||||
|
width: hitW + 4, height: hitH + 4,
|
||||||
|
fill: 'rgba(37,99,235,0.06)', stroke: '#2563eb',
|
||||||
|
'stroke-width': 1, 'stroke-dasharray': '4,3', rx: 2,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text content
|
||||||
|
if (lines.length === 1) {
|
||||||
|
g.appendChild(svgEl('text', {
|
||||||
|
x, y,
|
||||||
|
'font-size': fontSize,
|
||||||
|
'font-family': 'Helvetica, Arial, sans-serif',
|
||||||
|
'font-weight': fontWeight,
|
||||||
|
fill: '#111',
|
||||||
|
}, lines[0]));
|
||||||
|
} else {
|
||||||
|
const textEl = svgEl('text', {
|
||||||
|
x, y,
|
||||||
|
'font-size': fontSize,
|
||||||
|
'font-family': 'Helvetica, Arial, sans-serif',
|
||||||
|
'font-weight': fontWeight,
|
||||||
|
fill: '#111',
|
||||||
|
});
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
textEl.appendChild(svgEl('tspan', { x, dy: i === 0 ? 0 : lineHeight }, line));
|
||||||
|
});
|
||||||
|
g.appendChild(textEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachFieldEvents(g, f) {
|
||||||
|
g.addEventListener('mousedown', e => {
|
||||||
|
selectLayoutField(f.id);
|
||||||
|
startLayoutDrag(e, f);
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectLayoutField(id) {
|
||||||
|
layoutState.selectedId = id;
|
||||||
|
const sel = document.getElementById('layout-field-select');
|
||||||
|
if (sel) sel.value = id;
|
||||||
|
const f = layoutState.fields.find(x => x.id === id);
|
||||||
|
if (f) updateLayoutSidebar(f);
|
||||||
|
renderLayoutCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLayoutSidebar(f) {
|
||||||
|
const fmt = x => (x || 0).toFixed(4);
|
||||||
|
document.getElementById('layout-field-visible').checked = !!f.visible;
|
||||||
|
document.getElementById('layout-field-x').value = fmt(f.x_pos);
|
||||||
|
document.getElementById('layout-field-y').value = fmt(f.y_pos);
|
||||||
|
document.getElementById('layout-field-x2').value = fmt(f.x_end_pos);
|
||||||
|
document.getElementById('layout-field-y2').value = fmt(f.y_end_pos);
|
||||||
|
setFracEl('layout-field-x-frac', f.x_pos);
|
||||||
|
setFracEl('layout-field-y-frac', f.y_pos);
|
||||||
|
setFracEl('layout-field-x2-frac', f.x_end_pos);
|
||||||
|
setFracEl('layout-field-y2-frac', f.y_end_pos);
|
||||||
|
document.getElementById('layout-end-pos-group').hidden =
|
||||||
|
f.field_type !== 'Line' && f.field_type !== 'Graph';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLayoutSidebarChange() {
|
||||||
|
const f = layoutState.fields.find(x => x.id === layoutState.selectedId);
|
||||||
|
if (!f) return;
|
||||||
|
f.x_pos = clampIn(parseFloat(document.getElementById('layout-field-x').value) || 0, 0, 8.5);
|
||||||
|
f.y_pos = clampIn(parseFloat(document.getElementById('layout-field-y').value) || 0, 0, 3.5);
|
||||||
|
f.x_end_pos = clampIn(parseFloat(document.getElementById('layout-field-x2').value) || 0, 0, 8.5);
|
||||||
|
f.y_end_pos = clampIn(parseFloat(document.getElementById('layout-field-y2').value) || 0, 0, 3.5);
|
||||||
|
f.visible = document.getElementById('layout-field-visible').checked ? 1 : 0;
|
||||||
|
setFracEl('layout-field-x-frac', f.x_pos);
|
||||||
|
setFracEl('layout-field-y-frac', f.y_pos);
|
||||||
|
setFracEl('layout-field-x2-frac', f.x_end_pos);
|
||||||
|
setFracEl('layout-field-y2-frac', f.y_end_pos);
|
||||||
|
renderLayoutCanvas();
|
||||||
|
debounceLayoutSave(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLayoutDrag(e, f) {
|
||||||
|
layoutDrag = {
|
||||||
|
fieldId: f.id,
|
||||||
|
origX: f.x_pos, origY: f.y_pos,
|
||||||
|
origX2: f.x_end_pos, origY2: f.y_end_pos,
|
||||||
|
mouseX: e.clientX, mouseY: e.clientY,
|
||||||
|
moveEnd: f.field_type === 'Line' || f.field_type === 'Graph',
|
||||||
|
};
|
||||||
|
const onMove = ev => onLayoutDragMove(ev);
|
||||||
|
const onUp = ev => { onLayoutDragEnd(ev); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLayoutDragMove(e) {
|
||||||
|
if (!layoutDrag) return;
|
||||||
|
const dx = (e.clientX - layoutDrag.mouseX) / layoutState.scale;
|
||||||
|
const dy = (e.clientY - layoutDrag.mouseY) / layoutState.scale;
|
||||||
|
const f = layoutState.fields.find(x => x.id === layoutDrag.fieldId);
|
||||||
|
if (!f) return;
|
||||||
|
f.x_pos = clampIn(round16(layoutDrag.origX + dx), SAFE_LEFT, SAFE_RIGHT);
|
||||||
|
f.y_pos = clampIn(round16(layoutDrag.origY + dy), SAFE_TOP, SAFE_BOTTOM);
|
||||||
|
if (layoutDrag.moveEnd) {
|
||||||
|
f.x_end_pos = clampIn(round16(layoutDrag.origX2 + dx), SAFE_LEFT, SAFE_RIGHT);
|
||||||
|
f.y_end_pos = clampIn(round16(layoutDrag.origY2 + dy), SAFE_TOP, SAFE_BOTTOM);
|
||||||
|
}
|
||||||
|
// Update just the dragged element for smooth performance
|
||||||
|
const svg = document.querySelector('#layout-canvas-container svg');
|
||||||
|
if (svg) {
|
||||||
|
const old = svg.querySelector(`[data-field-id="${f.id}"]`);
|
||||||
|
if (old) {
|
||||||
|
const g = createFieldSvgElement(f, layoutState.scale, true);
|
||||||
|
old.replaceWith(g);
|
||||||
|
attachFieldEvents(g, f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateLayoutSidebar(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onLayoutDragEnd(e) {
|
||||||
|
if (!layoutDrag) return;
|
||||||
|
const id = layoutDrag.fieldId;
|
||||||
|
layoutDrag = null;
|
||||||
|
const f = layoutState.fields.find(x => x.id === id);
|
||||||
|
if (f) await saveLayoutField(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nudgeLayoutField(dx, dy) {
|
||||||
|
const f = layoutState.fields.find(x => x.id === layoutState.selectedId);
|
||||||
|
if (!f) return;
|
||||||
|
const S = 1 / 16;
|
||||||
|
f.x_pos = clampIn(round16(f.x_pos + dx * S), SAFE_LEFT, SAFE_RIGHT);
|
||||||
|
f.y_pos = clampIn(round16(f.y_pos + dy * S), SAFE_TOP, SAFE_BOTTOM);
|
||||||
|
if (f.field_type === 'Line' || f.field_type === 'Graph') {
|
||||||
|
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), SAFE_TOP, SAFE_BOTTOM);
|
||||||
|
}
|
||||||
|
updateLayoutSidebar(f);
|
||||||
|
renderLayoutCanvas();
|
||||||
|
debounceLayoutSave(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
function debounceLayoutSave(f) {
|
||||||
|
clearTimeout(layoutSaveTimer);
|
||||||
|
layoutSaveTimer = setTimeout(() => saveLayoutField(f), 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLayoutField(f) {
|
||||||
|
try {
|
||||||
|
await apiFetch('PUT', `/api/layout/${state.activeAccountId}/${f.id}`, {
|
||||||
|
x_pos: f.x_pos, y_pos: f.y_pos,
|
||||||
|
x_end_pos: f.x_end_pos, y_end_pos: f.y_end_pos,
|
||||||
|
visible: f.visible,
|
||||||
|
});
|
||||||
|
const el = document.getElementById('layout-save-status');
|
||||||
|
if (el) { el.textContent = 'Saved ✓'; setTimeout(() => { if (el) el.textContent = ''; }, 1500); }
|
||||||
|
} catch (err) {
|
||||||
|
const el = document.getElementById('layout-save-status');
|
||||||
|
if (el) el.textContent = 'Save failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
if (!confirm('Reset all layout fields to default positions? This cannot be undone.')) return;
|
||||||
|
try {
|
||||||
|
await apiFetch('POST', `/api/layout/${state.activeAccountId}/reset`);
|
||||||
|
await loadLayoutFields();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Reset failed: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
|||||||
+44
-63
@@ -10,7 +10,8 @@ const multer = require('multer');
|
|||||||
const session = require('express-session');
|
const session = require('express-session');
|
||||||
|
|
||||||
const db = require('./db/database');
|
const db = require('./db/database');
|
||||||
const { requireAuth, requireAdmin, canAccessAccount } = require('./middleware/auth');
|
const { seedLayoutFields } = require('./db/database');
|
||||||
|
const { requireAuth, requireAdmin, canAccessAccount, isEditorForAccount } = require('./middleware/auth');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const upload = multer({ dest: os.tmpdir() });
|
const upload = multer({ dest: os.tmpdir() });
|
||||||
@@ -105,7 +106,7 @@ app.put('/api/account/:id', requireAdmin, (req, res) => {
|
|||||||
bank_name, bank_info1, bank_info2, bank_info3, transit_code,
|
bank_name, bank_info1, bank_info2, bank_info3, transit_code,
|
||||||
routing_number, account_number,
|
routing_number, account_number,
|
||||||
offset_left, offset_right, offset_up, offset_down,
|
offset_left, offset_right, offset_up, offset_down,
|
||||||
logo_data, second_signature,
|
logo_data, second_signature, check_position,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (!company1 || !routing_number || !account_number) {
|
if (!company1 || !routing_number || !account_number) {
|
||||||
@@ -116,13 +117,16 @@ app.put('/api/account/:id', requireAdmin, (req, res) => {
|
|||||||
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.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALID_POSITIONS = ['3-per-page', 'top', 'middle', 'bottom'];
|
||||||
|
const resolvedPosition = VALID_POSITIONS.includes(check_position) ? check_position : '3-per-page';
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE account SET
|
UPDATE account SET
|
||||||
company1 = ?, company2 = ?, company3 = ?, company4 = ?,
|
company1 = ?, company2 = ?, company3 = ?, company4 = ?,
|
||||||
bank_name = ?, bank_info1 = ?, bank_info2 = ?, bank_info3 = ?, transit_code = ?,
|
bank_name = ?, bank_info1 = ?, bank_info2 = ?, bank_info3 = ?, transit_code = ?,
|
||||||
routing_number = ?, account_number = ?,
|
routing_number = ?, account_number = ?,
|
||||||
offset_left = ?, offset_right = ?, offset_up = ?, offset_down = ?,
|
offset_left = ?, offset_right = ?, offset_up = ?, offset_down = ?,
|
||||||
second_signature = ?,
|
second_signature = ?, check_position = ?,
|
||||||
logo_data = CASE WHEN ? IS NOT NULL THEN ? ELSE logo_data END,
|
logo_data = CASE WHEN ? IS NOT NULL THEN ? ELSE logo_data END,
|
||||||
updated_at = datetime('now')
|
updated_at = datetime('now')
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
@@ -132,7 +136,7 @@ app.put('/api/account/:id', requireAdmin, (req, res) => {
|
|||||||
routing_number, account_number,
|
routing_number, 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,
|
second_signature ? 1 : 0, resolvedPosition,
|
||||||
logo_data || null, logo_data || null,
|
logo_data || null, logo_data || null,
|
||||||
req.params.id
|
req.params.id
|
||||||
);
|
);
|
||||||
@@ -191,64 +195,6 @@ app.delete('/api/account/:id', requireAdmin, (req, res) => {
|
|||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Default layout fields for manually-created accounts (no .mdb import).
|
|
||||||
// Coordinates are in inches from the top-left of each check slot (8.5" × 3.5").
|
|
||||||
// Field names for type 'Regular' must match the keys in pdfService.resolveFieldValue.
|
|
||||||
function seedDefaultLayoutFields(accountId) {
|
|
||||||
const fields = [
|
|
||||||
// Company block — top left
|
|
||||||
{ field_name: 'Company Name', field_type: 'Regular', x_pos: 0.50, y_pos: 0.12, x_end_pos: 0, y_end_pos: 0, font_name: 'Helvetica-Bold', font_size: 10, font_bold: 1, field_text: null, line_thick: 1, visible: 1 },
|
|
||||||
{ field_name: 'Company Name2', field_type: 'Regular', x_pos: 0.50, y_pos: 0.30, x_end_pos: 0, y_end_pos: 0, font_name: 'Helvetica', font_size: 9, font_bold: 0, field_text: null, line_thick: 1, visible: 1 },
|
|
||||||
{ field_name: 'Company Name3', field_type: 'Regular', x_pos: 0.50, y_pos: 0.44, x_end_pos: 0, y_end_pos: 0, font_name: 'Helvetica', font_size: 9, font_bold: 0, field_text: null, line_thick: 1, visible: 1 },
|
|
||||||
{ field_name: 'Company Name4', field_type: 'Regular', x_pos: 0.50, y_pos: 0.58, x_end_pos: 0, y_end_pos: 0, font_name: 'Helvetica', font_size: 9, font_bold: 0, field_text: null, line_thick: 1, visible: 1 },
|
|
||||||
// Check number — top right
|
|
||||||
{ field_name: 'Check Number', field_type: 'Regular', x_pos: 7.20, y_pos: 0.12, x_end_pos: 0, y_end_pos: 0, font_name: 'Helvetica-Bold', font_size: 10, font_bold: 1, field_text: null, line_thick: 1, visible: 1 },
|
|
||||||
// Date — upper right
|
|
||||||
{ field_name: 'Date Label', field_type: 'Text', x_pos: 5.80, y_pos: 0.40, x_end_pos: 0, y_end_pos: 0, font_name: 'Helvetica', font_size: 8, font_bold: 0, field_text: 'DATE', line_thick: 1, visible: 1 },
|
|
||||||
{ field_name: 'Date', field_type: 'Regular', x_pos: 6.30, y_pos: 0.40, x_end_pos: 0, y_end_pos: 0, font_name: 'Helvetica', font_size: 9, font_bold: 0, field_text: null, line_thick: 1, visible: 1 },
|
|
||||||
// Pay to the order of
|
|
||||||
{ field_name: 'Pay To Label', field_type: 'Text', x_pos: 0.30, y_pos: 0.82, x_end_pos: 0, y_end_pos: 0, font_name: 'Helvetica', font_size: 7, font_bold: 0, field_text: 'PAY TO THE ORDER OF', line_thick: 1, visible: 1 },
|
|
||||||
{ field_name: 'Payee Name', field_type: 'Regular', x_pos: 2.15, y_pos: 0.80, x_end_pos: 0, y_end_pos: 0, font_name: 'Helvetica', font_size: 10, font_bold: 0, field_text: null, line_thick: 1, visible: 1 },
|
|
||||||
// Amount box
|
|
||||||
{ field_name: 'Dollar Sign', field_type: 'Text', x_pos: 6.80, y_pos: 0.80, x_end_pos: 0, y_end_pos: 0, font_name: 'Helvetica', font_size: 10, font_bold: 0, field_text: '$', line_thick: 1, visible: 1 },
|
|
||||||
{ field_name: 'Amount', field_type: 'Regular', x_pos: 6.95, y_pos: 0.80, x_end_pos: 0, y_end_pos: 0, font_name: 'Helvetica-Bold', font_size: 10, font_bold: 1, field_text: null, line_thick: 1, visible: 1 },
|
|
||||||
// Written amount
|
|
||||||
{ field_name: 'Text Amount', field_type: 'Regular', x_pos: 0.30, y_pos: 1.28, x_end_pos: 0, y_end_pos: 0, font_name: 'Helvetica', font_size: 9, font_bold: 0, field_text: null, line_thick: 1, visible: 1 },
|
|
||||||
{ field_name: 'Dollars Label', field_type: 'Text', x_pos: 6.30, y_pos: 1.28, x_end_pos: 0, y_end_pos: 0, font_name: 'Helvetica', font_size: 8, font_bold: 0, field_text: 'DOLLARS', line_thick: 1, visible: 1 },
|
|
||||||
// Bank info block
|
|
||||||
{ field_name: 'Bank Information', field_type: 'Regular', x_pos: 0.30, y_pos: 1.82, x_end_pos: 0, y_end_pos: 0, font_name: 'Helvetica', font_size: 8, font_bold: 0, field_text: null, line_thick: 1, visible: 1 },
|
|
||||||
{ field_name: 'Bank Transit Code', field_type: 'Regular', x_pos: 0.30, y_pos: 2.38, x_end_pos: 0, y_end_pos: 0, font_name: 'Helvetica', font_size: 7, font_bold: 0, field_text: null, line_thick: 1, visible: 1 },
|
|
||||||
// Payee address — center window area (for windowed envelopes)
|
|
||||||
{ field_name: 'Payee Address', field_type: 'Regular', x_pos: 3.50, y_pos: 1.82, x_end_pos: 0, y_end_pos: 0, font_name: 'Helvetica', font_size: 9, font_bold: 0, field_text: null, line_thick: 1, visible: 1 },
|
|
||||||
// Memo
|
|
||||||
{ field_name: 'Memo Label', field_type: 'Text', x_pos: 0.30, y_pos: 2.82, x_end_pos: 0, y_end_pos: 0, font_name: 'Helvetica', font_size: 7, font_bold: 0, field_text: 'MEMO', line_thick: 1, visible: 1 },
|
|
||||||
{ field_name: 'Memo', field_type: 'Regular', x_pos: 0.72, y_pos: 2.82, x_end_pos: 0, y_end_pos: 0, font_name: 'Helvetica', font_size: 9, font_bold: 0, field_text: null, line_thick: 1, visible: 1 },
|
|
||||||
// Auth signature label
|
|
||||||
{ field_name: 'Auth Signature Label', field_type: 'Text', x_pos: 5.00, y_pos: 3.14, x_end_pos: 0, y_end_pos: 0, font_name: 'Helvetica', font_size: 6, font_bold: 0, field_text: 'AUTHORIZED SIGNATURE', line_thick: 1, visible: 1 },
|
|
||||||
// Lines
|
|
||||||
{ field_name: 'Payee Line', field_type: 'Line', x_pos: 2.10, y_pos: 1.00, x_end_pos: 6.70, y_end_pos: 1.00, font_name: 'Helvetica', font_size: 10, font_bold: 0, field_text: null, line_thick: 1, visible: 1 },
|
|
||||||
{ field_name: 'Amount Box Top', field_type: 'Line', x_pos: 6.75, y_pos: 0.70, x_end_pos: 8.30, y_end_pos: 0.70, font_name: 'Helvetica', font_size: 10, font_bold: 0, field_text: null, line_thick: 1, visible: 1 },
|
|
||||||
{ field_name: 'Amount Box Left', field_type: 'Line', x_pos: 6.75, y_pos: 0.70, x_end_pos: 6.75, y_end_pos: 1.05, font_name: 'Helvetica', font_size: 10, font_bold: 0, field_text: null, line_thick: 1, visible: 1 },
|
|
||||||
{ field_name: 'Amount Box Bottom',field_type: 'Line', x_pos: 6.75, y_pos: 1.05, x_end_pos: 8.30, y_end_pos: 1.05, font_name: 'Helvetica', font_size: 10, font_bold: 0, field_text: null, line_thick: 1, visible: 1 },
|
|
||||||
{ field_name: 'Text Amount Line', field_type: 'Line', x_pos: 0.30, y_pos: 1.48, x_end_pos: 6.30, y_end_pos: 1.48, font_name: 'Helvetica', font_size: 10, font_bold: 0, field_text: null, line_thick: 1, visible: 1 },
|
|
||||||
{ field_name: 'Memo Line', field_type: 'Line', x_pos: 0.68, y_pos: 3.00, x_end_pos: 4.00, y_end_pos: 3.00, font_name: 'Helvetica', font_size: 10, font_bold: 0, field_text: null, line_thick: 1, visible: 1 },
|
|
||||||
{ field_name: 'Signature Line', field_type: 'Line', x_pos: 5.00, y_pos: 3.10, x_end_pos: 8.20, y_end_pos: 3.10, font_name: 'Helvetica', font_size: 10, font_bold: 0, field_text: null, line_thick: 1, visible: 1 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const stmt = db.prepare(`
|
|
||||||
INSERT OR IGNORE INTO layout_fields
|
|
||||||
(account_id, field_name, field_text, font_name, font_size, font_bold,
|
|
||||||
field_type, line_thick, x_pos, y_pos, x_end_pos, y_end_pos, visible)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`);
|
|
||||||
db.transaction(() => {
|
|
||||||
for (const f of fields) {
|
|
||||||
stmt.run(accountId, f.field_name, f.field_text, f.font_name, f.font_size, f.font_bold,
|
|
||||||
f.field_type, f.line_thick, f.x_pos, f.y_pos, f.x_end_pos, f.y_end_pos, f.visible);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /api/account/setup (admin only — creates a new checking account)
|
// POST /api/account/setup (admin only — creates a new checking account)
|
||||||
app.post('/api/account/setup', requireAdmin, (req, res) => {
|
app.post('/api/account/setup', requireAdmin, (req, res) => {
|
||||||
const {
|
const {
|
||||||
@@ -291,7 +237,7 @@ app.post('/api/account/setup', requireAdmin, (req, res) => {
|
|||||||
logo_data: logo_data || null,
|
logo_data: logo_data || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
seedDefaultLayoutFields(result.lastInsertRowid);
|
seedLayoutFields(result.lastInsertRowid);
|
||||||
|
|
||||||
res.status(201).json({ success: true, accountId: result.lastInsertRowid });
|
res.status(201).json({ success: true, accountId: result.lastInsertRowid });
|
||||||
});
|
});
|
||||||
@@ -318,6 +264,41 @@ app.post('/api/import', requireAdmin, upload.single('mdbfile'), (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Layout editor routes ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /api/layout/:accountId — all layout_fields for an account
|
||||||
|
app.get('/api/layout/:accountId', requireAuth, (req, res) => {
|
||||||
|
const accountId = parseInt(req.params.accountId, 10);
|
||||||
|
if (!canAccessAccount(req.session, accountId)) return res.status(403).json({ error: 'Access denied.' });
|
||||||
|
const fields = db.prepare('SELECT * FROM layout_fields WHERE account_id = ? ORDER BY id').all(accountId);
|
||||||
|
res.json(fields);
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/layout/:accountId/:fieldId — update position/visibility of one field
|
||||||
|
app.put('/api/layout/:accountId/:fieldId', requireAuth, (req, res) => {
|
||||||
|
const accountId = parseInt(req.params.accountId, 10);
|
||||||
|
const fieldId = parseInt(req.params.fieldId, 10);
|
||||||
|
if (!isEditorForAccount(req.session, accountId)) return res.status(403).json({ error: 'Write access required.' });
|
||||||
|
const { x_pos, y_pos, x_end_pos, y_end_pos, visible } = req.body;
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE layout_fields SET x_pos=?, y_pos=?, x_end_pos=?, y_end_pos=?, visible=?
|
||||||
|
WHERE id=? AND account_id=?
|
||||||
|
`).run(
|
||||||
|
parseFloat(x_pos) || 0, parseFloat(y_pos) || 0,
|
||||||
|
parseFloat(x_end_pos) || 0, parseFloat(y_end_pos) || 0,
|
||||||
|
visible ? 1 : 0, fieldId, accountId
|
||||||
|
);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/layout/:accountId/reset — wipe and re-seed default layout (admin only)
|
||||||
|
app.post('/api/layout/:accountId/reset', requireAdmin, (req, res) => {
|
||||||
|
const accountId = parseInt(req.params.accountId, 10);
|
||||||
|
db.prepare('DELETE FROM layout_fields WHERE account_id = ?').run(accountId);
|
||||||
|
seedLayoutFields(accountId);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
// Catch-all: serve index.html
|
// Catch-all: serve index.html
|
||||||
app.get('*', (req, res) => {
|
app.get('*', (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, '../public/index.html'));
|
res.sendFile(path.join(__dirname, '../public/index.html'));
|
||||||
|
|||||||
+60
-19
@@ -139,6 +139,17 @@ db.exec(`
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Migration: add OIDC columns to users
|
||||||
|
const usersInfo2 = db.prepare('PRAGMA table_info(users)').all();
|
||||||
|
if (!usersInfo2.some(c => c.name === 'oidc_sub')) {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE users ADD COLUMN oidc_sub TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN oidc_issuer TEXT;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc ON users(oidc_issuer, oidc_sub)
|
||||||
|
WHERE oidc_sub IS NOT NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
// Migration: create settings table
|
// Migration: create settings table
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
@@ -147,19 +158,10 @@ db.exec(`
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Migration: seed default layout fields for any account that has none.
|
// Default layout fields used for seeding and migration.
|
||||||
// Runs at every startup but INSERT OR IGNORE makes it idempotent.
|
const DEFAULT_LAYOUT_FIELDS = [
|
||||||
(function seedMissingLayoutFields() {
|
// Logo — top left corner (Graph type, rendered as image from account.logo_data)
|
||||||
const accounts = db.prepare('SELECT id FROM account').all();
|
{ field_name: 'Logo', field_type: 'Graph', x_pos: 0.10, y_pos: 0.08, x_end_pos: 0.45, y_end_pos: 0.58, font_name: 'Helvetica', font_size: 10, font_bold: 0, field_text: null, line_thick: 1, visible: 1 },
|
||||||
const countStmt = db.prepare('SELECT COUNT(*) AS n FROM layout_fields WHERE account_id = ?');
|
|
||||||
const insertStmt = db.prepare(`
|
|
||||||
INSERT OR IGNORE INTO layout_fields
|
|
||||||
(account_id, field_name, field_text, font_name, font_size, font_bold,
|
|
||||||
field_type, line_thick, x_pos, y_pos, x_end_pos, y_end_pos, visible)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`);
|
|
||||||
|
|
||||||
const defaultFields = [
|
|
||||||
// Company block — top left
|
// Company block — top left
|
||||||
{ field_name: 'Company Name', field_type: 'Regular', x_pos: 0.50, y_pos: 0.12, x_end_pos: 0, y_end_pos: 0, font_name: 'Helvetica-Bold', font_size: 10, font_bold: 1, field_text: null, line_thick: 1, visible: 1 },
|
{ field_name: 'Company Name', field_type: 'Regular', x_pos: 0.50, y_pos: 0.12, x_end_pos: 0, y_end_pos: 0, font_name: 'Helvetica-Bold', font_size: 10, font_bold: 1, field_text: null, line_thick: 1, visible: 1 },
|
||||||
{ field_name: 'Company Name2', field_type: 'Regular', x_pos: 0.50, y_pos: 0.30, x_end_pos: 0, y_end_pos: 0, font_name: 'Helvetica', font_size: 9, font_bold: 0, field_text: null, line_thick: 1, visible: 1 },
|
{ field_name: 'Company Name2', field_type: 'Regular', x_pos: 0.50, y_pos: 0.30, x_end_pos: 0, y_end_pos: 0, font_name: 'Helvetica', font_size: 9, font_bold: 0, field_text: null, line_thick: 1, visible: 1 },
|
||||||
@@ -199,18 +201,57 @@ db.exec(`
|
|||||||
{ field_name: 'Signature Line', field_type: 'Line', x_pos: 5.00, y_pos: 3.10, x_end_pos: 8.20, y_end_pos: 3.10, font_name: 'Helvetica', font_size: 10, font_bold: 0, field_text: null, line_thick: 1, visible: 1 },
|
{ field_name: 'Signature Line', field_type: 'Line', x_pos: 5.00, y_pos: 3.10, x_end_pos: 8.20, y_end_pos: 3.10, font_name: 'Helvetica', font_size: 10, font_bold: 0, field_text: null, line_thick: 1, visible: 1 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const seedAccount = db.transaction(accountId => {
|
function seedLayoutFields(accountId) {
|
||||||
for (const f of defaultFields) {
|
const insert = db.prepare(`
|
||||||
insertStmt.run(accountId, f.field_name, f.field_text, f.font_name, f.font_size, f.font_bold,
|
INSERT OR IGNORE INTO layout_fields
|
||||||
|
(account_id, field_name, field_text, font_name, font_size, font_bold,
|
||||||
|
field_type, line_thick, x_pos, y_pos, x_end_pos, y_end_pos, visible)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const f of DEFAULT_LAYOUT_FIELDS) {
|
||||||
|
insert.run(accountId, f.field_name, f.field_text, f.font_name, f.font_size, f.font_bold,
|
||||||
f.field_type, f.line_thick, f.x_pos, f.y_pos, f.x_end_pos, f.y_end_pos, f.visible);
|
f.field_type, f.line_thick, f.x_pos, f.y_pos, f.x_end_pos, f.y_end_pos, f.visible);
|
||||||
}
|
}
|
||||||
});
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: reset all accounts to default layout (runs once, gated by settings key).
|
||||||
|
// Replaces any .mdb-imported or legacy layout_fields with the clean default layout.
|
||||||
|
if (!db.prepare("SELECT value FROM settings WHERE key = 'layout_reset_v1'").get()) {
|
||||||
|
const accounts = db.prepare('SELECT id FROM account').all();
|
||||||
|
db.transaction(() => {
|
||||||
for (const { id } of accounts) {
|
for (const { id } of accounts) {
|
||||||
if (countStmt.get(id).n === 0) {
|
db.prepare('DELETE FROM layout_fields WHERE account_id = ?').run(id);
|
||||||
seedAccount(id);
|
seedLayoutFields(id);
|
||||||
}
|
}
|
||||||
|
db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('layout_reset_v1', '1')").run();
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: add Logo field to existing accounts that don't have one.
|
||||||
|
(function addLogoField() {
|
||||||
|
const accounts = db.prepare('SELECT id FROM account').all();
|
||||||
|
const insertLogo = db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO layout_fields
|
||||||
|
(account_id, field_name, field_text, font_name, font_size, font_bold,
|
||||||
|
field_type, line_thick, x_pos, y_pos, x_end_pos, y_end_pos, visible)
|
||||||
|
VALUES (?, 'Logo', NULL, 'Helvetica', 10, 0, 'Graph', 1, 0.10, 0.08, 0.45, 0.58, 1)
|
||||||
|
`);
|
||||||
|
for (const { id } of accounts) {
|
||||||
|
const existing = db.prepare("SELECT id FROM layout_fields WHERE account_id = ? AND field_name = 'Logo'").get(id);
|
||||||
|
if (!existing) insertLogo.run(id);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Migration: seed default layout fields for any account that has none (ongoing, idempotent).
|
||||||
|
(function seedMissingLayoutFields() {
|
||||||
|
const accounts = db.prepare('SELECT id FROM account').all();
|
||||||
|
for (const { id } of accounts) {
|
||||||
|
const { n } = db.prepare('SELECT COUNT(*) AS n FROM layout_fields WHERE account_id = ?').get(id);
|
||||||
|
if (n === 0) seedLayoutFields(id);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
module.exports = db;
|
module.exports = db;
|
||||||
|
module.exports.seedLayoutFields = seedLayoutFields;
|
||||||
|
|||||||
+203
-1
@@ -123,7 +123,13 @@ router.get('/me', (req, res) => {
|
|||||||
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.' });
|
||||||
}
|
}
|
||||||
res.json({ id: req.session.userId, username: req.session.username, role: req.session.role });
|
const user = db.prepare('SELECT oidc_sub FROM users WHERE id = ?').get(req.session.userId);
|
||||||
|
res.json({
|
||||||
|
id: req.session.userId,
|
||||||
|
username: req.session.username,
|
||||||
|
role: req.session.role,
|
||||||
|
oidc_linked: !!(user && user.oidc_sub),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/auth/change-password — any logged-in user can change their own password
|
// POST /api/auth/change-password — any logged-in user can change their own password
|
||||||
@@ -200,5 +206,201 @@ router.post('/reset-password', async (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── OIDC helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getOidcSettings() {
|
||||||
|
return {
|
||||||
|
enabled: process.env.OIDC_ENABLED === '1' || process.env.OIDC_ENABLED === 'true',
|
||||||
|
discovery_url: process.env.OIDC_DISCOVERY_URL || '',
|
||||||
|
client_id: process.env.OIDC_CLIENT_ID || '',
|
||||||
|
client_secret: process.env.OIDC_CLIENT_SECRET || '',
|
||||||
|
redirect_uri: process.env.OIDC_REDIRECT_URI || '',
|
||||||
|
button_label: process.env.OIDC_BUTTON_LABEL || 'Sign in with SSO',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOidcClient(settings) {
|
||||||
|
const { Issuer } = require('openid-client');
|
||||||
|
console.log('[oidc] discovering issuer from:', settings.discovery_url);
|
||||||
|
const issuer = await Issuer.discover(settings.discovery_url);
|
||||||
|
console.log('[oidc] discovered issuer:', issuer.issuer);
|
||||||
|
const client = new issuer.Client({
|
||||||
|
client_id: settings.client_id,
|
||||||
|
client_secret: settings.client_secret,
|
||||||
|
redirect_uris: [settings.redirect_uri],
|
||||||
|
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
|
||||||
|
router.get('/oidc/config', (req, res) => {
|
||||||
|
const s = getOidcSettings();
|
||||||
|
res.json({ enabled: s.enabled, button_label: s.button_label });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/auth/oidc/authorize — initiates the OIDC flow (redirect to provider)
|
||||||
|
router.get('/oidc/authorize', async (req, res) => {
|
||||||
|
try {
|
||||||
|
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.' });
|
||||||
|
|
||||||
|
const { generators } = require('openid-client');
|
||||||
|
const client = await getOidcClient(settings);
|
||||||
|
|
||||||
|
const code_verifier = generators.codeVerifier();
|
||||||
|
const code_challenge = generators.codeChallenge(code_verifier);
|
||||||
|
const state = generators.state();
|
||||||
|
const nonce = generators.nonce();
|
||||||
|
|
||||||
|
req.session.oidc = { code_verifier, state, nonce };
|
||||||
|
|
||||||
|
const authUrl = client.authorizationUrl({
|
||||||
|
scope: 'openid email profile',
|
||||||
|
state,
|
||||||
|
nonce,
|
||||||
|
code_challenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[oidc] authorize: redirecting to:', authUrl.substring(0, 200) + '...');
|
||||||
|
// Ensure session is persisted before redirecting (saveUninitialized is false)
|
||||||
|
req.session.save(() => res.redirect(authUrl));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[oidc] authorize error:', err.message, err.stack);
|
||||||
|
res.redirect('/#oidc-error=' + encodeURIComponent('Failed to initiate SSO login.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/auth/oidc/callback — handles the provider redirect
|
||||||
|
router.get('/oidc/callback', async (req, res) => {
|
||||||
|
try {
|
||||||
|
console.log('[oidc] callback: query params:', JSON.stringify(req.query));
|
||||||
|
const settings = getOidcSettings();
|
||||||
|
if (!settings.enabled) return res.redirect('/#oidc-error=' + encodeURIComponent('OIDC is not enabled.'));
|
||||||
|
|
||||||
|
const oidcSession = req.session.oidc;
|
||||||
|
if (!oidcSession) {
|
||||||
|
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.'));
|
||||||
|
}
|
||||||
|
console.log('[oidc] callback: session has oidc data, linking=%s, linkUserId=%s',
|
||||||
|
!!oidcSession.linking, oidcSession.linkUserId || 'n/a');
|
||||||
|
|
||||||
|
const client = await getOidcClient(settings);
|
||||||
|
const params = client.callbackParams(req);
|
||||||
|
console.log('[oidc] callback: exchanging code for tokens...');
|
||||||
|
|
||||||
|
const tokenSet = await client.callback(settings.redirect_uri, params, {
|
||||||
|
code_verifier: oidcSession.code_verifier,
|
||||||
|
state: oidcSession.state,
|
||||||
|
nonce: oidcSession.nonce,
|
||||||
|
});
|
||||||
|
|
||||||
|
const claims = tokenSet.claims();
|
||||||
|
const sub = claims.sub;
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Self-service linking flow
|
||||||
|
if (oidcSession.linking && oidcSession.linkUserId) {
|
||||||
|
console.log('[oidc] callback: linking flow for userId=%s', oidcSession.linkUserId);
|
||||||
|
const existing = db.prepare(
|
||||||
|
'SELECT id FROM users WHERE oidc_issuer = ? AND oidc_sub = ? AND id != ?'
|
||||||
|
).get(issuer, sub, oidcSession.linkUserId);
|
||||||
|
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.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare("UPDATE users SET oidc_sub = ?, oidc_issuer = ?, updated_at = datetime('now') WHERE id = ?")
|
||||||
|
.run(sub, issuer, oidcSession.linkUserId);
|
||||||
|
console.log('[oidc] callback: linked sub=%s to userId=%s', sub, oidcSession.linkUserId);
|
||||||
|
return res.redirect('/#oidc-linked');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login flow — look up user by OIDC identity
|
||||||
|
const user = db.prepare(
|
||||||
|
'SELECT id, username, role FROM users WHERE oidc_issuer = ? AND oidc_sub = ?'
|
||||||
|
).get(issuer, sub);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.warn('[oidc] callback: no user found for iss=%s sub=%s — not linked', issuer, sub);
|
||||||
|
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.'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[oidc] callback: login success, userId=%s, username=%s, role=%s', user.id, user.username, user.role);
|
||||||
|
req.session.userId = user.id;
|
||||||
|
req.session.username = user.username;
|
||||||
|
req.session.role = user.role;
|
||||||
|
|
||||||
|
// Load account access into session (mirrors login behavior)
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
const accts = db.prepare('SELECT account_id, role FROM user_accounts WHERE user_id = ?').all(user.id);
|
||||||
|
req.session.accounts = accts;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.redirect('/');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[oidc] callback error:', err.message, err.stack);
|
||||||
|
res.redirect('/#oidc-error=' + encodeURIComponent('SSO login failed. Please try again.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/auth/oidc/link — logged-in user initiates linking flow
|
||||||
|
router.get('/oidc/link', async (req, res) => {
|
||||||
|
if (!req.session || !req.session.userId) {
|
||||||
|
return res.redirect('/#oidc-error=' + encodeURIComponent('You must be signed in to link your account.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[oidc] link: userId=%s initiating linking flow', req.session.userId);
|
||||||
|
const settings = getOidcSettings();
|
||||||
|
if (!settings.enabled) return res.redirect('/#oidc-error=' + encodeURIComponent('OIDC is not enabled.'));
|
||||||
|
|
||||||
|
const { generators } = require('openid-client');
|
||||||
|
const client = await getOidcClient(settings);
|
||||||
|
|
||||||
|
const code_verifier = generators.codeVerifier();
|
||||||
|
const code_challenge = generators.codeChallenge(code_verifier);
|
||||||
|
const state = generators.state();
|
||||||
|
const nonce = generators.nonce();
|
||||||
|
|
||||||
|
req.session.oidc = { code_verifier, state, nonce, linking: true, linkUserId: req.session.userId };
|
||||||
|
|
||||||
|
const authUrl = client.authorizationUrl({
|
||||||
|
scope: 'openid email profile',
|
||||||
|
state,
|
||||||
|
nonce,
|
||||||
|
code_challenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[oidc] link: redirecting to provider');
|
||||||
|
req.session.save(() => res.redirect(authUrl));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[oidc] link error:', err.message, err.stack);
|
||||||
|
res.redirect('/#oidc-error=' + encodeURIComponent('Failed to initiate SSO linking.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/oidc/unlink — logged-in user removes their own OIDC link
|
||||||
|
router.post('/oidc/unlink', (req, res) => {
|
||||||
|
if (!req.session || !req.session.userId) {
|
||||||
|
return res.status(401).json({ error: 'Not authenticated.' });
|
||||||
|
}
|
||||||
|
db.prepare("UPDATE users SET oidc_sub = NULL, oidc_issuer = NULL, updated_at = datetime('now') WHERE id = ?")
|
||||||
|
.run(req.session.userId);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
module.exports.validatePassword = validatePassword;
|
module.exports.validatePassword = validatePassword;
|
||||||
|
|||||||
@@ -70,4 +70,55 @@ 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' });
|
||||||
|
|
||||||
|
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;
|
||||||
|
|||||||
+20
-3
@@ -11,7 +11,7 @@ const { validatePassword } = require('./auth');
|
|||||||
router.use(requireAuth, requireAdmin);
|
router.use(requireAuth, requireAdmin);
|
||||||
|
|
||||||
function userWithAccounts(id) {
|
function userWithAccounts(id) {
|
||||||
const user = db.prepare('SELECT id, username, email, role, created_at FROM users WHERE id = ?').get(id);
|
const user = db.prepare('SELECT id, username, email, role, oidc_sub, oidc_issuer, created_at FROM users WHERE id = ?').get(id);
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
user.accounts = db.prepare('SELECT account_id, role FROM user_accounts WHERE user_id = ?').all(id);
|
user.accounts = db.prepare('SELECT account_id, role FROM user_accounts WHERE user_id = ?').all(id);
|
||||||
return user;
|
return user;
|
||||||
@@ -19,7 +19,7 @@ function userWithAccounts(id) {
|
|||||||
|
|
||||||
// GET /api/users
|
// GET /api/users
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
const users = db.prepare('SELECT id, username, email, role, created_at FROM users ORDER BY id ASC').all();
|
const users = db.prepare('SELECT id, username, email, role, oidc_sub, oidc_issuer, created_at FROM users ORDER BY id ASC').all();
|
||||||
users.forEach(u => {
|
users.forEach(u => {
|
||||||
u.accounts = db.prepare('SELECT account_id, role FROM user_accounts WHERE user_id = ?').all(u.id);
|
u.accounts = db.prepare('SELECT account_id, role FROM user_accounts WHERE user_id = ?').all(u.id);
|
||||||
});
|
});
|
||||||
@@ -60,7 +60,7 @@ router.put('/:id', async (req, res) => {
|
|||||||
const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(req.params.id);
|
const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(req.params.id);
|
||||||
if (!user) return res.status(404).json({ error: 'User not found.' });
|
if (!user) return res.status(404).json({ error: 'User not found.' });
|
||||||
|
|
||||||
const { username, password, role, accounts, email } = req.body;
|
const { username, password, role, accounts, email, oidc_sub, oidc_issuer } = req.body;
|
||||||
|
|
||||||
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.' });
|
||||||
@@ -94,6 +94,23 @@ router.put('/:id', async (req, res) => {
|
|||||||
.run(hash, req.params.id);
|
.run(hash, req.params.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OIDC linking — admin can set or clear oidc_sub/oidc_issuer
|
||||||
|
if (oidc_sub !== undefined) {
|
||||||
|
const newSub = oidc_sub ? oidc_sub.trim() : null;
|
||||||
|
const newIssuer = oidc_issuer ? oidc_issuer.trim() : null;
|
||||||
|
if (newSub && !newIssuer) {
|
||||||
|
return res.status(400).json({ error: 'OIDC issuer is required when setting OIDC subject.' });
|
||||||
|
}
|
||||||
|
if (newSub) {
|
||||||
|
const existing = db.prepare(
|
||||||
|
'SELECT id FROM users WHERE oidc_issuer = ? AND oidc_sub = ? AND id != ?'
|
||||||
|
).get(newIssuer, newSub, req.params.id);
|
||||||
|
if (existing) return res.status(409).json({ error: 'This OIDC identity is already linked to another user.' });
|
||||||
|
}
|
||||||
|
db.prepare("UPDATE users SET oidc_sub = ?, oidc_issuer = ?, updated_at = datetime('now') WHERE id = ?")
|
||||||
|
.run(newSub, newSub ? newIssuer : null, req.params.id);
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(accounts)) {
|
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(req.params.id);
|
||||||
const effectiveRole = role || user.role;
|
const effectiveRole = role || user.role;
|
||||||
|
|||||||
@@ -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,26 +378,33 @@ 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
drawAmountRow(checksTotal, totalRows);
|
// "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);
|
||||||
|
|
||||||
// ── Rotated left strip elements ─────────────────────────────────────────
|
// ── Rotated left strip elements ─────────────────────────────────────────
|
||||||
// All elements use rotate(90): text flows downward on the page, which reads
|
// All elements use rotate(90): text flows downward on the page, which reads
|
||||||
@@ -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 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -131,14 +131,22 @@ function generateCheckPdf(account, checks, fields) {
|
|||||||
const offX = (account.offset_right - account.offset_left);
|
const offX = (account.offset_right - account.offset_left);
|
||||||
const offY = (account.offset_down - account.offset_up);
|
const offY = (account.offset_down - account.offset_up);
|
||||||
|
|
||||||
// Render checks in pages of 3; add a new page for each additional group
|
// Determine slot assignment based on check_position setting
|
||||||
const pages = Math.ceil(checks.length / 3);
|
const position = account.check_position || '3-per-page';
|
||||||
|
const SLOT_MAP = { top: 0, middle: 1, bottom: 2 };
|
||||||
|
const fixedSlot = SLOT_MAP[position]; // undefined for '3-per-page'
|
||||||
|
const checksPerPage = fixedSlot !== undefined ? 1 : 3;
|
||||||
|
|
||||||
|
const pages = Math.ceil(checks.length / checksPerPage);
|
||||||
for (let page = 0; page < pages; page++) {
|
for (let page = 0; page < pages; page++) {
|
||||||
if (page > 0) doc.addPage();
|
if (page > 0) doc.addPage();
|
||||||
|
|
||||||
for (let slot = 0; slot < 3; slot++) {
|
for (let slot = 0; slot < 3; slot++) {
|
||||||
const check = checks[page * 3 + slot] || null;
|
// For fixed-slot mode, only render in the designated slot
|
||||||
const slotOriginY = slot * SLOT_HEIGHT_IN + (slot === 0 ? -0.25 : 0);
|
if (fixedSlot !== undefined && slot !== fixedSlot) continue;
|
||||||
|
const checkIndex = fixedSlot !== undefined ? page : page * 3 + slot;
|
||||||
|
const check = checks[checkIndex] || null;
|
||||||
|
const slotOriginY = slot * SLOT_HEIGHT_IN;
|
||||||
|
|
||||||
// Helper: convert inches (relative to slot) to PDF points (absolute page)
|
// Helper: convert inches (relative to slot) to PDF points (absolute page)
|
||||||
const pt = (xIn, yIn) => ({
|
const pt = (xIn, yIn) => ({
|
||||||
@@ -227,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