Files
Epicure/apps/web/components/recipe/can-cook-content.tsx
T
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

151 lines
5.1 KiB
TypeScript
Raw 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 Link from "next/link";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { Package, Clock } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type ScoredItem = {
recipe: {
id: string;
title: string;
description: string | null;
photos: Array<{ url: string }>;
};
matched: number;
total: number;
pct: number;
missing: string[];
usesExpiring: string[];
};
type Props = {
pantryCount: number;
scored: ScoredItem[];
};
function RecipeRow({ s }: { s: ScoredItem }) {
const t = useTranslations("canCook");
const cover = s.recipe.photos?.[0];
return (
<Link
href={`/recipes/${s.recipe.id}`}
className="flex items-center gap-4 rounded-xl border p-3 hover:bg-accent transition-colors"
>
{cover ? (
<div className="relative h-14 w-14 rounded-lg overflow-hidden shrink-0">
<Image src={cover.url} alt={s.recipe.title} fill className="object-cover" />
</div>
) : (
<div className="h-14 w-14 rounded-lg bg-muted shrink-0" />
)}
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2">
<p className="font-medium truncate">{s.recipe.title}</p>
{s.usesExpiring.length > 0 && (
<Badge variant="outline" className="shrink-0 text-orange-500 border-orange-500 gap-1">
<Clock className="h-3 w-3" />
{t("useItUp")}
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Progress value={s.pct} className="h-1.5 flex-1 max-w-[120px]" />
<span className="text-xs text-muted-foreground">
{t("ingredientProgress", { matched: s.matched, total: s.total })}
</span>
</div>
{s.missing.length > 0 && (
<p className="text-xs text-muted-foreground truncate">
{t("missing")}: {s.missing.join(", ")}{s.missing.length < s.total - s.matched ? "…" : ""}
</p>
)}
</div>
<Badge variant={s.pct === 100 ? "default" : "secondary"} className="shrink-0">
{s.pct}%
</Badge>
</Link>
);
}
export function CanCookContent({ pantryCount, scored }: Props) {
const t = useTranslations("canCook");
if (pantryCount === 0) {
return (
<div className="space-y-6 max-w-2xl">
<div>
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
<p className="text-muted-foreground mt-1">{t("subtitle")}</p>
</div>
<div className="flex flex-col items-center justify-center h-64 border-2 border-dashed rounded-xl gap-4">
<Package className="h-8 w-8 text-muted-foreground" />
<p className="text-muted-foreground text-sm">{t("emptyPantry")}</p>
<Link href="/pantry" className={cn(buttonVariants({ size: "sm" }))}>
{t("addPantryItems")}
</Link>
</div>
</div>
);
}
const canCook = scored.filter((s) => s.pct === 100);
const almostCook = scored.filter((s) => s.pct >= 70 && s.pct < 100);
const rest = scored.filter((s) => s.pct < 70 && s.pct > 0);
return (
<div className="space-y-8 max-w-2xl">
<div>
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
<p className="text-muted-foreground mt-1">{t("subtitle", { count: pantryCount })}</p>
</div>
{canCook.length > 0 && (
<section className="space-y-3">
<h2 className="text-lg font-semibold flex items-center gap-2">
{t("readyToCook")}
<Badge>{canCook.length}</Badge>
</h2>
<div className="space-y-2">
{canCook.map((s) => <RecipeRow key={s.recipe.id} s={s} />)}
</div>
</section>
)}
{almostCook.length > 0 && (
<section className="space-y-3">
<h2 className="text-lg font-semibold flex items-center gap-2">
{t("almostThere")} <span className="text-sm font-normal text-muted-foreground">(7099%)</span>
<Badge variant="secondary">{almostCook.length}</Badge>
</h2>
<div className="space-y-2">
{almostCook.map((s) => <RecipeRow key={s.recipe.id} s={s} />)}
</div>
</section>
)}
{canCook.length === 0 && almostCook.length === 0 && (
<div className="flex flex-col items-center justify-center h-48 border-2 border-dashed rounded-xl gap-3">
<p className="text-muted-foreground text-sm">{t("noMatches")}</p>
<Link href="/pantry" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
{t("addPantryItems")}
</Link>
</div>
)}
{rest.length > 0 && (
<section className="space-y-3">
<h2 className="text-sm font-medium text-muted-foreground">{t("partialMatches")}</h2>
<div className="space-y-2">
{rest.slice(0, 5).map((s) => <RecipeRow key={s.recipe.id} s={s} />)}
</div>
</section>
)}
</div>
);
}