diff --git a/Dockerfile b/Dockerfile index e2db086..c9a05ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,10 @@ RUN --mount=type=secret,id=maven_user --mount=type=secret,id=maven_token \ # ---------- 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 WORKDIR /app diff --git a/README.md b/README.md index 77796d9..e327116 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,25 @@ The SPA renders; it doesn't decide anything. - **Per-page metadata** — `PageMetaController` rewrites ``/`<meta>`/OG tags per route. Next used 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 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 | | `SITE_BASE_URL` | absolute base for `og:url` | | `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 to `platform.contact.to`, and an unset variable leaves the property present-but-empty, which is enough diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f5502f8..0e84f3d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ 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 Footer from '@/components/Footer'; import HomePage from '@/pages/Home'; @@ -7,9 +7,7 @@ import ProductsPage from '@/pages/Products'; import HistoryPage from '@/pages/History'; import ContactPage from '@/pages/Contact'; import NotFoundPage from '@/pages/NotFound'; -import AdminPage from '@/pages/admin/AdminPage'; -import ProductEditorPage from '@/pages/admin/ProductEditorPage'; -import { AuthProvider } from '@/lib/auth'; +import AdminPage from '@/pages/Admin'; /** * Client-side navigation keeps the previous scroll position, which lands you halfway down a page you @@ -25,26 +23,44 @@ const ScrollToTop = () => { return null; }; -const App = () => ( - <AuthProvider> +/** The shop front: the nav, the footer, and the pages a customer sees. */ +const PublicLayout = () => ( <div className="min-h-screen bg-bakery-50 flex flex-col"> - <ScrollToTop /> <Header /> <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="/products" element={<ProductsPage />} /> <Route path="/history" element={<HistoryPage />} /> <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 />} /> - </Routes> - </main> - <Footer /> - </div> - </AuthProvider> + </Route> + <Route element={<AdminLayout />}> + <Route path="/admin" element={<AdminPage />} /> + </Route> + </Routes> + </> ); export default App; diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 3944779..bd3ab89 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,105 +1,117 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { motion, AnimatePresence } from 'motion/react'; -import { Link } from 'react-router-dom'; +import { Link, useLocation } from 'react-router-dom'; import Logo from './Logo'; +const navItems = [ + { label: 'Our Products', href: '/products' }, + { label: 'Our Story', href: '/history' }, + { label: 'Contact', href: '/contact' }, +]; + const Header = () => { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const { pathname } = useLocation(); - const navItems = [ - { label: 'Our Products', href: '/products' }, - { label: 'Our Story', href: '/history' }, - { label: 'Contact', href: '/contact' }, - ]; + // Close on navigation — without this the panel stays up over the page you just opened. + useEffect(() => setIsMobileMenuOpen(false), [pathname]); + + // 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 ( - <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"> - <div className="flex items-center justify-between gap-2 h-20 md:h-24"> - {/* Logo */} - <Logo className="text-bakery-700" /> - {/* Desktop Navigation */} - <nav className="hidden md:flex items-center gap-8"> - {navItems.map((item) => ( - <Link - key={item.href} - to={item.href} - className="text-sm uppercase tracking-[0.15em] text-bakery-700 hover:text-bakery-900 transition" + <> + <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"> + <div className="flex items-center justify-between gap-2 h-20 md:h-24"> + {/* Logo */} + <Logo className="text-bakery-700" /> + {/* Desktop Navigation */} + <nav className="hidden md:flex items-center gap-8"> + {navItems.map((item) => ( + <Link + key={item.href} + 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} - </Link> - ))} - </nav> - - {/* 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> + {isMobileMenuOpen ? <path d="M6 18L18 6M6 6l12 12" /> : <path d="M4 6h16M4 12h16M4 18h16" />} + </svg> + </button> + </div> </div> + </header> - {/* Mobile Navigation — must UNMOUNT when closed. A panel parked off-screen - at translate-x-full still extends the scrollable area, which is what let - you scroll sideways and find the menu. */} - <AnimatePresence> - {isMobileMenuOpen && ( - <motion.div - initial={{ x: '100%' }} - animate={{ x: 0 }} - exit={{ x: '100%' }} - transition={{ type: 'tween', duration: 0.25, ease: 'easeOut' }} - className="md:hidden fixed inset-0 bg-bakery-50 z-50" - > - <div className="p-4"> - <div className="flex justify-between items-center gap-2 mb-8 h-16"> - <Logo className="text-bakery-800" /> - <button - onClick={() => setIsMobileMenuOpen(false)} - className="p-2 shrink-0" - aria-label="Close menu" - > - <svg - className="h-6 w-6 text-bakery-700" - fill="none" - viewBox="0 0 24 24" - stroke="currentColor" - > - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> - </svg> - </button> - </div> - <nav className="flex flex-col"> - {navItems.map((item) => ( - <Link - key={item.href} - to={item.href} - className="text-bakery-800 hover:text-bakery-600 transition py-4 text-lg" - onClick={() => setIsMobileMenuOpen(false)} - > - {item.label} - </Link> - ))} - </nav> - </div> - </motion.div> - )} - </AnimatePresence> - </div> - </header> + {/* Mobile navigation. Three things here are load-bearing: + + It lives OUTSIDE <header>. The header carries `backdrop-blur`, and a backdrop-filter + makes an element a containing block for fixed-position descendants — so a `fixed` panel + nested inside it resolves against the 80px header box, not the viewport, and gets + clipped to a sliver. + + It starts BELOW the bar (`top-20`) instead of covering it, so the logo and the toggle + stay put and the panel needs no second copy of either. One logo, one position, every + breakpoint. + + It must UNMOUNT when closed: a panel parked off-screen still extends the scrollable + area, which is what used to let you scroll sideways and find the menu. */} + <AnimatePresence> + {isMobileMenuOpen && ( + <motion.div + initial={{ opacity: 0, y: -8 }} + animate={{ opacity: 1, y: 0 }} + exit={{ opacity: 0, y: -8 }} + transition={{ duration: 0.2, ease: 'easeOut' }} + className="md:hidden fixed inset-x-0 top-20 bottom-0 z-30 bg-bakery-50" + > + <nav className="container mx-auto px-4 flex flex-col"> + {navItems.map((item) => ( + <Link + key={item.href} + to={item.href} + className="text-bakery-800 hover:text-bakery-600 transition py-4 text-lg border-b border-bakery-100" + onClick={() => setIsMobileMenuOpen(false)} + > + {item.label} + </Link> + ))} + </nav> + </motion.div> + )} + </AnimatePresence> + </> ); }; diff --git a/frontend/src/components/ProductGallery.tsx b/frontend/src/components/ProductGallery.tsx index 30d929b..95016ad 100644 --- a/frontend/src/components/ProductGallery.tsx +++ b/frontend/src/components/ProductGallery.tsx @@ -5,31 +5,58 @@ interface ProductGalleryProps { 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 * React 16 — the same job in a fraction of the code, and one less unmaintained dependency in a * build we gate on CVEs. Behaviour is what the old cards did: one image at a time, square crop, - * arrows only when they'd do something — plus swipe and keyboard, which the old slider had on touch - * devices and the first version of this did not. + * arrows only when they'd do something. */ - /** Past this many pixels a horizontal drag counts as a swipe rather than a tap or a page scroll. */ const SWIPE_THRESHOLD = 40; const ProductGallery: React.FC<ProductGalleryProps> = ({ images, alt }) => { const [index, setIndex] = useState(0); + + // 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 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); + // 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 t = e.touches[0]; touchStart.current = { x: t.clientX, y: t.clientY }; }; - const onTouchEnd = (e: React.TouchEvent) => { const start = touchStart.current; touchStart.current = null; @@ -37,11 +64,13 @@ const ProductGallery: React.FC<ProductGalleryProps> = ({ images, alt }) => { const t = e.changedTouches[0]; const dx = t.clientX - start.x; const dy = t.clientY - start.y; - // Ignore anything more vertical than horizontal — that is the page being scrolled, not a swipe. if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dx) <= Math.abs(dy)) return; step(dx < 0 ? 1 : -1); }; + 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 ( <div className="relative aspect-square bg-bakery-100" @@ -64,10 +93,10 @@ const ProductGallery: React.FC<ProductGalleryProps> = ({ images, alt }) => { alt={i === 0 ? alt : ''} loading="lazy" decoding="async" - className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${ - i === index ? 'opacity-100' : 'opacity-0 pointer-events-none' + className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 motion-reduce:transition-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" onClick={() => step(-1)} 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 type="button" onClick={() => step(1)} 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> - {/* Which of how many. The old slider ran with bullets off, but once a card can be swiped - there is otherwise nothing to say it holds more than one photo. */} - <div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex gap-1.5" aria-hidden="true"> + + {/* Dots: how many photos there are, and which one you're on. */} + <div className="absolute inset-x-0 bottom-3 flex justify-center gap-1.5"> {images.map((src, i) => ( - <span + <button key={src} - className={`h-1.5 w-1.5 rounded-full transition ${ - i === index ? 'bg-white' : 'bg-white/40' + type="button" + 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' }`} /> ))} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 595eeb0..4fae6d0 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -11,14 +11,19 @@ export interface Product { images: string[]; } -/** - * Spring Security protects every mutating request with a CSRF token, and the platform's security - * starter writes it to a readable XSRF-TOKEN cookie. Without this header a POST is rejected 403 — - * including the public contact form, which is not obvious until the form stops working. - */ -function csrfHeaders(): Record<string, string> { - const token = document.cookie.split('; ').find((c) => c.startsWith('XSRF-TOKEN='))?.split('=')[1]; - return token ? { 'X-XSRF-TOKEN': decodeURIComponent(token) } : {}; +/** What the admin screens get back: the public shape plus where it sits in the order. */ +export interface AdminProduct extends Product { + position: number; + /** The same photos as `images`, in the same order — these are what arrangePhotos names them by. */ + keys: string[]; +} + +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> { @@ -32,82 +37,90 @@ export const fetchProducts = (category?: string) => export const fetchCategories = () => get<string[]>('/api/categories'); -// ---- who is signed in (public: the SPA asks on every page load) ---- -export interface Me { authenticated: boolean; admin: boolean; name: string | null } -export const fetchMe = () => get<Me>('/api/me'); - -// ---- admin (everything below needs an Authentik login) ---- -export interface AdminProduct { - id: number; - name: string; - category: string; - position: number; - imageKeys: string[]; - imageUrls: string[]; -} -export interface AdminEnquiry { - id: number; - name: string; - email: string; - message: string; - delivered: boolean; - receivedAt: string; -} -export interface ProductForm { - name: string; - category: string; - position: number | null; - imageKeys: string[]; +/** + * Spring hands the SPA a CSRF token in a cookie and wants it echoed on anything that writes. Read + * 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 + * happily on a deployment with no identity provider. + */ +export function csrfHeader(): Record<string, string> { + const token = document.cookie + .split('; ') + .find((c) => c.startsWith('XSRF-TOKEN=')) + ?.slice('XSRF-TOKEN='.length); + return token ? { 'X-XSRF-TOKEN': decodeURIComponent(token) } : {}; } -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, { method, - headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...csrfHeaders() }, - body: body === undefined ? undefined : JSON.stringify(body), + headers: { + 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. - let detail = `${method} ${path} responded ${res.status}`; - try { detail = (await res.json()).detail ?? detail; } catch { /* not JSON */ } - throw new Error(detail); + + if (res.status === 401 || res.status === 403) { + throw new Error('Your sign-in has expired — refresh the page to sign in again.'); } - 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'); -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'); +// --- products --------------------------------------------------------------- -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 async function uploadPhoto(file: File): Promise<UploadTarget> { - const target = await send<UploadTarget>( - `/api/admin/images/presign-upload?filename=${encodeURIComponent(file.name)}` - + `&contentType=${encodeURIComponent(file.type || 'application/octet-stream')}`, - 'POST'); - // Straight to the bucket, so no CSRF header here — it is a different origin and a presigned URL. - const put = await fetch(target.uploadUrl, { - method: 'PUT', - headers: { 'Content-Type': file.type || 'application/octet-stream' }, - body: file, - }); - if (!put.ok) throw new Error(`the bucket rejected the upload (${put.status})`); - return target; +export function createProduct(name: string, category: string, photos: File[]) { + const form = new FormData(); + form.append('name', name); + form.append('category', category); + photos.forEach((p) => form.append('photos', p)); + return send<AdminProduct>('/api/admin/products', 'POST', undefined, form); } -/** The public contact form. Mutating, so it needs the CSRF token too. */ -export async function submitContact(input: { name: string; email: string; message: string }) { - const res = await fetch('/api/contact', { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, - body: JSON.stringify(input), - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data.error || 'Could not send the message.'); - return data; +export const describeProduct = (id: number, name: string, category: string) => + send<AdminProduct>(`/api/admin/products/${id}`, 'PUT', { name, category }); + +export const deleteProduct = (id: number) => + send<{ ok: boolean }>(`/api/admin/products/${id}`, 'DELETE'); + +export function addPhotos(id: number, photos: File[]) { + const form = new FormData(); + photos.forEach((p) => form.append('photos', p)); + 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'); diff --git a/frontend/src/lib/auth.tsx b/frontend/src/lib/auth.tsx deleted file mode 100644 index 7e20477..0000000 --- a/frontend/src/lib/auth.tsx +++ /dev/null @@ -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'; -}; diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx new file mode 100644 index 0000000..bdad42c --- /dev/null +++ b/frontend/src/pages/Admin.tsx @@ -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; diff --git a/frontend/src/pages/Contact.tsx b/frontend/src/pages/Contact.tsx index 203a7a4..0a4f66e 100644 --- a/frontend/src/pages/Contact.tsx +++ b/frontend/src/pages/Contact.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { submitContact } from '@/lib/api'; +import { csrfHeader } from '@/lib/api'; type Status = 'idle' | 'sending' | 'sent' | 'error'; @@ -24,7 +24,16 @@ const ContactPage = () => { setStatus('sending'); setError(''); 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'); setFormData({ name: '', email: '', message: '' }); } catch (err) { diff --git a/frontend/src/pages/Products.tsx b/frontend/src/pages/Products.tsx index 3dbb31b..e3346f5 100644 --- a/frontend/src/pages/Products.tsx +++ b/frontend/src/pages/Products.tsx @@ -1,5 +1,4 @@ import { useEffect, useState } from 'react'; -import { motion, AnimatePresence } from 'motion/react'; import { fetchCategories, fetchProducts, type Product } from '@/lib/api'; import ProductGallery from '@/components/ProductGallery'; @@ -38,10 +37,9 @@ const ProductsPage = () => { {/* Categories */} <div className="flex flex-wrap justify-center gap-4 mb-12"> {categories.map((category) => ( - <motion.button + <button key={category} - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} + type="button" onClick={() => setSelectedCategory(category)} className={`px-6 py-2 rounded-full border text-sm uppercase tracking-[0.12em] transition-colors ${ selectedCategory === category @@ -50,7 +48,7 @@ const ProductsPage = () => { }`} > {category} - </motion.button> + </button> ))} </div> @@ -61,39 +59,33 @@ const ProductsPage = () => { </p> )} - {/* Products Grid */} - <motion.div - layout - className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8" - > - <AnimatePresence> - {products.map((product) => ( - <motion.div - key={product.id} - layout - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - exit={{ opacity: 0 }} - className="group bg-white rounded-3xl overflow-hidden shadow-xs hover:shadow-lg hover:-translate-y-1 transition duration-300" - > - {/* Image Container */} - <div className="relative w-full overflow-hidden"> - <ProductGallery images={product.images} alt={product.name} /> - </div> + {/* Products Grid — deliberately unanimated. Filtering used to run a `layout` reflow plus + an enter/exit fade on every card, which on a 40-card grid reads as the page lurching + rather than responding. Swapping the list outright is instant, and the only motion + left is the shadow on hover. */} + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> + {products.map((product) => ( + <div + key={product.id} + className="group bg-white rounded-3xl overflow-hidden shadow-xs transition-shadow duration-300 hover:shadow-lg" + > + {/* Image Container */} + <div className="relative w-full overflow-hidden"> + <ProductGallery images={product.images} alt={product.name} /> + </div> - {/* Content */} - <div className="p-6 text-center"> - <h3 className="font-adbhashitha text-xl text-bakery-900 mb-2 tracking-wide"> - {product.name} - </h3> - <span className="text-xs uppercase tracking-[0.15em] text-bakery-600"> - {product.category} - </span> - </div> - </motion.div> - ))} - </AnimatePresence> - </motion.div> + {/* Content */} + <div className="p-6 text-center"> + <h3 className="font-adbhashitha text-xl text-bakery-900 mb-2 tracking-wide"> + {product.name} + </h3> + <span className="text-xs uppercase tracking-[0.15em] text-bakery-600"> + {product.category} + </span> + </div> + </div> + ))} + </div> </div> </div> ); diff --git a/frontend/src/pages/admin/AdminPage.tsx b/frontend/src/pages/admin/AdminPage.tsx deleted file mode 100644 index 63f2615..0000000 --- a/frontend/src/pages/admin/AdminPage.tsx +++ /dev/null @@ -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> - ); -} diff --git a/frontend/src/pages/admin/ProductEditorPage.tsx b/frontend/src/pages/admin/ProductEditorPage.tsx deleted file mode 100644 index 1ffed52..0000000 --- a/frontend/src/pages/admin/ProductEditorPage.tsx +++ /dev/null @@ -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> - ); -} diff --git a/pom.xml b/pom.xml index f558983..3b2f49b 100644 --- a/pom.xml +++ b/pom.xml @@ -62,8 +62,10 @@ <groupId>net.thebennett.platform</groupId> <artifactId>platform-starter-contact</artifactId> </dependency> - <!-- Admin needs a login, and uploading a product photo needs the bucket. The public site - still reads photos straight from the bucket's public URLs. --> + <!-- The site is still a public brochure, but the catalogue is now editable from it: the admin + 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> <groupId>net.thebennett.platform</groupId> <artifactId>platform-starter-security</artifactId> @@ -85,8 +87,8 @@ <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> - <!-- 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. --> + <!-- springSecurity() for MockMvc — without it the filter chain is absent and every protected + path answers 200, so a security test would prove the opposite of what it claims. --> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> diff --git a/src/main/java/com/itsthevine/web/AdminCategoryController.java b/src/main/java/com/itsthevine/web/AdminCategoryController.java new file mode 100644 index 0000000..f1cecf1 --- /dev/null +++ b/src/main/java/com/itsthevine/web/AdminCategoryController.java @@ -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; + } +} diff --git a/src/main/java/com/itsthevine/web/AdminController.java b/src/main/java/com/itsthevine/web/AdminController.java deleted file mode 100644 index 66a0535..0000000 --- a/src/main/java/com/itsthevine/web/AdminController.java +++ /dev/null @@ -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("^/+", ""); - } -} diff --git a/src/main/java/com/itsthevine/web/AdminProductController.java b/src/main/java/com/itsthevine/web/AdminProductController.java new file mode 100644 index 0000000..29024db --- /dev/null +++ b/src/main/java/com/itsthevine/web/AdminProductController.java @@ -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())); + } +} diff --git a/src/main/java/com/itsthevine/web/MeController.java b/src/main/java/com/itsthevine/web/MeController.java deleted file mode 100644 index e77cea0..0000000 --- a/src/main/java/com/itsthevine/web/MeController.java +++ /dev/null @@ -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); - } -} diff --git a/src/main/java/com/itsthevine/web/ProductCatalog.java b/src/main/java/com/itsthevine/web/ProductCatalog.java index c7ad978..58926b6 100644 --- a/src/main/java/com/itsthevine/web/ProductCatalog.java +++ b/src/main/java/com/itsthevine/web/ProductCatalog.java @@ -15,6 +15,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; 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.ProductRepository; @@ -28,20 +30,15 @@ public class ProductCatalog { /** The filter shown first — every category at once. Not a stored category. */ 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 CategoryRepository categories; private final String assetBaseUrl; public ProductCatalog(ProductRepository products, + CategoryRepository categories, @Value("${site.assets.base-url:https://s3.thebennett.net/itsthevine}") String assetBaseUrl) { this.products = products; + this.categories = categories; // A trailing slash here would produce '//images/...' — harmless on most servers, but it shows // up in every image URL on the page. this.assetBaseUrl = assetBaseUrl.replaceAll("/+$", ""); @@ -60,23 +57,41 @@ public class ProductCatalog { 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) public List<String> categories() { Set<String> present = products.findAllByOrderByPositionAsc().stream() .map(Product::getCategory) .collect(Collectors.toCollection(LinkedHashSet::new)); + List<String> defined = categories.findAllByOrderByPositionAsc().stream() + .map(Category::getName) + .toList(); + List<String> ordered = new ArrayList<>(); ordered.add(ALL); - ORDER.stream().filter(present::contains).forEach(ordered::add); + defined.stream().filter(present::contains).forEach(ordered::add); present.stream() - .filter(c -> !ORDER.contains(c)) + .filter(c -> !defined.contains(c)) .sorted(Comparator.naturalOrder()) .forEach(ordered::add); 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) { return new ProductView(p.getId(), p.getName(), p.getCategory(), p.getImageKeys().stream().map(this::imageUrl).toList()); diff --git a/src/main/java/com/itsthevine/web/ProductPhotoService.java b/src/main/java/com/itsthevine/web/ProductPhotoService.java new file mode 100644 index 0000000..6945c98 --- /dev/null +++ b/src/main/java/com/itsthevine/web/ProductPhotoService.java @@ -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); + } +} diff --git a/src/main/java/com/itsthevine/web/domain/Category.java b/src/main/java/com/itsthevine/web/domain/Category.java new file mode 100644 index 0000000..9c6b9f4 --- /dev/null +++ b/src/main/java/com/itsthevine/web/domain/Category.java @@ -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; } +} diff --git a/src/main/java/com/itsthevine/web/domain/CategoryRepository.java b/src/main/java/com/itsthevine/web/domain/CategoryRepository.java new file mode 100644 index 0000000..2b7ea4d --- /dev/null +++ b/src/main/java/com/itsthevine/web/domain/CategoryRepository.java @@ -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); +} diff --git a/src/main/java/com/itsthevine/web/domain/Product.java b/src/main/java/com/itsthevine/web/domain/Product.java index e3114f0..632f549 100644 --- a/src/main/java/com/itsthevine/web/domain/Product.java +++ b/src/main/java/com/itsthevine/web/domain/Product.java @@ -12,8 +12,6 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.OrderColumn; import jakarta.persistence.Table; -import org.hibernate.annotations.BatchSize; - import net.thebennett.platform.data.BaseEntity; /** 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 * 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) @CollectionTable(name = "product_image", joinColumns = @JoinColumn(name = "product_id")) @OrderColumn(name = "position") @Column(name = "image_key", nullable = false, length = 300) - @BatchSize(size = 64) private List<String> imageKeys = new ArrayList<>(); protected Product() { @@ -57,15 +50,25 @@ public class Product extends BaseEntity { this.imageKeys = new ArrayList<>(imageKeys); } - /** Replaces every editable field — the admin form always submits the whole product. */ - public void update(String name, String category, int position, List<String> imageKeys) { + /** Rename and/or refile the item; both are free text the editor typed. */ + public void describe(String name, String category) { this.name = name; this.category = category; + } + + /** Where this sits on the products page. Reordering renumbers the whole catalogue. */ + public void moveTo(int 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.addAll(imageKeys); + this.imageKeys.addAll(keys); } public String getName() { return name; } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index ea7a05b..2dd04a1 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -36,30 +36,44 @@ platform: data: auditing: 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: to: ${CONTACT_TO:} from: ${CONTACT_FROM:} hub-url: ${CONTACT_HUB_URL:} - -vine: + security: + # 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: - 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. site: 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: endpoints: diff --git a/src/main/resources/db/migration/V3__categories.sql b/src/main/resources/db/migration/V3__categories.sql new file mode 100644 index 0000000..e928cde --- /dev/null +++ b/src/main/resources/db/migration/V3__categories.sql @@ -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()); diff --git a/src/test/java/com/itsthevine/web/AdminControllerTest.java b/src/test/java/com/itsthevine/web/AdminControllerTest.java deleted file mode 100644 index b92302c..0000000 --- a/src/test/java/com/itsthevine/web/AdminControllerTest.java +++ /dev/null @@ -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 = { - "platform.contact.to=test@example.com", - "platform.contact.from=noreply@example.com", - "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()); - } -} diff --git a/src/test/java/com/itsthevine/web/AdminSecurityTest.java b/src/test/java/com/itsthevine/web/AdminSecurityTest.java index dd23000..35ba57f 100644 --- a/src/test/java/com/itsthevine/web/AdminSecurityTest.java +++ b/src/test/java/com/itsthevine/web/AdminSecurityTest.java @@ -3,7 +3,6 @@ package com.itsthevine.web; 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.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 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.testcontainers.service.connection.ServiceConnection; import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MockMvc; 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.web.context.WebApplicationContext; import org.testcontainers.containers.PostgreSQLContainer; @@ -22,26 +21,26 @@ import org.testcontainers.junit.jupiter.Testcontainers; 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 - * menu, and the whole site is otherwise public. Running with {@code platform.security.mode=OIDC}, as - * production does — the default of NONE would leave everything open and prove nothing. + * <p>The admin can create, edit, reorder and delete the whole menu, and the shop is otherwise public — + * so the boundary between them is the thing most worth a test. Runs with {@code SECURITY_MODE=OIDC} + * because the default of NONE leaves everything open and would prove nothing. */ @SpringBootTest(properties = { - "platform.security.mode=OIDC", - // Endpoints stated outright rather than an issuer-uri: an issuer-uri makes Spring fetch the - // discovery document at startup, which needs the network and a real identity provider. + "SECURITY_MODE=OIDC", + // Endpoints stated outright rather than an issuer-uri, which would make Spring fetch the + // 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.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.user-info-uri=https://sso.example.test/userinfo", "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.authorization-grant-type=authorization_code", "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", "platform.contact.to=test@example.com", "platform.contact.from=noreply@example.com", "platform.storage.access-key=test", @@ -62,52 +61,51 @@ class AdminSecurityTest { @BeforeEach void setUp() { - // .apply(springSecurity()) is not optional here: webAppContextSetup alone leaves the security - // filter chain out, so every protected path returns 200 and the test proves nothing. + // .apply(springSecurity()) is not optional: webAppContextSetup alone leaves the filter chain + // out, so every protected path returns 200 and the test would assert nothing. mvc = MockMvcBuilders.webAppContextSetup(context) .apply(SecurityMockMvcConfigurers.springSecurity()) .build(); } @Test - void everyAdminEndpointIsClosedToAnonymousVisitors() throws Exception { - // 401 rather than a redirect: the platform's security starter answers /api/** with a status so - // the SPA can handle it, instead of bouncing an XHR to the identity provider. + void everyAdminApiIsClosedToAnonymousVisitors() throws Exception { + // csrf() on the writes, so these assert AUTHORIZATION (401), not a missing token. mvc.perform(get("/api/admin/products")).andExpect(status().isUnauthorized()); - mvc.perform(get("/api/admin/enquiries")).andExpect(status().isUnauthorized()); - mvc.perform(post("/api/admin/products").with(csrf()).contentType(MediaType.APPLICATION_JSON) - .content("{\"name\":\"x\",\"category\":\"Cakes\",\"imageKeys\":[\"a\"]}")) - .andExpect(status().isUnauthorized()); - mvc.perform(post("/api/admin/images/presign-upload?filename=x.jpg").with(csrf())) + mvc.perform(get("/api/admin/categories")).andExpect(status().isUnauthorized()); + mvc.perform(post("/api/admin/products").with(csrf())).andExpect(status().isUnauthorized()); + mvc.perform(post("/api/admin/categories").with(csrf()).contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"x\"}")) .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 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/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 - void theContactFormNeedsItsCsrfToken() throws Exception { - // Turning on the security starter turns on CSRF, which applies to the PUBLIC contact form too. - // Without the token the form silently 403s — the SPA reads the XSRF-TOKEN cookie and sends - // X-XSRF-TOKEN for exactly this reason. + void theContactFormStillNeedsItsCsrfToken() throws Exception { + // Enabling the security starter enables CSRF for the PUBLIC contact form too. Without the token + // it 403s; the SPA reads the XSRF-TOKEN cookie and sends X-XSRF-TOKEN. mvc.perform(post("/api/contact").contentType(MediaType.APPLICATION_JSON) .content("{\"name\":\"Ada\",\"email\":\"ada@example.com\",\"message\":\"hi\"}")) .andExpect(status().isForbidden()); - } - - @Test - void meIsPublicAndSaysNobodyIsSignedIn() throws Exception { - // 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)); + // 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) + .content("{\"name\":\"Ada\",\"email\":\"nope\",\"message\":\"hi\"}")) + .andExpect(status().isBadRequest()); } }