Files
Epicure/apps/web/components/social/cooked-it-review.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

226 lines
7.8 KiB
TypeScript

"use client";
import { useState, useEffect, useRef } from "react";
import Image from "next/image";
import { useTranslations, useLocale } from "next-intl";
import { Star, Camera, X, ChefHat } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { getPublicUrl } from "@/lib/storage";
import { cn } from "@/lib/utils";
type Review = {
id: string;
score: number;
reviewText: string | null;
photoKey: string | null;
createdAt: string;
user: { id: string; name: string; username: string | null; avatarUrl: string | null };
};
function Stars({
value,
hovered,
onHover,
onPick,
}: {
value: number;
hovered: number;
onHover: (v: number) => void;
onPick: (v: number) => void;
}) {
const display = hovered || value;
return (
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((i) => (
<button
key={i}
type="button"
onMouseEnter={() => onHover(i)}
onMouseLeave={() => onHover(0)}
onClick={() => onPick(i)}
className="hover:scale-110 transition-transform"
>
<Star className={cn("h-6 w-6", i <= display ? "fill-yellow-400 text-yellow-400" : "text-muted-foreground/30")} />
</button>
))}
</div>
);
}
export function CookedItReview({
recipeId,
initialScore = 0,
initialText = "",
initialPhotoKey = null,
}: {
recipeId: string;
initialScore?: number;
initialText?: string;
initialPhotoKey?: string | null;
}) {
const t = useTranslations("social");
const locale = useLocale();
const [score, setScore] = useState(initialScore);
const [hovered, setHovered] = useState(0);
const [text, setText] = useState(initialText);
const [photoKey, setPhotoKey] = useState<string | null>(initialPhotoKey);
const [preview, setPreview] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [saving, setSaving] = useState(false);
const [reviews, setReviews] = useState<Review[]>([]);
const [loadingReviews, setLoadingReviews] = useState(true);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
fetch(`/api/v1/recipes/${recipeId}/reviews`)
.then((res) => (res.ok ? res.json() : null))
.then((data: { data: Review[] } | null) => setReviews(data?.data ?? []))
.finally(() => setLoadingReviews(false));
}, [recipeId]);
async function handlePhoto(file: File) {
setUploading(true);
try {
const res = await fetch("/api/v1/upload/presign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ recipeId, contentType: file.type, purpose: "review", fileSize: file.size }),
});
if (!res.ok) {
toast.error(t("reviewPhotoFailed"));
return;
}
const { url, key } = (await res.json()) as { url: string; key: string };
await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": file.type } });
setPhotoKey(key);
setPreview(URL.createObjectURL(file));
} finally {
setUploading(false);
}
}
async function submit() {
if (score < 1) return;
setSaving(true);
try {
const res = await fetch(`/api/v1/recipes/${recipeId}/rate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ score, reviewText: text.trim() || undefined, photoKey: photoKey ?? undefined }),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string };
toast.error(err.error ?? t("ratingFailed"));
return;
}
toast.success(t("ratingSaved"));
const listRes = await fetch(`/api/v1/recipes/${recipeId}/reviews`);
if (listRes.ok) {
const data = (await listRes.json()) as { data: Review[] };
setReviews(data.data);
}
} finally {
setSaving(false);
}
}
return (
<div className="space-y-6">
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center gap-2 text-sm font-medium">
<ChefHat className="h-4 w-4 text-primary" />
{t("cookedItPrompt")}
</div>
<Stars value={score} hovered={hovered} onHover={setHovered} onPick={setScore} />
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={t("reviewTextPlaceholder")}
maxLength={2000}
rows={3}
/>
<div className="flex items-center gap-3">
{(preview || photoKey) && (
<div className="relative h-16 w-16">
<Image
src={preview ?? getPublicUrl(photoKey!)}
alt="Your cooked-it photo"
fill
className="rounded-lg object-cover border"
/>
<button
type="button"
onClick={() => { setPhotoKey(null); setPreview(null); }}
className="absolute -top-1.5 -right-1.5 rounded-full bg-background border p-0.5"
>
<X className="h-3 w-3" />
</button>
</div>
)}
<Button
type="button"
variant="outline"
size="sm"
disabled={uploading}
onClick={() => inputRef.current?.click()}
>
<Camera className="h-4 w-4 mr-1.5" />
{uploading ? t("reviewPhotoUploading") : t("reviewPhotoAdd")}
</Button>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/avif"
className="hidden"
onChange={(e) => e.target.files?.[0] && handlePhoto(e.target.files[0])}
/>
<Button type="button" size="sm" className="ml-auto" disabled={score < 1 || saving} onClick={submit}>
{saving ? t("reviewSubmitting") : t("reviewSubmit")}
</Button>
</div>
</div>
{!loadingReviews && reviews.length > 0 && (
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground">{t("reviewsTitle", { count: reviews.length })}</h3>
{reviews.map((r) => (
<div key={r.id} className="flex gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={r.user.avatarUrl ?? undefined} alt={r.user.name} />
<AvatarFallback>{r.user.name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-1.5">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium">{r.user.name}</span>
<div className="flex items-center">
{[1, 2, 3, 4, 5].map((i) => (
<Star key={i} className={cn("h-3 w-3", i <= r.score ? "fill-yellow-400 text-yellow-400" : "text-muted-foreground/30")} />
))}
</div>
<span className="text-xs text-muted-foreground">
{new Date(r.createdAt).toLocaleDateString(locale)}
</span>
</div>
{r.reviewText && <p className="text-sm text-muted-foreground">{r.reviewText}</p>}
{r.photoKey && (
<div className="relative h-32 w-32">
<Image
src={getPublicUrl(r.photoKey)}
alt={`${r.user.name}'s cooked-it photo`}
fill
className="rounded-lg object-cover border"
/>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}