fix(webapp): a bogus project deep link says so instead of swapping projects (BEA-83) (#131)

/no-such-project-xyz/some/file.md used to redirect to whichever project the
fallback chain picked, dropping the path — nothing on screen distinguished
"that link is wrong" from "you opened your project."

An unknown route.project now renders a "Project not found" panel at the URL
as typed, mirroring the org-not-found page two blocks up: shell, sidebar and
account bar stay mounted, and "Back to <name>" points at the same project the
fallback picks today. The fallback chain itself is unchanged, and "/" still
redirects to the remembered project (BEA-75).

All four redirects below the flag rewrite the address bar off current.id, so
they are gated together — /bad-id/insights and /bad-id/notes/ would otherwise
undo the fix on their own.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee (Sungwon)
2026-08-11 03:05:23 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent e19fec0534
commit 32a099997e
5 changed files with 88 additions and 44 deletions
@@ -808,6 +808,24 @@ test("an old version of an extensionless file previews the same way", async ({ p
await expect(page.locator("#content .empty")).toContainText("That version isn't available."); await expect(page.locator("#content .empty")).toContainText("That version isn't available.");
}); });
// BEA-83. A deep link to a project id you can't see used to swap in another
// project and throw the path away, with nothing on screen to say so.
test("a bogus project deep link says so and keeps the URL", async ({ page }) => {
await login(page);
await page.goto("/no-such-project-xyz/some/file.md");
await expect(page.locator("#content .empty")).toContainText("Project not found");
expect(page.url()).toContain("/no-such-project-xyz/some/file.md");
await expect(page.locator("#sidebar")).toBeVisible();
await page.reload(); // no bounce, no loop
await expect(page.locator("#content .empty")).toContainText("Project not found");
expect(page.url()).toContain("/no-such-project-xyz/some/file.md");
// The two other URL rewrites off current.id must not undo the fix either.
await page.goto("/no-such-project-xyz/insights");
expect(page.url()).toContain("/no-such-project-xyz/insights");
await page.goto("/no-such-project-xyz/notes/");
expect(page.url()).toContain("/no-such-project-xyz/notes/");
});
// BEA-81: an old URL for a file that has since been renamed or dragged into // BEA-81: an old URL for a file that has since been renamed or dragged into
// a folder still lands on the file, rewrites itself, and says what happened. // a folder still lands on the file, rewrites itself, and says what happened.
test("a moved file's old URL redirects and says so", async ({ page }) => { test("a moved file's old URL redirects and says so", async ({ page }) => {
+6 -2
View File
@@ -22,10 +22,14 @@ test("deep link to a project resolves after reload", async ({ page }) => {
await expect(page).toHaveURL("/" + pid); await expect(page).toHaveURL("/" + pid);
}); });
test("unknown project id falls back to a real project", async ({ page }) => { // BEA-83 replaced the silent fallback with a not-found page: the sidebar
// still shows a real project (the fallback chain is unchanged), but the URL
// you typed stays put and the content pane says the id resolved to nothing.
test("unknown project id says so instead of swapping projects", async ({ page }) => {
await login(page); await login(page);
await page.goto("/p-00000000"); await page.goto("/p-00000000");
await page.waitForURL(/\/[0-9a-f-]{36}$/); await expect(page.locator("#content .empty")).toContainText("Project not found");
await expect(page).toHaveURL("/p-00000000");
await expect(page.locator("#project-select")).toContainText(/.+/); await expect(page.locator("#project-select")).toContainText(/.+/);
}); });
+49 -27
View File
@@ -202,6 +202,25 @@ export default function HubApp({ config }: { config: ServerConfig }) {
} }
: null; : null;
// Same rule as orgMissing, for the project id: a deep link to an id that is
// not yours is not a landing. `current` still resolves (the fallback chain
// above is untouched) — it is what the sidebar shows and where "back" points.
const projectMissing = !!route.project && !projects.some((p) => p.id === route.project);
const projectPage = projectMissing
? {
crumb: "Project",
body: (
<div className="empty">
<h3>Project not found</h3>
<p>This project doesn't exist, or you're no longer a member.</p>
<p>
<a {...linkProps("/" + current.id)}>Back to {current.name}</a>
</p>
</div>
),
}
: null;
// Billing is hub-level (the managed deployment's surface), not // Billing is hub-level (the managed deployment's surface), not
// project-scoped — like the org route it borrows whichever project the // project-scoped — like the org route it borrows whichever project the
// sidebar is showing. An OSS hub has no billing block; a hand-typed // sidebar is showing. An OSS hub has no billing block; a hand-typed
@@ -249,35 +268,38 @@ export default function HubApp({ config }: { config: ServerConfig }) {
} }
: null; : null;
// Landing ("/") and unknown project ids both resolve to a real project // Every redirect below rewrites the address bar off `current.id`, so all of
// URL; replace so back/forward never bounces through the redirect. The // them are wrong for a bogus deep link: /bad-id, /bad-id/insights and
// org route is not project-scoped, so it is exempt — it borrows whichever // /bad-id/notes/ would each swap in another project and drop the path.
// project the sidebar is showing. // projectMissing renders instead (projectPage above) at the URL as typed.
if (!route.org && !route.billing && route.project !== current.id) { if (!projectMissing) {
return <Redirect to={"/" + current.id} />; // Landing ("/") resolves to a real project URL; replace so back/forward
} // never bounces through the redirect. The org route is not project-scoped,
// so it is exempt — it borrows whichever project the sidebar is showing.
if (!route.org && !route.billing && route.project !== current.id) {
return <Redirect to={"/" + current.id} />;
}
// A renamed view URL (/insights) still resolves; swap it for the current // A renamed view URL (/insights) still resolves; swap it for the current
// one so there is one live URL per page. Filters ride along: the hop is a // one so there is one live URL per page. Filters ride along: the hop is a
// rename, not a reset, and dropping them would silently widen the feed. // rename, not a reset, and dropping them would silently widen the feed.
if (route.legacyView && route.view) { if (route.legacyView && route.view) {
return <Redirect to={urlForView(route.view, current.id, route.viewTarget, route.filters)} />; return <Redirect to={urlForView(route.view, current.id, route.viewTarget, route.filters)} />;
} }
// /history?path=guide.md resolved to guide.md's feed (the query form is // /history?path=guide.md resolved to guide.md's feed (the query form is
// what the History API teaches); put the canonical path URL in the address // what the History API teaches); put the canonical path URL in the address
// bar. Below the unknown-project redirect for the same reason the trailing // bar.
// slash one is: normalizing on a bad project id would pin the wrong project. if (route.queryTarget && route.view) {
if (route.queryTarget && route.view) { return <Redirect to={urlForView(route.view, current.id, route.viewTarget, route.filters)} />;
return <Redirect to={urlForView(route.view, current.id, route.viewTarget, route.filters)} />; }
}
// /notes/ is the same page as /notes — resolve it, then take the slash off // /notes/ is the same page as /notes — resolve it, then take the slash off
// the address bar. After the rewrite the flag is false, so there is no // the address bar. After the rewrite the flag is false, so there is no
// second hop. Must stay below the unknown-project redirect above, or a bad // second hop.
// project id would be normalized on the path and keep the wrong project. if (route.trailingSlash && route.path) {
if (route.trailingSlash && route.path) { return <Redirect to={urlForPath(route.path, current.id, route.version)} />;
return <Redirect to={urlForPath(route.path, current.id, route.version)} />; }
} }
return ( return (
@@ -339,7 +361,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
), ),
orgBar: accountBar, orgBar: accountBar,
}} }}
panel={activePanel || orgPage || billingPage || routePage} panel={activePanel || orgPage || projectPage || billingPage || routePage}
onClosePanel={() => setPanel(null)} onClosePanel={() => setPanel(null)}
/> />
{newProjectDialog} {newProjectDialog}
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"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title> <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>"> <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-u2Ih5Asg.js"></script> <script type="module" crossorigin src="/assets/index-C64R_Rr_.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BdCy9HmN.css"> <link rel="stylesheet" crossorigin href="/assets/index-BdCy9HmN.css">
</head> </head>
<body> <body>