Files
Epicure/apps/web/components/shopping-lists/share-shopping-list-button.tsx
T
Arnaud 2beb23b360 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>
2026-07-13 12:53:52 +02:00

296 lines
9.8 KiB
TypeScript

"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { UserPlus, X, Link2, Copy, Check, Pencil } from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
type Role = "viewer" | "editor";
interface Member {
id: string;
userId: string;
role: Role;
createdAt: string;
user: {
name: string;
username: string | null;
avatarUrl: string | null;
};
}
interface Props {
listId: string;
initialIsPublic: boolean;
initialPublicEditable: boolean;
}
export function ShareShoppingListButton({ listId, initialIsPublic, initialPublicEditable }: Props) {
const t = useTranslations("shoppingLists");
const ts = useTranslations("shareDialog");
const tCommon = useTranslations("common");
const [open, setOpen] = useState(false);
const [email, setEmail] = useState("");
const [role, setRole] = useState<Role>("viewer");
const [members, setMembers] = useState<Member[]>([]);
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",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isPublic: checked }),
});
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 {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error(ts("copyLinkFailed"));
}
}
async function fetchMembers() {
setLoading(true);
try {
const res = await fetch(`/api/v1/shopping-lists/${listId}/members`);
if (!res.ok) throw new Error("Failed to load members");
const data = await res.json() as Member[];
setMembers(data);
} catch {
toast.error(ts("loadMembersFailed"));
} finally {
setLoading(false);
}
}
function handleOpenChange(next: boolean) {
setOpen(next);
if (next) {
void fetchMembers();
} else {
setEmail("");
setRole("viewer");
}
}
async function handleInvite() {
if (!email.trim()) {
toast.error(ts("enterEmail"));
return;
}
setInviting(true);
try {
const res = await fetch(`/api/v1/shopping-lists/${listId}/members`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim(), role }),
});
if (res.status === 409) { toast.error(ts("alreadyMember")); return; }
if (res.status === 404) { toast.error(ts("userNotFound")); return; }
if (!res.ok) { toast.error(ts("inviteFailed")); return; }
toast.success(ts("invitationSent"));
setEmail("");
await fetchMembers();
} catch {
toast.error(ts("inviteFailed"));
} finally {
setInviting(false);
}
}
async function handleRemove(memberId: string) {
try {
const res = await fetch(
`/api/v1/shopping-lists/${listId}/members?memberId=${memberId}`,
{ method: "DELETE" },
);
if (!res.ok) { toast.error(ts("removeMemberFailed")); return; }
setMembers((prev) => prev.filter((m) => m.id !== memberId));
toast.success(ts("memberRemoved"));
} catch {
toast.error(ts("removeMemberFailed"));
}
}
return (
<>
<Button variant="outline" size="sm" onClick={() => handleOpenChange(true)}>
<UserPlus className="h-4 w-4" />
{tCommon("share")}
</Button>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("shareTitle")}</DialogTitle>
<DialogDescription>
{t("shareDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
<div className="flex items-center gap-2 min-w-0">
<Link2 className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium">{ts("publicLinkTitle")}</p>
<p className="text-xs text-muted-foreground">{ts("publicLinkDescription")}</p>
</div>
</div>
<Switch checked={isPublic} disabled={savingPublic} onCheckedChange={(v) => { void togglePublic(v); }} />
</div>
{isPublic && (
<>
<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 />
<div className="flex gap-2 mt-2">
<Input
type="email"
placeholder={ts("emailPlaceholder")}
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void handleInvite(); }}
className="flex-1"
/>
<Select value={role} onValueChange={(v) => setRole(v as Role)}>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="viewer">{ts("viewer")}</SelectItem>
<SelectItem value="editor">{ts("editor")}</SelectItem>
</SelectContent>
</Select>
<Button onClick={() => void handleInvite()} disabled={inviting}>
{ts("invite")}
</Button>
</div>
<div className="mt-4 space-y-2">
{loading && (
<p className="text-sm text-muted-foreground">{ts("loadingMembers")}</p>
)}
{!loading && members.length === 0 && (
<p className="text-sm text-muted-foreground">{ts("noMembers")}</p>
)}
{members.map((m) => (
<div
key={m.id}
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<div className="flex-1 min-w-0">
<span className="font-medium truncate">{m.user.name}</span>
{m.user.username && (
<span className="text-muted-foreground ml-1">
@{m.user.username}
</span>
)}
</div>
<Badge variant={m.role === "editor" ? "default" : "secondary"}>
{ts(m.role)}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => void handleRemove(m.id)}
aria-label="Remove member"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
</DialogContent>
</Dialog>
</>
);
}