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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user