fix(hub): one Revoke per share link — the modal hands off, the banner undoes (BEA-32) (#77)

Opening Share on a file that already had a live public link showed the URL
twice, each copy with its own Revoke: the transient dialog on top of the
persistent ShareBanner. Both hit the same link, so nothing was at risk, but a
destructive control shown twice is a control nobody wants to click.

The dialog loses Revoke and keeps its job — confirm what happened, hand over
the URL (Copy link / Open / Done) and set an expiry. The banner keeps Revoke:
it is what BEA-16 added so the undo outlives the dialog, and it is still there
on every later visit to the file.

Deviation from the reviewed plan: it also had this delete the `token`
derivation and the `api` import, which existed only for the Revoke handler.
BEA-29 (#74) landed first and its expiry PATCH now uses both, so they stay —
the plan assumed this PR would go in ahead of it.

The e2e that revoked through the modal now revokes through the banner, and
asserts `.modal .ai-del` has count 0 right after Share — the assertion that
stops the duplication coming back.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee (Sungwon)
2026-07-31 07:04:30 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent 946a552c64
commit 31472c56e7
5 changed files with 28 additions and 26 deletions
+11 -2
View File
@@ -155,13 +155,22 @@ test("share mints a public link that serves the file, revoke kills it", async ({
const pid = await wikiId(page);
await page.goto(`/${pid}/guide.md`);
await page.click("#share-btn");
// BEA-32: the modal hands over the URL and nothing destructive — Revoke is
// the banner's, and two of them for one link is what this asserts away.
await expect(page.locator(".modal .ai-del")).toHaveCount(0);
const url = await page.locator(".modal-url").textContent();
expect(url).toContain("/s/");
const publicRes = await page.request.get(url!);
expect(publicRes.status()).toBe(200);
expect(await publicRes.text()).toContain("Second version");
await page.click(".modal .ai-del"); // revoke
await expectToast(page, "revoked");
// Revoke where the control actually lives, from the file page.
await page.click(".modal button:has-text('Done')");
const banner = page.locator(".share-banner");
await expect(banner).toBeVisible();
await banner.locator(".ai-del").click();
await page.click(".modal .danger-btn");
await expectToast(page, "Share revoked");
const gone = await page.request.get(url!);
expect(gone.status()).toBe(404);
});
@@ -515,7 +515,10 @@ export default function Browser(props: {
copied={share.copied}
onClose={() => {
setShare(null);
refreshShares(); // the dialog can revoke; the banner must agree
// Create may have handed back an existing link, in which case
// shareNow's own invalidation raced a stale cache. Refresh again
// so the banner agrees with what the dialog just showed.
refreshShares();
}}
/>
)}
@@ -20,9 +20,13 @@ const EXPIRY_PRESETS = [
];
/* A clear, explicitly-public share confirmation: warns that anyone with the
link can view, and offers copy / open / revoke. The title says "Public
link can view, and offers copy / open / expiry. The title says "Public
link", not "created": ShareDB.Create hands back the file's existing live
link when there is one, so a second Share click is not a second link. */
link when there is one, so a second Share click is not a second link.
Revoke lives on the ShareBanner, not here: this modal is a transient
handoff of the URL, the banner is what is still on the file after it
closes. Two Revokes for one link is a destructive control shown twice. */
export function ShareDialog({
url,
copied,
@@ -105,20 +109,6 @@ export function ShareDialog({
<Button variant="subtle" onClick={() => window.open(url, "_blank")}>
Open
</Button>
<Button
variant="subtle" className="ai-del"
onClick={async () => {
try {
await api("DELETE", "/api/shares/" + token);
toast("Link revoked — it no longer works.");
onClose();
} catch (e) {
toast((e as Error).message, true);
}
}}
>
Revoke
</Button>
<Button variant="subtle" onClick={onClose}>
Done
</Button>
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
<script type="module" crossorigin src="/assets/index-DJI_9uJj.js"></script>
<script type="module" crossorigin src="/assets/index-d1aFxMDF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dyv4ewL7.css">
</head>
<body>