feat: public shopping list links can allow editing

Owner opts in per-list via a new "Allow editing" toggle next to the
existing public-link switch. Anonymous writes are scoped to that one
list only — the link id is the sole credential, enforced in
getShoppingListAccess and the item routes (no session required there
now), with an IP rate limit on genuinely anonymous requests. Turning
off the public link also revokes editing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-13 12:53:52 +02:00
parent b1f4bba6dd
commit 2beb23b360
21 changed files with 5174 additions and 76 deletions
@@ -50,7 +50,9 @@ export default async function ShoppingListPage({ params }: Params) {
</div>
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
<GroceryExportButton listId={id} instacartEnabled={instacartEnabled} />
{access.role === "owner" && <ShareShoppingListButton listId={id} initialIsPublic={list.isPublic} />}
{access.role === "owner" && (
<ShareShoppingListButton listId={id} initialIsPublic={list.isPublic} initialPublicEditable={list.publicEditable} />
)}
<Link href={`/print/shopping-list/${id}`} target="_blank" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<Printer className="h-4 w-4" />
{m.common.print}
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, shoppingListItems, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { getOptionalSession } from "@/lib/api-auth";
import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access";
import { notifyShoppingListMembers } from "@/lib/shopping-list-notify";
@@ -17,11 +17,10 @@ const UpdateItemSchema = z.object({
});
export async function PUT(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id, itemId } = await params;
const session = await getOptionalSession();
const access = await getShoppingListAccess(id, session!.user.id);
const access = await getShoppingListAccess(id, session?.user.id ?? null);
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
@@ -54,7 +53,7 @@ export async function PUT(req: NextRequest, { params }: Params) {
columns: { rawName: true },
});
if (item) {
void notifyShoppingListMembers(id, session!.user.id, session!.user.name, {
void notifyShoppingListMembers(id, session?.user.id ?? "", session?.user.name ?? null, {
type: "checked",
itemName: item.rawName,
listName: access.list.name,
@@ -66,11 +65,10 @@ export async function PUT(req: NextRequest, { params }: Params) {
}
export async function DELETE(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id, itemId } = await params;
const session = await getOptionalSession();
const access = await getShoppingListAccess(id, session!.user.id);
const access = await getShoppingListAccess(id, session?.user.id ?? null);
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, shoppingListItems, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { getOptionalSession } from "@/lib/api-auth";
import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access";
import { guessAisle } from "@/lib/grocery-categories";
import { notifyShoppingListMembers } from "@/lib/shopping-list-notify";
@@ -9,13 +9,14 @@ import { notifyShoppingListMembers } from "@/lib/shopping-list-notify";
// Polled by the list view (components/meal-plan/shopping-list-view.tsx) so
// collaborators see each other's checked/added/reordered items without a
// manual refresh — no push/websocket infra in this app, so a short poll on
// the same shape the page's initial server-render already uses.
// the same shape the page's initial server-render already uses. No
// requireSession here: a public-editable list's own visitors (no account)
// need this too — getShoppingListAccess resolves that from the link itself.
export async function GET(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const session = await getOptionalSession();
const access = await getShoppingListAccess(id, session!.user.id);
const access = await getShoppingListAccess(id, session?.user.id ?? null);
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
const items = await db.query.shoppingListItems.findMany({
@@ -59,11 +60,10 @@ const BulkUpdateSchema = z.object({
type Params = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const session = await getOptionalSession();
const access = await getShoppingListAccess(id, session!.user.id);
const access = await getShoppingListAccess(id, session?.user.id ?? null);
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
@@ -84,7 +84,7 @@ export async function POST(req: NextRequest, { params }: Params) {
}))
);
void notifyShoppingListMembers(id, session!.user.id, session!.user.name, {
void notifyShoppingListMembers(id, session?.user.id ?? "", session?.user.name ?? null, {
type: "itemsAdded",
count: parsed.data.items.length,
listName: access.list.name,
@@ -94,11 +94,10 @@ export async function POST(req: NextRequest, { params }: Params) {
}
export async function PATCH(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const session = await getOptionalSession();
const access = await getShoppingListAccess(id, session!.user.id);
const access = await getShoppingListAccess(id, session?.user.id ?? null);
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
@@ -27,6 +27,7 @@ const PatchSchema = z.object({
completed: z.boolean().optional(),
name: z.string().min(1).max(100).optional(),
isPublic: z.boolean().optional(),
publicEditable: z.boolean().optional(),
});
export async function PATCH(req: NextRequest, { params }: Params) {
@@ -48,7 +49,17 @@ export async function PATCH(req: NextRequest, { params }: Params) {
if (body.data.isPublic !== undefined) {
if (access.role !== "owner") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
await db.update(shoppingLists).set({ isPublic: body.data.isPublic }).where(eq(shoppingLists.id, id));
// Turning the public link off also revokes public editing — otherwise
// re-enabling the link later would silently restore write access without
// the owner having re-opted into it.
await db.update(shoppingLists)
.set({ isPublic: body.data.isPublic, ...(body.data.isPublic ? {} : { publicEditable: false }) })
.where(eq(shoppingLists.id, id));
}
if (body.data.publicEditable !== undefined) {
if (access.role !== "owner") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
await db.update(shoppingLists).set({ publicEditable: body.data.publicEditable }).where(eq(shoppingLists.id, id));
}
if (body.data.completed) {
+43 -26
View File
@@ -2,12 +2,13 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { headers } from "next/headers";
import Link from "next/link";
import { Globe } from "lucide-react";
import { Globe, Pencil } from "lucide-react";
import { auth } from "@/lib/auth/server";
import { db, shoppingLists, eq, and } from "@epicure/db";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { getMessages, formatMessage } from "@/lib/i18n/server";
import { ShoppingListView } from "@/components/meal-plan/shopping-list-view";
type Params = { params: Promise<{ id: string }> };
@@ -43,8 +44,8 @@ export default async function PublicShoppingListPage({ params }: Params) {
return (
<div className="container mx-auto max-w-2xl px-4 py-8 space-y-6">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Globe className="h-3.5 w-3.5" />
<span>{m.publicShoppingList.sharedList}</span>
{list.publicEditable ? <Pencil className="h-3.5 w-3.5" /> : <Globe className="h-3.5 w-3.5" />}
<span>{list.publicEditable ? m.publicShoppingList.editableBadge : m.publicShoppingList.sharedList}</span>
{!session && (
<div className="ml-auto flex items-center gap-2">
<Link href="/signup" className={cn(buttonVariants({ size: "sm" }))}>{m.publicRecipe.signUpFree}</Link>
@@ -63,29 +64,45 @@ export default async function PublicShoppingListPage({ params }: Params) {
</p>
</div>
{aisles.map((aisle) => (
<div key={aisle} className="space-y-2">
{aisles.length > 1 && (
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground border-b pb-1">{aisle}</h2>
)}
<ul className="space-y-1.5">
{byAisle[aisle]!.map((item) => (
<li key={item.id} className="flex items-baseline gap-2 text-sm">
<span
className={cn(
"inline-block h-4 w-4 shrink-0 rounded border translate-y-0.5",
item.checked ? "bg-foreground border-foreground" : "border-input"
)}
/>
<span className="text-muted-foreground tabular-nums min-w-[3.5rem]">
{[item.quantity, item.unit].filter(Boolean).join(" ")}
</span>
<span className={cn(item.checked && "line-through text-muted-foreground")}>{item.rawName}</span>
</li>
))}
</ul>
</div>
))}
{list.publicEditable ? (
<ShoppingListView
listId={id}
readOnly={false}
initialItems={list.items.map((i) => ({
id: i.id,
rawName: i.rawName,
quantity: i.quantity,
unit: i.unit,
aisle: i.aisle,
checked: i.checked,
sortOrder: i.sortOrder,
}))}
/>
) : (
aisles.map((aisle) => (
<div key={aisle} className="space-y-2">
{aisles.length > 1 && (
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground border-b pb-1">{aisle}</h2>
)}
<ul className="space-y-1.5">
{byAisle[aisle]!.map((item) => (
<li key={item.id} className="flex items-baseline gap-2 text-sm">
<span
className={cn(
"inline-block h-4 w-4 shrink-0 rounded border translate-y-0.5",
item.checked ? "bg-foreground border-foreground" : "border-input"
)}
/>
<span className="text-muted-foreground tabular-nums min-w-[3.5rem]">
{[item.quantity, item.unit].filter(Boolean).join(" ")}
</span>
<span className={cn(item.checked && "line-through text-muted-foreground")}>{item.rawName}</span>
</li>
))}
</ul>
</div>
))
)}
{!session && (
<div className="rounded-xl border bg-muted/40 p-6 text-center space-y-3">