Archived
Reconcile: your admin wins, keeping main's non-admin work
You built a self-service catalogue admin on feature/admin-and-ui-wins while I built a
competing one that had already merged and deployed. Both forked from 27821cd. Per your
call, your implementation is the one that stays.
Kept from main (files your branch didn't touch, so no conflict):
- the CI test gate (tests now run and block the image)
- motion 12.42.2
- the platform contract test
Took from your branch:
- split AdminProductController / AdminCategoryController + ProductPhotoService (server-side
webp via cwebp)
- a real category table (Category, V3__categories.sql) behind the product filters
- pages/Admin.tsx, with server-side /admin protection that redirects a browser to Authentik
and returns it to /admin afterward — cleaner than my client-side gate, and it avoids the
post-login-to-home issue my version had
Deleted my competing admin (AdminController, MeController, pages/admin/*, auth.tsx, and my
admin tests).
Grafted onto your gallery: swipe + arrow keys, which the deployed version had and yours
didn't. Added an AdminSecurityTest for your endpoints (admin closed, shop public, contact
CSRF) — the admin was otherwise untested, and CI now gates on tests.
Verified against a running container: /admin redirects a browser to Authentik (a bare 401
only for */* fetches, which is correct). 25 tests green.
This commit is contained in:
+4
-1
@@ -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
|
||||
|
||||
|
||||
@@ -29,6 +29,25 @@ The SPA renders; it doesn't decide anything.
|
||||
- **Per-page metadata** — `PageMetaController` rewrites `<title>`/`<meta>`/OG tags per route. Next used
|
||||
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
|
||||
|
||||
+32
-16
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
+87
-74
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
deleteProduct, fetchAdminProducts, fetchEnquiries,
|
||||
type AdminEnquiry, type AdminProduct,
|
||||
} from '@/lib/api';
|
||||
import { useAuth, signIn } from '@/lib/auth';
|
||||
|
||||
type Tab = 'products' | 'enquiries';
|
||||
|
||||
export default function AdminPage() {
|
||||
const { me, loading } = useAuth();
|
||||
const [tab, setTab] = useState<Tab>('products');
|
||||
const [products, setProducts] = useState<AdminProduct[]>([]);
|
||||
const [enquiries, setEnquiries] = useState<AdminEnquiry[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = () => {
|
||||
// Only ask for admin data once we know there is a session; otherwise every anonymous visitor
|
||||
// who guesses this URL gets bounced to the identity provider.
|
||||
if (!me.admin) return;
|
||||
fetchAdminProducts().then(setProducts).catch((e) => setError(String(e.message ?? e)));
|
||||
fetchEnquiries().then(setEnquiries).catch((e) => setError(String(e.message ?? e)));
|
||||
};
|
||||
|
||||
useEffect(load, [me.admin]);
|
||||
|
||||
if (loading) {
|
||||
return <p className="container mx-auto px-4 py-20 text-bakery-700">Loading…</p>;
|
||||
}
|
||||
|
||||
if (!me.admin) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-20 text-center">
|
||||
<h1 className="font-adbhashitha text-3xl text-bakery-900 mb-6">Staff only</h1>
|
||||
<button
|
||||
onClick={signIn}
|
||||
className="px-8 py-3.5 bg-bakery-600 text-white rounded-full tracking-wide hover:bg-bakery-700 transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function remove(p: AdminProduct) {
|
||||
if (!window.confirm(`Remove “${p.name}” from the menu? The photos stay in storage.`)) return;
|
||||
try {
|
||||
await deleteProduct(p.id);
|
||||
setProducts((list) => list.filter((x) => x.id !== p.id));
|
||||
} catch (e) {
|
||||
setError(String((e as Error).message));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-4 mb-8">
|
||||
<h1 className="font-adbhashitha text-4xl text-bakery-900">Manage</h1>
|
||||
<span className="text-sm text-bakery-600">signed in as {me.name}</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="mb-6 rounded-xl bg-red-50 px-4 py-3 text-red-800">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 mb-8">
|
||||
{(['products', 'enquiries'] as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`px-6 py-2 rounded-full border text-sm uppercase tracking-[0.12em] transition-colors ${
|
||||
tab === t
|
||||
? 'bg-bakery-600 text-white border-bakery-600'
|
||||
: 'bg-white border-bakery-300 text-bakery-700 hover:bg-bakery-100'
|
||||
}`}
|
||||
>
|
||||
{t === 'products' ? `Products (${products.length})` : `Enquiries (${enquiries.length})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'products' ? (
|
||||
<>
|
||||
<Link
|
||||
to="/admin/products/new"
|
||||
className="inline-block mb-6 px-6 py-2.5 bg-bakery-600 text-white rounded-full text-sm tracking-wide hover:bg-bakery-700 transition-colors"
|
||||
>
|
||||
Add a product
|
||||
</Link>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{products.map((p) => (
|
||||
<div key={p.id} className="bg-white rounded-3xl overflow-hidden shadow-xs">
|
||||
{p.imageUrls[0] && (
|
||||
<img src={p.imageUrls[0]} alt="" className="w-full aspect-square object-cover" />
|
||||
)}
|
||||
<div className="p-5">
|
||||
<div className="font-adbhashitha text-lg text-bakery-900">{p.name}</div>
|
||||
<div className="text-xs uppercase tracking-[0.15em] text-bakery-600 mb-4">
|
||||
{p.category} · {p.imageKeys.length} photo{p.imageKeys.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
<div className="flex gap-3 text-sm">
|
||||
<Link
|
||||
to={`/admin/products/${p.id}`}
|
||||
className="underline underline-offset-4 text-bakery-700 hover:text-bakery-900"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => remove(p)}
|
||||
className="underline underline-offset-4 text-red-700 hover:text-red-900"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{products.length === 0 && <p className="text-bakery-700">Nothing on the menu yet.</p>}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{enquiries.map((e) => (
|
||||
<div key={e.id} className="bg-white rounded-3xl p-6 shadow-xs">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div className="font-medium text-bakery-900">
|
||||
{e.name} <a href={`mailto:${e.email}`} className="font-normal text-bakery-600 underline underline-offset-4">{e.email}</a>
|
||||
</div>
|
||||
<div className="text-sm text-bakery-600">
|
||||
{new Date(e.receivedAt).toLocaleString()}
|
||||
{!e.delivered && (
|
||||
// The enquiry was saved but the relay refused it — nobody got an email.
|
||||
<span className="ml-2 rounded-full bg-amber-100 px-2 py-0.5 text-xs text-amber-800">
|
||||
not emailed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 whitespace-pre-wrap text-bakery-800">{e.message}</p>
|
||||
</div>
|
||||
))}
|
||||
{enquiries.length === 0 && <p className="text-bakery-700">No enquiries yet.</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
createProduct, fetchAdminProducts, updateProduct, uploadPhoto,
|
||||
type AdminProduct,
|
||||
} from '@/lib/api';
|
||||
import { useAuth, signIn } from '@/lib/auth';
|
||||
|
||||
export default function ProductEditorPage() {
|
||||
const { id } = useParams();
|
||||
const editing = id !== undefined;
|
||||
const navigate = useNavigate();
|
||||
const { me, loading } = useAuth();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [position, setPosition] = useState<number | null>(null);
|
||||
const [images, setImages] = useState<{ key: string; url: string }[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing || !me.admin) return;
|
||||
fetchAdminProducts()
|
||||
.then((all) => {
|
||||
const p = all.find((x: AdminProduct) => String(x.id) === id);
|
||||
if (!p) { setError('That product no longer exists.'); return; }
|
||||
setName(p.name);
|
||||
setCategory(p.category);
|
||||
setPosition(p.position);
|
||||
setImages(p.imageKeys.map((k, i) => ({ key: k, url: p.imageUrls[i] })));
|
||||
})
|
||||
.catch((e) => setError(String(e.message ?? e)));
|
||||
}, [editing, id, me.admin]);
|
||||
|
||||
if (loading) return <p className="container mx-auto px-4 py-20 text-bakery-700">Loading…</p>;
|
||||
if (!me.admin) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-20 text-center">
|
||||
<button onClick={signIn} className="px-8 py-3.5 bg-bakery-600 text-white rounded-full">Sign in</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function onFiles(files: FileList | null) {
|
||||
if (!files?.length) return;
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
// Sequentially, so the order the photos are chosen is the order they appear on the card.
|
||||
for (const file of Array.from(files)) {
|
||||
const t = await uploadPhoto(file);
|
||||
setImages((list) => [...list, { key: t.key, url: t.publicUrl }]);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String((e as Error).message));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
const form = { name, category, position, imageKeys: images.map((i) => i.key) };
|
||||
if (editing) await updateProduct(Number(id), form);
|
||||
else await createProduct(form);
|
||||
navigate('/admin');
|
||||
} catch (err) {
|
||||
setError(String((err as Error).message));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const move = (from: number, to: number) => {
|
||||
if (to < 0 || to >= images.length) return;
|
||||
setImages((list) => {
|
||||
const next = [...list];
|
||||
const [it] = next.splice(from, 1);
|
||||
next.splice(to, 0, it);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-12 max-w-2xl">
|
||||
<h1 className="font-adbhashitha text-4xl text-bakery-900 mb-8">
|
||||
{editing ? 'Edit product' : 'Add a product'}
|
||||
</h1>
|
||||
|
||||
{error && <p role="alert" className="mb-6 rounded-xl bg-red-50 px-4 py-3 text-red-800">{error}</p>}
|
||||
|
||||
<form onSubmit={save} className="bg-white rounded-3xl p-8 shadow-xs">
|
||||
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="name">Name</label>
|
||||
<input
|
||||
id="name" value={name} onChange={(e) => setName(e.target.value)} required
|
||||
className="mb-5 w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500"
|
||||
/>
|
||||
|
||||
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="category">Category</label>
|
||||
<input
|
||||
id="category" value={category} onChange={(e) => setCategory(e.target.value)} required
|
||||
placeholder="Cakes, Cookies, Rolls, Pie, Brownies, Pastries"
|
||||
className="mb-1 w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500"
|
||||
/>
|
||||
<p className="mb-5 text-xs text-bakery-600">
|
||||
A new category appears as its own filter button, after the ones the site already knows.
|
||||
</p>
|
||||
|
||||
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="photos">Photos</label>
|
||||
<input
|
||||
id="photos" type="file" accept="image/*" multiple disabled={busy}
|
||||
onChange={(e) => onFiles(e.target.files)}
|
||||
className="mb-4 block w-full text-sm text-bakery-700 file:mr-4 file:rounded-full file:border-0 file:bg-bakery-600 file:px-5 file:py-2 file:text-white"
|
||||
/>
|
||||
|
||||
{images.length > 0 && (
|
||||
<div className="mb-6 grid grid-cols-3 gap-3">
|
||||
{images.map((img, i) => (
|
||||
<div key={img.key} className="relative">
|
||||
<img src={img.url} alt="" className="aspect-square w-full rounded-xl object-cover" />
|
||||
{i === 0 && (
|
||||
<span className="absolute top-1 left-1 rounded-full bg-bakery-900/70 px-2 py-0.5 text-[10px] uppercase tracking-wide text-white">
|
||||
card
|
||||
</span>
|
||||
)}
|
||||
<div className="mt-1 flex justify-between text-xs text-bakery-700">
|
||||
<button type="button" onClick={() => move(i, i - 1)} aria-label="Move earlier">←</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setImages((l) => l.filter((_, j) => j !== i))}
|
||||
className="text-red-700"
|
||||
>
|
||||
remove
|
||||
</button>
|
||||
<button type="button" onClick={() => move(i, i + 1)} aria-label="Move later">→</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || images.length === 0}
|
||||
className="px-8 py-3 bg-bakery-600 text-white rounded-full tracking-wide hover:bg-bakery-700 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{busy ? 'Working…' : 'Save'}
|
||||
</button>
|
||||
<button type="button" onClick={() => navigate('/admin')} className="text-bakery-700 underline underline-offset-4">
|
||||
Cancel
|
||||
</button>
|
||||
{images.length === 0 && <span className="text-sm text-bakery-600">Add at least one photo.</span>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.itsthevine.web.domain.Category;
|
||||
import com.itsthevine.web.domain.CategoryRepository;
|
||||
import com.itsthevine.web.domain.Product;
|
||||
import com.itsthevine.web.domain.ProductRepository;
|
||||
|
||||
/**
|
||||
* The filter buttons, editable. Gated on OIDC for the same reason as the product admin: with no
|
||||
* identity provider configured these endpoints shouldn't exist at all.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/categories")
|
||||
@ConditionalOnProperty(prefix = "platform.security", name = "mode", havingValue = "OIDC")
|
||||
public class AdminCategoryController {
|
||||
|
||||
private final CategoryRepository categories;
|
||||
private final ProductRepository products;
|
||||
|
||||
public AdminCategoryController(CategoryRepository categories, ProductRepository products) {
|
||||
this.categories = categories;
|
||||
this.products = products;
|
||||
}
|
||||
|
||||
/** {@code used} tells the editor whether deleting it would strand anything. */
|
||||
public record AdminView(Long id, String name, int position, long used) {}
|
||||
|
||||
public record Name(String name) {}
|
||||
|
||||
public record Order(List<Long> ids) {}
|
||||
|
||||
@GetMapping
|
||||
@Transactional(readOnly = true)
|
||||
public List<AdminView> list() {
|
||||
List<Product> all = products.findAllByOrderByPositionAsc();
|
||||
return categories.findAllByOrderByPositionAsc().stream()
|
||||
.map(c -> new AdminView(c.getId(), c.getName(), c.getPosition(), count(all, c.getName())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Transactional
|
||||
public AdminView create(@RequestBody Name body) {
|
||||
String name = required(body.name());
|
||||
categories.findByNameIgnoreCase(name).ifPresent(existing -> {
|
||||
throw new IllegalStateException("There's already a " + existing.getName() + " category.");
|
||||
});
|
||||
int last = categories.findAllByOrderByPositionAsc().stream()
|
||||
.mapToInt(Category::getPosition).max().orElse(0);
|
||||
Category saved = categories.save(new Category(name, last + 1));
|
||||
return new AdminView(saved.getId(), saved.getName(), saved.getPosition(), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renaming carries the products with it. They store the category by name, so without this the
|
||||
* rename would orphan everything filed under the old one — it would drop off the filter and
|
||||
* reappear at the end as an unlisted category.
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
@Transactional
|
||||
public AdminView rename(@PathVariable Long id, @RequestBody Name body) {
|
||||
Category category = find(id);
|
||||
String name = required(body.name());
|
||||
categories.findByNameIgnoreCase(name)
|
||||
.filter(other -> !other.getId().equals(id))
|
||||
.ifPresent(other -> {
|
||||
throw new IllegalStateException("There's already a " + other.getName() + " category.");
|
||||
});
|
||||
|
||||
String previous = category.getName();
|
||||
category.rename(name);
|
||||
categories.save(category);
|
||||
|
||||
List<Product> filed = products.findAllByCategoryOrderByPositionAsc(previous);
|
||||
filed.forEach(p -> p.describe(p.getName(), name));
|
||||
products.saveAll(filed);
|
||||
|
||||
return new AdminView(category.getId(), category.getName(), category.getPosition(), filed.size());
|
||||
}
|
||||
|
||||
@PutMapping("/order")
|
||||
@Transactional
|
||||
public List<AdminView> reorder(@RequestBody Order order) {
|
||||
List<Category> all = categories.findAllByOrderByPositionAsc();
|
||||
List<Category> arranged = new ArrayList<>();
|
||||
for (Long id : order.ids()) {
|
||||
all.stream().filter(c -> c.getId().equals(id)).findFirst().ifPresent(arranged::add);
|
||||
}
|
||||
all.stream().filter(c -> !arranged.contains(c)).forEach(arranged::add);
|
||||
|
||||
int position = 1;
|
||||
for (Category category : arranged) {
|
||||
category.moveTo(position++);
|
||||
}
|
||||
categories.saveAll(arranged);
|
||||
|
||||
List<Product> everything = products.findAllByOrderByPositionAsc();
|
||||
return arranged.stream()
|
||||
.map(c -> new AdminView(c.getId(), c.getName(), c.getPosition(), count(everything, c.getName())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id) {
|
||||
Category category = find(id);
|
||||
long used = count(products.findAllByOrderByPositionAsc(), category.getName());
|
||||
if (used > 0) {
|
||||
// Refuse rather than cascade: deleting the button shouldn't quietly decide what happens to
|
||||
// the items behind it.
|
||||
throw new IllegalStateException(
|
||||
used + " item" + (used == 1 ? " is" : "s are") + " still filed under "
|
||||
+ category.getName() + ". Move them first.");
|
||||
}
|
||||
categories.delete(category);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
|
||||
private Category find(Long id) {
|
||||
return categories.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("That category no longer exists."));
|
||||
}
|
||||
|
||||
private static long count(List<Product> all, String category) {
|
||||
return all.stream().filter(p -> p.getCategory().equalsIgnoreCase(category)).count();
|
||||
}
|
||||
|
||||
private static String required(String value) {
|
||||
String trimmed = value == null ? "" : value.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
throw new IllegalArgumentException("Please give the category a name.");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import com.itsthevine.web.domain.ContactEnquiry;
|
||||
import com.itsthevine.web.domain.ContactEnquiryRepository;
|
||||
import com.itsthevine.web.domain.Product;
|
||||
import com.itsthevine.web.domain.ProductRepository;
|
||||
|
||||
import net.thebennett.platform.storage.StorageService;
|
||||
|
||||
/**
|
||||
* Everything behind the login: the catalogue, and the enquiries people have sent.
|
||||
*
|
||||
* <p>The whole of {@code /api/admin/**} is gated by {@code platform.security.authenticated-paths}, so
|
||||
* any signed-in Authentik user is an administrator here. That is deliberate for a two-person bakery —
|
||||
* the alternative is a role model nobody would maintain.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
public class AdminController {
|
||||
|
||||
private final ProductRepository products;
|
||||
private final ContactEnquiryRepository enquiries;
|
||||
private final StorageService storage;
|
||||
private final String bucket;
|
||||
private final String publicBaseUrl;
|
||||
|
||||
public AdminController(ProductRepository products, ContactEnquiryRepository enquiries,
|
||||
StorageService storage,
|
||||
@Value("${vine.storage.bucket:itsthevine}") String bucket,
|
||||
@Value("${site.assets.base-url:https://s3.thebennett.net/itsthevine}") String publicBaseUrl) {
|
||||
this.products = products;
|
||||
this.enquiries = enquiries;
|
||||
this.storage = storage;
|
||||
this.bucket = bucket;
|
||||
this.publicBaseUrl = publicBaseUrl.replaceAll("/+$", "");
|
||||
}
|
||||
|
||||
// ---- products ----
|
||||
|
||||
/** @param imageKeys bucket keys, in display order; the first is the one the card shows */
|
||||
public record ProductForm(String name, String category, Integer position, List<String> imageKeys) {}
|
||||
|
||||
public record AdminProduct(Long id, String name, String category, int position,
|
||||
List<String> imageKeys, List<String> imageUrls) {}
|
||||
|
||||
@GetMapping("/products")
|
||||
@Transactional(readOnly = true)
|
||||
public List<AdminProduct> list() {
|
||||
return products.findAllByOrderByPositionAsc().stream().map(this::toAdmin).toList();
|
||||
}
|
||||
|
||||
@PostMapping("/products")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@Transactional
|
||||
public AdminProduct create(@RequestBody ProductForm form) {
|
||||
validate(form);
|
||||
// Default to the end of the list so a new product does not silently displace an existing one.
|
||||
int position = form.position() != null ? form.position() : nextPosition();
|
||||
return toAdmin(products.save(new Product(form.name().trim(), form.category().trim(),
|
||||
position, cleanKeys(form.imageKeys()))));
|
||||
}
|
||||
|
||||
@PutMapping("/products/{id}")
|
||||
@Transactional
|
||||
public AdminProduct update(@PathVariable Long id, @RequestBody ProductForm form) {
|
||||
validate(form);
|
||||
Product p = products.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "no such product"));
|
||||
p.update(form.name().trim(), form.category().trim(),
|
||||
form.position() != null ? form.position() : p.getPosition(),
|
||||
cleanKeys(form.imageKeys()));
|
||||
return toAdmin(p);
|
||||
}
|
||||
|
||||
@DeleteMapping("/products/{id}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
@Transactional
|
||||
public void delete(@PathVariable Long id) {
|
||||
if (!products.existsById(id)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "no such product");
|
||||
}
|
||||
// The photos stay in the bucket: they are cheap, and an accidental delete is recoverable if
|
||||
// the images survive it.
|
||||
products.deleteById(id);
|
||||
}
|
||||
|
||||
// ---- enquiries ----
|
||||
|
||||
/** @param delivered false means the relay refused it and nobody was notified */
|
||||
public record AdminEnquiry(Long id, String name, String email, String message,
|
||||
boolean delivered, Instant receivedAt) {}
|
||||
|
||||
@GetMapping("/enquiries")
|
||||
@Transactional(readOnly = true)
|
||||
public List<AdminEnquiry> enquiries() {
|
||||
return enquiries.findAllByOrderByCreatedAtDesc().stream()
|
||||
.map(e -> new AdminEnquiry(e.getId(), e.getName(), e.getEmail(), e.getMessage(),
|
||||
e.isDelivered(), e.getCreatedAt()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ---- photo upload ----
|
||||
|
||||
/**
|
||||
* @param key what to store on the product
|
||||
* @param uploadUrl short-lived; the browser PUTs the file straight to the bucket so the photo
|
||||
* never passes through this app
|
||||
* @param publicUrl where it will be readable from afterwards
|
||||
*/
|
||||
public record UploadTarget(String key, String uploadUrl, String publicUrl) {}
|
||||
|
||||
@PostMapping("/images/presign-upload")
|
||||
public UploadTarget presignUpload(@RequestParam String filename,
|
||||
@RequestParam(defaultValue = "application/octet-stream") String contentType) {
|
||||
// A UUID prefix rather than the bare filename: two people uploading "cake.jpg" must not
|
||||
// overwrite each other, and the bucket is public so keys should not be guessable.
|
||||
String safe = filename.toLowerCase().replaceAll("[^a-z0-9._-]", "-");
|
||||
String key = "images/products/" + UUID.randomUUID() + "-" + safe;
|
||||
return new UploadTarget(key.substring("images/".length()),
|
||||
storage.presignPut(bucket, key, contentType).toString(),
|
||||
publicBaseUrl + "/" + key);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private void validate(ProductForm form) {
|
||||
if (form.name() == null || form.name().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "a product needs a name");
|
||||
}
|
||||
if (form.category() == null || form.category().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "a product needs a category");
|
||||
}
|
||||
if (form.imageKeys() == null || cleanKeys(form.imageKeys()).isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "a product needs at least one photo");
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> cleanKeys(List<String> keys) {
|
||||
return keys == null ? List.of()
|
||||
: keys.stream().filter(k -> k != null && !k.isBlank()).map(String::trim).toList();
|
||||
}
|
||||
|
||||
private int nextPosition() {
|
||||
return products.findAllByOrderByPositionAsc().stream()
|
||||
.mapToInt(Product::getPosition).max().orElse(0) + 1;
|
||||
}
|
||||
|
||||
private AdminProduct toAdmin(Product p) {
|
||||
return new AdminProduct(p.getId(), p.getName(), p.getCategory(), p.getPosition(),
|
||||
p.getImageKeys(), p.getImageKeys().stream().map(this::publicUrl).toList());
|
||||
}
|
||||
|
||||
private String publicUrl(String key) {
|
||||
return publicBaseUrl + "/images/" + key.replaceAll("^/+", "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import com.itsthevine.web.domain.Product;
|
||||
import com.itsthevine.web.domain.ProductRepository;
|
||||
|
||||
/**
|
||||
* Editing the catalogue from the site, so a new cake is a photo and a name rather than a migration.
|
||||
*
|
||||
* The whole controller is conditional on OIDC being switched on. That is deliberate belt-and-braces:
|
||||
* the platform's permit-all filter chain is what runs when {@code platform.security.mode} is unset,
|
||||
* so if these endpoints existed unconditionally a deployment that forgot to configure Authentik
|
||||
* would be publishing catalogue writes to the open internet. Gated this way, "no auth configured"
|
||||
* means "no admin endpoints" — they 404 like any other unknown path, which is also what the platform
|
||||
* web contract expects of {@code /api/**}.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/products")
|
||||
@ConditionalOnProperty(prefix = "platform.security", name = "mode", havingValue = "OIDC")
|
||||
public class AdminProductController {
|
||||
|
||||
private final ProductRepository products;
|
||||
private final ProductPhotoService photos;
|
||||
private final ProductCatalog catalog;
|
||||
|
||||
public AdminProductController(ProductRepository products, ProductPhotoService photos, ProductCatalog catalog) {
|
||||
this.products = products;
|
||||
this.photos = photos;
|
||||
this.catalog = catalog;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the editor sees: the catalogue in display order.
|
||||
*
|
||||
* {@code images} and {@code keys} are the same photos in the same order — the URLs to show and the
|
||||
* identifiers to arrange by. The public view only needs the former, but an editor rearranging
|
||||
* photos has to name them back to us, and the URL is a rendering of the key rather than the key
|
||||
* itself.
|
||||
*/
|
||||
public record AdminView(Long id, String name, String category, int position,
|
||||
List<String> images, List<String> keys) {}
|
||||
|
||||
public record Details(String name, String category) {}
|
||||
|
||||
public record Order(List<Long> ids) {}
|
||||
|
||||
@GetMapping
|
||||
@Transactional(readOnly = true)
|
||||
public List<AdminView> list() {
|
||||
return products.findAllByOrderByPositionAsc().stream().map(this::toView).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* New items go to the front — the newest work is what's worth showing first, and it saves the
|
||||
* editor a reorder after every upload.
|
||||
*/
|
||||
@PostMapping
|
||||
@Transactional
|
||||
public AdminView create(@RequestParam String name,
|
||||
@RequestParam String category,
|
||||
@RequestParam("photos") List<MultipartFile> files) {
|
||||
String cleanName = required(name, "Please give it a name.");
|
||||
String cleanCategory = required(category, "Please choose a category.");
|
||||
if (files == null || files.isEmpty()) {
|
||||
throw new IllegalArgumentException("Please add at least one photo.");
|
||||
}
|
||||
|
||||
List<String> keys = new ArrayList<>();
|
||||
for (MultipartFile file : files) {
|
||||
keys.add(photos.store(bytes(file), file.getOriginalFilename(), cleanName));
|
||||
}
|
||||
|
||||
Product saved = products.save(new Product(cleanName, cleanCategory, 0, keys));
|
||||
renumberWithFirst(saved);
|
||||
return toView(saved);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Transactional
|
||||
public AdminView describe(@PathVariable Long id, @RequestBody Details details) {
|
||||
Product product = find(id);
|
||||
product.describe(required(details.name(), "Please give it a name."),
|
||||
required(details.category(), "Please choose a category."));
|
||||
return toView(products.save(product));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/photos")
|
||||
@Transactional
|
||||
public AdminView addPhotos(@PathVariable Long id, @RequestParam("photos") List<MultipartFile> files) {
|
||||
Product product = find(id);
|
||||
if (files == null || files.isEmpty()) {
|
||||
throw new IllegalArgumentException("Please choose a photo to add.");
|
||||
}
|
||||
List<String> keys = new ArrayList<>(product.getImageKeys());
|
||||
for (MultipartFile file : files) {
|
||||
keys.add(photos.store(bytes(file), file.getOriginalFilename(), product.getName()));
|
||||
}
|
||||
product.replacePhotos(keys);
|
||||
return toView(products.save(product));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reordering and removal both arrive as the full list the editor arranged, so the stored order is
|
||||
* whatever they last saw rather than the result of replaying moves.
|
||||
*/
|
||||
@PutMapping("/{id}/photos")
|
||||
@Transactional
|
||||
public AdminView arrangePhotos(@PathVariable Long id, @RequestBody List<String> keys) {
|
||||
Product product = find(id);
|
||||
List<String> existing = product.getImageKeys();
|
||||
List<String> arranged = keys.stream().filter(existing::contains).distinct().toList();
|
||||
if (arranged.isEmpty()) {
|
||||
throw new IllegalArgumentException("An item needs at least one photo.");
|
||||
}
|
||||
product.replacePhotos(arranged);
|
||||
return toView(products.save(product));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id) {
|
||||
products.delete(find(id));
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
|
||||
/** The ids in the order they should appear; anything omitted keeps its relative place after them. */
|
||||
@PutMapping("/order")
|
||||
@Transactional
|
||||
public List<AdminView> reorder(@RequestBody Order order) {
|
||||
List<Product> all = products.findAllByOrderByPositionAsc();
|
||||
List<Product> arranged = new ArrayList<>();
|
||||
for (Long id : order.ids()) {
|
||||
all.stream().filter(p -> p.getId().equals(id)).findFirst().ifPresent(arranged::add);
|
||||
}
|
||||
all.stream().filter(p -> !arranged.contains(p)).forEach(arranged::add);
|
||||
renumber(arranged);
|
||||
return arranged.stream().map(this::toView).toList();
|
||||
}
|
||||
|
||||
private void renumberWithFirst(Product first) {
|
||||
List<Product> arranged = new ArrayList<>();
|
||||
arranged.add(first);
|
||||
products.findAllByOrderByPositionAsc().stream()
|
||||
.filter(p -> !p.getId().equals(first.getId()))
|
||||
.forEach(arranged::add);
|
||||
renumber(arranged);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code product.position} has no unique constraint, so ordering is a full renumber rather than a
|
||||
* swap — forty rows, once in a while, from one editor.
|
||||
*/
|
||||
private void renumber(List<Product> arranged) {
|
||||
int position = 1;
|
||||
for (Product product : arranged) {
|
||||
product.moveTo(position++);
|
||||
}
|
||||
products.saveAll(arranged);
|
||||
}
|
||||
|
||||
private Product find(Long id) {
|
||||
return products.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("That item no longer exists."));
|
||||
}
|
||||
|
||||
private static byte[] bytes(MultipartFile file) {
|
||||
try {
|
||||
return file.getBytes();
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Could not read the uploaded photo.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String required(String value, String message) {
|
||||
String trimmed = value == null ? "" : value.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
throw new IllegalArgumentException(message);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** Reuses the catalogue's URL building so admin and public pages can never disagree about a photo. */
|
||||
private AdminView toView(Product product) {
|
||||
ProductCatalog.ProductView view = catalog.view(product);
|
||||
return new AdminView(view.id(), view.name(), view.category(), product.getPosition(),
|
||||
view.images(), List.copyOf(product.getImageKeys()));
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Who, if anyone, is signed in.
|
||||
*
|
||||
* <p>Deliberately PUBLIC: the SPA asks on every page load, and if this required a login the site would
|
||||
* bounce anonymous visitors — every one of them — to Authentik just to render the front page.
|
||||
*/
|
||||
@RestController
|
||||
public class MeController {
|
||||
|
||||
/** @param admin true for any signed-in user; there is one level of access here */
|
||||
public record Me(boolean authenticated, boolean admin, String name) {}
|
||||
|
||||
@GetMapping("/api/me")
|
||||
public Me me() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
boolean signedIn = auth != null && auth.isAuthenticated()
|
||||
&& !"anonymousUser".equals(auth.getPrincipal());
|
||||
if (!signedIn) {
|
||||
return new Me(false, false, null);
|
||||
}
|
||||
String name = auth.getName();
|
||||
if (auth.getPrincipal() instanceof OidcUser user) {
|
||||
name = user.getPreferredUsername() != null ? user.getPreferredUsername() : user.getSubject();
|
||||
}
|
||||
return new Me(true, true, name);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.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());
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import net.thebennett.platform.storage.StorageService;
|
||||
|
||||
/**
|
||||
* Turns whatever came off a phone into the one shape the bucket holds: a resized webp.
|
||||
*
|
||||
* The photos already in the bucket were re-encoded by hand once (50 MB of originals became 14 MB).
|
||||
* Uploads go through the same treatment so the catalogue doesn't slowly fill with 12 MP JPEGs, and
|
||||
* so nothing arrives carrying the GPS coordinates of the bakery's kitchen — decoding to a
|
||||
* {@link BufferedImage} and re-encoding drops every EXIF tag, because a raster has nowhere to put
|
||||
* them.
|
||||
*
|
||||
* Encoding shells out to {@code cwebp}. No pure-Java webp *writer* exists (TwelveMonkeys and
|
||||
* NightMonkeys both read only), and the libraries that do write bundle glibc natives that will not
|
||||
* load on the Alpine runtime — so the Dockerfile installs Alpine's own musl build of libwebp-tools
|
||||
* and we hand it bytes.
|
||||
*/
|
||||
@Service
|
||||
public class ProductPhotoService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ProductPhotoService.class);
|
||||
|
||||
/** Big enough for a full-bleed card on a retina screen; far smaller than anything a phone shoots. */
|
||||
private static final int MAX_EDGE = 2000;
|
||||
private static final int QUALITY = 82;
|
||||
private static final long ENCODE_TIMEOUT_SECONDS = 30;
|
||||
|
||||
private final StorageService storage;
|
||||
private final String bucket;
|
||||
private final String keyPrefix;
|
||||
|
||||
public ProductPhotoService(StorageService storage,
|
||||
@Value("${site.assets.bucket:itsthevine}") String bucket,
|
||||
@Value("${site.assets.key-prefix:images/}") String keyPrefix) {
|
||||
this.storage = storage;
|
||||
this.bucket = bucket;
|
||||
// ProductCatalog builds public URLs as <base>/images/<stored key>, so the object itself lives
|
||||
// one level deeper than the key we persist. Keeping the prefix here means the database keeps
|
||||
// storing exactly what it stores today.
|
||||
this.keyPrefix = keyPrefix.replaceAll("^/+", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the key to persist on the product — bucket-relative and WITHOUT the {@code images/}
|
||||
* prefix, matching everything already in {@code product_image}
|
||||
*/
|
||||
public String store(byte[] original, String filename, String nameHint) {
|
||||
BufferedImage decoded = decode(original, filename);
|
||||
byte[] webp = encodeWebp(resize(decoded));
|
||||
|
||||
String key = "products/" + slug(nameHint) + "-" + token() + ".webp";
|
||||
storage.put(bucket, keyPrefix + key, webp, "image/webp");
|
||||
log.info("stored product photo {} ({} KB from {} KB)", key, webp.length / 1024, original.length / 1024);
|
||||
return key;
|
||||
}
|
||||
|
||||
private BufferedImage decode(byte[] bytes, String filename) {
|
||||
try {
|
||||
BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes));
|
||||
if (image == null) {
|
||||
// ImageIO returns null rather than throwing when no reader claims the bytes — HEIC off
|
||||
// an iPhone lands here, as does anything that isn't really an image.
|
||||
throw new IllegalArgumentException(
|
||||
"That file isn't an image we can read (" + filename + "). JPEG or PNG works.");
|
||||
}
|
||||
return image;
|
||||
} catch (IOException e) {
|
||||
throw new IllegalArgumentException("Could not read " + filename + ".", e);
|
||||
}
|
||||
}
|
||||
|
||||
private BufferedImage resize(BufferedImage source) {
|
||||
int width = source.getWidth();
|
||||
int height = source.getHeight();
|
||||
double scale = Math.min(1.0, (double) MAX_EDGE / Math.max(width, height));
|
||||
int targetWidth = Math.max(1, (int) Math.round(width * scale));
|
||||
int targetHeight = Math.max(1, (int) Math.round(height * scale));
|
||||
|
||||
// TYPE_INT_RGB regardless of scale: it flattens any alpha channel onto a known background and
|
||||
// gives cwebp a predictable input. The photos are opaque product shots.
|
||||
BufferedImage target = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = target.createGraphics();
|
||||
try {
|
||||
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
|
||||
g.drawImage(source, 0, 0, targetWidth, targetHeight, null);
|
||||
} finally {
|
||||
g.dispose();
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Temp files rather than piping through stdin/stdout: cwebp's stream handling varies by build, and
|
||||
* a couple of files in the container's tmpdir is a cheaper bet than debugging that in production.
|
||||
*/
|
||||
private byte[] encodeWebp(BufferedImage image) {
|
||||
Path png = null;
|
||||
Path webp = null;
|
||||
try {
|
||||
png = Files.createTempFile("vine-photo-", ".png");
|
||||
webp = Files.createTempFile("vine-photo-", ".webp");
|
||||
if (!ImageIO.write(image, "png", png.toFile())) {
|
||||
throw new IllegalStateException("No PNG writer available to hand cwebp.");
|
||||
}
|
||||
|
||||
Process process = new ProcessBuilder(
|
||||
"cwebp", "-quiet", "-q", String.valueOf(QUALITY),
|
||||
png.toString(), "-o", webp.toString())
|
||||
.redirectErrorStream(true)
|
||||
.start();
|
||||
|
||||
if (!process.waitFor(ENCODE_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly();
|
||||
throw new IllegalStateException("Encoding that photo took too long.");
|
||||
}
|
||||
if (process.exitValue() != 0) {
|
||||
String output = new String(process.getInputStream().readAllBytes()).trim();
|
||||
throw new IllegalStateException("Could not convert that photo. " + output);
|
||||
}
|
||||
return Files.readAllBytes(webp);
|
||||
} catch (IOException e) {
|
||||
// The usual cause is cwebp not being installed — worth saying so plainly.
|
||||
throw new IllegalStateException("Photo conversion is unavailable on this server.", e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Photo conversion was interrupted.", e);
|
||||
} finally {
|
||||
delete(png);
|
||||
delete(webp);
|
||||
}
|
||||
}
|
||||
|
||||
private void delete(Path path) {
|
||||
if (path == null) return;
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException e) {
|
||||
log.warn("could not clean up {}", path, e);
|
||||
}
|
||||
}
|
||||
|
||||
private String slug(String value) {
|
||||
String slug = value == null ? "" : value.toLowerCase(Locale.ROOT)
|
||||
.replaceAll("[^a-z0-9]+", "-")
|
||||
.replaceAll("^-|-$", "");
|
||||
return slug.isBlank() ? "photo" : slug;
|
||||
}
|
||||
|
||||
/** Short random suffix so re-uploading the same dish never overwrites the previous photo. */
|
||||
private String token() {
|
||||
byte[] bytes = new byte[4];
|
||||
ThreadLocalRandom.current().nextBytes(bytes);
|
||||
return HexFormat.of().formatHex(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.itsthevine.web.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import net.thebennett.platform.data.BaseEntity;
|
||||
|
||||
/**
|
||||
* A filter button on the products page, and the order it sits in.
|
||||
*
|
||||
* Products still record their category by name, so this table is reference data rather than the
|
||||
* owner of the relationship — it exists to say which categories the bakery offers and in what order
|
||||
* to show them, both of which used to be a constant in the code.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "category")
|
||||
public class Category extends BaseEntity {
|
||||
|
||||
@Column(nullable = false, length = 60, unique = true)
|
||||
private String name;
|
||||
|
||||
/** Display order of the filter buttons, after "All". */
|
||||
@Column(name = "position", nullable = false)
|
||||
private int position;
|
||||
|
||||
protected Category() {
|
||||
// for JPA
|
||||
}
|
||||
|
||||
public Category(String name, int position) {
|
||||
this.name = name;
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
public void rename(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public void moveTo(int position) {
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
public String getName() { return name; }
|
||||
public int getPosition() { return position; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.itsthevine.web.domain;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface CategoryRepository extends JpaRepository<Category, Long> {
|
||||
|
||||
List<Category> findAllByOrderByPositionAsc();
|
||||
|
||||
Optional<Category> findByNameIgnoreCase(String name);
|
||||
}
|
||||
@@ -12,8 +12,6 @@ import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.OrderColumn;
|
||||
import jakarta.persistence.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; }
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- The filter buttons were a hard-coded List.of(...) in ProductCatalog. That meant adding a category
|
||||
-- was a deploy, and anything an editor invented appeared last, alphabetically, with no way to move
|
||||
-- it. This makes the order data so the admin screens can arrange it.
|
||||
--
|
||||
-- product.category deliberately stays a varchar holding the name rather than becoming a foreign key:
|
||||
-- every existing row, query and derived repository method keeps working untouched, and six rows of
|
||||
-- reference data don't warrant rewriting the catalogue's shape. Renaming a category updates the
|
||||
-- products alongside it, in one transaction.
|
||||
create table category (
|
||||
id bigserial primary key,
|
||||
name varchar(60) not null unique,
|
||||
position integer not null,
|
||||
created_at timestamptz not null,
|
||||
updated_at timestamptz
|
||||
);
|
||||
|
||||
-- Seeded in the order the page has always shown them, so the site looks identical the moment this
|
||||
-- lands. Categories found on products but missing here still appear on the filter (appended
|
||||
-- alphabetically) rather than silently vanishing.
|
||||
insert into category (name, position, created_at) values
|
||||
('Cookies', 1, now()),
|
||||
('Cakes', 2, now()),
|
||||
('Rolls', 3, now()),
|
||||
('Pie', 4, now()),
|
||||
('Brownies', 5, now()),
|
||||
('Pastries', 6, now());
|
||||
@@ -1,146 +0,0 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
|
||||
import com.itsthevine.web.domain.ProductRepository;
|
||||
|
||||
/**
|
||||
* The admin catalogue operations. Whether they're reachable without a login is covered separately by
|
||||
* {@link AdminSecurityTest} — this is about what they do once you're in.
|
||||
*/
|
||||
@SpringBootTest(properties = {
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"platform.storage.access-key=test",
|
||||
"platform.storage.secret-key=test",
|
||||
"site.assets.base-url=https://s3.example.test/itsthevine"
|
||||
})
|
||||
@Testcontainers
|
||||
class AdminControllerTest {
|
||||
|
||||
@Container
|
||||
@ServiceConnection
|
||||
static PostgreSQLContainer<?> postgres =
|
||||
new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
|
||||
|
||||
@Autowired
|
||||
AdminController admin;
|
||||
|
||||
@Autowired
|
||||
ProductCatalog catalog;
|
||||
|
||||
@Autowired
|
||||
ProductRepository products;
|
||||
|
||||
private static AdminController.ProductForm form(String name, String category, List<String> keys) {
|
||||
return new AdminController.ProductForm(name, category, null, keys);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsAProductAndItAppearsOnThePublicSite() {
|
||||
int before = catalog.list(null).size();
|
||||
|
||||
var created = admin.create(form("Test Loaf", "Rolls", List.of("products/test-loaf.webp")));
|
||||
|
||||
assertThat(created.id()).isNotNull();
|
||||
assertThat(catalog.list(null)).hasSize(before + 1);
|
||||
assertThat(catalog.list("Rolls"))
|
||||
.extracting(ProductCatalog.ProductView::name)
|
||||
.contains("Test Loaf");
|
||||
|
||||
admin.delete(created.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aNewProductGoesToTheEndRatherThanDisplacingOne() {
|
||||
// Position defaults matter: reusing an existing one would reorder the curated catalogue.
|
||||
int maxBefore = admin.list().stream().mapToInt(AdminController.AdminProduct::position).max().orElse(0);
|
||||
|
||||
var created = admin.create(form("末 Loaf", "Rolls", List.of("products/x.webp")));
|
||||
|
||||
assertThat(created.position()).isGreaterThan(maxBefore);
|
||||
admin.delete(created.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editingReplacesTheFieldsAndKeepsTheOrderOfPhotos() {
|
||||
var created = admin.create(form("Before", "Cakes", List.of("products/a.webp", "products/b.webp")));
|
||||
|
||||
var updated = admin.update(created.id(),
|
||||
new AdminController.ProductForm("After", "Pie", 3,
|
||||
List.of("products/b.webp", "products/a.webp", "products/c.webp")));
|
||||
|
||||
assertThat(updated.name()).isEqualTo("After");
|
||||
assertThat(updated.category()).isEqualTo("Pie");
|
||||
assertThat(updated.position()).isEqualTo(3);
|
||||
assertThat(updated.imageKeys())
|
||||
.containsExactly("products/b.webp", "products/a.webp", "products/c.webp");
|
||||
|
||||
admin.delete(created.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aProductWithoutAPhotoIsRejected() {
|
||||
// The card is a photo with a caption; without one it renders as an empty square.
|
||||
assertThatThrownBy(() -> admin.create(form("No photo", "Cakes", List.of())))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("at least one photo");
|
||||
assertThatThrownBy(() -> admin.create(form("No photo", "Cakes", List.of(" "))))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aProductWithoutANameOrCategoryIsRejected() {
|
||||
assertThatThrownBy(() -> admin.create(form(" ", "Cakes", List.of("products/a.webp"))))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("name");
|
||||
assertThatThrownBy(() -> admin.create(form("Thing", " ", List.of("products/a.webp"))))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("category");
|
||||
}
|
||||
|
||||
@Test
|
||||
void editingSomethingThatIsGoneIs404NotACrash() {
|
||||
assertThatThrownBy(() -> admin.update(9_999_999L, form("x", "Cakes", List.of("products/a.webp"))))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("404");
|
||||
assertThatThrownBy(() -> admin.delete(9_999_999L))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("404");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletingRemovesItFromThePublicCatalogue() {
|
||||
var created = admin.create(form("Temporary", "Brownies", List.of("products/t.webp")));
|
||||
assertThat(catalog.list("Brownies")).extracting(ProductCatalog.ProductView::name).contains("Temporary");
|
||||
|
||||
admin.delete(created.id());
|
||||
|
||||
assertThat(catalog.list("Brownies")).extracting(ProductCatalog.ProductView::name)
|
||||
.doesNotContain("Temporary");
|
||||
assertThat(products.findById(created.id())).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminListsCarryBothKeysAndUrlsSoTheEditorCanShowThumbnails() {
|
||||
var created = admin.create(form("Thumb", "Cookies", List.of("products/thumb.webp")));
|
||||
|
||||
var found = admin.list().stream().filter(p -> p.id().equals(created.id())).findFirst().orElseThrow();
|
||||
assertThat(found.imageKeys()).containsExactly("products/thumb.webp");
|
||||
assertThat(found.imageUrls())
|
||||
.containsExactly("https://s3.example.test/itsthevine/images/products/thumb.webp");
|
||||
|
||||
admin.delete(created.id());
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package com.itsthevine.web;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.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",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"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\":\"[email protected]\",\"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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user