2beb23b360
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>
296 lines
9.8 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|