Public: anyone can read this; the crew can change it.
Explore's empty state offers a stranger Create an account, not only Sign in #8627
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8463-explore-s-empty-state-offers-a-stranger → main 2 commits fork 2 threads · 2 unresolved
4 files
changed +66
−14
src/web/chrome.ts +10
| @@ -148,6 +148,16 @@ export function signInHref(ctx: Ctx): string { | ||
| 148 | 148 | return `/signin?next=${encodeURIComponent(next)}`; |
| 149 | 149 | } |
| 150 | 150 | |
| 151 | /** | |
| 152 | * Whether a signed-out visitor is offered Create an account beside Sign in. | |
| 153 | * Behind a platform edge the edge is the one way in, unless it has vouched | |
| 154 | * for them as a stranger who may sign up here. Home and explore share this. | |
| 155 | */ | |
| 156 | export function offersSignup(ctx: Ctx): boolean { | |
| 157 | const token = activeEdgeToken(ctx.app); | |
| 158 | return token === undefined || edgeStrangerLogin(ctx.request, token, ctx.auth.user) !== undefined; | |
| 159 | } | |
| 160 | ||
| 151 | 161 | // ---- repo header ---- |
| 152 | 162 | |
| 153 | 163 | export type RepoTab = |
src/web/studio/explore.ts +11 −9
| @@ -16,7 +16,8 @@ import { | ||
| 16 | 16 | } from "../../actions/explore.ts"; |
| 17 | 17 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 18 | 18 | import type { Ctx } from "../../http/context.ts"; |
| 19 | import { pageHead } from "../chrome.ts"; | |
| 19 | import { edgeLoginUrl } from "../../http/edge.ts"; | |
| 20 | import { offersSignup, pageHead } from "../chrome.ts"; | |
| 20 | 21 | import { html, type Html } from "../html.ts"; |
| 21 | 22 | import { page } from "../layout.ts"; |
| 22 | 23 | import { btn, empty, listCard, nameMark, row as rowOf, seg, timeAgo } from "../ui.ts"; |
| @@ -94,7 +95,7 @@ function terms(query: ExploreQuery): { term: string; without: ExploreQuery }[] { | ||
| 94 | 95 | } |
| 95 | 96 | |
| 96 | 97 | /** Nothing to list: a bare line means nothing is public yet, not a miss. */ |
| 97 | function emptyState(signedIn: boolean, query: ExploreQuery): Html { | |
| 98 | function emptyState(ctx: Ctx, query: ExploreQuery): Html { | |
| 98 | 99 | const line = exploreLine(query); |
| 99 | 100 | if (line !== "") |
| 100 | 101 | return empty({ |
| @@ -112,12 +113,13 @@ function emptyState(signedIn: boolean, query: ExploreQuery): Html { | ||
| 112 | 113 | icon: "search", |
| 113 | 114 | title: "Nothing public yet", |
| 114 | 115 | note: "Make the first app and it shows up here.", |
| 115 | action: signedIn | |
| 116 | ? btn("Make an app", { href: "/apps/new", variant: "primary" }) | |
| 117 | : btn("Sign in to make one", { | |
| 118 | href: "/signin?next=/apps/new", | |
| 119 | variant: "primary", | |
| 120 | }), | |
| 116 | action: | |
| 117 | ctx.auth.user !== undefined | |
| 118 | ? btn("Make an app", { href: "/apps/new", variant: "primary" }) | |
| 119 | : offersSignup(ctx) | |
| 120 | ? html`${btn("Create an account", { href: "/signup?next=/apps/new", variant: "primary" })} | |
| 121 | ${btn("Sign in", { href: "/signin?next=/apps/new" })}` | |
| 122 | : btn("Sign in", { href: edgeLoginUrl("/apps/new"), variant: "primary" }), | |
| 121 | 123 | }); |
outdated
This edge-login branch is untested. Add a test with an edge token and no vouched stranger that expects the edge login URL and no Create an account. Add a second test with a vouched stranger that expects Create an account. |
||
| 122 | 124 | } |
| 123 | 125 | |
| @@ -200,7 +202,7 @@ export async function explorePage(ctx: Ctx): Promise<Response> { | ||
| 200 | 202 | > |
| 201 | 203 | </div> |
| 202 | 204 | ${rows.length === 0 |
| 203 | ? emptyState(ctx.auth.user !== undefined, query) | |
| 205 | ? emptyState(ctx, query) | |
| 204 | 206 | : listCard(rows.map(rowHtml))}`; |
| 205 | 207 | const response = page({ ctx, title: "Explore", body }); |
| 206 | 208 | notePhase(ctx.request, "render", performance.now() - rendering); |
src/web/studio/home.ts +3 −3
| @@ -23,9 +23,9 @@ import { LOOK_SIZES } from "../../core/looks.ts"; | ||
| 23 | 23 | import { canRead, getRepo } from "../../core/repos.ts"; |
| 24 | 24 | import { type User } from "../../core/users.ts"; |
| 25 | 25 | import type { Ctx } from "../../http/context.ts"; |
| 26 | import { activeEdgeToken, edgeLoginUrl, edgeStrangerLogin } from "../../http/edge.ts"; | |
| 26 | import { edgeLoginUrl } from "../../http/edge.ts"; | |
| 27 | 27 | import { sshCommand } from "../../ssh/command.ts"; |
| 28 | import { repoBase, signInFirst, NOT_ADMIN_HERE, forbiddenPage } from "../chrome.ts"; | |
| 28 | import { repoBase, offersSignup, signInFirst, NOT_ADMIN_HERE, forbiddenPage } from "../chrome.ts"; | |
| 29 | 29 | import { field, readForm, type Form } from "../form.ts"; |
| 30 | 30 | import { liveRegionsJs } from "../client.ts"; |
| 31 | 31 | import { html, when, type Html } from "../html.ts"; |
| @@ -608,7 +608,7 @@ export function studioDoor(ctx: Ctx): Response { | ||
| 608 | 608 | <div class="bar"> |
| 609 | 609 | ${ctx.auth.user !== undefined |
| 610 | 610 | ? null |
| 611 | : activeEdgeToken(ctx.app) === undefined || edgeStrangerLogin(ctx.request, activeEdgeToken(ctx.app), ctx.auth.user) !== undefined | |
| 611 | : offersSignup(ctx) | |
| 612 | 612 | ? html`${btn("Create an account", { |
| 613 | 613 | href: `/signup${nextQuery}`, |
| 614 | 614 | variant: "primary", |
test/explore.test.ts +42 −2
| @@ -17,7 +17,8 @@ import { createIssue } from "../src/core/issues.ts"; | ||
| 17 | 17 | import type { Repo } from "../src/core/repos.ts"; |
| 18 | 18 | import { createToken } from "../src/core/tokens.ts"; |
| 19 | 19 | import { createUser, type User } from "../src/core/users.ts"; |
| 20 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 20 | import { edgeLoginUrl } from "../src/http/edge.ts"; | |
| 21 | import { forgeHttp, serve, type Servers } from "../src/http/server.ts"; | |
| 21 | 22 | import { quietLogs } from "./helpers/globals.ts"; |
| 22 | 23 | import { proveAll } from "./helpers/verified.ts"; |
| 23 | 24 | import { promote } from "./helpers/members.ts"; |
| @@ -434,11 +435,13 @@ describe("explore on a forge with nothing public", () => { | ||
| 434 | 435 | const out = await get("/explore", false); |
| 435 | 436 | expect(out).toContain("Nothing public yet"); |
| 436 | 437 | expect(out).toContain('href="/signin?next=/apps/new"'); |
| 438 | expect(out).toContain('href="/signup?next=/apps/new"'); | |
| 439 | expect(out).toContain("Create an account"); | |
| 437 | 440 | expect(out).not.toContain("Clear the line"); |
| 438 | 441 | const mine = await get("/explore", true); |
| 439 | 442 | expect(mine).toContain("Nothing public yet"); |
| 440 | 443 | expect(mine).toContain('href="/apps/new"'); |
| 441 | expect(mine).not.toContain("Sign in to make one"); | |
| 444 | expect(mine).not.toContain("Create an account"); | |
| 442 | 445 | }); |
| 443 | 446 | |
| 444 | 447 | test("a query with no hits still says nothing matches", async () => { |
| @@ -630,3 +633,40 @@ test("isExploreLine is true for each explore key and false for plain words", () | ||
| 630 | 633 | expect(isExploreLine(line)).toBe(true); |
| 631 | 634 | for (const line of ["", "garden", "zed-one", "downer:x"]) expect(isExploreLine(line)).toBe(false); |
| 632 | 635 | }); |
| 636 | ||
| 637 | describe("explore's empty state behind a platform edge", () => { | |
| 638 | const TOKEN = "edge-secret-for-explore"; | |
| 639 | let dataDir: string; | |
| 640 | let app: App; | |
| 641 | let http: ReturnType<typeof forgeHttp>; | |
| 642 | ||
| 643 | beforeAll(async () => { | |
| 644 | dataDir = await mkdtemp(join(tmpdir(), "forge-explore-edge-")); | |
| 645 | app = createApp({ dataDir, port: 0, url: "https://forge.example", edgeToken: TOKEN }); | |
| 646 | http = forgeHttp(app); | |
| 647 | }, 30_000); | |
| 648 | ||
| 649 | afterAll(async () => { | |
| 650 | http.close(); | |
| 651 | app.close(); | |
| 652 | await rm(dataDir, { recursive: true, force: true }); | |
| 653 | }); | |
| 654 | ||
| 655 | const get = async (headers: Record<string, string>) => | |
| 656 | (await http.handle(new Request("http://forge.test/explore", { headers }))).text(); | |
| 657 | ||
| 658 | test("a stranger the edge has not vouched for gets one Sign in to the edge, no Create an account", async () => { | |
| 659 | const out = await get({}); | |
| 660 | expect(out).toContain("Nothing public yet"); | |
| 661 | expect(out).toContain(`href="${edgeLoginUrl("/apps/new")}"`); | |
| 662 | expect(out).not.toContain("/signup"); | |
| 663 | expect(out).not.toContain("Create an account"); | |
| 664 | expect(out).not.toContain('href="/signin?next=/apps/new"'); | |
| 665 | }); | |
| 666 | ||
| 667 | test("a stranger the edge vouches for is offered Create an account", async () => { | |
| 668 | const out = await get({ "x-plat-edge-token": TOKEN, "x-plat-user": "newcomer" }); | |
| 669 | expect(out).toContain("Create an account"); | |
| 670 | expect(out).toContain('href="/signup?next=/apps/new"'); | |
| 671 | }); | |
| 672 | }); |
new above
offline
.form-actions carries margin-top:16px and exists for forms. Check the spacing on the phone look. If it is off, reuse the empty() action spacing rather than this wrapper.