Files
siftlode/frontend/src/components/ShareDialog.tsx
T
peter 6d49da17fe fix(api): a write that hit a connection failure is still reported, and every failure is reported once
Deep-review round 4, the "one report per failure" contract round 3 introduced.

- `req` marked gateway (502/503/504) and network failures "already reported" because they raise
  the connection notice. But that notice is about connectivity and removes itself on the very next
  answer — which every caller's own resync refetch fetches at once — so a delete that hit a
  keepalive reset flashed the notice and was then reported nowhere, the list still there. Only the
  durable reports (the error dialog, the 401 sign-out) count now; the caller says the action failed.
- A connection notice the user closed swallowed every later one for the rest of the visit; it is
  raised again.
- `req` and `uploadReq` share one failure policy (`reportFailure`). The upload copy had drifted: a
  502 opened the blocking "Server error (502)" dialog and cleared a notice that was still true.
- A taken Plex collection name is a structured 409, localized, and the create/rename calls are
  quiet: it was an English dialog plus a Hungarian toast. `notifyCollectionEditError` is gone.
- The mutation onErrors that toasted unconditionally after a non-quiet request (invite approve /
  deny / add, demo whitelist, audit clear, scheduler, share, full history, edit, config tests, tag
  attach) go through `notifyActionError`; the playlist delete's hand-rolled copy of it too (it takes
  a level now). The user role / suspend / delete actions had no onError at all: a user deleted in
  another tab failed silently and stayed listed — now said, and the list refetched.
2026-09-11 23:27:48 +02:00

396 lines
15 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
import clsx from "clsx";
import Modal from "./ui/Modal";
import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { copyText } from "../lib/clipboard";
import { api, type DownloadJob, type ShareLink } from "../lib/api";
import { inputCls } from "./ui/form";
import { Button } from "./ui/Button";
const EXPIRY_DAYS = [null, 1, 7, 30] as const;
// --- share with a registered user (ACL) -----------------------------------------------------
function UserShare({ job }: { job: DownloadJob }) {
const { t } = useTranslation();
const [q, setQ] = useState("");
const [open, setOpen] = useState(false);
const recipients = useQuery({ queryKey: qk.shareRecipients(), queryFn: api.shareRecipients });
const list = recipients.data ?? [];
const filtered = useMemo(() => {
const s = q.trim().toLowerCase();
if (!s) return list.slice(0, 8);
return list
.filter(
(u) =>
u.email.toLowerCase().includes(s) || (u.display_name ?? "").toLowerCase().includes(s),
)
.slice(0, 8);
}, [q, list]);
const share = useMutation({
mutationFn: (email: string) => api.shareDownload(job.id, email),
onSuccess: (r) => {
notify({ level: "success", message: t("downloads.share.done", { email: r.shared_with }) });
setQ("");
setOpen(false);
},
onError: (e) => notifyActionError(e, t("downloads.share.failed")),
});
return (
<div>
<div className="flex items-center gap-2 text-sm font-medium mb-1">
<UserPlus className="w-4 h-4 text-muted" /> {t("downloads.share.userSection")}
</div>
<p className="text-xs text-muted mb-2">{t("downloads.share.userHint")}</p>
<div className="relative">
<input
value={q}
onChange={(e) => {
setQ(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
// Close on blur, but after a tick so a click on a dropdown row still registers.
onBlur={() => setTimeout(() => setOpen(false), 150)}
placeholder={t("downloads.share.pickPlaceholder")}
className={inputCls}
/>
{open && filtered.length > 0 && (
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
{filtered.map((u) => (
<button
key={u.id}
onClick={() => share.mutate(u.email)}
disabled={share.isPending}
className="w-full text-left px-3 py-2 text-sm hover:bg-surface transition flex flex-col"
>
<span className="truncate">{u.display_name || u.email}</span>
{u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>}
</button>
))}
</div>
)}
</div>
{recipients.data && list.length === 0 && (
<p className="text-xs text-muted mt-1">{t("downloads.share.noUsers")}</p>
)}
</div>
);
}
// --- share by public link -------------------------------------------------------------------
function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }) {
const { t } = useTranslation();
const confirm = useConfirm();
const [copied, setCopied] = useState(false);
const [editPass, setEditPass] = useState(false);
const [newPass, setNewPass] = useState("");
const fullUrl = window.location.origin + link.url;
const copy = async () => {
if (await copyText(fullUrl)) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} else {
notify({ level: "error", message: t("downloads.share.copyFailed") });
}
};
const toggle = useMutation({
mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }),
onSuccess: onChanged,
});
const revoke = useMutation({
mutationFn: () => api.revokeDownloadLink(link.id),
onSuccess: onChanged,
});
// Set / change / clear the link password. Rotating (or clearing) it bumps the link's
// password_version server-side, which invalidates any watch grant issued under the old password.
const savePass = useMutation({
mutationFn: (pw: string) => api.updateDownloadLink(link.id, { password: pw }),
onSuccess: () => {
setEditPass(false);
setNewPass("");
onChanged();
},
});
const badges: string[] = [];
if (link.has_password) badges.push(t("downloads.share.hasPassword"));
if (link.allow_download) badges.push(t("downloads.share.downloadable"));
if (link.expires_at)
badges.push(
t("downloads.share.expiresOn", { date: new Date(link.expires_at).toLocaleDateString() }),
);
return (
<div className="rounded-lg glass-card p-2.5 space-y-2">
<div className="flex items-center gap-2 rounded-md focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
<Link2 className="w-4 h-4 text-muted shrink-0" />
<input
readOnly
value={fullUrl}
className="flex-1 min-w-0 bg-transparent text-xs text-muted truncate outline-hidden"
/>
<Button
variant="ghost"
size="icon-sm"
onClick={copy}
aria-label={t("downloads.share.copy")}
title={t("downloads.share.copy")}
>
{copied ? <Check className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
</Button>
{/* All three on the same rung. Only Copy was migrated in S1, which left one 2rem button
between two 1.75rem ones with a different radius and hover fill — and, on a coarse
pointer, `.touch-target` grew ONLY the migrated one to 2.75rem while the destructive
Revoke stayed small (E10 review round 1). */}
<Button
variant="ghost"
size="icon-sm"
onClick={() => {
setNewPass("");
setEditPass((v) => !v);
}}
label={
link.has_password
? t("downloads.share.changePassword")
: t("downloads.share.setPassword")
}
// On the ICON, not the button: `clsx` does not merge Tailwind, so a colour className loses
// to the variant's own `text-muted`/`hover:text-fg` by stylesheet order and the state was
// simply invisible (E10 review round 2). The Copy button above already does it this way.
icon={<Lock className={`w-4 h-4 ${link.has_password ? "text-accent" : ""}`} />}
/>
<Button
variant="ghost"
size="icon-sm"
onClick={async () => {
if (
await confirm({
title: t("downloads.share.revoke"),
message: t("downloads.share.revokeConfirm"),
confirmLabel: t("downloads.share.revoke"),
danger: true,
})
)
revoke.mutate();
}}
label={t("downloads.share.revoke")}
icon={<Trash2 className="w-4 h-4 text-danger" />}
/>
</div>
<div className="flex items-center gap-2 text-xs text-muted flex-wrap">
<span className="inline-flex items-center gap-1">
<Eye className="w-3 h-3" /> {t("downloads.share.views", { n: link.view_count })}
</span>
{badges.map((b) => (
<span
key={b}
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-sm bg-surface"
>
{b === t("downloads.share.hasPassword") && <Lock className="w-3 h-3" />}
{b}
</span>
))}
<div className="flex-1" />
<label className="inline-flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" checked={link.allow_download} onChange={() => toggle.mutate()} />
{t("downloads.share.allowDownload")}
</label>
</div>
{editPass && (
<div className="flex items-center gap-2">
<input
type="password"
autoComplete="new-password"
value={newPass}
onChange={(e) => setNewPass(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" && newPass.trim() && savePass.mutate(newPass.trim())
}
placeholder={t(
link.has_password ? "downloads.share.newPassword" : "downloads.share.setPassword",
)}
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<button
onClick={() => savePass.mutate(newPass.trim())}
disabled={savePass.isPending || !newPass.trim()}
className="px-2 py-1 rounded-md text-xs font-semibold btn-primary disabled:opacity-40 transition"
>
{t("downloads.share.savePassword")}
</button>
{link.has_password && (
<button
onClick={async () => {
if (
await confirm({
title: t("downloads.share.removePassword"),
message: t("downloads.share.removePasswordConfirm"),
confirmLabel: t("downloads.share.removePassword"),
danger: true,
})
)
savePass.mutate("");
}}
disabled={savePass.isPending}
className="px-2 py-1 rounded-md text-xs text-muted hover:text-danger transition"
>
{t("downloads.share.removePassword")}
</button>
)}
</div>
)}
</div>
);
}
function LinkShare({ job }: { job: DownloadJob }) {
const { t } = useTranslation();
const qc = useQueryClient();
const links = useQuery({
queryKey: qk.downloadLinks(job.id),
queryFn: () => api.downloadLinks(job.id),
});
const [creating, setCreating] = useState(false);
// D1 (E7 S3): downloadable by default — matches the one-click Share (which relies on the backend
// default) and the common "let them have the file" intent. The owner can uncheck it here.
const [allowDownload, setAllowDownload] = useState(true);
const [expiryDays, setExpiryDays] = useState<number | null>(null);
const [password, setPassword] = useState("");
const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadLinks(job.id) });
const create = useMutation({
mutationFn: () =>
api.createDownloadLink(job.id, {
allow_download: allowDownload,
expires_days: expiryDays,
password: password.trim() || undefined,
}),
onSuccess: () => {
invalidate();
setCreating(false);
setAllowDownload(false);
setExpiryDays(null);
setPassword("");
},
});
// Open with a link already generated: sharing is almost always the goal, so mint a default
// (no password, no expiry, downloadable per D1) link the moment the dialog finds none — as if the
// user had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
// extra links, revoke) stay available on the created link and via the "New link" button.
const autoTried = useRef(false);
useEffect(() => {
if (links.data && links.data.length === 0 && !autoTried.current && !create.isPending) {
autoTried.current = true;
create.mutate();
}
// Only links.data (empty → auto-create once) should drive this; `create` is a fresh object every
// render and would re-run the effect needlessly. The autoTried ref makes it fire exactly once.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [links.data]);
return (
<div>
<div className="flex items-center gap-2 text-sm font-medium mb-1">
<Link2 className="w-4 h-4 text-muted" /> {t("downloads.share.linkSection")}
</div>
<p className="text-xs text-muted mb-2">{t("downloads.share.linkHint")}</p>
<div className="space-y-2">
{(links.data ?? []).map((l) => (
<LinkRow key={l.id} link={l} onChanged={invalidate} />
))}
{links.data && links.data.length === 0 && !creating && !create.isPending && (
<p className="text-xs text-muted">{t("downloads.share.noLinks")}</p>
)}
</div>
{creating ? (
<div className="mt-2 rounded-lg border border-border p-3 space-y-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={allowDownload}
onChange={(e) => setAllowDownload(e.target.checked)}
/>
{t("downloads.share.allowDownload")}
</label>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted">{t("downloads.share.expiry")}:</span>
{EXPIRY_DAYS.map((d) => (
<button
key={String(d)}
onClick={() => setExpiryDays(d)}
className={clsx(
"px-2 py-1 rounded-md border text-xs transition",
expiryDays === d
? "border-accent bg-accent/10 text-accent"
: "border-border text-muted hover:text-fg",
)}
>
{d === null
? t("downloads.share.expiryNever")
: t("downloads.share.days", { n: d })}
</button>
))}
</div>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("downloads.share.password")}
className={inputCls}
autoComplete="new-password"
/>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setCreating(false)}>
{t("common.cancel")}
</Button>
<Button
variant="primary"
size="sm"
onClick={() => create.mutate()}
disabled={create.isPending}
>
{t("downloads.share.createLink")}
</Button>
</div>
</div>
) : (
<Button
variant="outline"
size="sm"
onClick={() => setCreating(true)}
icon={<Link2 className="w-4 h-4" />}
className="mt-2"
>
{t("downloads.share.newLink")}
</Button>
)}
</div>
);
}
export default function ShareDialog({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
const { t } = useTranslation();
return (
<Modal title={t("downloads.share.title")} onClose={onClose}>
<div className="space-y-5">
<UserShare job={job} />
<div className="border-t border-border" />
<LinkShare job={job} />
</div>
</Modal>
);
}