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,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { UserPlus, X, Link2, Copy, Check } from "lucide-react";
|
||||
import { UserPlus, X, Link2, Copy, Check, Pencil } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -41,9 +41,10 @@ interface Member {
|
||||
interface Props {
|
||||
listId: string;
|
||||
initialIsPublic: boolean;
|
||||
initialPublicEditable: boolean;
|
||||
}
|
||||
|
||||
export function ShareShoppingListButton({ listId, initialIsPublic }: Props) {
|
||||
export function ShareShoppingListButton({ listId, initialIsPublic, initialPublicEditable }: Props) {
|
||||
const t = useTranslations("shoppingLists");
|
||||
const ts = useTranslations("shareDialog");
|
||||
const tCommon = useTranslations("common");
|
||||
@@ -54,13 +55,18 @@ export function ShareShoppingListButton({ listId, initialIsPublic }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inviting, setInviting] = useState(false);
|
||||
const [isPublic, setIsPublic] = useState(initialIsPublic);
|
||||
const [publicEditable, setPublicEditable] = useState(initialPublicEditable);
|
||||
const [savingPublic, setSavingPublic] = useState(false);
|
||||
const [savingEditable, setSavingEditable] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function togglePublic(checked: boolean) {
|
||||
setSavingPublic(true);
|
||||
const previous = isPublic;
|
||||
const previousEditable = publicEditable;
|
||||
setIsPublic(checked);
|
||||
// Mirrors the API: turning the link off also revokes public editing.
|
||||
if (!checked) setPublicEditable(false);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/shopping-lists/${listId}`, {
|
||||
method: "PATCH",
|
||||
@@ -69,16 +75,40 @@ export function ShareShoppingListButton({ listId, initialIsPublic }: Props) {
|
||||
});
|
||||
if (!res.ok) {
|
||||
setIsPublic(previous);
|
||||
setPublicEditable(previousEditable);
|
||||
toast.error(ts("publicLinkToggleFailed"));
|
||||
}
|
||||
} catch {
|
||||
setIsPublic(previous);
|
||||
setPublicEditable(previousEditable);
|
||||
toast.error(ts("publicLinkToggleFailed"));
|
||||
} finally {
|
||||
setSavingPublic(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePublicEditable(checked: boolean) {
|
||||
setSavingEditable(true);
|
||||
const previous = publicEditable;
|
||||
setPublicEditable(checked);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/shopping-lists/${listId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ publicEditable: checked }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setPublicEditable(previous);
|
||||
toast.error(ts("publicLinkToggleFailed"));
|
||||
}
|
||||
} catch {
|
||||
setPublicEditable(previous);
|
||||
toast.error(ts("publicLinkToggleFailed"));
|
||||
} finally {
|
||||
setSavingEditable(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
const url = `${window.location.origin}/s/${listId}`;
|
||||
try {
|
||||
@@ -180,10 +210,22 @@ export function ShareShoppingListButton({ listId, initialIsPublic }: Props) {
|
||||
<Switch checked={isPublic} disabled={savingPublic} onCheckedChange={(v) => { void togglePublic(v); }} />
|
||||
</div>
|
||||
{isPublic && (
|
||||
<Button type="button" variant="outline" size="sm" className="w-full" onClick={() => void copyLink()}>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
{copied ? ts("linkCopied") : ts("copyLink")}
|
||||
</Button>
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Pencil className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{ts("publicEditableTitle")}</p>
|
||||
<p className="text-xs text-muted-foreground">{ts("publicEditableDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={publicEditable} disabled={savingEditable} onCheckedChange={(v) => { void togglePublicEditable(v); }} />
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" className="w-full" onClick={() => void copyLink()}>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
{copied ? ts("linkCopied") : ts("copyLink")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
Reference in New Issue
Block a user