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_PASSWORD=your_password
|
||||
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
|
||||
|
||||
```
|
||||
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/
|
||||
auth.js POST /api/login, /api/logout, GET /api/me; login throttle
|
||||
tables.js GET /api/tables, /api/tables/:schema/:tname/preview
|
||||
sources.js Source registration, col_meta, SQL generation
|
||||
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
|
||||
lib/
|
||||
sql_generator.js buildFilterClause, token substitution helpers
|
||||
auth.js scrypt hash/verify, requireAuth, sessionUser; `node lib/auth.js hash` CLI
|
||||
utils.js
|
||||
setup_sql/
|
||||
01_schema.sql pf schema DDL — run once to install
|
||||
02_auth.sql pf.app_user + pf.session
|
||||
ui/src/
|
||||
auth.jsx AuthProvider/useAuth; wraps fetch so any 401 returns to login
|
||||
views/
|
||||
Login.jsx Sign-in form
|
||||
Setup.jsx DB browser, source registration, col_meta editor
|
||||
Baseline.jsx Version management, baseline workbench, reference load
|
||||
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.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.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
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
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 ────────────────────────────────────────────────
|
||||
cat > .env <<EOF
|
||||
DB_HOST=${DB_HOST}
|
||||
@ -34,7 +40,10 @@ DB_NAME=${DB_NAME}
|
||||
DB_USER=${DB_USER}
|
||||
DB_PASSWORD=${DB_PASSWORD}
|
||||
PORT=${PORT}
|
||||
SESSION_SECRET=${SESSION_SECRET}
|
||||
COOKIE_SECURE=${COOKIE_SECURE}
|
||||
EOF
|
||||
chmod 600 .env
|
||||
echo "✓ .env written"
|
||||
|
||||
# ── npm install ───────────────────────────────────────────────
|
||||
@ -51,15 +60,23 @@ PGPASSWORD=${DB_PASSWORD} psql \
|
||||
-p "${DB_PORT}" \
|
||||
-U "${DB_USER}" \
|
||||
-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"
|
||||
|
||||
# ── first account ─────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "The app is behind a login. Create the first account now:"
|
||||
./pf.sh add-user
|
||||
|
||||
# ── done ─────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "========================================"
|
||||
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 "========================================"
|
||||
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",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"apache-arrow": "^21.1.0",
|
||||
"connect-pg-simple": "^10.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.0.0",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.19.0",
|
||||
"pg": "^8.11.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -369,6 +372,18 @@
|
||||
"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": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
@ -582,6 +597,29 @@
|
||||
"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": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@ -1085,6 +1123,15 @@
|
||||
"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": {
|
||||
"version": "1.3.3",
|
||||
"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",
|
||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.12.0",
|
||||
"pg-pool": "^3.13.0",
|
||||
@ -1276,6 +1324,15 @@
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
@ -1592,6 +1649,18 @@
|
||||
"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": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||
|
||||
@ -11,9 +11,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"apache-arrow": "^21.1.0",
|
||||
"connect-pg-simple": "^10.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.0.0",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.19.0",
|
||||
"pg": "^8.11.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
160
pf.sh
160
pf.sh
@ -4,6 +4,7 @@ set -euo pipefail
|
||||
# ---------------------------------------------------------------------------
|
||||
# pf.sh — Pivot Forecast management script
|
||||
# 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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -68,7 +69,7 @@ require_service() {
|
||||
}
|
||||
|
||||
# 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() {
|
||||
[[ -f "$ENV_FILE" ]] || return 0
|
||||
@ -192,13 +193,21 @@ cmd_db_setup() {
|
||||
command -v psql &>/dev/null || die "psql not found — install postgresql-client"
|
||||
|
||||
echo
|
||||
bold "DB Setup — will run: setup_sql/01_schema.sql"
|
||||
warn "This creates the pf schema and tables. Safe to re-run (CREATE IF NOT EXISTS)."
|
||||
bold "DB Setup — will run: setup_sql/01_schema.sql, setup_sql/02_auth.sql"
|
||||
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
|
||||
[[ "$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/02_auth.sql"
|
||||
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() {
|
||||
@ -239,6 +248,22 @@ cmd_config() {
|
||||
read -rp " PORT (app) [${cur_app_port:-3010}]: " input
|
||||
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.
|
||||
local tmp
|
||||
tmp=$(mktemp)
|
||||
@ -249,6 +274,8 @@ DB_NAME=${name}
|
||||
DB_USER=${user}
|
||||
DB_PASSWORD=${pass}
|
||||
PORT=${app_port}
|
||||
SESSION_SECRET=${secret}
|
||||
COOKIE_SECURE=${cookie_secure}
|
||||
EOF
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
local managed
|
||||
@ -266,6 +293,116 @@ EOF
|
||||
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() {
|
||||
require_systemd
|
||||
require_env
|
||||
@ -349,6 +486,11 @@ interactive_menu() {
|
||||
echo " 8) config set DB connection + app PORT"
|
||||
echo " 9) install-service create 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
|
||||
read -rp " Choice: " choice
|
||||
@ -363,6 +505,11 @@ interactive_menu() {
|
||||
8|config) cmd_config ;;
|
||||
9|install-service) cmd_install_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 ;;
|
||||
*) warn "Unknown option: $choice" ;;
|
||||
esac
|
||||
@ -382,6 +529,11 @@ case "${1:-}" in
|
||||
config) cmd_config ;;
|
||||
install-service) cmd_install_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 ;;
|
||||
*) 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
|
||||
|
||||
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 { tableFromArrays, tableToIPC } = require('apache-arrow');
|
||||
const { applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, esc } = require('../lib/sql_generator');
|
||||
const { sessionUser } = require('../lib/auth');
|
||||
const { fcTable } = require('../lib/utils');
|
||||
|
||||
module.exports = function(pool) {
|
||||
@ -304,7 +305,8 @@ module.exports = function(pool) {
|
||||
|
||||
// load baseline rows from source table — additive, no delete
|
||||
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 filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
|
||||
try {
|
||||
@ -339,7 +341,8 @@ module.exports = function(pool) {
|
||||
router.put('/versions/:id/baseline/:logid', async (req, res) => {
|
||||
const versionId = parseInt(req.params.id);
|
||||
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 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)
|
||||
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 filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
|
||||
try {
|
||||
@ -478,7 +482,8 @@ module.exports = function(pool) {
|
||||
// target or by an increment. With several slices selected, apply_mode decides
|
||||
// whether they are treated as one pool ('prorate') or independently ('each').
|
||||
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);
|
||||
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
|
||||
// inserts negative rows to zero out the original, positive rows with new dimension values
|
||||
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);
|
||||
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' });
|
||||
@ -618,7 +624,8 @@ module.exports = function(pool) {
|
||||
// clone one or more slices as new business under new dimension values
|
||||
// does not offset the original slice
|
||||
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);
|
||||
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' });
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
const express = require('express');
|
||||
const { generateSQL } = require('../lib/sql_generator');
|
||||
const { sessionUser } = require('../lib/auth');
|
||||
|
||||
module.exports = function(pool) {
|
||||
const router = express.Router();
|
||||
@ -20,7 +21,8 @@ module.exports = function(pool) {
|
||||
// register a source table
|
||||
// auto-populates col_meta from information_schema with role='ignore'
|
||||
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) {
|
||||
return res.status(400).json({ error: 'schema and tname are required' });
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
const express = require('express');
|
||||
const { fcTable, mapType } = require('../lib/utils');
|
||||
const { sessionUser } = require('../lib/auth');
|
||||
|
||||
module.exports = function(pool) {
|
||||
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
|
||||
router.post('/sources/:id/versions', async (req, res) => {
|
||||
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' });
|
||||
|
||||
const client = await pool.connect();
|
||||
@ -262,7 +264,7 @@ ${colDefs},
|
||||
|
||||
// close a version — blocks further edits
|
||||
router.post('/versions/:id/close', async (req, res) => {
|
||||
const { pf_user } = req.body;
|
||||
const pf_user = sessionUser(req);
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
UPDATE pf.version
|
||||
|
||||
51
server.js
51
server.js
@ -1,7 +1,10 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const session = require('express-session');
|
||||
const PgSession = require('connect-pg-simple')(session);
|
||||
const { Pool, types } = require('pg');
|
||||
const { requireAuth } = require('./lib/auth');
|
||||
|
||||
// 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>.
|
||||
@ -9,7 +12,15 @@ types.setTypeParser(20, v => v === null ? null : Number(v));
|
||||
types.setTypeParser(1700, v => v === null ? null : Number(v));
|
||||
|
||||
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.static('public/app'));
|
||||
|
||||
@ -26,6 +37,44 @@ pool.on('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/sources')(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 useTheme from '../theme.jsx'
|
||||
import useAuth from '../auth.jsx'
|
||||
|
||||
export default function StatusBar({ view, sources = [], sourceId, setSourceId, versions = [], versionId, setVersionId }) {
|
||||
const { dark, setDark } = useTheme()
|
||||
const { user, logout } = useAuth()
|
||||
const showVersion = view === 'baseline' || view === 'forecast'
|
||||
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
|
||||
onClick={() => setDark(d => !d)}
|
||||
className="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100"
|
||||
|
||||
@ -1,13 +1,27 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
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 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(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
<AuthProvider>
|
||||
<Gate />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@ -148,7 +148,6 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
const endpoint = isRef ? 'reference' : 'baseline'
|
||||
const body = {
|
||||
where_clause: clause,
|
||||
pf_user: 'admin',
|
||||
note: description || segNote,
|
||||
date_offset: offsetStr,
|
||||
...(useRaw ? { raw_where: clause } : { filters }),
|
||||
@ -242,7 +241,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
async function closeVersion() {
|
||||
const res = await fetch(`/api/versions/${versionId}/close`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pf_user: 'admin' })
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
const data = await res.json()
|
||||
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
|
||||
let body = {
|
||||
pf_user: 'admin',
|
||||
tag: opTag.trim() || undefined,
|
||||
slices: effectiveSlices,
|
||||
...(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