diff --git a/CLAUDE.md b/CLAUDE.md index c77a920..e5faf78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -380,9 +380,13 @@ Enforced at: - every operation, through `sliceUnits()` - `/sources/:id/values/:col` — completion reads the *source* table, which no scope has touched, so without it a dropdown enumerates the whole business -- `DELETE /log/:logid` — by owner, not territory: undo removes an entry's rows - wholesale, and half-undoing one would leave a state nothing describes. Your - own entries, or an admin's override. +- `DELETE /log/:logid` and `PATCH /log/:logid` — by owner, not territory. Undo + removes an entry's rows wholesale, and half-undoing one would leave a state + nothing describes. The PATCH looks like a private annotation and is not: + `label` and `bucket` name the pivot's columns for everyone in the version, so + unguarded it let any account rename the company's segments. Your own entries, + or an admin's override, and the UI greys out the rest rather than offering a + click that answers 403. - recode's `set` — a scoped account cannot set the territory column at all. Moving a row between territories is reassignment, not forecasting, and it would vanish from the view that would have shown what happened. diff --git a/routes/log.js b/routes/log.js index f021d5c..94e5e10 100644 --- a/routes/log.js +++ b/routes/log.js @@ -190,6 +190,21 @@ module.exports = function(pool) { }); } try { + // Same rule as undo: your own entries, or an admin's. These are + // annotations, but label and bucket name the pivot's columns for + // everyone who opens the version, so an unguarded PATCH let any + // account rename the company's segments -- including on loads whose + // rows it cannot see. + const owner = await pool.query( + `SELECT pf_user FROM pf.log WHERE id = $1`, [logId] + ); + if (!owner.rows.length) return res.status(404).json({ error: 'Log entry not found' }); + if (!req.session?.user?.is_admin && owner.rows[0].pf_user !== sessionUser(req)) { + return res.status(403).json({ + error: `That entry was made by ${owner.rows[0].pf_user || 'someone else'} — only they or an administrator can change it` + }); + } + // COALESCE on the flag, not the value: an explicit null or '' must be // able to clear a field, which COALESCE on the value alone would ignore const result = await pool.query( diff --git a/ui/src/views/Baseline.jsx b/ui/src/views/Baseline.jsx index 5a862f8..6e92d01 100644 --- a/ui/src/views/Baseline.jsx +++ b/ui/src/views/Baseline.jsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react' import Timeline from '../components/Timeline.jsx' +import useAuth from '../auth.jsx' const OPERATORS = ['BETWEEN', '=', '!=', 'IN', 'NOT IN', 'IS NULL', 'IS NOT NULL'] @@ -86,6 +87,7 @@ function normalizeFilters(stored) { } export default function Baseline({ sources = [], sourceId, versions = [], versionId, setVersionId, refreshVersions }) { + const { user: me } = useAuth() const [filterCols, setFilterCols] = useState([]) const [log, setLog] = useState([]) @@ -149,6 +151,11 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio // Both are only read at load time, hence the reload in the confirmation: the // label is part of the aggregated row the pivot holds, not something it can // re-derive in place. + // Same rule the server enforces: your own entries, or an admin's. A segment's + // label and bucket name the pivot's columns for everyone in the version, so + // they are not the private annotation they look like. + const canEdit = (entry) => !!me && (me.is_admin || entry.pf_user === me.username) + async function saveLogField(entry, field, value) { const next = value.trim() if (next === (entry[field] || '')) return @@ -516,6 +523,8 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio defaultValue={entry.label || ''} key={`label-${entry.id}-${entry.label || ''}`} onBlur={e => saveLogField(entry, 'label', e.target.value)} + readOnly={!canEdit(entry)} + title={canEdit(entry) ? '' : `${entry.pf_user || 'Another account'} made this segment`} placeholder={entry.tag || entry.note || '—'} className="w-full border border-transparent hover:border-gray-200 focus:border-blue-400 rounded px-1 py-0.5 text-xs @@ -539,6 +548,8 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio list="pf-bucket-options" onChange={e => setBuckets(b => ({ ...b, [entry.id]: e.target.value }))} onBlur={e => saveLogField(entry, 'bucket', e.target.value)} + readOnly={!canEdit(entry)} + title={canEdit(entry) ? '' : `${entry.pf_user || 'Another account'} made this segment`} placeholder="—" className="w-full border border-transparent hover:border-gray-200 focus:border-blue-400 rounded px-1 py-0.5 text-xs focus:outline-none bg-transparent" /> diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index fe05b10..c588132 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -1932,7 +1932,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio {fmtSliceSummary(entry.slice)} - ( @@ -1940,7 +1940,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio {v} )} /> - {entry.pf_user || '—'} @@ -2117,10 +2117,24 @@ function PanelChrome({ dock, setDock, floating, onMouseDown, onClose }) { // One inline-editable annotation cell in the change log. Click to edit, Enter to // save, Escape to cancel — the same gesture for note and tag. -function LogCell({ entry, field, placeholder, editing, setEditing, onSave, listId, render }) { - const active = editing?.id === entry.id && editing?.field === field +function LogCell({ entry, field, placeholder, editing, setEditing, onSave, listId, render, canEdit = true }) { + const active = canEdit && editing?.id === entry.id && editing?.field === field const value = entry[field] || '' + // Someone else's entry: shown, not editable. label and bucket name the + // pivot's columns for everyone, so these are not the private annotations + // they look like. + if (!canEdit) { + return ( + + + {value ? (render ? render(value) : value) : } + + + ) + } + if (active) { return (