feat: inbox of received transfers in /settings
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 47s
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:
parent
c46b23b8ec
commit
2452f2642a
@ -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
|
||||
* Sender can revoke. Marks deleted, purges the blob asynchronously.
|
||||
|
||||
@ -154,3 +154,11 @@ export async function deleteTransfer(id: string): Promise<void> {
|
||||
const res = await call(`/api/transfers/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
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;
|
||||
}
|
||||
|
||||
@ -6,6 +6,9 @@ import {
|
||||
receiveCloud,
|
||||
type ReceivedTransferPreview,
|
||||
} from "../lib/sendCloud";
|
||||
import { keyToFragment } from "../lib/cloudTransfer";
|
||||
import { saveTransferKey } from "../lib/localTransferKeys";
|
||||
import { claimTransfer } from "../lib/api";
|
||||
|
||||
type Stage =
|
||||
| { kind: "loading" }
|
||||
@ -61,9 +64,11 @@ export default function Receive() {
|
||||
}
|
||||
|
||||
previewTransfer(id, k)
|
||||
.then((preview) =>
|
||||
setStage({ kind: "preview", preview, password: "", passwordError: null }),
|
||||
)
|
||||
.then((preview) => {
|
||||
saveTransferKey(id, keyToFragment(k));
|
||||
claimTransfer(id).catch(() => {});
|
||||
setStage({ kind: "preview", preview, password: "", passwordError: null });
|
||||
})
|
||||
.catch((err) => {
|
||||
const msg = err instanceof Error ? err.message : "unknown";
|
||||
setStage({ kind: "error", message: msg });
|
||||
|
||||
@ -89,6 +89,8 @@ export default function Settings() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ReceivedSection />
|
||||
|
||||
<SharedLinksSection />
|
||||
|
||||
<section className="mb-12">
|
||||
@ -257,6 +259,96 @@ function itemStatus(t: LinkItem): "active" | "expired" | "consumed" {
|
||||
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() {
|
||||
const [items, setItems] = useState<LinkItem[] | null>(null);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user