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>
172 lines
7.2 KiB
JavaScript
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>
|
|
)
|
|
}
|