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.
This commit is contained in:
2026-07-23 11:58:21 -05:00
parent 27821cdb90
commit d2c62f35ed
18 changed files with 1040 additions and 15 deletions
+2 -7
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { submitContact } from '@/lib/api';
type Status = 'idle' | 'sending' | 'sent' | 'error';
@@ -23,13 +24,7 @@ const ContactPage = () => {
setStatus('sending');
setError('');
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Could not send the message.');
await submitContact(formData);
setStatus('sent');
setFormData({ name: '', email: '', message: '' });
} catch (err) {
+148
View File
@@ -0,0 +1,148 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
deleteProduct, fetchAdminProducts, fetchEnquiries,
type AdminEnquiry, type AdminProduct,
} from '@/lib/api';
import { useAuth, signIn } from '@/lib/auth';
type Tab = 'products' | 'enquiries';
export default function AdminPage() {
const { me, loading } = useAuth();
const [tab, setTab] = useState<Tab>('products');
const [products, setProducts] = useState<AdminProduct[]>([]);
const [enquiries, setEnquiries] = useState<AdminEnquiry[]>([]);
const [error, setError] = useState('');
const load = () => {
// Only ask for admin data once we know there is a session; otherwise every anonymous visitor
// who guesses this URL gets bounced to the identity provider.
if (!me.admin) return;
fetchAdminProducts().then(setProducts).catch((e) => setError(String(e.message ?? e)));
fetchEnquiries().then(setEnquiries).catch((e) => setError(String(e.message ?? e)));
};
useEffect(load, [me.admin]);
if (loading) {
return <p className="container mx-auto px-4 py-20 text-bakery-700">Loading</p>;
}
if (!me.admin) {
return (
<div className="container mx-auto px-4 py-20 text-center">
<h1 className="font-adbhashitha text-3xl text-bakery-900 mb-6">Staff only</h1>
<button
onClick={signIn}
className="px-8 py-3.5 bg-bakery-600 text-white rounded-full tracking-wide hover:bg-bakery-700 transition-colors"
>
Sign in
</button>
</div>
);
}
async function remove(p: AdminProduct) {
if (!window.confirm(`Remove “${p.name}” from the menu? The photos stay in storage.`)) return;
try {
await deleteProduct(p.id);
setProducts((list) => list.filter((x) => x.id !== p.id));
} catch (e) {
setError(String((e as Error).message));
}
}
return (
<div className="container mx-auto px-4 py-12">
<div className="flex flex-wrap items-baseline justify-between gap-4 mb-8">
<h1 className="font-adbhashitha text-4xl text-bakery-900">Manage</h1>
<span className="text-sm text-bakery-600">signed in as {me.name}</span>
</div>
{error && (
<p role="alert" className="mb-6 rounded-xl bg-red-50 px-4 py-3 text-red-800">{error}</p>
)}
<div className="flex gap-3 mb-8">
{(['products', 'enquiries'] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-6 py-2 rounded-full border text-sm uppercase tracking-[0.12em] transition-colors ${
tab === t
? 'bg-bakery-600 text-white border-bakery-600'
: 'bg-white border-bakery-300 text-bakery-700 hover:bg-bakery-100'
}`}
>
{t === 'products' ? `Products (${products.length})` : `Enquiries (${enquiries.length})`}
</button>
))}
</div>
{tab === 'products' ? (
<>
<Link
to="/admin/products/new"
className="inline-block mb-6 px-6 py-2.5 bg-bakery-600 text-white rounded-full text-sm tracking-wide hover:bg-bakery-700 transition-colors"
>
Add a product
</Link>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{products.map((p) => (
<div key={p.id} className="bg-white rounded-3xl overflow-hidden shadow-xs">
{p.imageUrls[0] && (
<img src={p.imageUrls[0]} alt="" className="w-full aspect-square object-cover" />
)}
<div className="p-5">
<div className="font-adbhashitha text-lg text-bakery-900">{p.name}</div>
<div className="text-xs uppercase tracking-[0.15em] text-bakery-600 mb-4">
{p.category} · {p.imageKeys.length} photo{p.imageKeys.length === 1 ? '' : 's'}
</div>
<div className="flex gap-3 text-sm">
<Link
to={`/admin/products/${p.id}`}
className="underline underline-offset-4 text-bakery-700 hover:text-bakery-900"
>
Edit
</Link>
<button
onClick={() => remove(p)}
className="underline underline-offset-4 text-red-700 hover:text-red-900"
>
Remove
</button>
</div>
</div>
</div>
))}
{products.length === 0 && <p className="text-bakery-700">Nothing on the menu yet.</p>}
</div>
</>
) : (
<div className="flex flex-col gap-4">
{enquiries.map((e) => (
<div key={e.id} className="bg-white rounded-3xl p-6 shadow-xs">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div className="font-medium text-bakery-900">
{e.name} <a href={`mailto:${e.email}`} className="font-normal text-bakery-600 underline underline-offset-4">{e.email}</a>
</div>
<div className="text-sm text-bakery-600">
{new Date(e.receivedAt).toLocaleString()}
{!e.delivered && (
// The enquiry was saved but the relay refused it — nobody got an email.
<span className="ml-2 rounded-full bg-amber-100 px-2 py-0.5 text-xs text-amber-800">
not emailed
</span>
)}
</div>
</div>
<p className="mt-3 whitespace-pre-wrap text-bakery-800">{e.message}</p>
</div>
))}
{enquiries.length === 0 && <p className="text-bakery-700">No enquiries yet.</p>}
</div>
)}
</div>
);
}
@@ -0,0 +1,159 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
createProduct, fetchAdminProducts, updateProduct, uploadPhoto,
type AdminProduct,
} from '@/lib/api';
import { useAuth, signIn } from '@/lib/auth';
export default function ProductEditorPage() {
const { id } = useParams();
const editing = id !== undefined;
const navigate = useNavigate();
const { me, loading } = useAuth();
const [name, setName] = useState('');
const [category, setCategory] = useState('');
const [position, setPosition] = useState<number | null>(null);
const [images, setImages] = useState<{ key: string; url: string }[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (!editing || !me.admin) return;
fetchAdminProducts()
.then((all) => {
const p = all.find((x: AdminProduct) => String(x.id) === id);
if (!p) { setError('That product no longer exists.'); return; }
setName(p.name);
setCategory(p.category);
setPosition(p.position);
setImages(p.imageKeys.map((k, i) => ({ key: k, url: p.imageUrls[i] })));
})
.catch((e) => setError(String(e.message ?? e)));
}, [editing, id, me.admin]);
if (loading) return <p className="container mx-auto px-4 py-20 text-bakery-700">Loading</p>;
if (!me.admin) {
return (
<div className="container mx-auto px-4 py-20 text-center">
<button onClick={signIn} className="px-8 py-3.5 bg-bakery-600 text-white rounded-full">Sign in</button>
</div>
);
}
async function onFiles(files: FileList | null) {
if (!files?.length) return;
setBusy(true); setError('');
try {
// Sequentially, so the order the photos are chosen is the order they appear on the card.
for (const file of Array.from(files)) {
const t = await uploadPhoto(file);
setImages((list) => [...list, { key: t.key, url: t.publicUrl }]);
}
} catch (e) {
setError(String((e as Error).message));
} finally {
setBusy(false);
}
}
async function save(e: React.FormEvent) {
e.preventDefault();
setBusy(true); setError('');
try {
const form = { name, category, position, imageKeys: images.map((i) => i.key) };
if (editing) await updateProduct(Number(id), form);
else await createProduct(form);
navigate('/admin');
} catch (err) {
setError(String((err as Error).message));
setBusy(false);
}
}
const move = (from: number, to: number) => {
if (to < 0 || to >= images.length) return;
setImages((list) => {
const next = [...list];
const [it] = next.splice(from, 1);
next.splice(to, 0, it);
return next;
});
};
return (
<div className="container mx-auto px-4 py-12 max-w-2xl">
<h1 className="font-adbhashitha text-4xl text-bakery-900 mb-8">
{editing ? 'Edit product' : 'Add a product'}
</h1>
{error && <p role="alert" className="mb-6 rounded-xl bg-red-50 px-4 py-3 text-red-800">{error}</p>}
<form onSubmit={save} className="bg-white rounded-3xl p-8 shadow-xs">
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="name">Name</label>
<input
id="name" value={name} onChange={(e) => setName(e.target.value)} required
className="mb-5 w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500"
/>
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="category">Category</label>
<input
id="category" value={category} onChange={(e) => setCategory(e.target.value)} required
placeholder="Cakes, Cookies, Rolls, Pie, Brownies, Pastries"
className="mb-1 w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500"
/>
<p className="mb-5 text-xs text-bakery-600">
A new category appears as its own filter button, after the ones the site already knows.
</p>
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="photos">Photos</label>
<input
id="photos" type="file" accept="image/*" multiple disabled={busy}
onChange={(e) => onFiles(e.target.files)}
className="mb-4 block w-full text-sm text-bakery-700 file:mr-4 file:rounded-full file:border-0 file:bg-bakery-600 file:px-5 file:py-2 file:text-white"
/>
{images.length > 0 && (
<div className="mb-6 grid grid-cols-3 gap-3">
{images.map((img, i) => (
<div key={img.key} className="relative">
<img src={img.url} alt="" className="aspect-square w-full rounded-xl object-cover" />
{i === 0 && (
<span className="absolute top-1 left-1 rounded-full bg-bakery-900/70 px-2 py-0.5 text-[10px] uppercase tracking-wide text-white">
card
</span>
)}
<div className="mt-1 flex justify-between text-xs text-bakery-700">
<button type="button" onClick={() => move(i, i - 1)} aria-label="Move earlier"></button>
<button
type="button"
onClick={() => setImages((l) => l.filter((_, j) => j !== i))}
className="text-red-700"
>
remove
</button>
<button type="button" onClick={() => move(i, i + 1)} aria-label="Move later"></button>
</div>
</div>
))}
</div>
)}
<div className="flex items-center gap-4">
<button
type="submit"
disabled={busy || images.length === 0}
className="px-8 py-3 bg-bakery-600 text-white rounded-full tracking-wide hover:bg-bakery-700 transition-colors disabled:opacity-60"
>
{busy ? 'Working…' : 'Save'}
</button>
<button type="button" onClick={() => navigate('/admin')} className="text-bakery-700 underline underline-offset-4">
Cancel
</button>
{images.length === 0 && <span className="text-sm text-bakery-600">Add at least one photo.</span>}
</div>
</form>
</div>
);
}