Admin for the menu and enquiries, plus gallery fixes

Admin
- /api/admin: products CRUD, the enquiry inbox, and presigned photo upload straight to the
  bucket so images never pass through the app. Gated by platform.security.authenticated-paths
  = /api/admin/**, so any signed-in Authentik user is staff — the alternative is a role model
  a two-person bakery would never maintain.
- /api/me is deliberately PUBLIC. The SPA asks on every page load, and requiring a login
  would bounce every anonymous visitor to Authentik just to read the menu.
- /admin screens: product list with edit and remove, an editor with drag-free photo
  reordering and upload, and an enquiry inbox that flags anything the relay refused.

Gallery
- swipe on touch devices, which the react-awesome-slider it replaced had and this did not,
  plus arrow keys and position dots — with swipe there is otherwise nothing to say a card
  holds more than one photo. Vertical drags are ignored so page scrolling still works.
- @BatchSize on the photo collection: the products page loaded the whole catalogue and
  Hibernate issued a query per product for its images, forty-odd round trips for a page
  that needs two.

Three things the tests caught, none of which are obvious:
- Adding the storage starter broke every existing test. It activates on a default endpoint,
  so an S3 client is built even in tests and dies on blank keys.
- MockMvc's webAppContextSetup leaves the security filter chain OUT, so the first version of
  the security test passed 200s and proved the opposite of what it claimed. It needs
  .apply(springSecurity()).
- Turning on the security starter turns on CSRF — for the PUBLIC contact form too, which
  then 403s. The SPA now reads the XSRF-TOKEN cookie and sends X-XSRF-TOKEN, and there is a
  test asserting the form is rejected without it.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XXKjx7FNyRVAjU8dgB5KhN
This commit is contained in:
2026-07-23 11:58:21 -05:00
co-authored by Claude Opus 4.8
parent c8cc8fe02d
commit 3b80584e22
18 changed files with 1040 additions and 15 deletions
+50 -3
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useRef, useState } from 'react';
interface ProductGalleryProps {
images: string[];
@@ -11,16 +11,51 @@ interface ProductGalleryProps {
* Replaces react-awesome-slider, which hasn't been published since 2020 and pins peer deps to
* React 16 — the same job in a fraction of the code, and one less unmaintained dependency in a
* build we gate on CVEs. Behaviour is what the old cards did: one image at a time, square crop,
* arrows only when they'd do something.
* arrows only when they'd do something — plus swipe and keyboard, which the old slider had on touch
* devices and the first version of this did not.
*/
/** Past this many pixels a horizontal drag counts as a swipe rather than a tap or a page scroll. */
const SWIPE_THRESHOLD = 40;
const ProductGallery: React.FC<ProductGalleryProps> = ({ images, alt }) => {
const [index, setIndex] = useState(0);
const many = images.length > 1;
const touchStart = useRef<{ x: number; y: number } | null>(null);
const step = (delta: number) => setIndex((i) => (i + delta + images.length) % images.length);
const onTouchStart = (e: React.TouchEvent) => {
const t = e.touches[0];
touchStart.current = { x: t.clientX, y: t.clientY };
};
const onTouchEnd = (e: React.TouchEvent) => {
const start = touchStart.current;
touchStart.current = null;
if (!start || !many) return;
const t = e.changedTouches[0];
const dx = t.clientX - start.x;
const dy = t.clientY - start.y;
// Ignore anything more vertical than horizontal — that is the page being scrolled, not a swipe.
if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dx) <= Math.abs(dy)) return;
step(dx < 0 ? 1 : -1);
};
return (
<div className="relative aspect-square bg-bakery-100">
<div
className="relative aspect-square bg-bakery-100"
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
onKeyDown={many ? (e) => {
if (e.key === 'ArrowLeft') { e.preventDefault(); step(-1); }
if (e.key === 'ArrowRight') { e.preventDefault(); step(1); }
} : undefined}
tabIndex={many ? 0 : undefined}
role={many ? 'group' : undefined}
aria-roledescription={many ? 'carousel' : undefined}
aria-label={many ? `${alt}${images.length} photos` : undefined}
>
{images.map((src, i) => (
<img
key={src}
@@ -54,6 +89,18 @@ const ProductGallery: React.FC<ProductGalleryProps> = ({ images, alt }) => {
>
<span aria-hidden="true">{'>'}</span>
</button>
{/* Which of how many. The old slider ran with bullets off, but once a card can be swiped
there is otherwise nothing to say it holds more than one photo. */}
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex gap-1.5" aria-hidden="true">
{images.map((src, i) => (
<span
key={src}
className={`h-1.5 w-1.5 rounded-full transition ${
i === index ? 'bg-white' : 'bg-white/40'
}`}
/>
))}
</div>
</>
)}
</div>