Files
Epicure/apps/web/components/recipe/add-to-shopping-list-button.tsx
Arnaud 362f65656b fix: audit fixes — tier-quota bypass, webhook SSRF, auth hardening, pagination, a11y
Full audit (bugs/UI-UX/backend/feature-gap) turned up a money-leak AI quota
bypass, webhook SSRF, and a long tail of missing pagination/auth/a11y work.
Fixes land together since HANDOFF.md tracked them as one backlog.

- AI routes charge tier quota before generating; nutrition POST is author-only
- Webhook dispatch re-validates URL per delivery (SSRF/DNS-rebinding), treats
  redirects as failures; recipe.published now actually dispatches
- New indexes/unique constraints on recipes, meal-planning, comments FK cascade
- Recipe PUT/restore snapshot only inside the transaction, after validation
- Recipe DELETE cleans up S3 objects (recipe + review photos)
- Optimistic UI (favorite/star/follow/shopping-list) rolls back on failure
- Upload presign enforces file size cap + per-tier storage quota
- Route-level loading/error/not-found states across (app), admin, and root
- middleware.ts guards (app)/admin; requireAdmin checks DB role, not cached
  session; rate limiting applied to both session and API-key branches,
  bucketed per key; Stripe webhook dedupes by event id
- Pagination added to recipes, feed, profile, comments, pantry, admin tables
- Nav shows real avatar + profile link + dark-mode toggle; destructive actions
  standardized on AlertDialog
- Unsaved-changes guard + real ingredient/step validation on recipe form;
  canonical /recipes/[id] used in-app; next/image migration; aria-labels and
  alt text across icon buttons, avatars, recipe photos
- packages/api-types removed (zero callers, too drifted to safely rewire);
  openapi.ts and ai-keys error shape drift fixed; BYOK decrypt failures now
  surface instead of silently falling back to the platform key

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 21:50:35 +02:00

239 lines
8.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { ShoppingCart, Loader2, Plus, Check } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Separator } from "@/components/ui/separator";
type Ingredient = {
rawName: string;
quantity?: string | number | null;
unit?: string | null;
};
type ShoppingList = { id: string; name: string };
function scaleQty(quantity: string | number | null | undefined, scale: number): string | undefined {
if (!quantity) return undefined;
const n = typeof quantity === "number" ? quantity : parseFloat(quantity);
if (isNaN(n)) return String(quantity);
if (n === 0) return undefined;
const scaled = n * scale;
return scaled % 1 === 0 ? String(scaled) : scaled.toFixed(2).replace(/\.?0+$/, "");
}
export function AddToShoppingListButton({
recipeId,
recipeTitle,
baseServings,
ingredients,
}: {
recipeId: string;
recipeTitle: string;
baseServings: number;
ingredients: Ingredient[];
}) {
const t = useTranslations("recipe");
const tShopping = useTranslations("shoppingLists");
const tCommon = useTranslations("common");
const tForm = useTranslations("recipeForm");
const [open, setOpen] = useState(false);
const [lists, setLists] = useState<ShoppingList[]>([]);
const [loadingLists, setLoadingLists] = useState(false);
const [mode, setMode] = useState<"existing" | "new">("existing");
const [selectedListId, setSelectedListId] = useState<string>("");
const [newListName, setNewListName] = useState(`${recipeTitle} shopping`);
const [servings, setServings] = useState(baseServings);
const [adding, setAdding] = useState(false);
useEffect(() => {
if (!open) return;
setLoadingLists(true);
fetch("/api/v1/shopping-lists")
.then((r) => r.json() as Promise<ShoppingList[]>)
.then((data) => {
setLists(data);
if (data.length > 0) {
setMode("existing");
setSelectedListId(data[0]!.id);
} else {
setMode("new");
}
})
.catch(() => setMode("new"))
.finally(() => setLoadingLists(false));
}, [open]);
const scale = servings / baseServings;
const items = ingredients.map((ing) => ({
rawName: ing.rawName,
quantity: scaleQty(ing.quantity, scale),
unit: ing.unit ?? undefined,
}));
async function handleAdd() {
setAdding(true);
try {
let listId = selectedListId;
if (mode === "new") {
const res = await fetch("/api/v1/shopping-lists", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newListName.trim() || recipeTitle }),
});
if (!res.ok) { toast.error(t("shoppingListCreateFailed")); return; }
const created = await res.json() as { id: string };
listId = created.id;
}
const res = await fetch(`/api/v1/shopping-lists/${listId}/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items }),
});
if (!res.ok) { toast.error(t("shoppingListAddFailed")); return; }
toast.success(tShopping("addedCount", { count: items.length }));
setOpen(false);
} finally {
setAdding(false);
}
}
return (
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<Button variant="ghost" size="icon" onClick={() => setOpen(true)} aria-label={tShopping("addToList")}>
<ShoppingCart className="h-4 w-4" />
</Button>
} />
<TooltipContent>{tShopping("addToList")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShoppingCart className="h-5 w-5 text-primary" />
{tShopping("dialogTitle")}
</DialogTitle>
<DialogDescription>
{tShopping.rich("dialogDescription", {
title: recipeTitle,
strong: (chunks) => <strong>{chunks}</strong>,
})}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Servings scaler */}
<div className="flex items-center gap-3">
<Label className="shrink-0">{tForm("servings")}</Label>
<div className="flex items-center gap-2">
<Button
type="button" variant="ghost" size="icon" className="h-7 w-7"
onClick={() => setServings((s) => Math.max(1, s - 1))}
disabled={servings <= 1}
aria-label={tCommon("decrease")}
></Button>
<span className="w-8 text-center font-medium">{servings}</span>
<Button
type="button" variant="ghost" size="icon" className="h-7 w-7"
onClick={() => setServings((s) => s + 1)}
aria-label={tCommon("increase")}
>+</Button>
</div>
{scale !== 1 && (
<span className="text-xs text-muted-foreground">({scale > 1 ? "×" : "÷"}{Math.abs(scale) !== 1 ? (scale > 1 ? scale.toFixed(2).replace(/\.?0+$/, "") : (1 / scale).toFixed(2).replace(/\.?0+$/, "")) : ""} {tShopping("scaledSuffix")}</span>
)}
</div>
<Separator />
{/* List picker */}
{loadingLists ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
<Loader2 className="h-4 w-4 animate-spin" /> {tShopping("loadingLists")}
</div>
) : (
<div className="space-y-3">
{lists.length > 0 && (
<RadioGroup value={mode} onValueChange={(v: string) => setMode(v as "existing" | "new")}>
<div className="flex items-center gap-2">
<RadioGroupItem value="existing" id="mode-existing" />
<Label htmlFor="mode-existing">{tShopping("modeExisting")}</Label>
</div>
{mode === "existing" && (
<div className="ml-6 space-y-1.5">
{lists.map((list) => (
<button
key={list.id}
onClick={() => setSelectedListId(list.id)}
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors flex items-center justify-between ${
selectedListId === list.id
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-accent"
}`}
>
{list.name}
{selectedListId === list.id && <Check className="h-3.5 w-3.5" />}
</button>
))}
</div>
)}
<div className="flex items-center gap-2">
<RadioGroupItem value="new" id="mode-new" />
<Label htmlFor="mode-new">{tShopping("modeNew")}</Label>
</div>
</RadioGroup>
)}
{(mode === "new" || lists.length === 0) && (
<div className="space-y-1.5 ml-6">
<Input
value={newListName}
onChange={(e) => setNewListName(e.target.value)}
placeholder={tShopping("listNamePlaceholder")}
/>
</div>
)}
</div>
)}
<Separator />
<p className="text-xs text-muted-foreground">{tShopping("ingredientsWillBeAdded", { count: items.length })}</p>
<div className="flex gap-2 justify-end">
<Button variant="ghost" onClick={() => setOpen(false)} disabled={adding}>{tCommon("cancel")}</Button>
<Button onClick={handleAdd} disabled={adding || loadingLists || (mode === "existing" && !selectedListId)}>
{adding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
{adding ? tShopping("adding") : tShopping("addIngredients")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}