diff --git a/routes/operations.js b/routes/operations.js
index 950b54d..3dcdf3b 100644
--- a/routes/operations.js
+++ b/routes/operations.js
@@ -3,7 +3,7 @@ const { tableFromArrays, tableToIPC } = require('apache-arrow');
const { applyTokens, buildWhere, buildWhereAny, COMPUTED_SLICE_COLS, buildScopeClause, buildTerritoryClause, buildExcludeClause, buildExcludePredicate, buildSetClause, dateGroupsOf, dimPeriodMapOf, esc,
SEGMENT_EXPR, BUCKET_EXPR, NOTE_EXPR, VERSION_JOIN,
ADJUSTMENT_SEGMENT, ADJUSTMENT_BUCKET } = require('../lib/sql_generator');
-const { sessionUser, sessionTerritory } = require('../lib/auth');
+const { sessionUser, sessionTerritory, requireAdmin } = require('../lib/auth');
const { fcTable } = require('../lib/utils');
module.exports = function(pool) {
@@ -572,7 +572,7 @@ module.exports = function(pool) {
});
// load baseline rows from source table — additive, no delete
- router.post('/versions/:id/baseline', async (req, res) => {
+ router.post('/versions/:id/baseline', requireAdmin, async (req, res) => {
const { where_clause, date_offset, note, filters, raw_where, label, bucket, tag } = req.body;
const pf_user = sessionUser(req);
const dateOffset = date_offset || '0 days';
@@ -611,7 +611,7 @@ module.exports = function(pool) {
// edit a baseline or reference segment in place — only allowed before any
// scale/recode/clone has been applied on this version, since those would
// have been calibrated against the old segment's totals.
- router.put('/versions/:id/baseline/:logid', async (req, res) => {
+ router.put('/versions/:id/baseline/:logid', requireAdmin, async (req, res) => {
const versionId = parseInt(req.params.id);
const logid = parseInt(req.params.logid);
const { where_clause, date_offset, note, filters, raw_where, label, bucket, tag } = req.body;
@@ -698,7 +698,7 @@ module.exports = function(pool) {
});
// delete all baseline rows and log entries for a version
- router.delete('/versions/:id/baseline', async (req, res) => {
+ router.delete('/versions/:id/baseline', requireAdmin, async (req, res) => {
const versionId = parseInt(req.params.id);
try {
const ctx = await getContext(versionId, 'baseline');
@@ -732,7 +732,7 @@ 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) => {
+ router.post('/versions/:id/reference', requireAdmin, async (req, res) => {
const { where_clause, date_offset, note, filters, raw_where, label, bucket, tag } = req.body;
const pf_user = sessionUser(req);
const dateOffset = date_offset || '0 days';
diff --git a/routes/plans.js b/routes/plans.js
index 5a03614..08673ec 100644
--- a/routes/plans.js
+++ b/routes/plans.js
@@ -1,5 +1,5 @@
const express = require('express');
-const { sessionUser } = require('../lib/auth');
+const { sessionUser, requireAdmin } = require('../lib/auth');
// Planned load segments — a version's definition, before it has been run.
//
@@ -41,7 +41,7 @@ module.exports = function(pool) {
}
});
- router.post('/versions/:id/plans', async (req, res) => {
+ router.post('/versions/:id/plans', requireAdmin, async (req, res) => {
const versionId = parseInt(req.params.id);
const b = req.body || {};
if (!['baseline', 'reference'].includes(b.kind)) {
@@ -73,7 +73,7 @@ module.exports = function(pool) {
// Any subset of the fields, plus run_logid when a run has just happened.
// The flag-and-value pair rather than COALESCE on the value: clearing a
// label to blank has to be distinguishable from not mentioning it.
- router.patch('/plans/:pid', async (req, res) => {
+ router.patch('/plans/:pid', requireAdmin, async (req, res) => {
const pid = parseInt(req.params.pid);
const b = req.body || {};
const keys = [...FIELDS, 'run_logid'].filter(k => b[k] !== undefined);
@@ -109,7 +109,7 @@ module.exports = function(pool) {
}
});
- router.delete('/plans/:pid', async (req, res) => {
+ router.delete('/plans/:pid', requireAdmin, async (req, res) => {
try {
const { rows } = await pool.query(
`DELETE FROM pf.load_plan WHERE id = $1 RETURNING id`, [parseInt(req.params.pid)]
diff --git a/routes/sources.js b/routes/sources.js
index 1a514b3..07fd397 100644
--- a/routes/sources.js
+++ b/routes/sources.js
@@ -1,7 +1,7 @@
const express = require('express');
const { generateSQL, buildTerritoryClause } = require('../lib/sql_generator');
const { RELATION_COLUMNS_SQL } = require('../lib/utils');
-const { sessionTerritory } = require('../lib/auth');
+const { sessionTerritory, requireAdmin } = require('../lib/auth');
const { sessionUser } = require('../lib/auth');
module.exports = function(pool) {
@@ -22,7 +22,7 @@ module.exports = function(pool) {
// register a source table
// auto-populates col_meta from information_schema with role='ignore'
- router.post('/sources', async (req, res) => {
+ router.post('/sources', requireAdmin, async (req, res) => {
const { schema, tname, label } = req.body;
const created_by = sessionUser(req);
if (!schema || !tname) {
@@ -81,7 +81,7 @@ module.exports = function(pool) {
});
// save col_meta — accepts full array, upserts each row
- router.put('/sources/:id/cols', async (req, res) => {
+ router.put('/sources/:id/cols', requireAdmin, async (req, res) => {
const sourceId = parseInt(req.params.id);
const cols = req.body;
if (!Array.isArray(cols)) {
@@ -142,7 +142,7 @@ module.exports = function(pool) {
});
// generate SQL for all operations from current col_meta and store in pf.sql
- router.post('/sources/:id/generate-sql', async (req, res) => {
+ router.post('/sources/:id/generate-sql', requireAdmin, async (req, res) => {
const sourceId = parseInt(req.params.id);
try {
const srcResult = await pool.query(
@@ -338,7 +338,7 @@ module.exports = function(pool) {
// Slow by nature -- it reads the whole source, which for a view over a
// transaction table is millions of rows -- so it is a deliberate action rather
// than something that happens on a page load.
- router.post('/sources/:id/dim/:group/refresh', async (req, res) => {
+ router.post('/sources/:id/dim/:group/refresh', requireAdmin, async (req, res) => {
const sourceId = parseInt(req.params.id);
const group = req.params.group;
try {
@@ -473,7 +473,7 @@ module.exports = function(pool) {
}
});
- router.delete('/sources/:id', async (req, res) => {
+ router.delete('/sources/:id', requireAdmin, async (req, res) => {
try {
const result = await pool.query(
`DELETE FROM pf.source WHERE id = $1 RETURNING *`,
diff --git a/routes/tables.js b/routes/tables.js
index 3dd4ee4..028d8cd 100644
--- a/routes/tables.js
+++ b/routes/tables.js
@@ -1,6 +1,9 @@
const express = require('express');
const { RELATION_COLUMNS_SQL } = require('../lib/utils');
+// Reads, deliberately open to everyone: Setup is visible read-only to
+// non-admins, so gating what the page needs in order to render turned it into
+// a 403 the client rendered as a crash. The writes it leads to are gated.
module.exports = function(pool) {
const router = express.Router();
diff --git a/routes/versions.js b/routes/versions.js
index 5ecbc59..4540443 100644
--- a/routes/versions.js
+++ b/routes/versions.js
@@ -1,6 +1,6 @@
const express = require('express');
const { fcTable, mapType, RELATION_COLUMNS_SQL } = require('../lib/utils');
-const { sessionUser, sessionTerritory } = require('../lib/auth');
+const { sessionUser, sessionTerritory, requireAdmin } = require('../lib/auth');
const { buildTerritoryClause } = require('../lib/sql_generator');
// Copy a version's definition onto a freshly created one: its load segments as
@@ -90,7 +90,7 @@ module.exports = function(pool) {
// create a new version
// 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', requireAdmin, async (req, res) => {
const sourceId = parseInt(req.params.id);
const { name, description, exclude_iters, clone_from } = req.body;
const created_by = sessionUser(req);
@@ -514,7 +514,7 @@ ${colDefs},
// The three name columns are flag-and-value pairs rather than COALESCE:
// clearing one back to the built-in means writing null, which COALESCE on
// the value alone cannot tell from "not mentioned".
- router.put('/versions/:id', async (req, res) => {
+ router.put('/versions/:id', requireAdmin, async (req, res) => {
const { name, description, exclude_iters,
adjustment_segment, adjustment_bucket, unlabeled_load,
is_default } = req.body;
@@ -595,7 +595,7 @@ ${colDefs},
});
// close a version — blocks further edits
- router.post('/versions/:id/close', async (req, res) => {
+ router.post('/versions/:id/close', requireAdmin, async (req, res) => {
const pf_user = sessionUser(req);
try {
const result = await pool.query(`
@@ -615,7 +615,7 @@ ${colDefs},
});
// reopen a closed version
- router.post('/versions/:id/reopen', async (req, res) => {
+ router.post('/versions/:id/reopen', requireAdmin, async (req, res) => {
try {
const result = await pool.query(`
UPDATE pf.version
@@ -635,7 +635,7 @@ ${colDefs},
// delete a version — drops forecast table then deletes version record
// log entries are removed by ON DELETE CASCADE on pf.log.version_id
- router.delete('/versions/:id', async (req, res) => {
+ router.delete('/versions/:id', requireAdmin, async (req, res) => {
const versionId = parseInt(req.params.id);
const client = await pool.connect();
try {
diff --git a/ui/src/App.jsx b/ui/src/App.jsx
index 9f1998f..77d70f2 100644
--- a/ui/src/App.jsx
+++ b/ui/src/App.jsx
@@ -39,8 +39,35 @@ export default function App() {
return list
}, [sourceId])
+ // Every source's forecasts, for the sidebar only.
+ //
+ // `versions` stays scoped to the selected source, because Baseline and
+ // Forecast are, and widening it would change what it means everywhere. The
+ // sidebar is the one place that has to be cross-source: it is how a forecast
+ // gets chosen, and a forecast carries its own source_id, so choosing one
+ // settles the source. Without this, a second registered source would be
+ // unreachable from anywhere but Setup -- and invisible from everywhere else,
+ // which is worse.
+ //
+ // A fan-out rather than an endpoint: there is one source today, so this is one
+ // request, and a `GET /versions` is not worth adding until there are enough to
+ // make the difference measurable.
+ const [allVersions, setAllVersions] = useState([])
+ const refreshAllVersions = useCallback(async (list) => {
+ const srcs = list ?? sources
+ if (!srcs.length) { setAllVersions([]); return }
+ const lists = await Promise.all(srcs.map(s =>
+ fetch(`/api/sources/${s.id}/versions`).then(r => r.json()).catch(() => [])))
+ setAllVersions(lists.flat().filter(v => v && v.id != null))
+ }, [sources])
+
useEffect(() => { refreshSources() }, [])
+ // Recomputed when the selected source's list changes, which is what creating
+ // or deleting a version does -- so the sidebar follows without every caller
+ // having to know it exists.
+ useEffect(() => { if (sourcesLoaded) refreshAllVersions() }, [sourcesLoaded, sources, versions])
+
// The selection is restored from localStorage and survives a deregister, so it
// has to be re-checked against the list itself rather than only at mount:
// deleting the selected source otherwise leaves a dead id behind and every
@@ -90,7 +117,12 @@ export default function App() {
return (
-
+
diff --git a/ui/src/components/ReadOnlyNotice.jsx b/ui/src/components/ReadOnlyNotice.jsx
new file mode 100644
index 0000000..435152c
--- /dev/null
+++ b/ui/src/components/ReadOnlyNotice.jsx
@@ -0,0 +1,22 @@
+// Says why everything on the page is greyed out.
+//
+// The disabling itself is one `