Public: anyone can read this; the crew can change it.
A signed-out visitor's command palette offers only what they can reach, and Sign in #7927
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7920-a-signed-out-visitor-s-command-palette-o → main 3 commits fork
3 files
changed +61
−6
README.md +2
| @@ -25,6 +25,8 @@ child's own data, and `/forges` says how many are running and why any is not. Ea | ||
| 25 | 25 | with why (also in the terminal and the MCP `forges`): "Not reporting" (no word |
| 26 | 26 | in 24 hours), "App down", or "Unclaimed" three days after it was sent; the rest |
| 27 | 27 | follow by last activity. |
| 28 | A signed-out visitor's command palette (Cmd-K) lists only places they can reach (Home, Apps, Explore, Front page, Search, public repositories) plus **Sign in**, which goes where the topbar's button does (`signInHref` in `src/web/layout.ts`: `/signin`, `/signup` for an edge stranger, the edge's login when an edge token is active); no Create or Settings rows. After signing in from the palette the visitor lands on the default page, not the one they were on. The terminal's guest palette already hides the signed-in verbs (New repository, Migrate, New forge and the rest) and offers Sign in. | |
| 29 | ||
| 28 | 30 | In the terminal an admin's palette offers "Forges you sent": each forge with its |
| 29 | 31 | claim state and the same apps-running note, never a claim link. Both carry the |
| 30 | 32 | crew's spend today against the daily ceiling that pauses it (`$3.20 of $10 today`), as the |
src/web/layout.ts +21 −6
| @@ -314,6 +314,9 @@ const STATIC_PALETTE: PaletteItem[] = [ | ||
| 314 | 314 | { t: "Origins", h: "/settings/origins", c: "Settings" }, |
| 315 | 315 | ]; |
| 316 | 316 | |
| 317 | /** What a signed-out visitor can reach: places to go, not Create and Settings rows that end at a sign-in wall. */ | |
| 318 | const VISITOR_PALETTE = new Set(["Home", "Apps", "Explore", "Front page", "Search"]); | |
| 319 | ||
| 317 | 320 | /** First path segments that are the forge's own pages, never an owner: no repository read for them. */ |
| 318 | 321 | const NOT_AN_OWNER = new Set([ |
| 319 | 322 | ...STATIC_PALETTE.map((p) => p.h.split("/")[1] ?? ""), |
| @@ -410,7 +413,16 @@ function paletteIndex(ctx: Ctx, extra: PaletteItem[]): Html { | ||
| 410 | 413 | }), |
| 411 | 414 | }; |
| 412 | 415 | }), |
| 413 | ...STATIC_PALETTE, | |
| 416 | ...(ctx.auth.user | |
| 417 | ? STATIC_PALETTE | |
| 418 | : [ | |
| 419 | ...STATIC_PALETTE.filter((p) => VISITOR_PALETTE.has(p.t)), | |
| 420 | { | |
| 421 | t: "Sign in", | |
| 422 | h: signInHref(ctx), | |
| 423 | c: "Go to", | |
| 424 | }, | |
| 425 | ]), | |
| 414 | 426 | ...(sendsForges(ctx) |
| 415 | 427 | ? [{ t: "New forge", h: "/forges/new", c: "Create" }] |
| 416 | 428 | : []), |
| @@ -509,6 +521,13 @@ function flashToast(flash: Flash): Html { | ||
| 509 | 521 | |
| 510 | 522 | // ---- the chrome ---- |
| 511 | 523 | |
| 524 | /** Where a signed-out visitor's way in leads: the topbar button and the palette's Sign in share it. */ | |
| 525 | export function signInHref(ctx: Ctx): string { | |
| 526 | const token = activeEdgeToken(ctx.app); | |
| 527 | if (edgeStrangerLogin(ctx.request, token, ctx.auth.user) !== undefined) return "/signup"; | |
| 528 | return token === undefined ? "/signin" : edgeLoginUrl(ctx.url.pathname + ctx.url.search); | |
| 529 | } | |
| 530 | ||
| 512 | 531 | /** The skip link and the topbar: the wordmark, the crumbs, the tools. */ |
| 513 | 532 | function topbar( |
| 514 | 533 | ctx: Ctx, |
| @@ -552,11 +571,7 @@ function topbar( | ||
| 552 | 571 | : { label: "Sign out", post: "/logout", icon: "signOut" }, |
| 553 | 572 | )}` |
| 554 | 573 | : html`${guestMenu(theme)}${btn("Sign in", { |
| 555 | href: edgeStrangerLogin(ctx.request, activeEdgeToken(ctx.app), ctx.auth.user) !== undefined | |
| 556 | ? "/signup" | |
| 557 | : activeEdgeToken(ctx.app) === undefined | |
| 558 | ? "/signin" | |
| 559 | : edgeLoginUrl(ctx.url.pathname + ctx.url.search), | |
| 574 | href: signInHref(ctx), | |
| 560 | 575 | })}`} |
| 561 | 576 | </span> |
| 562 | 577 | </header>`; |
test/palette-repos.test.ts +38
| @@ -106,3 +106,41 @@ test("a private app is not known to a stranger by search, and is to its owner", | ||
| 106 | 106 | expect(await flag(zed)).toBe(false); |
| 107 | 107 | expect(await flag(undefined)).toBe(false); |
| 108 | 108 | }); |
| 109 | ||
| 110 | test("a visitor's palette holds places to go and Sign in; a signed-in one holds the full list", async () => { | |
| 111 | const rows = async (headers: Record<string, string>, path: string) => { | |
| 112 | const html = await (await fetch(`${servers.url}${path}`, { headers })).text(); | |
| 113 | const m = /<script type="application\/json" id="palette-index">([\s\S]*?)<\/script>/.exec(html)!; | |
| 114 | return (JSON.parse(m[1]!) as { items: { t: string; h: string; c?: string }[] }).items.filter((i) => i.c !== "Repositories"); | |
| 115 | }; | |
| 116 | const out = await rows({}, "/explore?q=a%26b"); | |
| 117 | expect(out.map((i) => i.t)).toEqual(["Home", "Apps", "Explore", "Front page", "Search", "Sign in"]); | |
| 118 | expect(out.find((i) => i.t === "Sign in")!.h).toBe("/signin"); | |
| 119 | const inn = (await rows({ cookie }, "/explore")).map((i) => i.t); | |
| 120 | for (const t of ["New app", "New repository", "Migrate a repository", "New organization", "What you asked for", "Account settings", "Access tokens", "SSH keys", "Origins", "Map"]) expect(inn).toContain(t); | |
| 121 | expect(inn).not.toContain("Sign in"); | |
| 122 | }); | |
| 123 | ||
| 124 | test("the palette's Sign in leads where the topbar's button does, on an edge-login forge too", async () => { | |
| 125 | const edgeDir = await mkdtemp(join(tmpdir(), "forge-palette-edge-")); | |
| 126 | const edgeApp = createApp({ dataDir: edgeDir, port: 0, edgeToken: "edge-secret-for-palette" }); | |
| 127 | const edgeServers = await serve(edgeApp); | |
| 128 | try { | |
| 129 | const items = async (headers: Record<string, string> = {}) => { | |
| 130 | const body = await (await fetch(`${edgeServers.url}/explore?q=a`, { headers })).text(); | |
| 131 | const index = JSON.parse(body.match(/id="palette-index">([^<]*)</)![1]!) as { items: { t: string; h: string }[] }; | |
| 132 | const button = body.match(/<a\s+href="([^"]*)"[^>]*>\s*Sign in\s*<\/a/)![1]!; | |
| 133 | return { palette: index.items.find((i) => i.t === "Sign in")!.h, button: button.replaceAll("&", "&") }; | |
| 134 | }; | |
| 135 | const visitor = await items(); | |
| 136 | expect(visitor.palette).toBe("/_plat/login?rd=%2Fexplore%3Fq%3Da"); | |
| 137 | expect(visitor.button).toBe(visitor.palette); | |
| 138 | const stranger = await items({ "x-plat-user": "newcomer", "x-plat-edge-token": "edge-secret-for-palette" }); | |
| 139 | expect(stranger.palette).toBe("/signup"); | |
| 140 | expect(stranger.button).toBe("/signup"); | |
| 141 | } finally { | |
| 142 | edgeServers.stop(); | |
| 143 | edgeApp.close(); | |
| 144 | await rm(edgeDir, { recursive: true, force: true }); | |
| 145 | } | |
| 146 | }); |
new above
offline