pf_app/ui/src/components/StatusBar.jsx
Paul Trowbridge c2e6fc8e77 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>
2026-09-14 22:20:25 -04:00

172 lines
7.2 KiB
JavaScript

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))
const [info, setInfo] = useState(null)
const [showInfo, setShow] = useState(false)
const [copied, setCopied] = useState(false)
const refreshInfo = useCallback(async () => {
if (!versionId || !showVersion) { setInfo(null); return }
try {
const r = await fetch(`/api/versions/${versionId}/table-info`)
setInfo(r.ok ? await r.json() : null)
} catch { setInfo(null) }
}, [versionId, showVersion])
useEffect(() => { refreshInfo() }, [refreshInfo])
// operations broadcast this after a write so the row count stays honest
useEffect(() => {
const onChange = () => refreshInfo()
window.addEventListener('pf-data-changed', onChange)
return () => window.removeEventListener('pf-data-changed', onChange)
}, [refreshInfo])
async function copyTable() {
if (!info?.fc_table) return
try {
await navigator.clipboard.writeText(info.fc_table)
setCopied(true)
setTimeout(() => setCopied(false), 1200)
} catch {}
}
const fmt = (n) => n == null ? '—' : n.toLocaleString()
return (
<div className="bg-white border-b border-gray-200 px-3 h-9 flex items-center gap-3 shrink-0 text-xs relative">
<span className="text-gray-400">Source</span>
<select
value={sourceId || ''}
onChange={e => setSourceId(e.target.value)}
disabled={sources.length === 0}
className="border border-gray-200 rounded px-2 py-0.5 bg-white"
>
{sources.length === 0
? <option value=""> no sources </option>
: sources.map(s => <option key={s.id} value={s.id}>{s.tname}</option>)}
</select>
{showVersion && (
<>
<span className="text-gray-200">|</span>
<span className="text-gray-400">Version</span>
<select
value={versionId || ''}
onChange={e => setVersionId(e.target.value)}
disabled={versions.length === 0}
className="border border-gray-200 rounded px-2 py-0.5 bg-white"
>
{versions.length === 0
? <option value=""> no versions </option>
: versions.map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
{selectedVersion && (
<span className={`font-medium ${selectedVersion.status === 'open' ? 'text-green-600' : 'text-gray-400'}`}>
{selectedVersion.status}
</span>
)}
{/* write target — the physical table every operation appends to */}
{info && (
<>
<span className="text-gray-200">|</span>
<span className="text-gray-400" title="Operations append to this table">writes to</span>
<button
onClick={copyTable}
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
className={`font-mono px-1.5 py-0.5 rounded border hover:bg-gray-50 ${
info.exists ? 'text-gray-700 border-gray-200' : 'text-amber-700 border-amber-200 bg-amber-50'
}`}
title={info.exists ? 'Click to copy table name' : 'Table does not exist yet'}
>
{copied ? 'copied!' : info.fc_table}
</button>
<span className="text-gray-400 font-mono">
{info.exists ? `${fmt(info.rows)} rows` : 'not created'}
</span>
{showInfo && (
<div className="absolute top-9 left-0 z-30 bg-white border border-gray-200 rounded shadow-lg p-3 text-xs min-w-[260px]">
<div className="text-gray-400 uppercase tracking-wide mb-2" style={{ fontSize: '10px' }}>Write target</div>
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
<span className="text-gray-400">table</span>
<span className="font-mono text-gray-700">{info.fc_table}</span>
<span className="text-gray-400">reads from</span>
<span className="font-mono text-gray-700">{info.source}</span>
<span className="text-gray-400">total rows</span>
<span className="font-mono text-gray-700">{fmt(info.rows)}</span>
</div>
{info.by_iter?.length > 0 && (
<>
<div className="text-gray-400 uppercase tracking-wide mt-3 mb-1" style={{ fontSize: '10px' }}>Rows by iter</div>
<table className="w-full">
<tbody>
{info.by_iter.map(r => (
<tr key={r.pf_iter}>
<td className="text-gray-500 capitalize pr-3">{r.pf_iter}</td>
<td className="text-right font-mono text-gray-700">{fmt(r.n)}</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
)}
</>
)}
</>
)}
<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"
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{dark ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4"/>
<line x1="12" y1="2" x2="12" y2="5"/>
<line x1="12" y1="19" x2="12" y2="22"/>
<line x1="4.93" y1="4.93" x2="7.05" y2="7.05"/>
<line x1="16.95" y1="16.95" x2="19.07" y2="19.07"/>
<line x1="2" y1="12" x2="5" y2="12"/>
<line x1="19" y1="12" x2="22" y2="12"/>
<line x1="4.93" y1="19.07" x2="7.05" y2="16.95"/>
<line x1="16.95" y1="7.05" x2="19.07" y2="4.93"/>
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
)}
</button>
</div>
</div>
)
}