Merge the catalogue admin (your branch, reconciled onto main)

This commit is contained in:
2026-07-23 13:10:46 -05:00
26 changed files with 1790 additions and 1037 deletions
+4 -1
View File
@@ -15,7 +15,10 @@ RUN --mount=type=secret,id=maven_user --mount=type=secret,id=maven_token \
# ---------- runtime ---------- # ---------- runtime ----------
FROM eclipse-temurin:25-jre-alpine AS runtime FROM eclipse-temurin:25-jre-alpine AS runtime
RUN apk -U upgrade --no-cache && apk add --no-cache curl # libwebp-tools supplies cwebp, which ProductPhotoService shells out to when an editor uploads a
# photo. Alpine's build is musl-native — the Java webp writers on Maven Central bundle glibc natives
# that will not load here, and no pure-Java webp encoder exists.
RUN apk -U upgrade --no-cache && apk add --no-cache curl libwebp-tools
RUN addgroup -S spring && adduser -S -D -H -h /app -s /sbin/nologin -G spring spring RUN addgroup -S spring && adduser -S -D -H -h /app -s /sbin/nologin -G spring spring
WORKDIR /app WORKDIR /app
+25
View File
@@ -29,6 +29,25 @@ The SPA renders; it doesn't decide anything.
- **Per-page metadata** — `PageMetaController` rewrites `<title>`/`<meta>`/OG tags per route. Next used - **Per-page metadata** — `PageMetaController` rewrites `<title>`/`<meta>`/OG tags per route. Next used
to server-render these; a plain SPA would hand crawlers and link-preview scrapers one generic shell. to server-render these; a plain SPA would hand crawlers and link-preview scrapers one generic shell.
## /admin
The catalogue is editable from the site: add an item with a photo and a name, reorder it, rename or
reorder the category filters. Nothing there needs a deploy or a migration — which is the point, since
the person adding a cake is the person who baked it.
Photos are resized, stripped of EXIF, converted to webp and put in the bucket on upload
(`ProductPhotoService`, using `cwebp` from `libwebp-tools` — the pure-Java encoders either can't write
webp or ship glibc natives that don't run on Alpine).
**The admin only exists when `SECURITY_MODE=OIDC`.** `AdminProductController` and
`AdminCategoryController` are `@ConditionalOnProperty` on it, so a deployment that forgets to configure
Authentik gets 404s rather than catalogue writes open to the internet. `/admin` and `/api/admin/**` are
both authenticated paths: a browser opening the page is sent to Authentik first, while `fetch` calls get
a bare 401 to handle.
Known gap: `StorageService` has no delete, so removing a product or a photo leaves the object in the
bucket. Harmless — nothing links to it — but it accumulates.
## Photos ## Photos
Re-encoded to webp and uploaded to the bucket once (50 MB of originals → 14 MB), served with a Re-encoded to webp and uploaded to the bucket once (50 MB of originals → 14 MB), served with a
@@ -63,6 +82,12 @@ mvn verify
| `CONTACT_HUB_URL` | optional n8n webhook; best-effort, never blocks a submission | | `CONTACT_HUB_URL` | optional n8n webhook; best-effort, never blocks a submission |
| `SITE_BASE_URL` | absolute base for `og:url` | | `SITE_BASE_URL` | absolute base for `og:url` |
| `VITE_ASSET_BASE` / `site.assets.base-url` | photo bucket | | `VITE_ASSET_BASE` / `site.assets.base-url` | photo bucket |
| `SECURITY_MODE` | `OIDC` turns on Authentik login **and brings `/admin` into existence**. Unset = brochure site, no admin |
| `STORAGE_ENDPOINT` / `STORAGE_ACCESS_KEY` / `STORAGE_SECRET_KEY` / `STORAGE_BUCKET` | MinIO, for admin photo uploads. Blank endpoint leaves storage switched off |
With `SECURITY_MODE=OIDC` the app also needs the standard Spring OAuth2 client properties for the
Authentik application — `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_*` and
`..._PROVIDER_*_ISSUER_URI`. The starter configures the filter chain, not the identity provider.
A missing `CONTACT_TO` **stops the app from starting**. That is deliberate: `application.yaml` maps it A missing `CONTACT_TO` **stops the app from starting**. That is deliberate: `application.yaml` maps it
to `platform.contact.to`, and an unset variable leaves the property present-but-empty, which is enough to `platform.contact.to`, and an unset variable leaves the property present-but-empty, which is enough
+32 -16
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Route, Routes, useLocation } from 'react-router-dom'; import { Outlet, Route, Routes, useLocation } from 'react-router-dom';
import Header from '@/components/Header'; import Header from '@/components/Header';
import Footer from '@/components/Footer'; import Footer from '@/components/Footer';
import HomePage from '@/pages/Home'; import HomePage from '@/pages/Home';
@@ -7,9 +7,7 @@ import ProductsPage from '@/pages/Products';
import HistoryPage from '@/pages/History'; import HistoryPage from '@/pages/History';
import ContactPage from '@/pages/Contact'; import ContactPage from '@/pages/Contact';
import NotFoundPage from '@/pages/NotFound'; import NotFoundPage from '@/pages/NotFound';
import AdminPage from '@/pages/admin/AdminPage'; import AdminPage from '@/pages/Admin';
import ProductEditorPage from '@/pages/admin/ProductEditorPage';
import { AuthProvider } from '@/lib/auth';
/** /**
* Client-side navigation keeps the previous scroll position, which lands you halfway down a page you * Client-side navigation keeps the previous scroll position, which lands you halfway down a page you
@@ -25,26 +23,44 @@ const ScrollToTop = () => {
return null; return null;
}; };
const App = () => ( /** The shop front: the nav, the footer, and the pages a customer sees. */
<AuthProvider> const PublicLayout = () => (
<div className="min-h-screen bg-bakery-50 flex flex-col"> <div className="min-h-screen bg-bakery-50 flex flex-col">
<ScrollToTop />
<Header /> <Header />
<main className="flex-grow"> <main className="flex-grow">
<Routes> <Outlet />
</main>
<Footer />
</div>
);
/**
* The admin sits outside the public chrome deliberately. It isn't a page you'd browse to — the nav
* would offer a signed-in editor links away from unsaved work, and the opening hours in the footer
* are noise on a screen whose whole job is the catalogue.
*/
const AdminLayout = () => (
<div className="min-h-screen bg-bakery-50">
<Outlet />
</div>
);
const App = () => (
<>
<ScrollToTop />
<Routes>
<Route element={<PublicLayout />}>
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/products" element={<ProductsPage />} /> <Route path="/products" element={<ProductsPage />} />
<Route path="/history" element={<HistoryPage />} /> <Route path="/history" element={<HistoryPage />} />
<Route path="/contact" element={<ContactPage />} /> <Route path="/contact" element={<ContactPage />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/admin/products/new" element={<ProductEditorPage />} />
<Route path="/admin/products/:id" element={<ProductEditorPage />} />
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
</Routes> </Route>
</main> <Route element={<AdminLayout />}>
<Footer /> <Route path="/admin" element={<AdminPage />} />
</div> </Route>
</AuthProvider> </Routes>
</>
); );
export default App; export default App;
+103 -91
View File
@@ -1,105 +1,117 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react'; import { motion, AnimatePresence } from 'motion/react';
import { Link } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import Logo from './Logo'; import Logo from './Logo';
const navItems = [
{ label: 'Our Products', href: '/products' },
{ label: 'Our Story', href: '/history' },
{ label: 'Contact', href: '/contact' },
];
const Header = () => { const Header = () => {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const { pathname } = useLocation();
const navItems = [ // Close on navigation — without this the panel stays up over the page you just opened.
{ label: 'Our Products', href: '/products' }, useEffect(() => setIsMobileMenuOpen(false), [pathname]);
{ label: 'Our Story', href: '/history' },
{ label: 'Contact', href: '/contact' }, // Escape closes it, and the page behind it doesn't scroll while it's up.
]; useEffect(() => {
if (!isMobileMenuOpen) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setIsMobileMenuOpen(false); };
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', onKey);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener('keydown', onKey);
};
}, [isMobileMenuOpen]);
return ( return (
<header className="sticky top-0 z-40 bg-bakery-50/90 backdrop-blur border-b border-bakery-200"> <>
<div className="container mx-auto px-4"> <header className="sticky top-0 z-40 bg-bakery-50/90 backdrop-blur border-b border-bakery-200">
<div className="flex items-center justify-between gap-2 h-20 md:h-24"> <div className="container mx-auto px-4">
{/* Logo */} <div className="flex items-center justify-between gap-2 h-20 md:h-24">
<Logo className="text-bakery-700" /> {/* Logo */}
{/* Desktop Navigation */} <Logo className="text-bakery-700" />
<nav className="hidden md:flex items-center gap-8"> {/* Desktop Navigation */}
{navItems.map((item) => ( <nav className="hidden md:flex items-center gap-8">
<Link {navItems.map((item) => (
key={item.href} <Link
to={item.href} key={item.href}
className="text-sm uppercase tracking-[0.15em] text-bakery-700 hover:text-bakery-900 transition" to={item.href}
className="text-sm uppercase tracking-[0.15em] text-bakery-700 hover:text-bakery-900 transition"
>
{item.label}
</Link>
))}
</nav>
{/* Mobile menu button — the same control opens and closes, so the bar never
disappears out from under your thumb. */}
<button
className="md:hidden p-2 shrink-0"
onClick={() => setIsMobileMenuOpen((open) => !open)}
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
aria-expanded={isMobileMenuOpen}
>
<svg
className="h-6 w-6 text-bakery-700"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
> >
{item.label} {isMobileMenuOpen ? <path d="M6 18L18 6M6 6l12 12" /> : <path d="M4 6h16M4 12h16M4 18h16" />}
</Link> </svg>
))} </button>
</nav> </div>
{/* Mobile menu button */}
<button
className="md:hidden p-2 shrink-0"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
aria-label="Toggle menu"
aria-expanded={isMobileMenuOpen}
>
<svg
className="h-6 w-6 text-bakery-700"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
</div> </div>
</header>
{/* Mobile Navigation — must UNMOUNT when closed. A panel parked off-screen {/* Mobile navigation. Three things here are load-bearing:
at translate-x-full still extends the scrollable area, which is what let
you scroll sideways and find the menu. */} It lives OUTSIDE <header>. The header carries `backdrop-blur`, and a backdrop-filter
<AnimatePresence> makes an element a containing block for fixed-position descendants — so a `fixed` panel
{isMobileMenuOpen && ( nested inside it resolves against the 80px header box, not the viewport, and gets
<motion.div clipped to a sliver.
initial={{ x: '100%' }}
animate={{ x: 0 }} It starts BELOW the bar (`top-20`) instead of covering it, so the logo and the toggle
exit={{ x: '100%' }} stay put and the panel needs no second copy of either. One logo, one position, every
transition={{ type: 'tween', duration: 0.25, ease: 'easeOut' }} breakpoint.
className="md:hidden fixed inset-0 bg-bakery-50 z-50"
> It must UNMOUNT when closed: a panel parked off-screen still extends the scrollable
<div className="p-4"> area, which is what used to let you scroll sideways and find the menu. */}
<div className="flex justify-between items-center gap-2 mb-8 h-16"> <AnimatePresence>
<Logo className="text-bakery-800" /> {isMobileMenuOpen && (
<button <motion.div
onClick={() => setIsMobileMenuOpen(false)} initial={{ opacity: 0, y: -8 }}
className="p-2 shrink-0" animate={{ opacity: 1, y: 0 }}
aria-label="Close menu" exit={{ opacity: 0, y: -8 }}
> transition={{ duration: 0.2, ease: 'easeOut' }}
<svg className="md:hidden fixed inset-x-0 top-20 bottom-0 z-30 bg-bakery-50"
className="h-6 w-6 text-bakery-700" >
fill="none" <nav className="container mx-auto px-4 flex flex-col">
viewBox="0 0 24 24" {navItems.map((item) => (
stroke="currentColor" <Link
> key={item.href}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> to={item.href}
</svg> className="text-bakery-800 hover:text-bakery-600 transition py-4 text-lg border-b border-bakery-100"
</button> onClick={() => setIsMobileMenuOpen(false)}
</div> >
<nav className="flex flex-col"> {item.label}
{navItems.map((item) => ( </Link>
<Link ))}
key={item.href} </nav>
to={item.href} </motion.div>
className="text-bakery-800 hover:text-bakery-600 transition py-4 text-lg" )}
onClick={() => setIsMobileMenuOpen(false)} </AnimatePresence>
> </>
{item.label}
</Link>
))}
</nav>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</header>
); );
}; };
+53 -20
View File
@@ -5,31 +5,58 @@ interface ProductGalleryProps {
alt: string; alt: string;
} }
const Chevron = ({ direction }: { direction: 'left' | 'right' }) => (
<svg
className="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d={direction === 'left' ? 'M15 18l-6-6 6-6' : 'M9 18l6-6-6-6'} />
</svg>
);
/** /**
* The square photo on a product card, with arrows when there's more than one shot. * The square photo on a product card, with arrows and dots when there's more than one shot.
* *
* Replaces react-awesome-slider, which hasn't been published since 2020 and pins peer deps to * 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 * 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, * 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 — plus swipe and keyboard, which the old slider had on touch * arrows only when they'd do something.
* 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. */ /** Past this many pixels a horizontal drag counts as a swipe rather than a tap or a page scroll. */
const SWIPE_THRESHOLD = 40; const SWIPE_THRESHOLD = 40;
const ProductGallery: React.FC<ProductGalleryProps> = ({ images, alt }) => { const ProductGallery: React.FC<ProductGalleryProps> = ({ images, alt }) => {
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
// Filtering swaps the product under a reused component instance, so a stale index can point
// past the new list — every frame then renders at opacity-0 and the card goes blank. Reset
// during render (the React-sanctioned way to derive state from props) rather than in an effect,
// so the correct frame paints on the first pass instead of flashing an empty square.
const [renderedFor, setRenderedFor] = useState(images);
if (renderedFor !== images) {
setRenderedFor(images);
setIndex(0);
}
const many = images.length > 1; const many = images.length > 1;
const touchStart = useRef<{ x: number; y: number } | null>(null); const active = index < images.length ? index : 0;
const step = (delta: number) => setIndex((i) => (i + delta + images.length) % images.length); const step = (delta: number) => setIndex((i) => (i + delta + images.length) % images.length);
// Swipe on touch devices and arrow keys — the react-awesome-slider this replaced had swipe, and
// the products page is browsed mostly on phones. Vertical drags are left alone so the page still
// scrolls through the card.
const touchStart = useRef<{ x: number; y: number } | null>(null);
const onTouchStart = (e: React.TouchEvent) => { const onTouchStart = (e: React.TouchEvent) => {
const t = e.touches[0]; const t = e.touches[0];
touchStart.current = { x: t.clientX, y: t.clientY }; touchStart.current = { x: t.clientX, y: t.clientY };
}; };
const onTouchEnd = (e: React.TouchEvent) => { const onTouchEnd = (e: React.TouchEvent) => {
const start = touchStart.current; const start = touchStart.current;
touchStart.current = null; touchStart.current = null;
@@ -37,11 +64,13 @@ const ProductGallery: React.FC<ProductGalleryProps> = ({ images, alt }) => {
const t = e.changedTouches[0]; const t = e.changedTouches[0];
const dx = t.clientX - start.x; const dx = t.clientX - start.x;
const dy = t.clientY - start.y; 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; if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dx) <= Math.abs(dy)) return;
step(dx < 0 ? 1 : -1); step(dx < 0 ? 1 : -1);
}; };
const arrowClass =
'absolute top-1/2 -translate-y-1/2 grid place-items-center h-10 w-10 rounded-full bg-bakery-900/40 text-white backdrop-blur-sm transition hover:bg-bakery-900/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-white';
return ( return (
<div <div
className="relative aspect-square bg-bakery-100" className="relative aspect-square bg-bakery-100"
@@ -64,10 +93,10 @@ const ProductGallery: React.FC<ProductGalleryProps> = ({ images, alt }) => {
alt={i === 0 ? alt : ''} alt={i === 0 ? alt : ''}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${ className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 motion-reduce:transition-none ${
i === index ? 'opacity-100' : 'opacity-0 pointer-events-none' i === active ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`} }`}
aria-hidden={i === index ? undefined : true} aria-hidden={i === active ? undefined : true}
/> />
))} ))}
@@ -77,26 +106,30 @@ const ProductGallery: React.FC<ProductGalleryProps> = ({ images, alt }) => {
type="button" type="button"
onClick={() => step(-1)} onClick={() => step(-1)}
aria-label={`Previous photo of ${alt}`} aria-label={`Previous photo of ${alt}`}
className="absolute left-2 top-1/2 -translate-y-1/2 grid place-items-center h-10 w-10 rounded-full bg-bakery-900/40 text-white text-2xl leading-none backdrop-blur-sm transition hover:bg-bakery-900/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-white" className={`${arrowClass} left-2`}
> >
<span aria-hidden="true">{'<'}</span> <Chevron direction="left" />
</button> </button>
<button <button
type="button" type="button"
onClick={() => step(1)} onClick={() => step(1)}
aria-label={`Next photo of ${alt}`} aria-label={`Next photo of ${alt}`}
className="absolute right-2 top-1/2 -translate-y-1/2 grid place-items-center h-10 w-10 rounded-full bg-bakery-900/40 text-white text-2xl leading-none backdrop-blur-sm transition hover:bg-bakery-900/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-white" className={`${arrowClass} right-2`}
> >
<span aria-hidden="true">{'>'}</span> <Chevron direction="right" />
</button> </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. */} {/* Dots: how many photos there are, and which one you're on. */}
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex gap-1.5" aria-hidden="true"> <div className="absolute inset-x-0 bottom-3 flex justify-center gap-1.5">
{images.map((src, i) => ( {images.map((src, i) => (
<span <button
key={src} key={src}
className={`h-1.5 w-1.5 rounded-full transition ${ type="button"
i === index ? 'bg-white' : 'bg-white/40' onClick={() => setIndex(i)}
aria-label={`Show photo ${i + 1} of ${images.length} of ${alt}`}
aria-current={i === active ? 'true' : undefined}
className={`h-1.5 rounded-full shadow-xs transition-all motion-reduce:transition-none focus:outline-none focus-visible:ring-2 focus-visible:ring-white ${
i === active ? 'w-4 bg-white' : 'w-1.5 bg-white/60 hover:bg-white/80'
}`} }`}
/> />
))} ))}
+87 -74
View File
@@ -11,14 +11,19 @@ export interface Product {
images: string[]; images: string[];
} }
/** /** What the admin screens get back: the public shape plus where it sits in the order. */
* Spring Security protects every mutating request with a CSRF token, and the platform's security export interface AdminProduct extends Product {
* starter writes it to a readable XSRF-TOKEN cookie. Without this header a POST is rejected 403 — position: number;
* including the public contact form, which is not obvious until the form stops working. /** The same photos as `images`, in the same order — these are what arrangePhotos names them by. */
*/ keys: string[];
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) } : {}; export interface AdminCategory {
id: number;
name: string;
position: number;
/** How many products are filed under it — deleting one that's in use is refused. */
used: number;
} }
async function get<T>(path: string): Promise<T> { async function get<T>(path: string): Promise<T> {
@@ -32,82 +37,90 @@ export const fetchProducts = (category?: string) =>
export const fetchCategories = () => get<string[]>('/api/categories'); 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 } * Spring hands the SPA a CSRF token in a cookie and wants it echoed on anything that writes. Read
export const fetchMe = () => get<Me>('/api/me'); * per request rather than cached: it rotates on sign-in, and a stale token fails exactly like a
* missing one. Returns nothing when security is off, which is why the contact form still posts
// ---- admin (everything below needs an Authentik login) ---- * happily on a deployment with no identity provider.
export interface AdminProduct { */
id: number; export function csrfHeader(): Record<string, string> {
name: string; const token = document.cookie
category: string; .split('; ')
position: number; .find((c) => c.startsWith('XSRF-TOKEN='))
imageKeys: string[]; ?.slice('XSRF-TOKEN='.length);
imageUrls: string[]; return token ? { 'X-XSRF-TOKEN': decodeURIComponent(token) } : {};
}
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> { /**
* Every admin write funnels through here so one place understands the server's failure shapes: a
* 401/403 means the session lapsed (the OIDC chain answers /api with a status rather than bouncing
* you to a login page), and anything else carries a ProblemDetail whose `detail` is the sentence
* the server wants the editor to read.
*/
async function send<T>(path: string, method: string, body?: unknown, form?: FormData): Promise<T> {
const res = await fetch(path, { const res = await fetch(path, {
method, method,
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...csrfHeaders() }, headers: {
body: body === undefined ? undefined : JSON.stringify(body), Accept: 'application/json',
...(form ? {} : { 'Content-Type': 'application/json' }),
...csrfHeader(),
},
body: form ?? (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. if (res.status === 401 || res.status === 403) {
let detail = `${method} ${path} responded ${res.status}`; throw new Error('Your sign-in has expired — refresh the page to sign in again.');
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>); if (!res.ok) {
const problem = await res.json().catch(() => null);
throw new Error(problem?.detail || problem?.error || 'That did not save. Please try again.');
}
return (res.status === 204 ? undefined : await res.json()) as T;
} }
export const fetchAdminProducts = () => get<AdminProduct[]>('/api/admin/products'); // --- 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 } export const adminProducts = () => get<AdminProduct[]>('/api/admin/products');
/** Presign, then PUT the file straight to the bucket — the photo never passes through the app. */ export function createProduct(name: string, category: string, photos: File[]) {
export async function uploadPhoto(file: File): Promise<UploadTarget> { const form = new FormData();
const target = await send<UploadTarget>( form.append('name', name);
`/api/admin/images/presign-upload?filename=${encodeURIComponent(file.name)}` form.append('category', category);
+ `&contentType=${encodeURIComponent(file.type || 'application/octet-stream')}`, photos.forEach((p) => form.append('photos', p));
'POST'); return send<AdminProduct>('/api/admin/products', 'POST', undefined, form);
// 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 const describeProduct = (id: number, name: string, category: string) =>
export async function submitContact(input: { name: string; email: string; message: string }) { send<AdminProduct>(`/api/admin/products/${id}`, 'PUT', { name, category });
const res = await fetch('/api/contact', {
method: 'POST', export const deleteProduct = (id: number) =>
headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, send<{ ok: boolean }>(`/api/admin/products/${id}`, 'DELETE');
body: JSON.stringify(input),
}); export function addPhotos(id: number, photos: File[]) {
const data = await res.json().catch(() => ({})); const form = new FormData();
if (!res.ok) throw new Error(data.error || 'Could not send the message.'); photos.forEach((p) => form.append('photos', p));
return data; return send<AdminProduct>(`/api/admin/products/${id}/photos`, 'POST', undefined, form);
} }
/** The full arrangement the editor is looking at — removing a photo is just an omission. */
export const arrangePhotos = (id: number, keys: string[]) =>
send<AdminProduct>(`/api/admin/products/${id}/photos`, 'PUT', keys);
export const reorderProducts = (ids: number[]) =>
send<AdminProduct[]>('/api/admin/products/order', 'PUT', { ids });
// --- categories -------------------------------------------------------------
export const adminCategories = () => get<AdminCategory[]>('/api/admin/categories');
export const createCategory = (name: string) =>
send<AdminCategory>('/api/admin/categories', 'POST', { name });
export const renameCategory = (id: number, name: string) =>
send<AdminCategory>(`/api/admin/categories/${id}`, 'PUT', { name });
export const reorderCategories = (ids: number[]) =>
send<AdminCategory[]>('/api/admin/categories/order', 'PUT', { ids });
export const deleteCategory = (id: number) =>
send<{ ok: boolean }>(`/api/admin/categories/${id}`, 'DELETE');
-53
View File
@@ -1,53 +0,0 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import type { ReactNode } from 'react';
import { fetchMe, type Me } from './api';
const ANON: Me = { authenticated: false, admin: false, name: null };
const AuthContext = createContext<{ me: Me; loading: boolean }>({ me: ANON, loading: true });
/**
* Resolves the signed-in user from the PUBLIC /api/me.
*
* It has to be public: this runs on every page load, and if it required a login every anonymous
* visitor would be bounced to Authentik just to read the menu.
*/
/** Where the reader was when they clicked Sign in, so they can be put back afterwards. */
const RETURN_TO = 'vine:post-login-path';
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<{ me: Me; loading: boolean }>({ me: ANON, loading: true });
const navigate = useNavigate();
useEffect(() => {
fetchMe()
.then((me) => {
setState({ me, loading: false });
if (!me.authenticated) return;
// Spring only remembers where you were if you were BOUNCED off a protected page. Every route
// here is public — the SPA sends you to the identity provider itself — so there is nothing
// saved and login lands on "/". Put the reader back where they started.
const back = sessionStorage.getItem(RETURN_TO);
if (back) {
sessionStorage.removeItem(RETURN_TO);
if (back !== window.location.pathname + window.location.search) {
navigate(back, { replace: true });
}
}
})
.catch(() => setState({ me: ANON, loading: false }));
}, [navigate]);
return <AuthContext.Provider value={state}>{children}</AuthContext.Provider>;
}
export const useAuth = () => useContext(AuthContext);
/** Full-page navigation, not fetch: the OIDC handshake is a redirect chain the browser must follow. */
export const signIn = () => {
// sessionStorage rather than a query parameter: it survives the whole redirect chain, stays in this
// tab, and never becomes something an attacker can point at another site.
sessionStorage.setItem(RETURN_TO, window.location.pathname + window.location.search);
window.location.href = '/oauth2/authorization/authentik';
};
+716
View File
@@ -0,0 +1,716 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
addPhotos,
adminCategories,
adminProducts,
arrangePhotos,
createCategory,
createProduct,
deleteCategory,
deleteProduct,
describeProduct,
renameCategory,
reorderCategories,
reorderProducts,
type AdminCategory,
type AdminProduct,
} from '@/lib/api';
/**
* The catalogue, editable by the person who bakes it.
*
* The whole screen is built around one rule: nothing waits on the network to look like it happened.
* Reordering and deleting apply to the list on screen first and reconcile afterwards, because an
* editor tidying twenty items shouldn't be typing into a page that freezes between every click.
* Uploads are the exception — they genuinely take a moment (resize, convert, send), so they say so.
*
* Getting here at all means signing in: /admin is an authenticated path, so an unknown visitor is
* sent to the identity provider before this ever loads.
*/
// --- little pieces ----------------------------------------------------------
const Icon = ({ d, className = '' }: { d: string; className?: string }) => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={`w-4 h-4 ${className}`}
aria-hidden="true"
>
<path d={d} />
</svg>
);
const ARROW_UP = 'M12 19V5M5 12l7-7 7 7';
const ARROW_DOWN = 'M12 5v14M19 12l-7 7-7-7';
const ARROW_LEFT = 'M19 12H5M12 19l-7-7 7-7';
const ARROW_RIGHT = 'M5 12h14M12 5l7 7-7 7';
const TRASH = 'M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6';
const PLUS = 'M12 5v14M5 12h14';
const CHECK = 'M20 6L9 17l-5-5';
const X = 'M18 6L6 18M6 6l12 12';
const button =
'inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium ' +
'transition-colors disabled:opacity-40 disabled:cursor-not-allowed';
const primary = `${button} bg-bakery-600 text-white hover:bg-bakery-700`;
const secondary = `${button} border border-bakery-300 text-bakery-800 hover:bg-bakery-100`;
const danger = `${button} text-red-700 hover:bg-red-50`;
const iconButton =
'inline-flex items-center justify-center w-7 h-7 rounded-md border border-bakery-300 ' +
'text-bakery-700 hover:bg-bakery-100 transition-colors disabled:opacity-30 disabled:cursor-not-allowed';
const field =
'w-full rounded-md border border-bakery-300 bg-white px-3 py-2 text-sm ' +
'focus:border-bakery-500 focus:outline-none focus:ring-1 focus:ring-bakery-500';
/** Moves one entry of a list by `delta`, or returns the list untouched if that would fall off an end. */
function shift<T>(items: T[], index: number, delta: number): T[] {
const target = index + delta;
if (target < 0 || target >= items.length) return items;
const next = [...items];
[next[index], next[target]] = [next[target], next[index]];
return next;
}
// --- photos -----------------------------------------------------------------
/**
* The photos on one item. Order matters — the first is the one the products page leads with — so
* arranging is left/right rather than a drag target, which is far easier to hit on a phone.
*/
const Photos = ({
product,
onArrange,
busy,
}: {
product: AdminProduct;
onArrange: (keys: string[]) => void;
busy: boolean;
}) => (
<div className="flex flex-wrap gap-3">
{product.images.map((url, i) => (
<figure key={product.keys[i] ?? url} className="w-28">
<img
src={url}
alt=""
loading="lazy"
className="w-28 h-28 rounded-md object-cover border border-bakery-200 bg-bakery-100"
/>
<figcaption className="mt-1 flex items-center justify-between gap-1">
<div className="flex gap-1">
<button
type="button"
className={iconButton}
disabled={busy || i === 0}
onClick={() => onArrange(shift(product.keys, i, -1))}
aria-label="Move photo earlier"
>
<Icon d={ARROW_LEFT} />
</button>
<button
type="button"
className={iconButton}
disabled={busy || i === product.images.length - 1}
onClick={() => onArrange(shift(product.keys, i, 1))}
aria-label="Move photo later"
>
<Icon d={ARROW_RIGHT} />
</button>
</div>
<button
type="button"
className={iconButton}
// The server refuses an empty arrangement; saying so up front beats an error message.
disabled={busy || product.images.length === 1}
onClick={() => onArrange(product.keys.filter((_, at) => at !== i))}
aria-label="Remove photo"
title={product.images.length === 1 ? 'An item needs at least one photo' : 'Remove photo'}
>
<Icon d={TRASH} />
</button>
</figcaption>
</figure>
))}
</div>
);
/** A file picker styled as a button, resetting itself so the same file can be chosen twice. */
const PhotoPicker = ({
label,
onPick,
disabled,
className = secondary,
}: {
label: string;
onPick: (files: File[]) => void;
disabled?: boolean;
className?: string;
}) => {
const input = useRef<HTMLInputElement>(null);
return (
<>
<input
ref={input}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
e.target.value = '';
if (files.length) onPick(files);
}}
/>
<button type="button" className={className} disabled={disabled} onClick={() => input.current?.click()}>
<Icon d={PLUS} />
{label}
</button>
</>
);
};
// --- one item ---------------------------------------------------------------
const ProductCard = ({
product,
categories,
first,
last,
onChange,
onMove,
onDelete,
onError,
}: {
product: AdminProduct;
categories: string[];
first: boolean;
last: boolean;
onChange: (updated: AdminProduct) => void;
onMove: (delta: number) => void;
onDelete: () => void;
onError: (message: string) => void;
}) => {
const [name, setName] = useState(product.name);
const [category, setCategory] = useState(product.category);
const [busy, setBusy] = useState(false);
// A reorder or an upload re-fetches this product; the fields should follow unless they're being
// edited, which is what the dirty check below decides.
const dirty = name !== product.name || category !== product.category;
const [synced, setSynced] = useState(product);
if (synced !== product) {
setSynced(product);
if (!dirty) {
setName(product.name);
setCategory(product.category);
}
}
const run = async (work: () => Promise<AdminProduct>) => {
setBusy(true);
try {
onChange(await work());
} catch (e) {
onError(e instanceof Error ? e.message : 'That did not save.');
} finally {
setBusy(false);
}
};
return (
<li className="rounded-lg border border-bakery-200 bg-white p-4 shadow-sm">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
<div className="flex sm:flex-col gap-1 sm:pt-1">
<button
type="button"
className={iconButton}
disabled={first}
onClick={() => onMove(-1)}
aria-label={`Move ${product.name} up`}
>
<Icon d={ARROW_UP} />
</button>
<button
type="button"
className={iconButton}
disabled={last}
onClick={() => onMove(1)}
aria-label={`Move ${product.name} down`}
>
<Icon d={ARROW_DOWN} />
</button>
</div>
<div className="flex-1 min-w-0 space-y-3">
<div className="grid gap-2 sm:grid-cols-[1fr_12rem]">
<label className="block">
<span className="sr-only">Name</span>
<input
className={field}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name"
/>
</label>
<label className="block">
<span className="sr-only">Category</span>
<select className={field} value={category} onChange={(e) => setCategory(e.target.value)}>
{/* A product can sit in a category nobody defined; don't silently retype it. */}
{!categories.includes(category) && <option value={category}>{category}</option>}
{categories.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</label>
</div>
<Photos
product={product}
busy={busy}
onArrange={(keys) => run(() => arrangePhotos(product.id, keys))}
/>
<div className="flex flex-wrap items-center gap-2">
<PhotoPicker
label="Add photos"
disabled={busy}
onPick={(files) => run(() => addPhotos(product.id, files))}
/>
{dirty && (
<>
<button
type="button"
className={primary}
disabled={busy}
onClick={() => run(() => describeProduct(product.id, name.trim(), category))}
>
<Icon d={CHECK} />
Save
</button>
<button
type="button"
className={secondary}
disabled={busy}
onClick={() => {
setName(product.name);
setCategory(product.category);
}}
>
<Icon d={X} />
Cancel
</button>
</>
)}
<button type="button" className={`${danger} ml-auto`} disabled={busy} onClick={onDelete}>
<Icon d={TRASH} />
Delete
</button>
</div>
{busy && <p className="text-sm text-bakery-600">Working</p>}
</div>
</div>
</li>
);
};
// --- adding an item ---------------------------------------------------------
const NewItem = ({
categories,
onAdded,
onError,
}: {
categories: string[];
onAdded: (product: AdminProduct) => void;
onError: (message: string) => void;
}) => {
const [name, setName] = useState('');
const [category, setCategory] = useState(categories[0] ?? '');
const [files, setFiles] = useState<File[]>([]);
const [busy, setBusy] = useState(false);
// The category list arrives after the first render, so the default has to catch up once.
useEffect(() => {
setCategory((c) => (c || categories[0] || ''));
}, [categories]);
const submit = async () => {
setBusy(true);
try {
onAdded(await createProduct(name.trim(), category, files));
setName('');
setFiles([]);
} catch (e) {
onError(e instanceof Error ? e.message : 'That did not save.');
} finally {
setBusy(false);
}
};
return (
<section className="rounded-lg border border-bakery-200 bg-white p-4 shadow-sm">
<h2 className="font-adbhashitha text-xl text-bakery-800">Add something new</h2>
<p className="mt-1 text-sm text-bakery-600">New items go to the top of the products page.</p>
<div className="mt-3 grid gap-2 sm:grid-cols-[1fr_12rem]">
<input
className={field}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="What is it? e.g. Chocolate drip cake"
/>
<select className={field} value={category} onChange={(e) => setCategory(e.target.value)}>
{categories.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
{files.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{files.map((file, i) => (
<div key={`${file.name}-${i}`} className="relative">
<img
src={URL.createObjectURL(file)}
alt=""
className="w-20 h-20 rounded-md object-cover border border-bakery-200"
// Revoking once it's painted keeps the preview from holding the file in memory.
onLoad={(e) => URL.revokeObjectURL(e.currentTarget.src)}
/>
<button
type="button"
className="absolute -top-2 -right-2 w-6 h-6 rounded-full bg-white border border-bakery-300 text-bakery-700 flex items-center justify-center"
onClick={() => setFiles(files.filter((_, at) => at !== i))}
aria-label={`Remove ${file.name}`}
>
<Icon d={X} className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
<div className="mt-3 flex flex-wrap items-center gap-2">
<PhotoPicker
label={files.length ? 'Add more photos' : 'Choose photos'}
disabled={busy}
onPick={(picked) => setFiles((current) => [...current, ...picked])}
/>
<button
type="button"
className={primary}
disabled={busy || !name.trim() || !category || files.length === 0}
onClick={submit}
>
<Icon d={PLUS} />
{busy ? 'Uploading…' : 'Add to the page'}
</button>
{busy && <span className="text-sm text-bakery-600">Photos can take a few seconds each.</span>}
</div>
</section>
);
};
// --- categories -------------------------------------------------------------
const Categories = ({
categories,
setCategories,
onError,
onChanged,
}: {
categories: AdminCategory[];
setCategories: (next: AdminCategory[]) => void;
onError: (message: string) => void;
onChanged: () => void;
}) => {
const [fresh, setFresh] = useState('');
const [editing, setEditing] = useState<number | null>(null);
const [draft, setDraft] = useState('');
const [busy, setBusy] = useState(false);
const guard = async (work: () => Promise<unknown>, optimistic?: AdminCategory[]) => {
const before = categories;
if (optimistic) setCategories(optimistic);
setBusy(true);
try {
await work();
onChanged();
} catch (e) {
if (optimistic) setCategories(before);
onError(e instanceof Error ? e.message : 'That did not save.');
} finally {
setBusy(false);
}
};
return (
<section className="rounded-lg border border-bakery-200 bg-white p-4 shadow-sm">
<h2 className="font-adbhashitha text-xl text-bakery-800">Categories</h2>
<p className="mt-1 text-sm text-bakery-600">
These are the filter buttons on the products page, in this order. Renaming one moves
everything filed under it too.
</p>
<ul className="mt-3 divide-y divide-bakery-100">
{categories.map((category, i) => (
<li key={category.id} className="flex items-center gap-2 py-2">
<div className="flex gap-1">
<button
type="button"
className={iconButton}
disabled={busy || i === 0}
onClick={() =>
guard(
() => reorderCategories(shift(categories, i, -1).map((c) => c.id)),
shift(categories, i, -1),
)
}
aria-label={`Move ${category.name} up`}
>
<Icon d={ARROW_UP} />
</button>
<button
type="button"
className={iconButton}
disabled={busy || i === categories.length - 1}
onClick={() =>
guard(
() => reorderCategories(shift(categories, i, 1).map((c) => c.id)),
shift(categories, i, 1),
)
}
aria-label={`Move ${category.name} down`}
>
<Icon d={ARROW_DOWN} />
</button>
</div>
{editing === category.id ? (
<>
<input
className={field}
value={draft}
autoFocus
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Escape' && setEditing(null)}
/>
<button
type="button"
className={primary}
disabled={busy || !draft.trim()}
onClick={() =>
guard(async () => {
await renameCategory(category.id, draft.trim());
setEditing(null);
})
}
>
<Icon d={CHECK} />
Save
</button>
<button type="button" className={secondary} onClick={() => setEditing(null)}>
<Icon d={X} />
</button>
</>
) : (
<>
<span className="flex-1 text-bakery-900">{category.name}</span>
<span className="text-sm text-bakery-500">
{category.used} item{category.used === 1 ? '' : 's'}
</span>
<button
type="button"
className={secondary}
onClick={() => {
setEditing(category.id);
setDraft(category.name);
}}
>
Rename
</button>
<button
type="button"
className={danger}
disabled={busy || category.used > 0}
title={category.used > 0 ? 'Move its items somewhere else first' : 'Delete'}
onClick={() =>
guard(
() => deleteCategory(category.id),
categories.filter((c) => c.id !== category.id),
)
}
>
<Icon d={TRASH} />
</button>
</>
)}
</li>
))}
</ul>
<div className="mt-3 flex gap-2">
<input
className={field}
value={fresh}
onChange={(e) => setFresh(e.target.value)}
placeholder="New category"
onKeyDown={(e) => e.key === 'Enter' && fresh.trim() && guard(async () => {
await createCategory(fresh.trim());
setFresh('');
})}
/>
<button
type="button"
className={primary}
disabled={busy || !fresh.trim()}
onClick={() =>
guard(async () => {
await createCategory(fresh.trim());
setFresh('');
})
}
>
<Icon d={PLUS} />
Add
</button>
</div>
</section>
);
};
// --- the page ---------------------------------------------------------------
const AdminPage = () => {
const [products, setProducts] = useState<AdminProduct[] | null>(null);
const [categories, setCategories] = useState<AdminCategory[]>([]);
const [error, setError] = useState('');
const load = useCallback(async () => {
try {
const [items, cats] = await Promise.all([adminProducts(), adminCategories()]);
setProducts(items);
setCategories(cats);
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not load the catalogue.');
setProducts([]);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const names = categories.map((c) => c.name);
/** Reorder and delete both apply on screen first — the point of this page is that it keeps up. */
const settle = async (optimistic: AdminProduct[], work: () => Promise<unknown>) => {
const before = products ?? [];
setProducts(optimistic);
try {
await work();
} catch (e) {
setProducts(before);
setError(e instanceof Error ? e.message : 'That did not save.');
}
};
return (
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-6">
<header className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="font-lejour text-4xl text-bakery-700">The Vine</h1>
<p className="text-bakery-600">Everything on the products page lives here.</p>
</div>
<div className="flex items-center gap-2">
<a href="/products" className={secondary}>
View the page
</a>
{/* A real form post: the platform's logout expects one, and it also ends the Authentik session. */}
<form method="post" action="/logout">
<button type="submit" className={secondary}>
Sign out
</button>
</form>
</div>
</header>
{error && (
<div
role="alert"
className="mt-6 flex items-start gap-3 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800"
>
<span className="flex-1">{error}</span>
<button type="button" onClick={() => setError('')} aria-label="Dismiss">
<Icon d={X} />
</button>
</div>
)}
{products === null ? (
<p className="mt-10 text-bakery-600">Loading</p>
) : (
<div className="mt-6 space-y-6">
<Categories
categories={categories}
setCategories={setCategories}
onError={setError}
// A rename rewrites the products filed under it, so the list has to come back fresh.
onChanged={() => void load()}
/>
<NewItem
categories={names}
onError={setError}
onAdded={(product) => setProducts([product, ...products])}
/>
<section>
<h2 className="font-adbhashitha text-xl text-bakery-800">
On the page ({products.length})
</h2>
<ul className="mt-3 space-y-3">
{products.map((product, i) => (
<ProductCard
key={product.id}
product={product}
categories={names}
first={i === 0}
last={i === products.length - 1}
onError={setError}
onChange={(updated) =>
setProducts(products.map((p) => (p.id === updated.id ? updated : p)))
}
onMove={(delta) => {
const moved = shift(products, i, delta);
void settle(moved, () => reorderProducts(moved.map((p) => p.id)));
}}
onDelete={() => {
if (!confirm(`Remove ${product.name} from the products page?`)) return;
void settle(
products.filter((p) => p.id !== product.id),
() => deleteProduct(product.id),
);
}}
/>
))}
</ul>
{products.length === 0 && (
<p className="mt-3 text-bakery-600">Nothing here yet add something above.</p>
)}
</section>
</div>
)}
</div>
);
};
export default AdminPage;
+11 -2
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { submitContact } from '@/lib/api'; import { csrfHeader } from '@/lib/api';
type Status = 'idle' | 'sending' | 'sent' | 'error'; type Status = 'idle' | 'sending' | 'sent' | 'error';
@@ -24,7 +24,16 @@ const ContactPage = () => {
setStatus('sending'); setStatus('sending');
setError(''); setError('');
try { try {
await submitContact(formData); // csrfHeader() is empty unless security is switched on, so this posts the same as it always
// has on a deployment with no identity provider — and keeps working once one is configured,
// where an unaccompanied POST would otherwise be rejected.
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...csrfHeader() },
body: JSON.stringify(formData),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Could not send the message.');
setStatus('sent'); setStatus('sent');
setFormData({ name: '', email: '', message: '' }); setFormData({ name: '', email: '', message: '' });
} catch (err) { } catch (err) {
+29 -37
View File
@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { fetchCategories, fetchProducts, type Product } from '@/lib/api'; import { fetchCategories, fetchProducts, type Product } from '@/lib/api';
import ProductGallery from '@/components/ProductGallery'; import ProductGallery from '@/components/ProductGallery';
@@ -38,10 +37,9 @@ const ProductsPage = () => {
{/* Categories */} {/* Categories */}
<div className="flex flex-wrap justify-center gap-4 mb-12"> <div className="flex flex-wrap justify-center gap-4 mb-12">
{categories.map((category) => ( {categories.map((category) => (
<motion.button <button
key={category} key={category}
whileHover={{ scale: 1.05 }} type="button"
whileTap={{ scale: 0.95 }}
onClick={() => setSelectedCategory(category)} onClick={() => setSelectedCategory(category)}
className={`px-6 py-2 rounded-full border text-sm uppercase tracking-[0.12em] transition-colors ${ className={`px-6 py-2 rounded-full border text-sm uppercase tracking-[0.12em] transition-colors ${
selectedCategory === category selectedCategory === category
@@ -50,7 +48,7 @@ const ProductsPage = () => {
}`} }`}
> >
{category} {category}
</motion.button> </button>
))} ))}
</div> </div>
@@ -61,39 +59,33 @@ const ProductsPage = () => {
</p> </p>
)} )}
{/* Products Grid */} {/* Products Grid — deliberately unanimated. Filtering used to run a `layout` reflow plus
<motion.div an enter/exit fade on every card, which on a 40-card grid reads as the page lurching
layout rather than responding. Swapping the list outright is instant, and the only motion
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8" left is the shadow on hover. */}
> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<AnimatePresence> {products.map((product) => (
{products.map((product) => ( <div
<motion.div key={product.id}
key={product.id} className="group bg-white rounded-3xl overflow-hidden shadow-xs transition-shadow duration-300 hover:shadow-lg"
layout >
initial={{ opacity: 0 }} {/* Image Container */}
animate={{ opacity: 1 }} <div className="relative w-full overflow-hidden">
exit={{ opacity: 0 }} <ProductGallery images={product.images} alt={product.name} />
className="group bg-white rounded-3xl overflow-hidden shadow-xs hover:shadow-lg hover:-translate-y-1 transition duration-300" </div>
>
{/* Image Container */}
<div className="relative w-full overflow-hidden">
<ProductGallery images={product.images} alt={product.name} />
</div>
{/* Content */} {/* Content */}
<div className="p-6 text-center"> <div className="p-6 text-center">
<h3 className="font-adbhashitha text-xl text-bakery-900 mb-2 tracking-wide"> <h3 className="font-adbhashitha text-xl text-bakery-900 mb-2 tracking-wide">
{product.name} {product.name}
</h3> </h3>
<span className="text-xs uppercase tracking-[0.15em] text-bakery-600"> <span className="text-xs uppercase tracking-[0.15em] text-bakery-600">
{product.category} {product.category}
</span> </span>
</div> </div>
</motion.div> </div>
))} ))}
</AnimatePresence> </div>
</motion.div>
</div> </div>
</div> </div>
); );
-148
View File
@@ -1,148 +0,0 @@
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>
);
}
@@ -1,159 +0,0 @@
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>
);
}
+6 -4
View File
@@ -62,8 +62,10 @@
<groupId>net.thebennett.platform</groupId> <groupId>net.thebennett.platform</groupId>
<artifactId>platform-starter-contact</artifactId> <artifactId>platform-starter-contact</artifactId>
</dependency> </dependency>
<!-- Admin needs a login, and uploading a product photo needs the bucket. The public site <!-- The site is still a public brochure, but the catalogue is now editable from it: the admin
still reads photos straight from the bucket's public URLs. --> screens sign in against Authentik (OIDC) and upload photos to the bucket. Both are off
unless platform.security.mode=OIDC, which is what gates the admin endpoints existing at
all — see AdminProductController. -->
<dependency> <dependency>
<groupId>net.thebennett.platform</groupId> <groupId>net.thebennett.platform</groupId>
<artifactId>platform-starter-security</artifactId> <artifactId>platform-starter-security</artifactId>
@@ -85,8 +87,8 @@
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- springSecurity() for MockMvc: without it the filter chain is absent and every protected <!-- springSecurity() for MockMvc without it the filter chain is absent and every protected
path answers 200, which would make a security test prove the opposite of what it says. --> path answers 200, so a security test would prove the opposite of what it claims. -->
<dependency> <dependency>
<groupId>org.springframework.security</groupId> <groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId> <artifactId>spring-security-test</artifactId>
@@ -0,0 +1,151 @@
package com.itsthevine.web;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.itsthevine.web.domain.Category;
import com.itsthevine.web.domain.CategoryRepository;
import com.itsthevine.web.domain.Product;
import com.itsthevine.web.domain.ProductRepository;
/**
* The filter buttons, editable. Gated on OIDC for the same reason as the product admin: with no
* identity provider configured these endpoints shouldn't exist at all.
*/
@RestController
@RequestMapping("/api/admin/categories")
@ConditionalOnProperty(prefix = "platform.security", name = "mode", havingValue = "OIDC")
public class AdminCategoryController {
private final CategoryRepository categories;
private final ProductRepository products;
public AdminCategoryController(CategoryRepository categories, ProductRepository products) {
this.categories = categories;
this.products = products;
}
/** {@code used} tells the editor whether deleting it would strand anything. */
public record AdminView(Long id, String name, int position, long used) {}
public record Name(String name) {}
public record Order(List<Long> ids) {}
@GetMapping
@Transactional(readOnly = true)
public List<AdminView> list() {
List<Product> all = products.findAllByOrderByPositionAsc();
return categories.findAllByOrderByPositionAsc().stream()
.map(c -> new AdminView(c.getId(), c.getName(), c.getPosition(), count(all, c.getName())))
.toList();
}
@PostMapping
@Transactional
public AdminView create(@RequestBody Name body) {
String name = required(body.name());
categories.findByNameIgnoreCase(name).ifPresent(existing -> {
throw new IllegalStateException("There's already a " + existing.getName() + " category.");
});
int last = categories.findAllByOrderByPositionAsc().stream()
.mapToInt(Category::getPosition).max().orElse(0);
Category saved = categories.save(new Category(name, last + 1));
return new AdminView(saved.getId(), saved.getName(), saved.getPosition(), 0);
}
/**
* Renaming carries the products with it. They store the category by name, so without this the
* rename would orphan everything filed under the old one — it would drop off the filter and
* reappear at the end as an unlisted category.
*/
@PutMapping("/{id}")
@Transactional
public AdminView rename(@PathVariable Long id, @RequestBody Name body) {
Category category = find(id);
String name = required(body.name());
categories.findByNameIgnoreCase(name)
.filter(other -> !other.getId().equals(id))
.ifPresent(other -> {
throw new IllegalStateException("There's already a " + other.getName() + " category.");
});
String previous = category.getName();
category.rename(name);
categories.save(category);
List<Product> filed = products.findAllByCategoryOrderByPositionAsc(previous);
filed.forEach(p -> p.describe(p.getName(), name));
products.saveAll(filed);
return new AdminView(category.getId(), category.getName(), category.getPosition(), filed.size());
}
@PutMapping("/order")
@Transactional
public List<AdminView> reorder(@RequestBody Order order) {
List<Category> all = categories.findAllByOrderByPositionAsc();
List<Category> arranged = new ArrayList<>();
for (Long id : order.ids()) {
all.stream().filter(c -> c.getId().equals(id)).findFirst().ifPresent(arranged::add);
}
all.stream().filter(c -> !arranged.contains(c)).forEach(arranged::add);
int position = 1;
for (Category category : arranged) {
category.moveTo(position++);
}
categories.saveAll(arranged);
List<Product> everything = products.findAllByOrderByPositionAsc();
return arranged.stream()
.map(c -> new AdminView(c.getId(), c.getName(), c.getPosition(), count(everything, c.getName())))
.toList();
}
@DeleteMapping("/{id}")
@Transactional
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id) {
Category category = find(id);
long used = count(products.findAllByOrderByPositionAsc(), category.getName());
if (used > 0) {
// Refuse rather than cascade: deleting the button shouldn't quietly decide what happens to
// the items behind it.
throw new IllegalStateException(
used + " item" + (used == 1 ? " is" : "s are") + " still filed under "
+ category.getName() + ". Move them first.");
}
categories.delete(category);
return ResponseEntity.ok(Map.of("ok", true));
}
private Category find(Long id) {
return categories.findById(id)
.orElseThrow(() -> new IllegalArgumentException("That category no longer exists."));
}
private static long count(List<Product> all, String category) {
return all.stream().filter(p -> p.getCategory().equalsIgnoreCase(category)).count();
}
private static String required(String value) {
String trimmed = value == null ? "" : value.trim();
if (trimmed.isEmpty()) {
throw new IllegalArgumentException("Please give the category a name.");
}
return trimmed;
}
}
@@ -1,175 +0,0 @@
package com.itsthevine.web;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import com.itsthevine.web.domain.ContactEnquiry;
import com.itsthevine.web.domain.ContactEnquiryRepository;
import com.itsthevine.web.domain.Product;
import com.itsthevine.web.domain.ProductRepository;
import net.thebennett.platform.storage.StorageService;
/**
* Everything behind the login: the catalogue, and the enquiries people have sent.
*
* <p>The whole of {@code /api/admin/**} is gated by {@code platform.security.authenticated-paths}, so
* any signed-in Authentik user is an administrator here. That is deliberate for a two-person bakery —
* the alternative is a role model nobody would maintain.
*/
@RestController
@RequestMapping("/api/admin")
public class AdminController {
private final ProductRepository products;
private final ContactEnquiryRepository enquiries;
private final StorageService storage;
private final String bucket;
private final String publicBaseUrl;
public AdminController(ProductRepository products, ContactEnquiryRepository enquiries,
StorageService storage,
@Value("${vine.storage.bucket:itsthevine}") String bucket,
@Value("${site.assets.base-url:https://s3.thebennett.net/itsthevine}") String publicBaseUrl) {
this.products = products;
this.enquiries = enquiries;
this.storage = storage;
this.bucket = bucket;
this.publicBaseUrl = publicBaseUrl.replaceAll("/+$", "");
}
// ---- products ----
/** @param imageKeys bucket keys, in display order; the first is the one the card shows */
public record ProductForm(String name, String category, Integer position, List<String> imageKeys) {}
public record AdminProduct(Long id, String name, String category, int position,
List<String> imageKeys, List<String> imageUrls) {}
@GetMapping("/products")
@Transactional(readOnly = true)
public List<AdminProduct> list() {
return products.findAllByOrderByPositionAsc().stream().map(this::toAdmin).toList();
}
@PostMapping("/products")
@ResponseStatus(HttpStatus.CREATED)
@Transactional
public AdminProduct create(@RequestBody ProductForm form) {
validate(form);
// Default to the end of the list so a new product does not silently displace an existing one.
int position = form.position() != null ? form.position() : nextPosition();
return toAdmin(products.save(new Product(form.name().trim(), form.category().trim(),
position, cleanKeys(form.imageKeys()))));
}
@PutMapping("/products/{id}")
@Transactional
public AdminProduct update(@PathVariable Long id, @RequestBody ProductForm form) {
validate(form);
Product p = products.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "no such product"));
p.update(form.name().trim(), form.category().trim(),
form.position() != null ? form.position() : p.getPosition(),
cleanKeys(form.imageKeys()));
return toAdmin(p);
}
@DeleteMapping("/products/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
public void delete(@PathVariable Long id) {
if (!products.existsById(id)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "no such product");
}
// The photos stay in the bucket: they are cheap, and an accidental delete is recoverable if
// the images survive it.
products.deleteById(id);
}
// ---- enquiries ----
/** @param delivered false means the relay refused it and nobody was notified */
public record AdminEnquiry(Long id, String name, String email, String message,
boolean delivered, Instant receivedAt) {}
@GetMapping("/enquiries")
@Transactional(readOnly = true)
public List<AdminEnquiry> enquiries() {
return enquiries.findAllByOrderByCreatedAtDesc().stream()
.map(e -> new AdminEnquiry(e.getId(), e.getName(), e.getEmail(), e.getMessage(),
e.isDelivered(), e.getCreatedAt()))
.toList();
}
// ---- photo upload ----
/**
* @param key what to store on the product
* @param uploadUrl short-lived; the browser PUTs the file straight to the bucket so the photo
* never passes through this app
* @param publicUrl where it will be readable from afterwards
*/
public record UploadTarget(String key, String uploadUrl, String publicUrl) {}
@PostMapping("/images/presign-upload")
public UploadTarget presignUpload(@RequestParam String filename,
@RequestParam(defaultValue = "application/octet-stream") String contentType) {
// A UUID prefix rather than the bare filename: two people uploading "cake.jpg" must not
// overwrite each other, and the bucket is public so keys should not be guessable.
String safe = filename.toLowerCase().replaceAll("[^a-z0-9._-]", "-");
String key = "images/products/" + UUID.randomUUID() + "-" + safe;
return new UploadTarget(key.substring("images/".length()),
storage.presignPut(bucket, key, contentType).toString(),
publicBaseUrl + "/" + key);
}
// ---- helpers ----
private void validate(ProductForm form) {
if (form.name() == null || form.name().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "a product needs a name");
}
if (form.category() == null || form.category().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "a product needs a category");
}
if (form.imageKeys() == null || cleanKeys(form.imageKeys()).isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "a product needs at least one photo");
}
}
private static List<String> cleanKeys(List<String> keys) {
return keys == null ? List.of()
: keys.stream().filter(k -> k != null && !k.isBlank()).map(String::trim).toList();
}
private int nextPosition() {
return products.findAllByOrderByPositionAsc().stream()
.mapToInt(Product::getPosition).max().orElse(0) + 1;
}
private AdminProduct toAdmin(Product p) {
return new AdminProduct(p.getId(), p.getName(), p.getCategory(), p.getPosition(),
p.getImageKeys(), p.getImageKeys().stream().map(this::publicUrl).toList());
}
private String publicUrl(String key) {
return publicBaseUrl + "/images/" + key.replaceAll("^/+", "");
}
}
@@ -0,0 +1,206 @@
package com.itsthevine.web;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import com.itsthevine.web.domain.Product;
import com.itsthevine.web.domain.ProductRepository;
/**
* Editing the catalogue from the site, so a new cake is a photo and a name rather than a migration.
*
* The whole controller is conditional on OIDC being switched on. That is deliberate belt-and-braces:
* the platform's permit-all filter chain is what runs when {@code platform.security.mode} is unset,
* so if these endpoints existed unconditionally a deployment that forgot to configure Authentik
* would be publishing catalogue writes to the open internet. Gated this way, "no auth configured"
* means "no admin endpoints" — they 404 like any other unknown path, which is also what the platform
* web contract expects of {@code /api/**}.
*/
@RestController
@RequestMapping("/api/admin/products")
@ConditionalOnProperty(prefix = "platform.security", name = "mode", havingValue = "OIDC")
public class AdminProductController {
private final ProductRepository products;
private final ProductPhotoService photos;
private final ProductCatalog catalog;
public AdminProductController(ProductRepository products, ProductPhotoService photos, ProductCatalog catalog) {
this.products = products;
this.photos = photos;
this.catalog = catalog;
}
/**
* What the editor sees: the catalogue in display order.
*
* {@code images} and {@code keys} are the same photos in the same order — the URLs to show and the
* identifiers to arrange by. The public view only needs the former, but an editor rearranging
* photos has to name them back to us, and the URL is a rendering of the key rather than the key
* itself.
*/
public record AdminView(Long id, String name, String category, int position,
List<String> images, List<String> keys) {}
public record Details(String name, String category) {}
public record Order(List<Long> ids) {}
@GetMapping
@Transactional(readOnly = true)
public List<AdminView> list() {
return products.findAllByOrderByPositionAsc().stream().map(this::toView).toList();
}
/**
* New items go to the front — the newest work is what's worth showing first, and it saves the
* editor a reorder after every upload.
*/
@PostMapping
@Transactional
public AdminView create(@RequestParam String name,
@RequestParam String category,
@RequestParam("photos") List<MultipartFile> files) {
String cleanName = required(name, "Please give it a name.");
String cleanCategory = required(category, "Please choose a category.");
if (files == null || files.isEmpty()) {
throw new IllegalArgumentException("Please add at least one photo.");
}
List<String> keys = new ArrayList<>();
for (MultipartFile file : files) {
keys.add(photos.store(bytes(file), file.getOriginalFilename(), cleanName));
}
Product saved = products.save(new Product(cleanName, cleanCategory, 0, keys));
renumberWithFirst(saved);
return toView(saved);
}
@PutMapping("/{id}")
@Transactional
public AdminView describe(@PathVariable Long id, @RequestBody Details details) {
Product product = find(id);
product.describe(required(details.name(), "Please give it a name."),
required(details.category(), "Please choose a category."));
return toView(products.save(product));
}
@PostMapping("/{id}/photos")
@Transactional
public AdminView addPhotos(@PathVariable Long id, @RequestParam("photos") List<MultipartFile> files) {
Product product = find(id);
if (files == null || files.isEmpty()) {
throw new IllegalArgumentException("Please choose a photo to add.");
}
List<String> keys = new ArrayList<>(product.getImageKeys());
for (MultipartFile file : files) {
keys.add(photos.store(bytes(file), file.getOriginalFilename(), product.getName()));
}
product.replacePhotos(keys);
return toView(products.save(product));
}
/**
* Reordering and removal both arrive as the full list the editor arranged, so the stored order is
* whatever they last saw rather than the result of replaying moves.
*/
@PutMapping("/{id}/photos")
@Transactional
public AdminView arrangePhotos(@PathVariable Long id, @RequestBody List<String> keys) {
Product product = find(id);
List<String> existing = product.getImageKeys();
List<String> arranged = keys.stream().filter(existing::contains).distinct().toList();
if (arranged.isEmpty()) {
throw new IllegalArgumentException("An item needs at least one photo.");
}
product.replacePhotos(arranged);
return toView(products.save(product));
}
@DeleteMapping("/{id}")
@Transactional
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id) {
products.delete(find(id));
return ResponseEntity.ok(Map.of("ok", true));
}
/** The ids in the order they should appear; anything omitted keeps its relative place after them. */
@PutMapping("/order")
@Transactional
public List<AdminView> reorder(@RequestBody Order order) {
List<Product> all = products.findAllByOrderByPositionAsc();
List<Product> arranged = new ArrayList<>();
for (Long id : order.ids()) {
all.stream().filter(p -> p.getId().equals(id)).findFirst().ifPresent(arranged::add);
}
all.stream().filter(p -> !arranged.contains(p)).forEach(arranged::add);
renumber(arranged);
return arranged.stream().map(this::toView).toList();
}
private void renumberWithFirst(Product first) {
List<Product> arranged = new ArrayList<>();
arranged.add(first);
products.findAllByOrderByPositionAsc().stream()
.filter(p -> !p.getId().equals(first.getId()))
.forEach(arranged::add);
renumber(arranged);
}
/**
* {@code product.position} has no unique constraint, so ordering is a full renumber rather than a
* swap — forty rows, once in a while, from one editor.
*/
private void renumber(List<Product> arranged) {
int position = 1;
for (Product product : arranged) {
product.moveTo(position++);
}
products.saveAll(arranged);
}
private Product find(Long id) {
return products.findById(id)
.orElseThrow(() -> new IllegalArgumentException("That item no longer exists."));
}
private static byte[] bytes(MultipartFile file) {
try {
return file.getBytes();
} catch (IOException e) {
throw new IllegalStateException("Could not read the uploaded photo.", e);
}
}
private static String required(String value, String message) {
String trimmed = value == null ? "" : value.trim();
if (trimmed.isEmpty()) {
throw new IllegalArgumentException(message);
}
return trimmed;
}
/** Reuses the catalogue's URL building so admin and public pages can never disagree about a photo. */
private AdminView toView(Product product) {
ProductCatalog.ProductView view = catalog.view(product);
return new AdminView(view.id(), view.name(), view.category(), product.getPosition(),
view.images(), List.copyOf(product.getImageKeys()));
}
}
@@ -1,35 +0,0 @@
package com.itsthevine.web;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Who, if anyone, is signed in.
*
* <p>Deliberately PUBLIC: the SPA asks on every page load, and if this required a login the site would
* bounce anonymous visitors — every one of them — to Authentik just to render the front page.
*/
@RestController
public class MeController {
/** @param admin true for any signed-in user; there is one level of access here */
public record Me(boolean authenticated, boolean admin, String name) {}
@GetMapping("/api/me")
public Me me() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
boolean signedIn = auth != null && auth.isAuthenticated()
&& !"anonymousUser".equals(auth.getPrincipal());
if (!signedIn) {
return new Me(false, false, null);
}
String name = auth.getName();
if (auth.getPrincipal() instanceof OidcUser user) {
name = user.getPreferredUsername() != null ? user.getPreferredUsername() : user.getSubject();
}
return new Me(true, true, name);
}
}
@@ -15,6 +15,8 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import com.itsthevine.web.domain.Category;
import com.itsthevine.web.domain.CategoryRepository;
import com.itsthevine.web.domain.Product; import com.itsthevine.web.domain.Product;
import com.itsthevine.web.domain.ProductRepository; import com.itsthevine.web.domain.ProductRepository;
@@ -28,20 +30,15 @@ public class ProductCatalog {
/** The filter shown first — every category at once. Not a stored category. */ /** The filter shown first — every category at once. Not a stored category. */
public static final String ALL = "All"; public static final String ALL = "All";
/**
* Curated display order. The catalogue is sorted for browsing, not alphabetically, and the order
* predates the database, so it's stated here. Categories that exist in the data but aren't listed
* still show up (appended, alphabetically) rather than silently disappearing from the filter.
*/
private static final List<String> ORDER =
List.of("Cookies", "Cakes", "Rolls", "Pie", "Brownies", "Pastries");
private final ProductRepository products; private final ProductRepository products;
private final CategoryRepository categories;
private final String assetBaseUrl; private final String assetBaseUrl;
public ProductCatalog(ProductRepository products, public ProductCatalog(ProductRepository products,
CategoryRepository categories,
@Value("${site.assets.base-url:https://s3.thebennett.net/itsthevine}") String assetBaseUrl) { @Value("${site.assets.base-url:https://s3.thebennett.net/itsthevine}") String assetBaseUrl) {
this.products = products; this.products = products;
this.categories = categories;
// A trailing slash here would produce '//images/...' — harmless on most servers, but it shows // A trailing slash here would produce '//images/...' — harmless on most servers, but it shows
// up in every image URL on the page. // up in every image URL on the page.
this.assetBaseUrl = assetBaseUrl.replaceAll("/+$", ""); this.assetBaseUrl = assetBaseUrl.replaceAll("/+$", "");
@@ -60,23 +57,41 @@ public class ProductCatalog {
return found.stream().map(this::toView).toList(); return found.stream().map(this::toView).toList();
} }
/** The filter buttons, in display order, starting with "All". */ /**
* The filter buttons, in display order, starting with "All".
*
* Order comes from the category table. Only categories something is actually filed under are
* offered — an empty filter button is a dead end — and a category found on a product but missing
* from the table still shows up (appended, alphabetically) rather than silently disappearing.
*/
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<String> categories() { public List<String> categories() {
Set<String> present = products.findAllByOrderByPositionAsc().stream() Set<String> present = products.findAllByOrderByPositionAsc().stream()
.map(Product::getCategory) .map(Product::getCategory)
.collect(Collectors.toCollection(LinkedHashSet::new)); .collect(Collectors.toCollection(LinkedHashSet::new));
List<String> defined = categories.findAllByOrderByPositionAsc().stream()
.map(Category::getName)
.toList();
List<String> ordered = new ArrayList<>(); List<String> ordered = new ArrayList<>();
ordered.add(ALL); ordered.add(ALL);
ORDER.stream().filter(present::contains).forEach(ordered::add); defined.stream().filter(present::contains).forEach(ordered::add);
present.stream() present.stream()
.filter(c -> !ORDER.contains(c)) .filter(c -> !defined.contains(c))
.sorted(Comparator.naturalOrder()) .sorted(Comparator.naturalOrder())
.forEach(ordered::add); .forEach(ordered::add);
return ordered; return ordered;
} }
/**
* Public so the admin screens render photos through exactly the same URL building as the shop
* front — an editor should never arrange something that looks different once it's live.
*/
public ProductView view(Product product) {
return toView(product);
}
private ProductView toView(Product p) { private ProductView toView(Product p) {
return new ProductView(p.getId(), p.getName(), p.getCategory(), return new ProductView(p.getId(), p.getName(), p.getCategory(),
p.getImageKeys().stream().map(this::imageUrl).toList()); p.getImageKeys().stream().map(this::imageUrl).toList());
@@ -0,0 +1,176 @@
package com.itsthevine.web;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HexFormat;
import java.util.Locale;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import javax.imageio.ImageIO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import net.thebennett.platform.storage.StorageService;
/**
* Turns whatever came off a phone into the one shape the bucket holds: a resized webp.
*
* The photos already in the bucket were re-encoded by hand once (50 MB of originals became 14 MB).
* Uploads go through the same treatment so the catalogue doesn't slowly fill with 12 MP JPEGs, and
* so nothing arrives carrying the GPS coordinates of the bakery's kitchen — decoding to a
* {@link BufferedImage} and re-encoding drops every EXIF tag, because a raster has nowhere to put
* them.
*
* Encoding shells out to {@code cwebp}. No pure-Java webp *writer* exists (TwelveMonkeys and
* NightMonkeys both read only), and the libraries that do write bundle glibc natives that will not
* load on the Alpine runtime — so the Dockerfile installs Alpine's own musl build of libwebp-tools
* and we hand it bytes.
*/
@Service
public class ProductPhotoService {
private static final Logger log = LoggerFactory.getLogger(ProductPhotoService.class);
/** Big enough for a full-bleed card on a retina screen; far smaller than anything a phone shoots. */
private static final int MAX_EDGE = 2000;
private static final int QUALITY = 82;
private static final long ENCODE_TIMEOUT_SECONDS = 30;
private final StorageService storage;
private final String bucket;
private final String keyPrefix;
public ProductPhotoService(StorageService storage,
@Value("${site.assets.bucket:itsthevine}") String bucket,
@Value("${site.assets.key-prefix:images/}") String keyPrefix) {
this.storage = storage;
this.bucket = bucket;
// ProductCatalog builds public URLs as <base>/images/<stored key>, so the object itself lives
// one level deeper than the key we persist. Keeping the prefix here means the database keeps
// storing exactly what it stores today.
this.keyPrefix = keyPrefix.replaceAll("^/+", "");
}
/**
* @return the key to persist on the product — bucket-relative and WITHOUT the {@code images/}
* prefix, matching everything already in {@code product_image}
*/
public String store(byte[] original, String filename, String nameHint) {
BufferedImage decoded = decode(original, filename);
byte[] webp = encodeWebp(resize(decoded));
String key = "products/" + slug(nameHint) + "-" + token() + ".webp";
storage.put(bucket, keyPrefix + key, webp, "image/webp");
log.info("stored product photo {} ({} KB from {} KB)", key, webp.length / 1024, original.length / 1024);
return key;
}
private BufferedImage decode(byte[] bytes, String filename) {
try {
BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes));
if (image == null) {
// ImageIO returns null rather than throwing when no reader claims the bytes — HEIC off
// an iPhone lands here, as does anything that isn't really an image.
throw new IllegalArgumentException(
"That file isn't an image we can read (" + filename + "). JPEG or PNG works.");
}
return image;
} catch (IOException e) {
throw new IllegalArgumentException("Could not read " + filename + ".", e);
}
}
private BufferedImage resize(BufferedImage source) {
int width = source.getWidth();
int height = source.getHeight();
double scale = Math.min(1.0, (double) MAX_EDGE / Math.max(width, height));
int targetWidth = Math.max(1, (int) Math.round(width * scale));
int targetHeight = Math.max(1, (int) Math.round(height * scale));
// TYPE_INT_RGB regardless of scale: it flattens any alpha channel onto a known background and
// gives cwebp a predictable input. The photos are opaque product shots.
BufferedImage target = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g = target.createGraphics();
try {
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g.drawImage(source, 0, 0, targetWidth, targetHeight, null);
} finally {
g.dispose();
}
return target;
}
/**
* Temp files rather than piping through stdin/stdout: cwebp's stream handling varies by build, and
* a couple of files in the container's tmpdir is a cheaper bet than debugging that in production.
*/
private byte[] encodeWebp(BufferedImage image) {
Path png = null;
Path webp = null;
try {
png = Files.createTempFile("vine-photo-", ".png");
webp = Files.createTempFile("vine-photo-", ".webp");
if (!ImageIO.write(image, "png", png.toFile())) {
throw new IllegalStateException("No PNG writer available to hand cwebp.");
}
Process process = new ProcessBuilder(
"cwebp", "-quiet", "-q", String.valueOf(QUALITY),
png.toString(), "-o", webp.toString())
.redirectErrorStream(true)
.start();
if (!process.waitFor(ENCODE_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
process.destroyForcibly();
throw new IllegalStateException("Encoding that photo took too long.");
}
if (process.exitValue() != 0) {
String output = new String(process.getInputStream().readAllBytes()).trim();
throw new IllegalStateException("Could not convert that photo. " + output);
}
return Files.readAllBytes(webp);
} catch (IOException e) {
// The usual cause is cwebp not being installed — worth saying so plainly.
throw new IllegalStateException("Photo conversion is unavailable on this server.", e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Photo conversion was interrupted.", e);
} finally {
delete(png);
delete(webp);
}
}
private void delete(Path path) {
if (path == null) return;
try {
Files.deleteIfExists(path);
} catch (IOException e) {
log.warn("could not clean up {}", path, e);
}
}
private String slug(String value) {
String slug = value == null ? "" : value.toLowerCase(Locale.ROOT)
.replaceAll("[^a-z0-9]+", "-")
.replaceAll("^-|-$", "");
return slug.isBlank() ? "photo" : slug;
}
/** Short random suffix so re-uploading the same dish never overwrites the previous photo. */
private String token() {
byte[] bytes = new byte[4];
ThreadLocalRandom.current().nextBytes(bytes);
return HexFormat.of().formatHex(bytes);
}
}
@@ -0,0 +1,46 @@
package com.itsthevine.web.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import net.thebennett.platform.data.BaseEntity;
/**
* A filter button on the products page, and the order it sits in.
*
* Products still record their category by name, so this table is reference data rather than the
* owner of the relationship — it exists to say which categories the bakery offers and in what order
* to show them, both of which used to be a constant in the code.
*/
@Entity
@Table(name = "category")
public class Category extends BaseEntity {
@Column(nullable = false, length = 60, unique = true)
private String name;
/** Display order of the filter buttons, after "All". */
@Column(name = "position", nullable = false)
private int position;
protected Category() {
// for JPA
}
public Category(String name, int position) {
this.name = name;
this.position = position;
}
public void rename(String name) {
this.name = name;
}
public void moveTo(int position) {
this.position = position;
}
public String getName() { return name; }
public int getPosition() { return position; }
}
@@ -0,0 +1,13 @@
package com.itsthevine.web.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CategoryRepository extends JpaRepository<Category, Long> {
List<Category> findAllByOrderByPositionAsc();
Optional<Category> findByNameIgnoreCase(String name);
}
@@ -12,8 +12,6 @@ import jakarta.persistence.JoinColumn;
import jakarta.persistence.OrderColumn; import jakarta.persistence.OrderColumn;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import org.hibernate.annotations.BatchSize;
import net.thebennett.platform.data.BaseEntity; import net.thebennett.platform.data.BaseEntity;
/** Something the bakery makes, with the photos that show it off. */ /** Something the bakery makes, with the photos that show it off. */
@@ -34,16 +32,11 @@ public class Product extends BaseEntity {
/** /**
* Object keys, not URLs — where the bucket lives is deployment configuration, so the absolute * Object keys, not URLs — where the bucket lives is deployment configuration, so the absolute
* URL is built at the edge of the app ({@code ProductCatalog}) rather than baked into the data. * URL is built at the edge of the app ({@code ProductCatalog}) rather than baked into the data.
*
* <p>{@code @BatchSize} because the products page loads the whole catalogue at once: without it
* Hibernate issues a separate query per product for its photos — forty-odd round trips for a page
* that needs two.
*/ */
@ElementCollection(fetch = FetchType.EAGER) @ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "product_image", joinColumns = @JoinColumn(name = "product_id")) @CollectionTable(name = "product_image", joinColumns = @JoinColumn(name = "product_id"))
@OrderColumn(name = "position") @OrderColumn(name = "position")
@Column(name = "image_key", nullable = false, length = 300) @Column(name = "image_key", nullable = false, length = 300)
@BatchSize(size = 64)
private List<String> imageKeys = new ArrayList<>(); private List<String> imageKeys = new ArrayList<>();
protected Product() { protected Product() {
@@ -57,15 +50,25 @@ public class Product extends BaseEntity {
this.imageKeys = new ArrayList<>(imageKeys); this.imageKeys = new ArrayList<>(imageKeys);
} }
/** Replaces every editable field — the admin form always submits the whole product. */ /** Rename and/or refile the item; both are free text the editor typed. */
public void update(String name, String category, int position, List<String> imageKeys) { public void describe(String name, String category) {
this.name = name; this.name = name;
this.category = category; this.category = category;
}
/** Where this sits on the products page. Reordering renumbers the whole catalogue. */
public void moveTo(int position) {
this.position = position; this.position = position;
// Mutate in place rather than reassigning: Hibernate tracks THIS list instance, and handing it }
// a different one makes it delete and re-insert every row.
/**
* Replace the photo list wholesale. {@code @OrderColumn} makes Hibernate rewrite the tail of the
* collection on any insert or removal anyway, so there's nothing to gain from finer-grained
* mutators — and one path in means the stored order always matches what the editor arranged.
*/
public void replacePhotos(List<String> keys) {
this.imageKeys.clear(); this.imageKeys.clear();
this.imageKeys.addAll(imageKeys); this.imageKeys.addAll(keys);
} }
public String getName() { return name; } public String getName() { return name; }
+29 -15
View File
@@ -36,30 +36,44 @@ platform:
data: data:
auditing: auditing:
enabled: true enabled: true
security:
# Public site: only the admin API needs a login. An allowlist of public paths would mean
# enumerating every static directory, and anything missed 401s — which is exactly how the
# confessions site broke its own cover images. mode=OIDC comes from the deploy env so tests
# stay on NONE.
authenticated-paths:
- /api/admin/**
storage:
endpoint: ${S3_ENDPOINT:https://s3.thebennett.net}
access-key: ${S3_ACCESS_KEY:}
secret-key: ${S3_SECRET_KEY:}
path-style-access: true
contact: contact:
to: ${CONTACT_TO:} to: ${CONTACT_TO:}
from: ${CONTACT_FROM:} from: ${CONTACT_FROM:}
hub-url: ${CONTACT_HUB_URL:} hub-url: ${CONTACT_HUB_URL:}
security:
vine: # Unset means the platform's permit-all chain, which is what a brochure site wants and what the
# web contract expects (an unknown /api path must 404, not 401). Set SECURITY_MODE=OIDC in the
# deployment to turn on Authentik login — that, and only that, brings the admin endpoints into
# existence. Leaving it unset in dev keeps `mvn spring-boot:run` working with no identity provider.
mode: ${SECURITY_MODE:NONE}
permit-paths:
- /api/products
- /api/categories
- /api/contact
- /actuator/health/**
authenticated-paths:
- /api/admin/**
# The admin screen itself, not just its API. The platform sends /api/** a bare 401 (right for
# fetch) but bounces everything else to Authentik, so protecting the page means a browser that
# opens /admin lands on the login form and comes back signed in — rather than loading an editor
# whose every request immediately fails. It also keeps the page out of strangers' hands entirely.
- /admin/**
storage: storage:
bucket: ${VINE_BUCKET:itsthevine} # Only consulted when someone uploads a photo, i.e. only in a deployment that also set OIDC above.
# Blank endpoint leaves the storage auto-config switched off, so tests and local runs boot without
# MinIO credentials.
endpoint: ${STORAGE_ENDPOINT:}
access-key: ${STORAGE_ACCESS_KEY:}
secret-key: ${STORAGE_SECRET_KEY:}
# Absolute URLs for og:url. Only matters to link-preview scrapers, which need a full URL. # Absolute URLs for og:url. Only matters to link-preview scrapers, which need a full URL.
site: site:
base-url: ${SITE_BASE_URL:https://itsthevine.com} base-url: ${SITE_BASE_URL:https://itsthevine.com}
assets:
# Where uploaded photos land. The key stored on a product is bucket-relative and excludes the
# prefix, because ProductCatalog re-adds `/images/` when it builds the public URL.
bucket: ${STORAGE_BUCKET:itsthevine}
key-prefix: images/
management: management:
endpoints: endpoints:
@@ -0,0 +1,26 @@
-- The filter buttons were a hard-coded List.of(...) in ProductCatalog. That meant adding a category
-- was a deploy, and anything an editor invented appeared last, alphabetically, with no way to move
-- it. This makes the order data so the admin screens can arrange it.
--
-- product.category deliberately stays a varchar holding the name rather than becoming a foreign key:
-- every existing row, query and derived repository method keeps working untouched, and six rows of
-- reference data don't warrant rewriting the catalogue's shape. Renaming a category updates the
-- products alongside it, in one transaction.
create table category (
id bigserial primary key,
name varchar(60) not null unique,
position integer not null,
created_at timestamptz not null,
updated_at timestamptz
);
-- Seeded in the order the page has always shown them, so the site looks identical the moment this
-- lands. Categories found on products but missing here still appear on the filter (appended
-- alphabetically) rather than silently vanishing.
insert into category (name, position, created_at) values
('Cookies', 1, now()),
('Cakes', 2, now()),
('Rolls', 3, now()),
('Pie', 4, now()),
('Brownies', 5, now()),
('Pastries', 6, now());
@@ -1,146 +0,0 @@
package com.itsthevine.web;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.web.server.ResponseStatusException;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import com.itsthevine.web.domain.ProductRepository;
/**
* The admin catalogue operations. Whether they're reachable without a login is covered separately by
* {@link AdminSecurityTest} — this is about what they do once you're in.
*/
@SpringBootTest(properties = {
"[email protected]",
"[email protected]",
"platform.storage.access-key=test",
"platform.storage.secret-key=test",
"site.assets.base-url=https://s3.example.test/itsthevine"
})
@Testcontainers
class AdminControllerTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
@Autowired
AdminController admin;
@Autowired
ProductCatalog catalog;
@Autowired
ProductRepository products;
private static AdminController.ProductForm form(String name, String category, List<String> keys) {
return new AdminController.ProductForm(name, category, null, keys);
}
@Test
void createsAProductAndItAppearsOnThePublicSite() {
int before = catalog.list(null).size();
var created = admin.create(form("Test Loaf", "Rolls", List.of("products/test-loaf.webp")));
assertThat(created.id()).isNotNull();
assertThat(catalog.list(null)).hasSize(before + 1);
assertThat(catalog.list("Rolls"))
.extracting(ProductCatalog.ProductView::name)
.contains("Test Loaf");
admin.delete(created.id());
}
@Test
void aNewProductGoesToTheEndRatherThanDisplacingOne() {
// Position defaults matter: reusing an existing one would reorder the curated catalogue.
int maxBefore = admin.list().stream().mapToInt(AdminController.AdminProduct::position).max().orElse(0);
var created = admin.create(form("末 Loaf", "Rolls", List.of("products/x.webp")));
assertThat(created.position()).isGreaterThan(maxBefore);
admin.delete(created.id());
}
@Test
void editingReplacesTheFieldsAndKeepsTheOrderOfPhotos() {
var created = admin.create(form("Before", "Cakes", List.of("products/a.webp", "products/b.webp")));
var updated = admin.update(created.id(),
new AdminController.ProductForm("After", "Pie", 3,
List.of("products/b.webp", "products/a.webp", "products/c.webp")));
assertThat(updated.name()).isEqualTo("After");
assertThat(updated.category()).isEqualTo("Pie");
assertThat(updated.position()).isEqualTo(3);
assertThat(updated.imageKeys())
.containsExactly("products/b.webp", "products/a.webp", "products/c.webp");
admin.delete(created.id());
}
@Test
void aProductWithoutAPhotoIsRejected() {
// The card is a photo with a caption; without one it renders as an empty square.
assertThatThrownBy(() -> admin.create(form("No photo", "Cakes", List.of())))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("at least one photo");
assertThatThrownBy(() -> admin.create(form("No photo", "Cakes", List.of(" "))))
.isInstanceOf(ResponseStatusException.class);
}
@Test
void aProductWithoutANameOrCategoryIsRejected() {
assertThatThrownBy(() -> admin.create(form(" ", "Cakes", List.of("products/a.webp"))))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("name");
assertThatThrownBy(() -> admin.create(form("Thing", " ", List.of("products/a.webp"))))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("category");
}
@Test
void editingSomethingThatIsGoneIs404NotACrash() {
assertThatThrownBy(() -> admin.update(9_999_999L, form("x", "Cakes", List.of("products/a.webp"))))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("404");
assertThatThrownBy(() -> admin.delete(9_999_999L))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("404");
}
@Test
void deletingRemovesItFromThePublicCatalogue() {
var created = admin.create(form("Temporary", "Brownies", List.of("products/t.webp")));
assertThat(catalog.list("Brownies")).extracting(ProductCatalog.ProductView::name).contains("Temporary");
admin.delete(created.id());
assertThat(catalog.list("Brownies")).extracting(ProductCatalog.ProductView::name)
.doesNotContain("Temporary");
assertThat(products.findById(created.id())).isEmpty();
}
@Test
void adminListsCarryBothKeysAndUrlsSoTheEditorCanShowThumbnails() {
var created = admin.create(form("Thumb", "Cookies", List.of("products/thumb.webp")));
var found = admin.list().stream().filter(p -> p.id().equals(created.id())).findFirst().orElseThrow();
assertThat(found.imageKeys()).containsExactly("products/thumb.webp");
assertThat(found.imageUrls())
.containsExactly("https://s3.example.test/itsthevine/images/products/thumb.webp");
admin.delete(created.id());
}
}
@@ -3,7 +3,6 @@ package com.itsthevine.web;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -12,8 +11,8 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers; import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext; import org.springframework.web.context.WebApplicationContext;
import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.containers.PostgreSQLContainer;
@@ -22,26 +21,26 @@ import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName; import org.testcontainers.utility.DockerImageName;
/** /**
* What an anonymous visitor can and cannot reach. * What an anonymous visitor can and cannot reach, with security on as production runs it.
* *
* <p>This is the test that matters most on this branch: the admin API can create, edit and delete the * <p>The admin can create, edit, reorder and delete the whole menu, and the shop is otherwise public —
* menu, and the whole site is otherwise public. Running with {@code platform.security.mode=OIDC}, as * so the boundary between them is the thing most worth a test. Runs with {@code SECURITY_MODE=OIDC}
* production does — the default of NONE would leave everything open and prove nothing. * because the default of NONE leaves everything open and would prove nothing.
*/ */
@SpringBootTest(properties = { @SpringBootTest(properties = {
"platform.security.mode=OIDC", "SECURITY_MODE=OIDC",
// Endpoints stated outright rather than an issuer-uri: an issuer-uri makes Spring fetch the // Endpoints stated outright rather than an issuer-uri, which would make Spring fetch the
// discovery document at startup, which needs the network and a real identity provider. // discovery document at startup — that needs the network and a real identity provider.
"spring.security.oauth2.client.provider.authentik.authorization-uri=https://sso.example.test/authorize", "spring.security.oauth2.client.provider.authentik.authorization-uri=https://sso.example.test/authorize",
"spring.security.oauth2.client.provider.authentik.token-uri=https://sso.example.test/token", "spring.security.oauth2.client.provider.authentik.token-uri=https://sso.example.test/token",
"spring.security.oauth2.client.provider.authentik.jwk-set-uri=https://sso.example.test/jwks", "spring.security.oauth2.client.provider.authentik.jwk-set-uri=https://sso.example.test/jwks",
"spring.security.oauth2.client.provider.authentik.user-info-uri=https://sso.example.test/userinfo", "spring.security.oauth2.client.provider.authentik.user-info-uri=https://sso.example.test/userinfo",
"spring.security.oauth2.client.provider.authentik.user-name-attribute=preferred_username", "spring.security.oauth2.client.provider.authentik.user-name-attribute=preferred_username",
"spring.security.oauth2.client.registration.authentik.client-id=test",
"spring.security.oauth2.client.registration.authentik.client-secret=test",
"spring.security.oauth2.client.registration.authentik.scope=openid,profile,email", "spring.security.oauth2.client.registration.authentik.scope=openid,profile,email",
"spring.security.oauth2.client.registration.authentik.authorization-grant-type=authorization_code", "spring.security.oauth2.client.registration.authentik.authorization-grant-type=authorization_code",
"spring.security.oauth2.client.registration.authentik.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}", "spring.security.oauth2.client.registration.authentik.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}",
"spring.security.oauth2.client.registration.authentik.client-id=test",
"spring.security.oauth2.client.registration.authentik.client-secret=test",
"[email protected]", "[email protected]",
"[email protected]", "[email protected]",
"platform.storage.access-key=test", "platform.storage.access-key=test",
@@ -62,52 +61,51 @@ class AdminSecurityTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// .apply(springSecurity()) is not optional here: webAppContextSetup alone leaves the security // .apply(springSecurity()) is not optional: webAppContextSetup alone leaves the filter chain
// filter chain out, so every protected path returns 200 and the test proves nothing. // out, so every protected path returns 200 and the test would assert nothing.
mvc = MockMvcBuilders.webAppContextSetup(context) mvc = MockMvcBuilders.webAppContextSetup(context)
.apply(SecurityMockMvcConfigurers.springSecurity()) .apply(SecurityMockMvcConfigurers.springSecurity())
.build(); .build();
} }
@Test @Test
void everyAdminEndpointIsClosedToAnonymousVisitors() throws Exception { void everyAdminApiIsClosedToAnonymousVisitors() throws Exception {
// 401 rather than a redirect: the platform's security starter answers /api/** with a status so // csrf() on the writes, so these assert AUTHORIZATION (401), not a missing token.
// the SPA can handle it, instead of bouncing an XHR to the identity provider.
mvc.perform(get("/api/admin/products")).andExpect(status().isUnauthorized()); mvc.perform(get("/api/admin/products")).andExpect(status().isUnauthorized());
mvc.perform(get("/api/admin/enquiries")).andExpect(status().isUnauthorized()); mvc.perform(get("/api/admin/categories")).andExpect(status().isUnauthorized());
mvc.perform(post("/api/admin/products").with(csrf()).contentType(MediaType.APPLICATION_JSON) mvc.perform(post("/api/admin/products").with(csrf())).andExpect(status().isUnauthorized());
.content("{\"name\":\"x\",\"category\":\"Cakes\",\"imageKeys\":[\"a\"]}")) mvc.perform(post("/api/admin/categories").with(csrf()).contentType(MediaType.APPLICATION_JSON)
.andExpect(status().isUnauthorized()); .content("{\"name\":\"x\"}"))
mvc.perform(post("/api/admin/images/presign-upload?filename=x.jpg").with(csrf()))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@Test
void theAdminPageRedirectsABrowserToLogin() throws Exception {
// Protecting /admin server-side is what makes sign-in work: a browser opening it is bounced to
// Authentik and comes back signed in. The redirect only fires for a request that prefers HTML —
// the platform answers */* (a fetch/XHR) with a bare 401 so the SPA can handle it — so this
// must send a browser's Accept header to see the 302. (Verified against a running container.)
mvc.perform(get("/admin").header("Accept", "text/html,application/xhtml+xml"))
.andExpect(status().is3xxRedirection());
}
@Test @Test
void theShopStaysPublic() throws Exception { void theShopStaysPublic() throws Exception {
// The whole point of authenticated-paths: locking the admin API must not lock the menu. // Locking the admin must not lock the menu.
mvc.perform(get("/api/products")).andExpect(status().isOk()); mvc.perform(get("/api/products")).andExpect(status().isOk());
mvc.perform(get("/api/categories")).andExpect(status().isOk()); mvc.perform(get("/api/categories")).andExpect(status().isOk());
mvc.perform(post("/api/contact").with(csrf()).contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"Ada\",\"email\":\"nope\",\"message\":\"hi\"}"))
.andExpect(status().isBadRequest()); // reached the controller, rejected on content
} }
@Test @Test
void theContactFormNeedsItsCsrfToken() throws Exception { void theContactFormStillNeedsItsCsrfToken() throws Exception {
// Turning on the security starter turns on CSRF, which applies to the PUBLIC contact form too. // Enabling the security starter enables CSRF for the PUBLIC contact form too. Without the token
// Without the token the form silently 403s the SPA reads the XSRF-TOKEN cookie and sends // it 403s; the SPA reads the XSRF-TOKEN cookie and sends X-XSRF-TOKEN.
// X-XSRF-TOKEN for exactly this reason.
mvc.perform(post("/api/contact").contentType(MediaType.APPLICATION_JSON) mvc.perform(post("/api/contact").contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"Ada\",\"email\":\"[email protected]\",\"message\":\"hi\"}")) .content("{\"name\":\"Ada\",\"email\":\"[email protected]\",\"message\":\"hi\"}"))
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} // With the token it reaches the controller (400 on the deliberately bad email below).
mvc.perform(post("/api/contact").with(csrf()).contentType(MediaType.APPLICATION_JSON)
@Test .content("{\"name\":\"Ada\",\"email\":\"nope\",\"message\":\"hi\"}"))
void meIsPublicAndSaysNobodyIsSignedIn() throws Exception { .andExpect(status().isBadRequest());
// If this required a login, every anonymous visitor would be bounced to Authentik on page load.
mvc.perform(get("/api/me"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.authenticated").value(false))
.andExpect(jsonPath("$.admin").value(false));
} }
} }