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