Put the app behind a login
The server had no authentication: every /api route was open, CORS allowed any origin, and the identity written to the audit log came from the request body — the UI sent a hardcoded pf_user: 'admin', which any client could have set to anything it liked. Accounts live in pf.app_user with scrypt hashes from node's own crypto, so there is no native build step and the parameters travel with each hash. Sessions are express-session over connect-pg-simple in pf.session: a restart no longer signs everyone out, and a session can be revoked by deleting its row, which is how disable-user cuts off access immediately rather than at cookie expiry. Everything under /api except login/logout/me now requires a session, and the React app is mounted only once there is one — its load effects call the API on mount, so a logged-out mount would just fire a burst of 401s. A session that expires while the app is open lands back on the login screen: auth.jsx wraps fetch once rather than teaching every call site to check. Identity is now read from the session for pf_user, created_by and closed_by, and the body values are ignored. Hardened for an internet-facing deployment: trust proxy so req.ip and secure-cookie detection are right behind TLS termination, httpOnly + SameSite=Lax + Secure cookies, ten login failures per IP per fifteen minutes, one error message for unknown, wrong and disabled alike, and a fresh session id on success. CORS is off entirely unless CORS_ORIGIN names an origin — a wildcard alongside a session cookie would be CSRF by construction. The server refuses to boot without SESSION_SECRET rather than falling back to a guessable default. pf.sh grows add-user, passwd, list-users, disable-user and enable-user; passwords are read on stdin and hashed before they reach psql, so no plaintext in argv or shell history. install.sh generates the secret, applies 02_auth.sql, and creates the first account. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
146961cc17
commit
c2e6fc8e77
15
.env.example
15
.env.example
@ -4,3 +4,18 @@ DB_NAME=your_database
|
|||||||
DB_USER=your_user
|
DB_USER=your_user
|
||||||
DB_PASSWORD=your_password
|
DB_PASSWORD=your_password
|
||||||
PORT=3010
|
PORT=3010
|
||||||
|
|
||||||
|
# Signs the session cookie. Generate with:
|
||||||
|
# node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))'
|
||||||
|
# or let ./pf.sh config do it. Changing it signs everyone out.
|
||||||
|
SESSION_SECRET=
|
||||||
|
|
||||||
|
# Send the session cookie over HTTPS only. Keep true behind a TLS proxy;
|
||||||
|
# set false only to reach the app over plain HTTP on a trusted network.
|
||||||
|
COOKIE_SECURE=true
|
||||||
|
|
||||||
|
# Reverse proxy hops express should trust for req.ip / protocol. Default 1.
|
||||||
|
#TRUST_PROXY=1
|
||||||
|
|
||||||
|
# Only needed if the UI is served from a different origin than the API.
|
||||||
|
#CORS_ORIGIN=https://forecast.example.com
|
||||||
|
|||||||
36
CLAUDE.md
36
CLAUDE.md
@ -22,8 +22,9 @@ Data transport architecture options: `pf_perspective_options.md`
|
|||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
```
|
```
|
||||||
server.js Express entry point; pg pool; type parsers for bigint/numeric
|
server.js Express entry point; pg pool; session; type parsers for bigint/numeric
|
||||||
routes/
|
routes/
|
||||||
|
auth.js POST /api/login, /api/logout, GET /api/me; login throttle
|
||||||
tables.js GET /api/tables, /api/tables/:schema/:tname/preview
|
tables.js GET /api/tables, /api/tables/:schema/:tname/preview
|
||||||
sources.js Source registration, col_meta, SQL generation
|
sources.js Source registration, col_meta, SQL generation
|
||||||
versions.js Version CRUD, baseline/reference load, data stream
|
versions.js Version CRUD, baseline/reference load, data stream
|
||||||
@ -31,11 +32,15 @@ routes/
|
|||||||
log.js GET /api/versions/:id/log, DELETE /api/log/:logid
|
log.js GET /api/versions/:id/log, DELETE /api/log/:logid
|
||||||
lib/
|
lib/
|
||||||
sql_generator.js buildFilterClause, token substitution helpers
|
sql_generator.js buildFilterClause, token substitution helpers
|
||||||
|
auth.js scrypt hash/verify, requireAuth, sessionUser; `node lib/auth.js hash` CLI
|
||||||
utils.js
|
utils.js
|
||||||
setup_sql/
|
setup_sql/
|
||||||
01_schema.sql pf schema DDL — run once to install
|
01_schema.sql pf schema DDL — run once to install
|
||||||
|
02_auth.sql pf.app_user + pf.session
|
||||||
ui/src/
|
ui/src/
|
||||||
|
auth.jsx AuthProvider/useAuth; wraps fetch so any 401 returns to login
|
||||||
views/
|
views/
|
||||||
|
Login.jsx Sign-in form
|
||||||
Setup.jsx DB browser, source registration, col_meta editor
|
Setup.jsx DB browser, source registration, col_meta editor
|
||||||
Baseline.jsx Version management, baseline workbench, reference load
|
Baseline.jsx Version management, baseline workbench, reference load
|
||||||
Forecast.jsx Perspective pivot, selection handling, operation dispatch
|
Forecast.jsx Perspective pivot, selection handling, operation dispatch
|
||||||
@ -57,6 +62,8 @@ ui/src/
|
|||||||
- **`pf.fc_{tname}_{version_id}`** — one forecast table per version; contains both operational rows (`pf_iter = baseline|scale|recode|clone`) and reference rows (`pf_iter = reference`)
|
- **`pf.fc_{tname}_{version_id}`** — one forecast table per version; contains both operational rows (`pf_iter = baseline|scale|recode|clone`) and reference rows (`pf_iter = reference`)
|
||||||
- **`pf.log`** — audit log; every write gets one entry; `slice` + `params` stored as jsonb
|
- **`pf.log`** — audit log; every write gets one entry; `slice` + `params` stored as jsonb
|
||||||
- **`pf.sql`** — generated SQL templates per source/operation; tokens substituted at request time
|
- **`pf.sql`** — generated SQL templates per source/operation; tokens substituted at request time
|
||||||
|
- **`pf.app_user`** — login accounts; scrypt `pass_hash`, `is_active`, `last_login_at`
|
||||||
|
- **`pf.session`** — express-session store (connect-pg-simple layout)
|
||||||
- **`pf.dim_period`** — calendar lookup table (2018–2035); one row per month keyed on `sdat` (month start date); provides cal/fiscal year, quarter, and month columns; populated by `setup_sql/gen_dim_period.sql` with a configurable fiscal year start month
|
- **`pf.dim_period`** — calendar lookup table (2018–2035); one row per month keyed on `sdat` (month start date); provides cal/fiscal year, quarter, and month columns; populated by `setup_sql/gen_dim_period.sql` with a configurable fiscal year start month
|
||||||
|
|
||||||
### Key token substitution tokens
|
### Key token substitution tokens
|
||||||
@ -144,6 +151,33 @@ All three operations follow the same structure: insert a `pf.log` row in a CTE,
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Everything under `/api` except the auth routes sits behind a session; the React
|
||||||
|
app is only mounted once there is one (`Gate` in `main.jsx`), because its load
|
||||||
|
effects call the API immediately.
|
||||||
|
|
||||||
|
- **Accounts:** `pf.app_user` — scrypt hashes from `lib/auth.js`, never plaintext.
|
||||||
|
Managed with `./pf.sh add-user | passwd | list-users | disable-user | enable-user`;
|
||||||
|
the password is read on stdin and hashed before it reaches psql.
|
||||||
|
- **Sessions:** `express-session` + `connect-pg-simple` in `pf.session`, so a
|
||||||
|
restart doesn't sign everyone out and a session can be revoked by deleting its
|
||||||
|
row (`disable-user` does exactly that). Cookie `pf.sid`: httpOnly, SameSite=Lax,
|
||||||
|
Secure unless `COOKIE_SECURE=false`, 12h rolling.
|
||||||
|
- **Config:** `SESSION_SECRET` is required — the server exits at boot without one.
|
||||||
|
`TRUST_PROXY` (default 1) makes `req.ip` and secure-cookie detection correct
|
||||||
|
behind the TLS proxy. `CORS_ORIGIN` is the only way CORS is enabled at all; a
|
||||||
|
wildcard origin plus a session cookie would be cross-site request forgery by
|
||||||
|
construction.
|
||||||
|
- **Login hardening:** `routes/auth.js` throttles to 10 failures per IP per 15
|
||||||
|
minutes (in-memory), returns one message for unknown/wrong/disabled alike, and
|
||||||
|
regenerates the session id on success.
|
||||||
|
|
||||||
|
**Identity is server-side.** `pf_user`, `created_by` and `closed_by` come from
|
||||||
|
`sessionUser(req)`, never from the request body — the UI used to send a hardcoded
|
||||||
|
`pf_user: 'admin'`, which any client could have set to anything. The audit log
|
||||||
|
now names the account that made the change.
|
||||||
|
|
||||||
## Light / dark mode
|
## Light / dark mode
|
||||||
|
|
||||||
Theme state lives in `ui/src/theme.jsx` — a React context (`ThemeContext`) with a `ThemeProvider` that wraps the app in `main.jsx`.
|
Theme state lives in `ui/src/theme.jsx` — a React context (`ThemeContext`) with a `ThemeProvider` that wraps the app in `main.jsx`.
|
||||||
|
|||||||
21
install.sh
21
install.sh
@ -26,6 +26,12 @@ echo ""
|
|||||||
read -p "App port [3030]: " PORT
|
read -p "App port [3030]: " PORT
|
||||||
PORT=${PORT:-3030}
|
PORT=${PORT:-3030}
|
||||||
|
|
||||||
|
# Session cookies are signed with this; the server refuses to start without it.
|
||||||
|
SESSION_SECRET=$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')
|
||||||
|
|
||||||
|
read -p "Send session cookie over HTTPS only? [Y/n]: " SECURE_ANS
|
||||||
|
case "${SECURE_ANS:-y}" in [Nn]*) COOKIE_SECURE=false ;; *) COOKIE_SECURE=true ;; esac
|
||||||
|
|
||||||
# ── Write .env ────────────────────────────────────────────────
|
# ── Write .env ────────────────────────────────────────────────
|
||||||
cat > .env <<EOF
|
cat > .env <<EOF
|
||||||
DB_HOST=${DB_HOST}
|
DB_HOST=${DB_HOST}
|
||||||
@ -34,7 +40,10 @@ DB_NAME=${DB_NAME}
|
|||||||
DB_USER=${DB_USER}
|
DB_USER=${DB_USER}
|
||||||
DB_PASSWORD=${DB_PASSWORD}
|
DB_PASSWORD=${DB_PASSWORD}
|
||||||
PORT=${PORT}
|
PORT=${PORT}
|
||||||
|
SESSION_SECRET=${SESSION_SECRET}
|
||||||
|
COOKIE_SECURE=${COOKIE_SECURE}
|
||||||
EOF
|
EOF
|
||||||
|
chmod 600 .env
|
||||||
echo "✓ .env written"
|
echo "✓ .env written"
|
||||||
|
|
||||||
# ── npm install ───────────────────────────────────────────────
|
# ── npm install ───────────────────────────────────────────────
|
||||||
@ -51,15 +60,23 @@ PGPASSWORD=${DB_PASSWORD} psql \
|
|||||||
-p "${DB_PORT}" \
|
-p "${DB_PORT}" \
|
||||||
-U "${DB_USER}" \
|
-U "${DB_USER}" \
|
||||||
-d "${DB_NAME}" \
|
-d "${DB_NAME}" \
|
||||||
-f setup_sql/01_schema.sql
|
-v ON_ERROR_STOP=1 \
|
||||||
|
-f setup_sql/01_schema.sql \
|
||||||
|
-f setup_sql/02_auth.sql
|
||||||
|
|
||||||
echo "✓ schema installed"
|
echo "✓ schema installed"
|
||||||
|
|
||||||
|
# ── first account ─────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "The app is behind a login. Create the first account now:"
|
||||||
|
./pf.sh add-user
|
||||||
|
|
||||||
# ── done ─────────────────────────────────────────────────────
|
# ── done ─────────────────────────────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
echo " Install complete"
|
echo " Install complete"
|
||||||
echo " Start with: npm run dev"
|
echo " Start with: npm run dev
|
||||||
|
More accounts: ./pf.sh add-user"
|
||||||
echo " Open: http://$(hostname -I | awk '{print $1}'):${PORT}"
|
echo " Open: http://$(hostname -I | awk '{print $1}'):${PORT}"
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
70
lib/auth.js
Normal file
70
lib/auth.js
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
// Password hashing and the route guard.
|
||||||
|
//
|
||||||
|
// Hashes are scrypt, from node's own crypto — no native build step, and the
|
||||||
|
// stored form carries its own parameters so they can be raised later without
|
||||||
|
// invalidating existing rows:
|
||||||
|
//
|
||||||
|
// scrypt$<N>$<r>$<p>$<salt base64>$<derived key base64>
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const SCRYPT = { N: 16384, r: 8, p: 1, keylen: 64 };
|
||||||
|
|
||||||
|
function hashPassword(password, params = SCRYPT) {
|
||||||
|
const { N, r, p, keylen } = params;
|
||||||
|
const salt = crypto.randomBytes(16);
|
||||||
|
const dk = crypto.scryptSync(password, salt, keylen, { N, r, p, maxmem: 256 * 1024 * 1024 });
|
||||||
|
return `scrypt$${N}$${r}$${p}$${salt.toString('base64')}$${dk.toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constant-time compare. Returns false rather than throwing on a malformed or
|
||||||
|
// legacy hash, so one bad row can't 500 the login route.
|
||||||
|
function verifyPassword(password, stored) {
|
||||||
|
if (typeof stored !== 'string') return false;
|
||||||
|
const parts = stored.split('$');
|
||||||
|
if (parts.length !== 6 || parts[0] !== 'scrypt') return false;
|
||||||
|
|
||||||
|
const [, N, r, p, saltB64, dkB64] = parts;
|
||||||
|
try {
|
||||||
|
const salt = Buffer.from(saltB64, 'base64');
|
||||||
|
const expected = Buffer.from(dkB64, 'base64');
|
||||||
|
const actual = crypto.scryptSync(password, salt, expected.length, {
|
||||||
|
N: Number(N), r: Number(r), p: Number(p), maxmem: 256 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
return crypto.timingSafeEqual(actual, expected);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every /api route except the auth ones sits behind this.
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
if (req.session?.user?.username) return next();
|
||||||
|
res.status(401).json({ error: 'Not authenticated' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The identity used for pf.log.pf_user and the created_by/closed_by columns.
|
||||||
|
// Read from the session only — never from the request body, which the browser
|
||||||
|
// controls and which used to carry a hardcoded 'admin'.
|
||||||
|
function sessionUser(req) {
|
||||||
|
return req.session?.user?.username || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { hashPassword, verifyPassword, requireAuth, sessionUser, SCRYPT };
|
||||||
|
|
||||||
|
// CLI: `node lib/auth.js hash` reads a password on stdin and prints its hash,
|
||||||
|
// so ./pf.sh can create users without the plaintext touching argv or psql.
|
||||||
|
if (require.main === module) {
|
||||||
|
if (process.argv[2] !== 'hash') {
|
||||||
|
console.error('usage: node lib/auth.js hash (password on stdin)');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
let input = '';
|
||||||
|
process.stdin.setEncoding('utf8');
|
||||||
|
process.stdin.on('data', chunk => { input += chunk; });
|
||||||
|
process.stdin.on('end', () => {
|
||||||
|
const password = input.replace(/\r?\n$/, '');
|
||||||
|
if (!password) { console.error('empty password'); process.exit(2); }
|
||||||
|
process.stdout.write(hashPassword(password) + '\n');
|
||||||
|
});
|
||||||
|
}
|
||||||
69
package-lock.json
generated
69
package-lock.json
generated
@ -7,11 +7,14 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "pf_app",
|
"name": "pf_app",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"apache-arrow": "^21.1.0",
|
"apache-arrow": "^21.1.0",
|
||||||
|
"connect-pg-simple": "^10.0.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.0.0",
|
"dotenv": "^16.0.0",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"express-session": "^1.19.0",
|
||||||
"pg": "^8.11.3"
|
"pg": "^8.11.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -369,6 +372,18 @@
|
|||||||
"node": ">=12.20.0"
|
"node": ">=12.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/connect-pg-simple": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-pBGVazlqiMrackzCr0eKhn4LO5trJXsOX0nQoey9wCOayh80MYtThCbq8eoLsjpiWgiok/h+1/uti9/2/Una8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg": "^8.12.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=22.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
@ -582,6 +597,29 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-session": {
|
||||||
|
"version": "1.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
|
||||||
|
"integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "~0.7.2",
|
||||||
|
"cookie-signature": "~1.0.7",
|
||||||
|
"debug": "~2.6.9",
|
||||||
|
"depd": "~2.0.0",
|
||||||
|
"on-headers": "~1.1.0",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"safe-buffer": "~5.2.1",
|
||||||
|
"uid-safe": "~2.1.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@ -1085,6 +1123,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/on-headers": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@ -1105,6 +1152,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.12.0",
|
"pg-connection-string": "^2.12.0",
|
||||||
"pg-pool": "^3.13.0",
|
"pg-pool": "^3.13.0",
|
||||||
@ -1276,6 +1324,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/random-bytes": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/range-parser": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
@ -1592,6 +1649,18 @@
|
|||||||
"node": ">=12.17"
|
"node": ">=12.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uid-safe": {
|
||||||
|
"version": "2.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
|
||||||
|
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"random-bytes": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undefsafe": {
|
"node_modules/undefsafe": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||||
|
|||||||
@ -11,9 +11,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"apache-arrow": "^21.1.0",
|
"apache-arrow": "^21.1.0",
|
||||||
|
"connect-pg-simple": "^10.0.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.0.0",
|
"dotenv": "^16.0.0",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"express-session": "^1.19.0",
|
||||||
"pg": "^8.11.3"
|
"pg": "^8.11.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
160
pf.sh
160
pf.sh
@ -4,6 +4,7 @@ set -euo pipefail
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# pf.sh — Pivot Forecast management script
|
# pf.sh — Pivot Forecast management script
|
||||||
# Usage: ./pf.sh [deploy|start|stop|restart|status|logs|db-setup|config]
|
# Usage: ./pf.sh [deploy|start|stop|restart|status|logs|db-setup|config]
|
||||||
|
# ./pf.sh [add-user|passwd|list-users|disable-user|enable-user]
|
||||||
# ./pf.sh (interactive menu)
|
# ./pf.sh (interactive menu)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@ -68,7 +69,7 @@ require_service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# The keys cmd_config manages; anything else in .env is left alone.
|
# The keys cmd_config manages; anything else in .env is left alone.
|
||||||
ENV_KEYS=(DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD PORT)
|
ENV_KEYS=(DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD PORT SESSION_SECRET COOKIE_SECURE)
|
||||||
|
|
||||||
env_get() {
|
env_get() {
|
||||||
[[ -f "$ENV_FILE" ]] || return 0
|
[[ -f "$ENV_FILE" ]] || return 0
|
||||||
@ -192,13 +193,21 @@ cmd_db_setup() {
|
|||||||
command -v psql &>/dev/null || die "psql not found — install postgresql-client"
|
command -v psql &>/dev/null || die "psql not found — install postgresql-client"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
bold "DB Setup — will run: setup_sql/01_schema.sql"
|
bold "DB Setup — will run: setup_sql/01_schema.sql, setup_sql/02_auth.sql"
|
||||||
warn "This creates the pf schema and tables. Safe to re-run (CREATE IF NOT EXISTS)."
|
warn "This creates the pf schema, tables, and the account/session tables."
|
||||||
|
warn "Safe to re-run (CREATE IF NOT EXISTS)."
|
||||||
read -rp " Continue? [y/N] " confirm
|
read -rp " Continue? [y/N] " confirm
|
||||||
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; return; }
|
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; return; }
|
||||||
|
|
||||||
run_psql -v ON_ERROR_STOP=1 -f "${APP_DIR}/setup_sql/01_schema.sql"
|
run_psql -v ON_ERROR_STOP=1 -f "${APP_DIR}/setup_sql/01_schema.sql"
|
||||||
|
run_psql -v ON_ERROR_STOP=1 -f "${APP_DIR}/setup_sql/02_auth.sql"
|
||||||
success "Schema applied."
|
success "Schema applied."
|
||||||
|
|
||||||
|
local n
|
||||||
|
n=$(run_psql -tAc "SELECT count(*) FROM pf.app_user WHERE is_active" 2>/dev/null || echo 0)
|
||||||
|
if [[ "${n:-0}" == "0" ]]; then
|
||||||
|
warn "No active accounts yet — create one with: ./pf.sh add-user"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_config() {
|
cmd_config() {
|
||||||
@ -239,6 +248,22 @@ cmd_config() {
|
|||||||
read -rp " PORT (app) [${cur_app_port:-3010}]: " input
|
read -rp " PORT (app) [${cur_app_port:-3010}]: " input
|
||||||
local app_port="${input:-${cur_app_port:-3010}}"
|
local app_port="${input:-${cur_app_port:-3010}}"
|
||||||
|
|
||||||
|
# Session cookies are signed with this; regenerating it signs everyone out,
|
||||||
|
# so an existing secret is kept rather than re-rolled on every config run.
|
||||||
|
local secret
|
||||||
|
secret=$(env_get SESSION_SECRET)
|
||||||
|
if [[ -z "$secret" ]]; then
|
||||||
|
secret=$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))')
|
||||||
|
success "SESSION_SECRET generated."
|
||||||
|
else
|
||||||
|
success "SESSION_SECRET kept (delete the line in .env to re-roll)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
local cur_secure
|
||||||
|
cur_secure=$(env_get COOKIE_SECURE)
|
||||||
|
read -rp " COOKIE_SECURE — HTTPS-only cookie [${cur_secure:-true}]: " input
|
||||||
|
local cookie_secure="${input:-${cur_secure:-true}}"
|
||||||
|
|
||||||
# Rewrite the managed keys, carrying over any other lines already in .env.
|
# Rewrite the managed keys, carrying over any other lines already in .env.
|
||||||
local tmp
|
local tmp
|
||||||
tmp=$(mktemp)
|
tmp=$(mktemp)
|
||||||
@ -249,6 +274,8 @@ DB_NAME=${name}
|
|||||||
DB_USER=${user}
|
DB_USER=${user}
|
||||||
DB_PASSWORD=${pass}
|
DB_PASSWORD=${pass}
|
||||||
PORT=${app_port}
|
PORT=${app_port}
|
||||||
|
SESSION_SECRET=${secret}
|
||||||
|
COOKIE_SECURE=${cookie_secure}
|
||||||
EOF
|
EOF
|
||||||
if [[ -f "$ENV_FILE" ]]; then
|
if [[ -f "$ENV_FILE" ]]; then
|
||||||
local managed
|
local managed
|
||||||
@ -266,6 +293,116 @@ EOF
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# -- Accounts ----------------------------------------------------------------
|
||||||
|
|
||||||
|
# Reads a password twice without echo and hashes it with lib/auth.js, so the
|
||||||
|
# plaintext never reaches argv, psql, or the shell history.
|
||||||
|
read_new_password() {
|
||||||
|
local p1 p2
|
||||||
|
# Prompts and their newlines go to stderr: stdout is the hash, and a stray
|
||||||
|
# newline there ends up prefixed to it by the caller's $( ).
|
||||||
|
read -rsp " Password: " p1; echo >&2
|
||||||
|
[[ -z "$p1" ]] && { error "Password cannot be empty."; return 1; }
|
||||||
|
read -rsp " Confirm : " p2; echo >&2
|
||||||
|
[[ "$p1" != "$p2" ]] && { error "Passwords do not match."; return 1; }
|
||||||
|
printf '%s' "$p1" | node "${APP_DIR}/lib/auth.js" hash
|
||||||
|
}
|
||||||
|
|
||||||
|
# psql single-quoted literal: double any embedded quote.
|
||||||
|
sql_lit() { printf "%s" "${1//\'/\'\'}"; }
|
||||||
|
|
||||||
|
cmd_add_user() {
|
||||||
|
require_env; load_env
|
||||||
|
check_node >/dev/null
|
||||||
|
|
||||||
|
echo; bold "Add account"
|
||||||
|
local username display hash
|
||||||
|
read -rp " Username: " username
|
||||||
|
[[ -z "$username" ]] && die "Username is required."
|
||||||
|
read -rp " Display name [${username}]: " display
|
||||||
|
display="${display:-$username}"
|
||||||
|
|
||||||
|
hash=$(read_new_password) || return 1
|
||||||
|
|
||||||
|
run_psql -v ON_ERROR_STOP=1 -tAc "
|
||||||
|
WITH ins AS (
|
||||||
|
INSERT INTO pf.app_user (username, pass_hash, display_name)
|
||||||
|
VALUES ('$(sql_lit "$username")', '$(sql_lit "$hash")', '$(sql_lit "$display")')
|
||||||
|
ON CONFLICT (username) DO NOTHING
|
||||||
|
RETURNING id
|
||||||
|
) SELECT id FROM ins" | grep -q . \
|
||||||
|
&& success "Account '${username}' created." \
|
||||||
|
|| die "Account '${username}' already exists — change its password with: ./pf.sh passwd"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_passwd() {
|
||||||
|
require_env; load_env
|
||||||
|
check_node >/dev/null
|
||||||
|
|
||||||
|
echo; bold "Change password"
|
||||||
|
local username hash
|
||||||
|
read -rp " Username: " username
|
||||||
|
[[ -z "$username" ]] && die "Username is required."
|
||||||
|
|
||||||
|
hash=$(read_new_password) || return 1
|
||||||
|
|
||||||
|
run_psql -v ON_ERROR_STOP=1 -tAc "
|
||||||
|
WITH upd AS (
|
||||||
|
UPDATE pf.app_user SET pass_hash = '$(sql_lit "$hash")'
|
||||||
|
WHERE lower(username) = lower('$(sql_lit "$username")')
|
||||||
|
RETURNING id
|
||||||
|
) SELECT id FROM upd" | grep -q . \
|
||||||
|
&& success "Password updated for '${username}'." \
|
||||||
|
|| die "No such account: ${username}"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_list_users() {
|
||||||
|
require_env; load_env
|
||||||
|
echo; bold "Accounts"
|
||||||
|
run_psql -c "
|
||||||
|
SELECT username, display_name, is_active,
|
||||||
|
to_char(last_login_at, 'YYYY-MM-DD HH24:MI') AS last_login
|
||||||
|
FROM pf.app_user ORDER BY username"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Deactivating leaves the row (and its history) in place, and drops any live
|
||||||
|
# session so the account loses access immediately rather than at cookie expiry.
|
||||||
|
cmd_disable_user() {
|
||||||
|
require_env; load_env
|
||||||
|
local username="${1:-}"
|
||||||
|
[[ -z "$username" ]] && { read -rp " Username to disable: " username; }
|
||||||
|
[[ -z "$username" ]] && die "Username is required."
|
||||||
|
|
||||||
|
run_psql -v ON_ERROR_STOP=1 -tAc "
|
||||||
|
WITH upd AS (
|
||||||
|
UPDATE pf.app_user SET is_active = false
|
||||||
|
WHERE lower(username) = lower('$(sql_lit "$username")')
|
||||||
|
RETURNING id
|
||||||
|
) SELECT id FROM upd" | grep -q . \
|
||||||
|
|| die "No such account: ${username}"
|
||||||
|
|
||||||
|
run_psql -v ON_ERROR_STOP=1 -c "
|
||||||
|
DELETE FROM pf.session
|
||||||
|
WHERE sess::jsonb -> 'user' ->> 'username' ILIKE '$(sql_lit "$username")'" >/dev/null
|
||||||
|
success "Account '${username}' disabled and signed out."
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_enable_user() {
|
||||||
|
require_env; load_env
|
||||||
|
local username="${1:-}"
|
||||||
|
[[ -z "$username" ]] && { read -rp " Username to enable: " username; }
|
||||||
|
[[ -z "$username" ]] && die "Username is required."
|
||||||
|
|
||||||
|
run_psql -v ON_ERROR_STOP=1 -tAc "
|
||||||
|
WITH upd AS (
|
||||||
|
UPDATE pf.app_user SET is_active = true
|
||||||
|
WHERE lower(username) = lower('$(sql_lit "$username")')
|
||||||
|
RETURNING id
|
||||||
|
) SELECT id FROM upd" | grep -q . \
|
||||||
|
&& success "Account '${username}' enabled." \
|
||||||
|
|| die "No such account: ${username}"
|
||||||
|
}
|
||||||
|
|
||||||
cmd_install_service() {
|
cmd_install_service() {
|
||||||
require_systemd
|
require_systemd
|
||||||
require_env
|
require_env
|
||||||
@ -349,6 +486,11 @@ interactive_menu() {
|
|||||||
echo " 8) config set DB connection + app PORT"
|
echo " 8) config set DB connection + app PORT"
|
||||||
echo " 9) install-service create systemd unit file"
|
echo " 9) install-service create systemd unit file"
|
||||||
echo " 10) uninstall-service remove systemd unit file"
|
echo " 10) uninstall-service remove systemd unit file"
|
||||||
|
echo " 11) add-user create a login account"
|
||||||
|
echo " 12) passwd change an account password"
|
||||||
|
echo " 13) list-users show accounts"
|
||||||
|
echo " 14) disable-user deactivate an account and sign it out"
|
||||||
|
echo " 15) enable-user reactivate an account"
|
||||||
echo " q) quit"
|
echo " q) quit"
|
||||||
echo
|
echo
|
||||||
read -rp " Choice: " choice
|
read -rp " Choice: " choice
|
||||||
@ -363,6 +505,11 @@ interactive_menu() {
|
|||||||
8|config) cmd_config ;;
|
8|config) cmd_config ;;
|
||||||
9|install-service) cmd_install_service ;;
|
9|install-service) cmd_install_service ;;
|
||||||
10|uninstall-service) cmd_uninstall_service ;;
|
10|uninstall-service) cmd_uninstall_service ;;
|
||||||
|
11|add-user) cmd_add_user ;;
|
||||||
|
12|passwd) cmd_passwd ;;
|
||||||
|
13|list-users) cmd_list_users ;;
|
||||||
|
14|disable-user) cmd_disable_user ;;
|
||||||
|
15|enable-user) cmd_enable_user ;;
|
||||||
q|Q|quit|exit) echo "Bye."; exit 0 ;;
|
q|Q|quit|exit) echo "Bye."; exit 0 ;;
|
||||||
*) warn "Unknown option: $choice" ;;
|
*) warn "Unknown option: $choice" ;;
|
||||||
esac
|
esac
|
||||||
@ -382,6 +529,11 @@ case "${1:-}" in
|
|||||||
config) cmd_config ;;
|
config) cmd_config ;;
|
||||||
install-service) cmd_install_service ;;
|
install-service) cmd_install_service ;;
|
||||||
uninstall-service) cmd_uninstall_service ;;
|
uninstall-service) cmd_uninstall_service ;;
|
||||||
|
add-user) cmd_add_user ;;
|
||||||
|
passwd) cmd_passwd ;;
|
||||||
|
list-users) cmd_list_users ;;
|
||||||
|
disable-user) cmd_disable_user "${2:-}" ;;
|
||||||
|
enable-user) cmd_enable_user "${2:-}" ;;
|
||||||
"") interactive_menu ;;
|
"") interactive_menu ;;
|
||||||
*) die "Unknown command: $1. Valid: deploy start stop restart status logs db-setup config install-service uninstall-service" ;;
|
*) die "Unknown command: $1. Valid: deploy start stop restart status logs db-setup config install-service uninstall-service add-user passwd list-users disable-user enable-user" ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
107
routes/auth.js
Normal file
107
routes/auth.js
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { verifyPassword } = require('../lib/auth');
|
||||||
|
|
||||||
|
// Per-IP login throttle. In-memory on purpose: it only has to blunt online
|
||||||
|
// guessing, and a counter that resets on restart is the acceptable cost of not
|
||||||
|
// writing a failed-attempt row for every knock on an internet-facing port.
|
||||||
|
const WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
const MAX_ATTEMPTS = 10;
|
||||||
|
const attempts = new Map(); // ip -> { count, resetAt }
|
||||||
|
|
||||||
|
function tooManyAttempts(ip) {
|
||||||
|
const rec = attempts.get(ip);
|
||||||
|
if (!rec || Date.now() > rec.resetAt) return false;
|
||||||
|
return rec.count >= MAX_ATTEMPTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordFailure(ip) {
|
||||||
|
const rec = attempts.get(ip);
|
||||||
|
if (!rec || Date.now() > rec.resetAt) {
|
||||||
|
attempts.set(ip, { count: 1, resetAt: Date.now() + WINDOW_MS });
|
||||||
|
} else {
|
||||||
|
rec.count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the map from growing without bound on a long-lived process.
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [ip, rec] of attempts) if (now > rec.resetAt) attempts.delete(ip);
|
||||||
|
}, WINDOW_MS).unref();
|
||||||
|
|
||||||
|
module.exports = function(pool) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
const ip = req.ip;
|
||||||
|
if (tooManyAttempts(ip)) {
|
||||||
|
return res.status(429).json({ error: 'Too many failed attempts. Try again later.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = String(req.body?.username || '').trim();
|
||||||
|
const password = String(req.body?.password || '');
|
||||||
|
if (!username || !password) {
|
||||||
|
return res.status(400).json({ error: 'Username and password are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT id, username, display_name, pass_hash, is_active
|
||||||
|
FROM pf.app_user WHERE lower(username) = lower($1)`,
|
||||||
|
[username]
|
||||||
|
);
|
||||||
|
const user = result.rows[0];
|
||||||
|
|
||||||
|
// Same message and roughly the same work either way: no unknown
|
||||||
|
// user / wrong password / disabled distinction to enumerate.
|
||||||
|
const ok = user && user.is_active && verifyPassword(password, user.pass_hash);
|
||||||
|
if (!ok) {
|
||||||
|
recordFailure(ip);
|
||||||
|
return res.status(401).json({ error: 'Invalid username or password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// New session id on login — an existing cookie can't be fixated.
|
||||||
|
req.session.regenerate(err => {
|
||||||
|
if (err) {
|
||||||
|
console.error(err);
|
||||||
|
return res.status(500).json({ error: 'Could not start session' });
|
||||||
|
}
|
||||||
|
req.session.user = {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
display_name: user.display_name,
|
||||||
|
};
|
||||||
|
pool.query(`UPDATE pf.app_user SET last_login_at = now() WHERE id = $1`, [user.id])
|
||||||
|
.catch(e => console.error('last_login_at update failed', e));
|
||||||
|
req.session.save(err2 => {
|
||||||
|
if (err2) {
|
||||||
|
console.error(err2);
|
||||||
|
return res.status(500).json({ error: 'Could not start session' });
|
||||||
|
}
|
||||||
|
attempts.delete(ip);
|
||||||
|
res.json({ user: req.session.user });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/logout', (req, res) => {
|
||||||
|
const name = req.session?.cookie && req.app.get('session cookie name');
|
||||||
|
req.session.destroy(err => {
|
||||||
|
if (err) console.error(err);
|
||||||
|
res.clearCookie(name || 'pf.sid');
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The UI calls this on load to decide between the login screen and the app.
|
||||||
|
router.get('/me', (req, res) => {
|
||||||
|
if (!req.session?.user) return res.status(401).json({ error: 'Not authenticated' });
|
||||||
|
res.json({ user: req.session.user });
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { tableFromArrays, tableToIPC } = require('apache-arrow');
|
const { tableFromArrays, tableToIPC } = require('apache-arrow');
|
||||||
const { applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, esc } = require('../lib/sql_generator');
|
const { applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, esc } = require('../lib/sql_generator');
|
||||||
|
const { sessionUser } = require('../lib/auth');
|
||||||
const { fcTable } = require('../lib/utils');
|
const { fcTable } = require('../lib/utils');
|
||||||
|
|
||||||
module.exports = function(pool) {
|
module.exports = function(pool) {
|
||||||
@ -304,7 +305,8 @@ module.exports = function(pool) {
|
|||||||
|
|
||||||
// load baseline rows from source table — additive, no delete
|
// load baseline rows from source table — additive, no delete
|
||||||
router.post('/versions/:id/baseline', async (req, res) => {
|
router.post('/versions/:id/baseline', async (req, res) => {
|
||||||
const { where_clause, date_offset, pf_user, note, filters, raw_where } = req.body;
|
const { where_clause, date_offset, note, filters, raw_where } = req.body;
|
||||||
|
const pf_user = sessionUser(req);
|
||||||
const dateOffset = date_offset || '0 days';
|
const dateOffset = date_offset || '0 days';
|
||||||
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
|
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
|
||||||
try {
|
try {
|
||||||
@ -339,7 +341,8 @@ module.exports = function(pool) {
|
|||||||
router.put('/versions/:id/baseline/:logid', async (req, res) => {
|
router.put('/versions/:id/baseline/:logid', async (req, res) => {
|
||||||
const versionId = parseInt(req.params.id);
|
const versionId = parseInt(req.params.id);
|
||||||
const logid = parseInt(req.params.logid);
|
const logid = parseInt(req.params.logid);
|
||||||
const { where_clause, date_offset, pf_user, note, filters, raw_where } = req.body;
|
const { where_clause, date_offset, note, filters, raw_where } = req.body;
|
||||||
|
const pf_user = sessionUser(req);
|
||||||
const dateOffset = date_offset || '0 days';
|
const dateOffset = date_offset || '0 days';
|
||||||
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
|
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
|
||||||
|
|
||||||
@ -445,7 +448,8 @@ module.exports = function(pool) {
|
|||||||
|
|
||||||
// load reference rows from source table (additive — does not clear prior reference rows)
|
// load reference rows from source table (additive — does not clear prior reference rows)
|
||||||
router.post('/versions/:id/reference', async (req, res) => {
|
router.post('/versions/:id/reference', async (req, res) => {
|
||||||
const { where_clause, date_offset, pf_user, note, filters, raw_where } = req.body;
|
const { where_clause, date_offset, note, filters, raw_where } = req.body;
|
||||||
|
const pf_user = sessionUser(req);
|
||||||
const dateOffset = date_offset || '0 days';
|
const dateOffset = date_offset || '0 days';
|
||||||
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
|
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
|
||||||
try {
|
try {
|
||||||
@ -478,7 +482,8 @@ module.exports = function(pool) {
|
|||||||
// target or by an increment. With several slices selected, apply_mode decides
|
// target or by an increment. With several slices selected, apply_mode decides
|
||||||
// whether they are treated as one pool ('prorate') or independently ('each').
|
// whether they are treated as one pool ('prorate') or independently ('each').
|
||||||
router.post('/versions/:id/scale', async (req, res) => {
|
router.post('/versions/:id/scale', async (req, res) => {
|
||||||
const { pf_user, note, apply_mode } = req.body;
|
const { note, apply_mode } = req.body;
|
||||||
|
const pf_user = sessionUser(req);
|
||||||
const slices = normalizeSlices(req.body);
|
const slices = normalizeSlices(req.body);
|
||||||
if (slices.length === 0) return res.status(400).json({ error: 'slice is required' });
|
if (slices.length === 0) return res.status(400).json({ error: 'slice is required' });
|
||||||
|
|
||||||
@ -565,7 +570,8 @@ module.exports = function(pool) {
|
|||||||
// recode dimension values on one or more slices
|
// recode dimension values on one or more slices
|
||||||
// inserts negative rows to zero out the original, positive rows with new dimension values
|
// inserts negative rows to zero out the original, positive rows with new dimension values
|
||||||
router.post('/versions/:id/recode', async (req, res) => {
|
router.post('/versions/:id/recode', async (req, res) => {
|
||||||
const { pf_user, note, set, apply_mode } = req.body;
|
const { note, set, apply_mode } = req.body;
|
||||||
|
const pf_user = sessionUser(req);
|
||||||
const slices = normalizeSlices(req.body);
|
const slices = normalizeSlices(req.body);
|
||||||
if (slices.length === 0) return res.status(400).json({ error: 'slice is required' });
|
if (slices.length === 0) return res.status(400).json({ error: 'slice is required' });
|
||||||
if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' });
|
if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' });
|
||||||
@ -618,7 +624,8 @@ module.exports = function(pool) {
|
|||||||
// clone one or more slices as new business under new dimension values
|
// clone one or more slices as new business under new dimension values
|
||||||
// does not offset the original slice
|
// does not offset the original slice
|
||||||
router.post('/versions/:id/clone', async (req, res) => {
|
router.post('/versions/:id/clone', async (req, res) => {
|
||||||
const { pf_user, note, set, scale, apply_mode } = req.body;
|
const { note, set, scale, apply_mode } = req.body;
|
||||||
|
const pf_user = sessionUser(req);
|
||||||
const slices = normalizeSlices(req.body);
|
const slices = normalizeSlices(req.body);
|
||||||
if (slices.length === 0) return res.status(400).json({ error: 'slice is required' });
|
if (slices.length === 0) return res.status(400).json({ error: 'slice is required' });
|
||||||
if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' });
|
if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' });
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { generateSQL } = require('../lib/sql_generator');
|
const { generateSQL } = require('../lib/sql_generator');
|
||||||
|
const { sessionUser } = require('../lib/auth');
|
||||||
|
|
||||||
module.exports = function(pool) {
|
module.exports = function(pool) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@ -20,7 +21,8 @@ module.exports = function(pool) {
|
|||||||
// register a source table
|
// register a source table
|
||||||
// auto-populates col_meta from information_schema with role='ignore'
|
// auto-populates col_meta from information_schema with role='ignore'
|
||||||
router.post('/sources', async (req, res) => {
|
router.post('/sources', async (req, res) => {
|
||||||
const { schema, tname, label, created_by } = req.body;
|
const { schema, tname, label } = req.body;
|
||||||
|
const created_by = sessionUser(req);
|
||||||
if (!schema || !tname) {
|
if (!schema || !tname) {
|
||||||
return res.status(400).json({ error: 'schema and tname are required' });
|
return res.status(400).json({ error: 'schema and tname are required' });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { fcTable, mapType } = require('../lib/utils');
|
const { fcTable, mapType } = require('../lib/utils');
|
||||||
|
const { sessionUser } = require('../lib/auth');
|
||||||
|
|
||||||
module.exports = function(pool) {
|
module.exports = function(pool) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@ -22,7 +23,8 @@ module.exports = function(pool) {
|
|||||||
// inserts version row, then CREATE TABLE pf.fc_{tname}_{version_id} in one transaction
|
// inserts version row, then CREATE TABLE pf.fc_{tname}_{version_id} in one transaction
|
||||||
router.post('/sources/:id/versions', async (req, res) => {
|
router.post('/sources/:id/versions', async (req, res) => {
|
||||||
const sourceId = parseInt(req.params.id);
|
const sourceId = parseInt(req.params.id);
|
||||||
const { name, description, created_by, exclude_iters } = req.body;
|
const { name, description, exclude_iters } = req.body;
|
||||||
|
const created_by = sessionUser(req);
|
||||||
if (!name) return res.status(400).json({ error: 'name is required' });
|
if (!name) return res.status(400).json({ error: 'name is required' });
|
||||||
|
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
@ -262,7 +264,7 @@ ${colDefs},
|
|||||||
|
|
||||||
// close a version — blocks further edits
|
// close a version — blocks further edits
|
||||||
router.post('/versions/:id/close', async (req, res) => {
|
router.post('/versions/:id/close', async (req, res) => {
|
||||||
const { pf_user } = req.body;
|
const pf_user = sessionUser(req);
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
UPDATE pf.version
|
UPDATE pf.version
|
||||||
|
|||||||
51
server.js
51
server.js
@ -1,7 +1,10 @@
|
|||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
|
const session = require('express-session');
|
||||||
|
const PgSession = require('connect-pg-simple')(session);
|
||||||
const { Pool, types } = require('pg');
|
const { Pool, types } = require('pg');
|
||||||
|
const { requireAuth } = require('./lib/auth');
|
||||||
|
|
||||||
// Return bigint (oid 20) and numeric (oid 1700) as JS numbers instead of strings,
|
// Return bigint (oid 20) and numeric (oid 1700) as JS numbers instead of strings,
|
||||||
// so apache-arrow's tableFromJSON infers Int/Float64 rather than Dictionary<Utf8>.
|
// so apache-arrow's tableFromJSON infers Int/Float64 rather than Dictionary<Utf8>.
|
||||||
@ -9,7 +12,15 @@ types.setTypeParser(20, v => v === null ? null : Number(v));
|
|||||||
types.setTypeParser(1700, v => v === null ? null : Number(v));
|
types.setTypeParser(1700, v => v === null ? null : Number(v));
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
|
||||||
|
// Sessions ride on a cookie, so a wildcard CORS origin would let any site make
|
||||||
|
// credentialed calls on behalf of a logged-in user. The UI is served from this
|
||||||
|
// same origin and needs no CORS at all; set CORS_ORIGIN only for a separate
|
||||||
|
// front-end host, and it is then allowed by name, never by wildcard.
|
||||||
|
if (process.env.CORS_ORIGIN) {
|
||||||
|
app.use(cors({ origin: process.env.CORS_ORIGIN.split(',').map(o => o.trim()), credentials: true }));
|
||||||
|
}
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.static('public/app'));
|
app.use(express.static('public/app'));
|
||||||
|
|
||||||
@ -26,6 +37,44 @@ pool.on('error', (err) => {
|
|||||||
console.error('pg pool error', err);
|
console.error('pg pool error', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Authentication ────────────────────────────────────────────
|
||||||
|
// Refuse to boot without a secret rather than fall back to a default one:
|
||||||
|
// a predictable secret means forgeable session cookies.
|
||||||
|
const sessionSecret = process.env.SESSION_SECRET;
|
||||||
|
if (!sessionSecret) {
|
||||||
|
console.error('SESSION_SECRET is not set. Run: ./pf.sh config');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLS terminates at the reverse proxy, so express has to trust its headers for
|
||||||
|
// req.ip (the login throttle) and for secure-cookie detection to be right.
|
||||||
|
app.set('trust proxy', process.env.TRUST_PROXY || 1);
|
||||||
|
|
||||||
|
const cookieSecure = process.env.COOKIE_SECURE !== 'false';
|
||||||
|
if (!cookieSecure) {
|
||||||
|
console.warn('COOKIE_SECURE=false — session cookie will be sent over plain HTTP.');
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(session({
|
||||||
|
name: 'pf.sid',
|
||||||
|
store: new PgSession({ pool, schemaName: 'pf', tableName: 'session', createTableIfMissing: false }),
|
||||||
|
secret: sessionSecret,
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
rolling: true,
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: cookieSecure,
|
||||||
|
maxAge: 1000 * 60 * 60 * 12,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.use('/api', require('./routes/auth')(pool));
|
||||||
|
|
||||||
|
// Everything below this line requires a session.
|
||||||
|
app.use('/api', requireAuth);
|
||||||
|
|
||||||
app.use('/api', require('./routes/tables')(pool));
|
app.use('/api', require('./routes/tables')(pool));
|
||||||
app.use('/api', require('./routes/sources')(pool));
|
app.use('/api', require('./routes/sources')(pool));
|
||||||
app.use('/api', require('./routes/versions')(pool));
|
app.use('/api', require('./routes/versions')(pool));
|
||||||
|
|||||||
26
setup_sql/02_auth.sql
Normal file
26
setup_sql/02_auth.sql
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
-- Pivot Forecast — authentication
|
||||||
|
-- Run after 01_schema.sql: psql -d <db> -f setup_sql/02_auth.sql
|
||||||
|
-- Safe to re-run.
|
||||||
|
|
||||||
|
-- Application accounts. Passwords are scrypt hashes written by lib/auth.js;
|
||||||
|
-- the plaintext never reaches the database. Manage with ./pf.sh add-user.
|
||||||
|
CREATE TABLE IF NOT EXISTS pf.app_user (
|
||||||
|
id serial PRIMARY KEY,
|
||||||
|
username text NOT NULL UNIQUE,
|
||||||
|
pass_hash text NOT NULL,
|
||||||
|
display_name text,
|
||||||
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
last_login_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Session store for express-session (connect-pg-simple layout). Sessions live
|
||||||
|
-- here rather than in memory so a restart doesn't sign everyone out, and so a
|
||||||
|
-- session can be revoked by deleting its row.
|
||||||
|
CREATE TABLE IF NOT EXISTS pf.session (
|
||||||
|
sid varchar PRIMARY KEY NOT NULL COLLATE "default",
|
||||||
|
sess json NOT NULL,
|
||||||
|
expire timestamp(6) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS session_expire_idx ON pf.session (expire);
|
||||||
62
ui/src/auth.jsx
Normal file
62
ui/src/auth.jsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
|
const AuthContext = createContext()
|
||||||
|
|
||||||
|
// A session can expire while the app is open. Rather than teach every one of
|
||||||
|
// the app's fetch calls to check for it, wrap fetch once: any 401 from /api
|
||||||
|
// drops the whole UI back to the login screen. Cookies ride along on their own
|
||||||
|
// — fetch defaults to same-origin credentials, and the UI is served from the
|
||||||
|
// same origin as the API.
|
||||||
|
function installUnauthorizedHandler(onUnauthorized) {
|
||||||
|
const original = window.fetch
|
||||||
|
window.fetch = async (...args) => {
|
||||||
|
const res = await original(...args)
|
||||||
|
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || ''
|
||||||
|
if (res.status === 401 && url.includes('/api/') && !url.includes('/api/login')) {
|
||||||
|
onUnauthorized()
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
return () => { window.fetch = original }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null)
|
||||||
|
const [checking, setCheck] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/me')
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(d => setUser(d?.user || null))
|
||||||
|
.catch(() => setUser(null))
|
||||||
|
.finally(() => setCheck(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => installUnauthorizedHandler(() => setUser(null)), [])
|
||||||
|
|
||||||
|
const login = useCallback(async (username, password) => {
|
||||||
|
const r = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
})
|
||||||
|
const data = await r.json().catch(() => ({}))
|
||||||
|
if (!r.ok) throw new Error(data.error || 'Login failed')
|
||||||
|
setUser(data.user)
|
||||||
|
return data.user
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try { await fetch('/api/logout', { method: 'POST' }) } catch {}
|
||||||
|
setUser(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, checking, login, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useAuth = () => useContext(AuthContext)
|
||||||
|
export default useAuth
|
||||||
@ -1,8 +1,10 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import useTheme from '../theme.jsx'
|
import useTheme from '../theme.jsx'
|
||||||
|
import useAuth from '../auth.jsx'
|
||||||
|
|
||||||
export default function StatusBar({ view, sources = [], sourceId, setSourceId, versions = [], versionId, setVersionId }) {
|
export default function StatusBar({ view, sources = [], sourceId, setSourceId, versions = [], versionId, setVersionId }) {
|
||||||
const { dark, setDark } = useTheme()
|
const { dark, setDark } = useTheme()
|
||||||
|
const { user, logout } = useAuth()
|
||||||
const showVersion = view === 'baseline' || view === 'forecast'
|
const showVersion = view === 'baseline' || view === 'forecast'
|
||||||
const selectedVersion = versions.find(v => String(v.id) === String(versionId))
|
const selectedVersion = versions.find(v => String(v.id) === String(versionId))
|
||||||
|
|
||||||
@ -125,7 +127,21 @@ export default function StatusBar({ view, sources = [], sourceId, setSourceId, v
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="ml-auto">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{user && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-500" title={`Signed in as ${user.username}`}>
|
||||||
|
{user.display_name || user.username}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-700 border border-gray-200 px-2 py-0.5 rounded"
|
||||||
|
title="Sign out"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setDark(d => !d)}
|
onClick={() => setDark(d => !d)}
|
||||||
className="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100"
|
className="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100"
|
||||||
|
|||||||
@ -1,13 +1,27 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { ThemeProvider } from './theme.jsx'
|
import { ThemeProvider } from './theme.jsx'
|
||||||
|
import { AuthProvider } from './auth.jsx'
|
||||||
|
import useAuth from './auth.jsx'
|
||||||
|
import Login from './views/Login.jsx'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
|
|
||||||
|
// App is mounted only once there is a session — its load effects call /api
|
||||||
|
// straight away, and mounting it logged-out would just fire a burst of 401s.
|
||||||
|
function Gate() {
|
||||||
|
const { user, checking } = useAuth()
|
||||||
|
if (checking) return <div className="flex items-center justify-center h-screen text-sm text-gray-400">Loading…</div>
|
||||||
|
if (!user) return <Login />
|
||||||
|
return <App />
|
||||||
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<App />
|
<AuthProvider>
|
||||||
|
<Gate />
|
||||||
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -148,7 +148,6 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
const endpoint = isRef ? 'reference' : 'baseline'
|
const endpoint = isRef ? 'reference' : 'baseline'
|
||||||
const body = {
|
const body = {
|
||||||
where_clause: clause,
|
where_clause: clause,
|
||||||
pf_user: 'admin',
|
|
||||||
note: description || segNote,
|
note: description || segNote,
|
||||||
date_offset: offsetStr,
|
date_offset: offsetStr,
|
||||||
...(useRaw ? { raw_where: clause } : { filters }),
|
...(useRaw ? { raw_where: clause } : { filters }),
|
||||||
@ -242,7 +241,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
|||||||
async function closeVersion() {
|
async function closeVersion() {
|
||||||
const res = await fetch(`/api/versions/${versionId}/close`, {
|
const res = await fetch(`/api/versions/${versionId}/close`, {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ pf_user: 'admin' })
|
body: JSON.stringify({})
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) { flash(data.error, 'error'); return }
|
if (!res.ok) { flash(data.error, 'error'); return }
|
||||||
|
|||||||
@ -945,7 +945,6 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
}
|
}
|
||||||
// apply_mode only changes the maths when more than one slice is selected
|
// apply_mode only changes the maths when more than one slice is selected
|
||||||
let body = {
|
let body = {
|
||||||
pf_user: 'admin',
|
|
||||||
tag: opTag.trim() || undefined,
|
tag: opTag.trim() || undefined,
|
||||||
slices: effectiveSlices,
|
slices: effectiveSlices,
|
||||||
...(effectiveSlices.length > 1 ? { apply_mode: applyMode } : {}),
|
...(effectiveSlices.length > 1 ? { apply_mode: applyMode } : {}),
|
||||||
|
|||||||
68
ui/src/views/Login.jsx
Normal file
68
ui/src/views/Login.jsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import useAuth from '../auth.jsx'
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { login } = useAuth()
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
async function submit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(''); setBusy(true)
|
||||||
|
try {
|
||||||
|
await login(username.trim(), password)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
setPassword('')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen w-full text-sm">
|
||||||
|
<form onSubmit={submit} className="bg-white border border-gray-200 rounded p-6 w-80 flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-medium">Pivot Forecast</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">Sign in to continue</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-gray-500">Username</span>
|
||||||
|
<input
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
autoComplete="username"
|
||||||
|
className="border border-gray-200 rounded px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-gray-500">Password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="border border-gray-200 rounded px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="px-3 py-2 text-xs rounded font-medium bg-red-50 text-red-700">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || !username.trim() || !password}
|
||||||
|
className="bg-blue-600 text-white text-xs px-3 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? 'Signing in…' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user