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>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, KeyboardEvent } from "react";
|
||||
import { useState, useRef, useEffect, KeyboardEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plus, Trash2, GripVertical, X, Tag } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -10,6 +10,16 @@ import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { DietaryTagPicker } from "./dietary-tag-picker";
|
||||
import { PhotoUploader, type PhotoEntry } from "./photo-uploader";
|
||||
|
||||
@@ -90,6 +100,51 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
||||
);
|
||||
const [photos, setPhotos] = useState<PhotoEntry[]>(defaultValues?.photos ?? []);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false);
|
||||
const hasMountedRef = useRef(false);
|
||||
|
||||
// Mark the form dirty on any edit after the initial mount, so we can warn the
|
||||
// user before they navigate away and silently lose their changes.
|
||||
useEffect(() => {
|
||||
if (!hasMountedRef.current) {
|
||||
hasMountedRef.current = true;
|
||||
return;
|
||||
}
|
||||
setDirty(true);
|
||||
}, [
|
||||
title,
|
||||
description,
|
||||
baseServings,
|
||||
visibility,
|
||||
difficulty,
|
||||
prepMins,
|
||||
cookMins,
|
||||
tags,
|
||||
dietaryTags,
|
||||
ingredients,
|
||||
steps,
|
||||
photos,
|
||||
]);
|
||||
|
||||
// Browser-level guard: warn on tab close / reload / external navigation while dirty.
|
||||
useEffect(() => {
|
||||
if (!dirty || saving) return;
|
||||
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
}
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [dirty, saving]);
|
||||
|
||||
function handleCancelClick() {
|
||||
if (dirty) {
|
||||
setDiscardConfirmOpen(true);
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
}
|
||||
|
||||
function updateIngredient(i: number, patch: Partial<IngredientRow>) {
|
||||
setIngredients((prev) => prev.map((row, idx) => idx === i ? { ...row, ...patch } : row));
|
||||
@@ -134,6 +189,34 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredIngredients = ingredients
|
||||
.filter((ing) => ing.rawName.trim())
|
||||
.map((ing, i) => ({
|
||||
rawName: ing.rawName.trim(),
|
||||
quantity: ing.quantity.trim() || undefined,
|
||||
unit: ing.unit.trim() || undefined,
|
||||
note: ing.note.trim() || undefined,
|
||||
order: i,
|
||||
}));
|
||||
|
||||
if (filteredIngredients.length === 0) {
|
||||
toast.error(t("ingredientsRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredSteps = steps
|
||||
.filter((s) => s.instruction.trim())
|
||||
.map((s, i) => ({
|
||||
instruction: s.instruction.trim(),
|
||||
timerSeconds: s.timerSeconds ? parseInt(s.timerSeconds) : undefined,
|
||||
order: i,
|
||||
}));
|
||||
|
||||
if (filteredSteps.length === 0) {
|
||||
toast.error(t("stepsRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
@@ -146,22 +229,8 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
||||
cookMins: cookMins ? parseInt(cookMins) : undefined,
|
||||
tags,
|
||||
dietaryTags,
|
||||
ingredients: ingredients
|
||||
.filter((ing) => ing.rawName.trim())
|
||||
.map((ing, i) => ({
|
||||
rawName: ing.rawName.trim(),
|
||||
quantity: ing.quantity.trim() || undefined,
|
||||
unit: ing.unit.trim() || undefined,
|
||||
note: ing.note.trim() || undefined,
|
||||
order: i,
|
||||
})),
|
||||
steps: steps
|
||||
.filter((s) => s.instruction.trim())
|
||||
.map((s, i) => ({
|
||||
instruction: s.instruction.trim(),
|
||||
timerSeconds: s.timerSeconds ? parseInt(s.timerSeconds) : undefined,
|
||||
order: i,
|
||||
})),
|
||||
ingredients: filteredIngredients,
|
||||
steps: filteredSteps,
|
||||
};
|
||||
|
||||
const url = isEdit ? `/api/v1/recipes/${id}` : "/api/v1/recipes";
|
||||
@@ -181,6 +250,7 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
||||
|
||||
const saved = await res.json() as { id: string };
|
||||
toast.success(isEdit ? t("updateSuccess") : t("createSuccess"));
|
||||
setDirty(false);
|
||||
router.push(`/recipes/${saved.id}`);
|
||||
router.refresh();
|
||||
} finally {
|
||||
@@ -443,10 +513,32 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? t("saving") : isEdit ? t("saveChanges") : t("createRecipe")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => router.back()}>
|
||||
<Button type="button" variant="outline" onClick={handleCancelClick}>
|
||||
{t_common("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={discardConfirmOpen} onOpenChange={setDiscardConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("discardChangesTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t("discardChangesDescription")}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("keepEditing")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setDiscardConfirmOpen(false);
|
||||
setDirty(false);
|
||||
router.back();
|
||||
}}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{t("discardChanges")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user