fix(hub): open the project Dashboard to every member, rename /insights → /dashboard (BEA-12) (#62)

* fix(hub): open the project Dashboard to every member, rename /insights → /dashboard (BEA-12)

"Dashboard" was the first sidebar item a new member clicked and it always
refused: it landed on /<project>/insights showing "Insights is for hub
admins and org owners." The gate was client-side only — GET /heat is
gated on project membership and returns counts without actor identities,
so every member's browser could already fetch every number the page draws.

Drops the canInsights gate (nav item, dedicated route, project-home
embed, ⋯ menu entry) and renames the view route insights → dashboard so
the nav label, the URL and the page title finally agree. The shipped
/insights URL still resolves and normalizes to /dashboard (LEGACY_VIEWS
in router.ts) so bookmarks don't 404 and only one URL stays live.

No server change: /heat gating and response shape are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(architecture): router VIEW_ROUTES now names dashboard, with LEGACY_VIEWS

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow W. Lee (Sungwon)
2026-07-28 07:05:00 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent 224b3d6f53
commit 589be3a6de
21 changed files with 180 additions and 121 deletions
+45 -18
View File
@@ -1,11 +1,11 @@
import { test, expect } from "@playwright/test";
import { login, wikiId, MEMBER, READER, expectToast } from "./helpers";
// Phase 3: project home (connect guide + embedded insights), the dedicated
// insights route, and the history views. Ports the original parity checks
// Phase 3: project home (connect guide + embedded dashboard), the dedicated
// dashboard route, and the history views. Ports the original parity checks
// from the pre-migration smoke suite.
test("landing is the project home (guide), not an insights redirect", async ({ page }) => {
test("landing is the project home (guide), not a dashboard redirect", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.waitForURL("/" + pid);
@@ -51,11 +51,11 @@ test("guide: manual fallback has the full command list and the docs link", async
await expect(page.locator('.gd-manual a[href="https://docs.beardrive.ai/manual/install/"]')).toHaveCount(1);
});
test("admin home embeds insights below the guide; member home does not", async ({ page, browser }) => {
test("home embeds the dashboard below the guide, for members too", async ({ page, browser }) => {
await login(page);
await page.waitForSelector(".guide");
await expect(page.locator(".home-insights .insights")).toBeVisible();
// Guide renders above the embedded insights
// Guide renders above the embedded dashboard
const order = await page.evaluate(() => {
const g = document.querySelector(".guide");
const i = document.querySelector(".home-insights");
@@ -69,24 +69,51 @@ test("admin home embeds insights below the guide; member home does not", async (
const p2 = await ctx.newPage();
await login(p2, MEMBER);
await p2.waitForSelector(".guide");
await expect(p2.locator(".home-insights")).toHaveCount(0);
await expect(p2.locator(".home-insights .insights")).toBeVisible();
await ctx.close();
});
test("dedicated insights route still works and survives reload", async ({ page }) => {
test("dedicated dashboard route still works and survives reload", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/insights`);
await expect(page.locator("#crumb")).toHaveText("Insights — wiki");
await page.goto(`/${pid}/dashboard`);
await expect(page.locator("#crumb")).toHaveText("Dashboard — wiki");
await expect(page.locator(".in-treemap")).toBeVisible();
await page.reload();
await expect(page.locator(".in-treemap")).toBeVisible();
});
test("a plain member gets the Dashboard, not a refusal", async ({ page }) => {
await login(page, MEMBER);
const pid = await wikiId(page);
await page.click("#nav-dashboard");
await page.waitForURL(`/${pid}/dashboard`);
await expect(page.locator("#nav-dashboard")).toHaveClass(/active/);
await expect(page.locator("#crumb")).toHaveText("Dashboard — wiki");
await expect(page.locator(".in-treemap")).toBeVisible();
await expect(page.locator("body")).not.toContainText("hub admins and org owners");
// …and a scoped deep link resolves on a cold page, back/forward included.
await page.goto(`/${pid}/dashboard/notes`);
await expect(page.locator(".in-title .in-scope")).toContainText("notes");
await page.goBack();
await page.waitForURL(`/${pid}/dashboard`);
});
test("the retired /insights URL still lands on the Dashboard", async ({ page }) => {
await login(page, MEMBER);
const pid = await wikiId(page);
await page.goto(`/${pid}/insights`);
await page.waitForURL(`/${pid}/dashboard`); // normalized, one live URL per page
await expect(page.locator(".in-treemap")).toBeVisible();
await page.goto(`/${pid}/insights/notes`);
await page.waitForURL(`/${pid}/dashboard/notes`);
await expect(page.locator(".in-title .in-scope")).toContainText("notes");
});
test("hot path row opens the file", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/insights`);
await page.goto(`/${pid}/dashboard`);
await page.click(".in-hp-row:first-child");
await page.waitForURL(/\/(index|guide)\.md$/);
await expect(page.locator("#content h1")).toBeVisible();
@@ -101,13 +128,13 @@ test("vault name returns to the project home", async ({ page }) => {
await expect(page.locator(".guide")).toBeVisible();
});
test("back/forward walks home → file → insights", async ({ page }) => {
test("back/forward walks home → file → dashboard", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.waitForURL("/" + pid);
await page.click('#tree .row[data-path="index.md"]');
await page.waitForURL(`/${pid}/index.md`);
await page.goto(`/${pid}/insights`);
await page.goto(`/${pid}/dashboard`);
await page.goBack();
await expect(page.locator("#content h1")).toHaveText("Wiki");
await page.goBack();
@@ -206,13 +233,13 @@ test("folder listing's Full history goes to the subtree feed", async ({ page })
for (const p of paths) expect(p).toContain("notes/");
});
test("insights scopes to the selected folder via the ⋯ menu", async ({ page }) => {
await login(page);
test("the dashboard scopes to the selected folder via the ⋯ menu", async ({ page }) => {
await login(page, MEMBER); // members get the scoped entry too
const pid = await wikiId(page);
await page.goto(`/${pid}/notes`);
await page.click("#more-btn");
await page.click("#more-menu .more-item:has-text('Insights')");
await page.waitForURL(`/${pid}/insights/notes`);
await page.click("#more-menu .more-item:has-text('Dashboard')");
await page.waitForURL(`/${pid}/dashboard/notes`);
await expect(page.locator(".in-title .in-scope")).toContainText("notes");
// Scope note in the subtitle is the stable assertion.
await expect(page.locator(".insights .dl-sub")).toContainText("notes and everything in it");
@@ -222,7 +249,7 @@ test("project menu pages each own a URL: Dashboard, Installation, Settings", asy
await login(page);
const pid = await wikiId(page);
await page.click("#nav-dashboard");
await page.waitForURL(`/${pid}/insights`);
await page.waitForURL(`/${pid}/dashboard`);
await expect(page.locator(".insights .in-title")).toContainText("Knowledge insights");
await expect(page.locator("#nav-dashboard")).toHaveClass(/active/);
await page.click("#nav-install");
@@ -237,7 +264,7 @@ test("project menu pages each own a URL: Dashboard, Installation, Settings", asy
await expect(page.locator("#crumb")).toHaveText("Project settings");
await expect(page.locator(".project-settings h2")).toHaveText("wiki");
await page.click("#nav-dashboard");
await page.waitForURL(`/${pid}/insights`);
await page.waitForURL(`/${pid}/dashboard`);
await expect(page.locator("#nav-dashboard")).toHaveClass(/active/);
// Deep link + reload land on the page, like any URL.
await page.goto(`/${pid}/settings`);
+2 -2
View File
@@ -45,7 +45,7 @@ test("every view shares one column system", async ({ page }) => {
await visit("", "app"); // project home
await visit("/install", "app"); // the same guide, so the same column
await visit("/settings", "app");
await visit("/insights", "app"); // charts cap their own measure; the column is normal
await visit("/dashboard", "app"); // charts cap their own measure; the column is normal
await visit("/history", "app"); // structured view, not a file render
await visit("/index.md", "read"); // rendered markdown — the only read surface
await visit("/notes", "app"); // folder listing is a structured view too
@@ -73,7 +73,7 @@ test("charts never scale past the size they were drawn at", async ({ page }) =>
await page.setViewportSize({ width: 1600, height: 900 });
await login(page);
const pid = await wikiId(page);
await page.goto(`http://localhost:8993/${pid}/insights`);
await page.goto(`http://localhost:8993/${pid}/dashboard`);
await page.waitForSelector(".in-chart");
const worst = await page.evaluate(() => {
let max = 0;
+6 -6
View File
@@ -1,26 +1,26 @@
import { test, expect } from "@playwright/test";
import { login, wikiId } from "./helpers";
test("insights via ⋯ scopes to the open file", async ({ page }) => {
test("dashboard via ⋯ scopes to the open file", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/notes/readme.md`);
await page.click("#more-btn");
await page.click("#more-menu .more-item:has-text('Insights')");
await expect(page).toHaveURL(`/${pid}/insights/notes/readme.md`);
await page.click("#more-menu .more-item:has-text('Dashboard')");
await expect(page).toHaveURL(`/${pid}/dashboard/notes/readme.md`);
await expect(page.locator(".in-title .in-scope")).toContainText("notes/readme.md");
// The subject stays selected in the tree; Dashboard does NOT light up.
await expect(page.locator('#tree .row[data-path="notes/readme.md"]')).toHaveClass(/active/);
await expect(page.locator("#nav-dashboard")).not.toHaveClass(/active/);
});
test("insights via ⋯ scopes to the selected folder", async ({ page }) => {
test("dashboard via ⋯ scopes to the selected folder", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/notes`);
await page.click("#more-btn");
await page.click("#more-menu .more-item:has-text('Insights')");
await expect(page).toHaveURL(`/${pid}/insights/notes`);
await page.click("#more-menu .more-item:has-text('Dashboard')");
await expect(page).toHaveURL(`/${pid}/dashboard/notes`);
await expect(page.locator(".in-title .in-scope")).toContainText("notes");
await expect(page.locator('#tree .row[data-path="notes"]')).toHaveClass(/active/);
await expect(page.locator("#nav-dashboard")).not.toHaveClass(/active/);
+25
View File
@@ -0,0 +1,25 @@
import { chromium } from "@playwright/test";
const OUT = process.argv[2], B = "http://localhost:8993";
const b = await chromium.launch();
async function as(email) {
const c = await b.newContext({ viewport: { width: 1280, height: 860 } });
const p = await c.newPage();
await p.goto(B + "/"); await p.waitForURL(/auth\/login/);
await p.fill('input[name="email"]', email); await p.fill('input[name="password"]', "e2e-pass-1");
await p.click("form button"); await p.waitForSelector("#sidebar");
return p;
}
const r = await as("reader@example.com");
const pid = (await (await r.request.get(B+"/api/projects")).json()).projects.find(x=>x.name==="wiki").id;
await r.goto(`${B}/${pid}/dashboard`); await r.waitForTimeout(1200);
console.log("reader crumb:", await r.locator("#crumb").innerText());
console.log("reader treemap count:", await r.locator(".in-treemap").count());
const m = await as("member@example.com");
await m.goto(`${B}/${pid}/notes`); await m.waitForTimeout(800);
await m.click("#more-btn"); await m.waitForTimeout(300);
console.log("member ⋯ items:", await m.locator("#more-menu .more-item").allInnerTexts());
await m.screenshot({ path: `${OUT}/after-05-member-more-menu.png` });
const res = await m.request.get(`${B}/api/p/${pid}/heat`);
const body = await res.text();
console.log("member /heat:", res.status(), "identity leak:", /@|token|device_id/.test(body));
await b.close();
+25 -30
View File
@@ -38,7 +38,6 @@ export default function Browser(props: {
hub: boolean;
project?: Project;
projects?: Project[];
canInsights?: boolean;
sidebar: { vault: ReactNode; projectsNav?: ReactNode; orgBar?: ReactNode };
// Admin panels (org admin, hub settings) replace the content pane without
// touching the URL — matching the classic app, where they were never
@@ -52,10 +51,10 @@ export default function Browser(props: {
const { tree, flatFiles, dirIndex, loaded } = useTree(apiBase, !hub || !!project);
const heatMap = useHeat(apiBase, hub && !!project && !!config.reads?.enabled);
// Insights data: the per-device breakdown, plus a fresh heat fetch when
// an insights surface opens (the ambient heat cache may be a minute old).
// Dashboard data: the per-device breakdown, plus a fresh heat fetch when
// a dashboard surface opens (the ambient heat cache may be a minute old).
const isHome = hub && !!project && !route.path && !route.view;
const insightsOpen = !!props.canInsights && (route.view === "insights" || isHome);
const insightsOpen = route.view === "dashboard" || isHome;
const devices = useInsightsDevices(apiBase, insightsOpen);
useEffect(() => {
if (insightsOpen) qc.invalidateQueries({ queryKey: ["heat", apiBase] });
@@ -64,9 +63,9 @@ export default function Browser(props: {
const path = route.path;
// ?v= belongs to the file page; a view route or folder ignores it.
const version = !route.view ? route.version : undefined;
// On scoped view routes (/insights/<p>, /history/<p>) the subject of the
// On scoped view routes (/dashboard/<p>, /history/<p>) the subject of the
// page is the target — the tree highlights it, not a menu item.
const treePath = path || (route.view === "insights" || route.view === "history" ? route.viewTarget || "" : "");
const treePath = path || (route.view === "dashboard" || route.view === "history" ? route.viewTarget || "" : "");
const isDir = !!path && dirIndex.has(path);
// A file only counts as one when the tree actually contains it — a
// missing path gets the not-found view, not a broken file view.
@@ -87,7 +86,7 @@ export default function Browser(props: {
}, [tree]);
useEffect(() => {
// Opening any path (tree click, palette, wikilink, deep link — or a
// scoped insights/history view of it) unfolds the way to it; a selected
// scoped dashboard/history view of it) unfolds the way to it; a selected
// folder itself opens too.
if (!treePath || !loaded) return;
setExpanded((s) => {
@@ -248,8 +247,8 @@ export default function Browser(props: {
let view: ReactNode;
if (panel) {
view = panel.body;
} else if (route.view === "insights") {
view = props.canInsights ? (
} else if (route.view === "dashboard") {
view = (
<Insights
flatFiles={flatFiles}
heatMap={heatMap}
@@ -259,8 +258,6 @@ export default function Browser(props: {
onOpenFolder={openPath}
isFolder={isFolderFn}
/>
) : (
<div className="empty">Insights is for hub admins and org owners.</div>
);
} else if (route.view === "history") {
// structured view — default app column, like the folder listing it shares rows with
@@ -338,23 +335,21 @@ export default function Browser(props: {
);
}
} else if (isHome) {
// The project's index page: the connect-an-agent guide, with Insights
// below for admins/owners.
// The project's index page: the connect-an-agent guide, with the
// dashboard below it.
view = (
<>
<ConnectGuide project={project!} />
{props.canInsights && (
<div className="home-insights">
<Insights
flatFiles={flatFiles}
heatMap={heatMap}
devices={devices}
onOpenFile={openPath}
onOpenFolder={openPath}
isFolder={isFolderFn}
/>
</div>
)}
<div className="home-insights">
<Insights
flatFiles={flatFiles}
heatMap={heatMap}
devices={devices}
onOpenFile={openPath}
onOpenFolder={openPath}
isFolder={isFolderFn}
/>
</div>
</>
);
} else {
@@ -365,8 +360,8 @@ export default function Browser(props: {
panel.crumb
) : path ? (
<Breadcrumbs path={path} onOpenFolder={openPath} />
) : route.view === "insights" ? (
"Insights — " + (route.viewTarget || project?.name || "")
) : route.view === "dashboard" ? (
"Dashboard — " + (route.viewTarget || project?.name || "")
) : route.view === "history" ? (
"History — " + historyTitle(route.viewTarget || "", isFolderFn)
) : isHome ? (
@@ -421,15 +416,15 @@ export default function Browser(props: {
Download
</button>
)}
{props.canInsights && (
{hub && !!project && (
<button
className="more-item"
onClick={() => {
props.onClosePanel?.();
navigate(urlForView("insights", project?.id, path));
navigate(urlForView("dashboard", project?.id, path));
}}
>
Insights
Dashboard
</button>
)}
</div>
+9 -7
View File
@@ -71,9 +71,6 @@ export default function HubApp({ config }: { config: ServerConfig }) {
const brand = config.brand || "BearDrive";
const org = (current && orgs?.find((o) => o.id === current.org)) || null;
// Insights (embedded on the project home and behind the ⋯ menu) is for
// hub admins and owners of the project's org.
const canInsights = isAdmin || (org ? org.role === "owner" : false);
// Top of the sidebar is the brand; project and account actions live in
// their own sections below (PropelAuth-style layout).
@@ -211,6 +208,12 @@ export default function HubApp({ config }: { config: ServerConfig }) {
return <Redirect to={"/" + current.id} />;
}
// A renamed view URL (/insights) still resolves; swap it for the current
// one so there is one live URL per page.
if (route.legacyView && route.view) {
return <Redirect to={urlForView(route.view, current.id, route.viewTarget)} />;
}
return (
<Browser
key={current.id} // fresh tree/fold state per project
@@ -220,7 +223,6 @@ export default function HubApp({ config }: { config: ServerConfig }) {
hub
project={current}
projects={projects}
canInsights={canInsights}
sidebar={{
vault,
projectsNav: (
@@ -228,12 +230,12 @@ export default function HubApp({ config }: { config: ServerConfig }) {
projects={projects}
currentId={current.id}
menu={{
// Scoped views (/insights/<path>, /history/<path>) belong to
// Scoped views (/dashboard/<path>, /history/<path>) belong to
// the file/folder — the tree carries the selection, no menu
// item lights up.
active: panel
? null
: route.view === "insights" && !route.viewTarget
: route.view === "dashboard" && !route.viewTarget
? "dashboard"
: route.view === "install"
? "install"
@@ -246,7 +248,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
// same-path navigation doesn't change pathname.
onDashboard: () => {
setPanel(null);
navigate(urlForView("insights", current.id));
navigate(urlForView("dashboard", current.id));
closeSidebarOnMobile();
},
onInstall: () => {
@@ -4,11 +4,12 @@ import { getJSON } from "../api/http";
import type { HeatMap, Node } from "../api/types";
import { heatTotal } from "../hooks/useBrowse";
/* ---- insights: the read×write matrix ----
/* ---- the project Dashboard: the read×write matrix ----
Every file plotted by how much it is read (30 days, from the heat API)
against how long since it last changed (from the tree). The hot-but-stale
quadrant is the danger zone: knowledge people still rely on that nobody
maintains. Admin/org-owner only — members get the ambient heat dots. */
maintains. Every project member sees this — /heat is gated on membership
and returns counts only, never actor identities (reads.go). */
const HOT_READS = 3; // ≥ this many reads/30d = hot
const STALE_DAYS = 30; // ≥ this many days since last write = stale
@@ -233,7 +233,7 @@ export function Mark({ size = 22 }: { size?: number }) {
gutter; `<Page>` owns width and centering nothing else may set either.
Three widths cover every view: `read` for rendered files only (markdown
prose), `app` for every structured view (guide, listings, history,
insights, settings, admin), `wide` for content that is itself a
dashboard, settings, admin), `wide` for content that is itself a
page (a rendered HTML file in its frame). `read` and `app` both resolve
to Tailwind's md (768px); they stay separate classes because the file
view carries markdown typography and the widths may diverge again.
+13 -5
View File
@@ -17,16 +17,20 @@ export function decodePath(p: string): string {
// Special views are RESTful routes under the project — the first segment
// after the project id is reserved when it names a view:
// /<project-id>/insights[/<path>] the Insights dashboard (optionally scoped)
// /<project-id>/dashboard[/<path>] the read×staleness dashboard (optionally scoped)
// /<project-id>/history[/<path>] change feed (project / subtree / file)
// /<project-id>/install connect-a-device guide
// /<project-id>/settings project settings
// Rule: every page gets its own URL path (see CLAUDE.md) — new surfaces are
// view routes here, not ephemeral panel state. (Root-level files literally
// named like a view lose the URL shortcut and remain reachable via the tree.)
export const VIEW_ROUTES = new Set(["insights", "history", "install", "settings"]);
export const VIEW_ROUTES = new Set(["dashboard", "history", "install", "settings"]);
export type ViewName = "insights" | "history" | "install" | "settings";
// Shipped URLs that were renamed. Parsed into the new view and normalized
// away on arrival, so bookmarks resolve without a second live name.
const LEGACY_VIEWS: Record<string, ViewName> = { insights: "dashboard" };
export type ViewName = "dashboard" | "history" | "install" | "settings";
export interface Route {
// Org administration is not project-scoped, so it is a top-level route
@@ -41,6 +45,9 @@ export interface Route {
path: string;
view?: ViewName;
viewTarget?: string;
// The URL used a renamed segment (e.g. /insights): the app replaces it
// with the canonical one instead of leaving two URLs for one page.
legacyView?: boolean;
// A past version of `path`, by content hash (?v=<sha>). Not a view route:
// the first segment after the project id is reserved for view names, and a
// version is the same page pinned to older bytes, so it rides as a query
@@ -71,8 +78,9 @@ function parsePath(pathname: string, mode: "volume" | "hub"): Route {
const r: Route = { project: raw.slice(0, slash), path: decodePath(raw.slice(slash + 1)) };
const seg = r.path.indexOf("/");
const head = seg === -1 ? r.path : r.path.slice(0, seg);
if (VIEW_ROUTES.has(head)) {
r.view = head as ViewName;
if (VIEW_ROUTES.has(head) || LEGACY_VIEWS[head]) {
r.view = LEGACY_VIEWS[head] || (head as ViewName);
if (LEGACY_VIEWS[head]) r.legacyView = true;
r.viewTarget = seg === -1 ? "" : r.path.slice(seg + 1).replace(/\/+$/, "");
r.path = "";
}
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-Czoh6fKl.js"></script>
<script type="module" crossorigin src="/assets/index-CH2MFMbS.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CzGgqyHx.css">
</head>
<body>