feat: inbox of received transfers in /settings
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 47s

Auto-claim on receive so /settings shows transfers others sent you.
Filename decryption stays client-side using the key stored in localStorage
when the share link is opened.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-04-20 12:47:10 +02:00
parent c46b23b8ec
commit 2452f2642a
4 changed files with 138 additions and 3 deletions

View File

@ -267,6 +267,36 @@ transferRoutes.get("/transfers", async (c) => {
}); });
}); });
/**
* POST /api/transfers/:id/claim
* Link a transfer to the authenticated user as recipient. Called by the
* Receive page after the key-proves-access check. Idempotent: refuses to
* overwrite an existing recipient, and refuses to let the sender claim
* their own outgoing as incoming.
*/
transferRoutes.post("/transfers/:id/claim", async (c) => {
const user = await resolveSession(c);
if (!user) return c.json({ error: "unauthenticated" }, 401);
const id = c.req.param("id");
if (!isValidUuid(id)) return c.json({ error: "not_found" }, 404);
const result = await db
.update(transfers)
.set({ recipientUserId: user.id })
.where(
and(
eq(transfers.id, id),
isNull(transfers.recipientUserId),
isNull(transfers.deletedAt),
sql`${transfers.senderUserId} IS DISTINCT FROM ${user.id}`,
),
)
.returning({ id: transfers.id });
return c.json({ claimed: result.length > 0 });
});
/** /**
* DELETE /api/transfers/:id * DELETE /api/transfers/:id
* Sender can revoke. Marks deleted, purges the blob asynchronously. * Sender can revoke. Marks deleted, purges the blob asynchronously.

View File

@ -154,3 +154,11 @@ export async function deleteTransfer(id: string): Promise<void> {
const res = await call(`/api/transfers/${encodeURIComponent(id)}`, { method: "DELETE" }); const res = await call(`/api/transfers/${encodeURIComponent(id)}`, { method: "DELETE" });
if (!res.ok && res.status !== 204) throw new Error(`deleteTransfer failed: ${res.status}`); if (!res.ok && res.status !== 204) throw new Error(`deleteTransfer failed: ${res.status}`);
} }
export async function claimTransfer(id: string): Promise<boolean> {
const res = await call(`/api/transfers/${encodeURIComponent(id)}/claim`, { method: "POST" });
if (res.status === 401) return false;
if (!res.ok) return false;
const body = (await res.json()) as { claimed: boolean };
return body.claimed;
}

View File

@ -6,6 +6,9 @@ import {
receiveCloud, receiveCloud,
type ReceivedTransferPreview, type ReceivedTransferPreview,
} from "../lib/sendCloud"; } from "../lib/sendCloud";
import { keyToFragment } from "../lib/cloudTransfer";
import { saveTransferKey } from "../lib/localTransferKeys";
import { claimTransfer } from "../lib/api";
type Stage = type Stage =
| { kind: "loading" } | { kind: "loading" }
@ -61,9 +64,11 @@ export default function Receive() {
} }
previewTransfer(id, k) previewTransfer(id, k)
.then((preview) => .then((preview) => {
setStage({ kind: "preview", preview, password: "", passwordError: null }), saveTransferKey(id, keyToFragment(k));
) claimTransfer(id).catch(() => {});
setStage({ kind: "preview", preview, password: "", passwordError: null });
})
.catch((err) => { .catch((err) => {
const msg = err instanceof Error ? err.message : "unknown"; const msg = err instanceof Error ? err.message : "unknown";
setStage({ kind: "error", message: msg }); setStage({ kind: "error", message: msg });

View File

@ -89,6 +89,8 @@ export default function Settings() {
</div> </div>
</section> </section>
<ReceivedSection />
<SharedLinksSection /> <SharedLinksSection />
<section className="mb-12"> <section className="mb-12">
@ -257,6 +259,96 @@ function itemStatus(t: LinkItem): "active" | "expired" | "consumed" {
return "active"; return "active";
} }
function ReceivedSection() {
const [items, setItems] = useState<LinkItem[] | null>(null);
useEffect(() => {
listInboxTransfers()
.then((list) => {
const received = list
.filter((t) => t.direction === "received")
.map<LinkItem>((t) => {
const keyFrag = getTransferKey(t.id);
let filename: string | null = null;
if (keyFrag) {
try {
const key = fragmentToKey(keyFrag);
filename = openMetadata(key, t.encryptedMetadata).name;
} catch {
filename = null;
}
}
return { ...t, filename, keyFrag };
});
setItems(received);
})
.catch(() => setItems([]));
}, []);
return (
<section className="mb-12">
<div className="text-xs uppercase tracking-[0.22em] text-ink-muted">
Inbox
</div>
<h2 className="font-display text-2xl text-ink mt-2 mb-5 tracking-tight">
Received transfers
</h2>
<div className="paper-panel divide-y divide-paper-edge">
{items === null && (
<div className="px-5 py-6 text-sm text-ink-muted">Loading</div>
)}
{items !== null && items.length === 0 && (
<div className="px-5 py-6 text-sm text-ink-muted">
Nothing here yet. Links others send you will show up here after you open them.
</div>
)}
{items?.map((item) => {
const status = itemStatus(item);
const dimmed = status !== "active";
const openUrl = item.keyFrag
? `/r/${item.id}#k=${item.keyFrag}`
: null;
return (
<div
key={item.id}
className={`px-5 py-4 ${dimmed ? "opacity-60" : ""}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="text-ink text-sm truncate">
{item.filename ?? "Encrypted file"}
</div>
<div className="font-mono text-[11px] text-ink-muted mt-1 flex flex-wrap gap-x-3 gap-y-1">
<span>{formatSize(item.sizeBytes)}</span>
<span>
{item.downloadCount}/{item.maxDownloads} downloads
</span>
<span>
{status === "expired"
? "expired"
: status === "consumed"
? "done"
: `expires ${formatExpiry(item.expiresAt)}`}
</span>
</div>
</div>
{openUrl && status === "active" && (
<a
href={openUrl}
className="shrink-0 text-xs text-ink-muted hover:text-ink transition-colors"
>
Open
</a>
)}
</div>
</div>
);
})}
</div>
</section>
);
}
function SharedLinksSection() { function SharedLinksSection() {
const [items, setItems] = useState<LinkItem[] | null>(null); const [items, setItems] = useState<LinkItem[] | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null); const [copiedId, setCopiedId] = useState<string | null>(null);