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
+90
View File
@@ -11,6 +11,16 @@ export interface Product {
images: string[];
}
/**
* Spring Security protects every mutating request with a CSRF token, and the platform's security
* starter writes it to a readable XSRF-TOKEN cookie. Without this header a POST is rejected 403 —
* including the public contact form, which is not obvious until the form stops working.
*/
function csrfHeaders(): Record<string, string> {
const token = document.cookie.split('; ').find((c) => c.startsWith('XSRF-TOKEN='))?.split('=')[1];
return token ? { 'X-XSRF-TOKEN': decodeURIComponent(token) } : {};
}
async function get<T>(path: string): Promise<T> {
const res = await fetch(path, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`${path} responded ${res.status}`);
@@ -21,3 +31,83 @@ export const fetchProducts = (category?: string) =>
get<Product[]>(category && category !== 'All' ? `/api/products?category=${encodeURIComponent(category)}` : '/api/products');
export const fetchCategories = () => get<string[]>('/api/categories');
// ---- who is signed in (public: the SPA asks on every page load) ----
export interface Me { authenticated: boolean; admin: boolean; name: string | null }
export const fetchMe = () => get<Me>('/api/me');
// ---- admin (everything below needs an Authentik login) ----
export interface AdminProduct {
id: number;
name: string;
category: string;
position: number;
imageKeys: string[];
imageUrls: string[];
}
export interface AdminEnquiry {
id: number;
name: string;
email: string;
message: string;
delivered: boolean;
receivedAt: string;
}
export interface ProductForm {
name: string;
category: string;
position: number | null;
imageKeys: string[];
}
async function send<T>(path: string, method: string, body?: unknown): Promise<T> {
const res = await fetch(path, {
method,
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...csrfHeaders() },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) {
// The backend puts a human-readable reason in `detail`; show that rather than a status code.
let detail = `${method} ${path} responded ${res.status}`;
try { detail = (await res.json()).detail ?? detail; } catch { /* not JSON */ }
throw new Error(detail);
}
return res.status === 204 ? (undefined as T) : (res.json() as Promise<T>);
}
export const fetchAdminProducts = () => get<AdminProduct[]>('/api/admin/products');
export const createProduct = (f: ProductForm) => send<AdminProduct>('/api/admin/products', 'POST', f);
export const updateProduct = (id: number, f: ProductForm) =>
send<AdminProduct>(`/api/admin/products/${id}`, 'PUT', f);
export const deleteProduct = (id: number) => send<void>(`/api/admin/products/${id}`, 'DELETE');
export const fetchEnquiries = () => get<AdminEnquiry[]>('/api/admin/enquiries');
export interface UploadTarget { key: string; uploadUrl: string; publicUrl: string }
/** Presign, then PUT the file straight to the bucket — the photo never passes through the app. */
export async function uploadPhoto(file: File): Promise<UploadTarget> {
const target = await send<UploadTarget>(
`/api/admin/images/presign-upload?filename=${encodeURIComponent(file.name)}`
+ `&contentType=${encodeURIComponent(file.type || 'application/octet-stream')}`,
'POST');
// Straight to the bucket, so no CSRF header here — it is a different origin and a presigned URL.
const put = await fetch(target.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type || 'application/octet-stream' },
body: file,
});
if (!put.ok) throw new Error(`the bucket rejected the upload (${put.status})`);
return target;
}
/** The public contact form. Mutating, so it needs the CSRF token too. */
export async function submitContact(input: { name: string; email: string; message: string }) {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
body: JSON.stringify(input),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Could not send the message.');
return data;
}