Restrict whole-event door takings to admin and organizer.

The door session sheet showed every staff member what the event had
taken overall, by tender and against pre-sale. That is management
information, not door information, and it matches the convention
already applied to the other revenue aggregates (admin/analytics,
admin/export/financial are both admin-only).

Door staff keep their own shift cash-up: the "This session" totals are
computed on the device from its own action log, so nothing they need to
reconcile at the end of the night is lost.

The gate is on GET /api/events/:eventId/door-summary, not only on the
section that renders it -- hiding the panel while the endpoint still
returned the figures would leave them one network response away. The
client skips the request entirely for staff rather than provoking a 403.

The test auth mock previously waved every role through, so it could not
have caught a wrong gate; it now honours the role list, which also puts
several already-written assertions onto real code paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-08-23 06:35:57 +00:00
co-authored by Claude Opus 5
parent 02a12ee9e0
commit 745af4184f
4 changed files with 92 additions and 18 deletions
@@ -96,6 +96,7 @@ export function SessionSheet({
entries,
summary,
summaryLoading,
showEventTotals,
currency,
onRefresh,
onClose,
@@ -103,6 +104,8 @@ export function SessionSheet({
entries: SessionEntry[];
summary: DoorSummary | null;
summaryLoading: boolean;
/** Whole-event takings are admin/organizer only; door staff see their own shift. */
showEventTotals: boolean;
currency: string;
onRefresh: () => void;
onClose: () => void;
@@ -118,13 +121,15 @@ export function SessionSheet({
<p className="text-xs text-gray-500">{liveEntries.length} checked in from this device</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={onRefresh}
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all"
aria-label="Refresh totals"
>
<ArrowPathIcon className={clsx('w-5 h-5', summaryLoading && 'animate-spin')} />
</button>
{showEventTotals && (
<button
onClick={onRefresh}
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all"
aria-label="Refresh totals"
>
<ArrowPathIcon className={clsx('w-5 h-5', summaryLoading && 'animate-spin')} />
</button>
)}
<button
onClick={onClose}
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all"
@@ -145,7 +150,9 @@ export function SessionSheet({
<CashUpGrid totals={totals} currency={currency} />
</section>
{/* Whole event, from the server — the number to reconcile the cash box against */}
{/* Whole event, from the server — the number to reconcile the cash box
against. Admin/organizer only; the API enforces the same split. */}
{showEventTotals && (
<section className="space-y-2">
<div className="flex items-baseline justify-between">
<h2 className="text-sm font-bold text-white uppercase tracking-wide">Door total, whole event</h2>
@@ -175,6 +182,7 @@ export function SessionSheet({
</p>
)}
</section>
)}
{/* Feed */}
<section className="space-y-2">
+7 -2
View File
@@ -66,6 +66,10 @@ export default function AdminDoorPage() {
const router = useRouter();
const { user } = useAuth();
const backHref = user?.role === 'staff' ? '/admin/events' : '/admin';
// Whole-event takings are management information. Door staff reconcile their
// own shift from the session feed below, which is local to this device; the
// API enforces the same split (see REVENUE_ROLES in routes/door.ts).
const canSeeEventTotals = user?.role === 'admin' || user?.role === 'organizer';
// ── Events ──
const [events, setEvents] = useState<Event[]>([]);
@@ -531,7 +535,7 @@ export default function AdminDoorPage() {
// ─── Session summary ─────────────────────────────────────────
const loadSummary = useCallback(async () => {
const eventId = selectedEventIdRef.current;
if (!eventId) return;
if (!eventId || !canSeeEventTotals) return;
setSummaryLoading(true);
try {
setSummary(await doorApi.summary(eventId));
@@ -540,7 +544,7 @@ export default function AdminDoorPage() {
} finally {
setSummaryLoading(false);
}
}, []);
}, [canSeeEventTotals]);
useEffect(() => {
if (sessionOpen) loadSummary();
@@ -733,6 +737,7 @@ export default function AdminDoorPage() {
entries={sessionEntries}
summary={summary}
summaryLoading={summaryLoading}
showEventTotals={canSeeEventTotals}
currency={currency}
onRefresh={loadSummary}
onClose={() => {