Public: anyone can read this; the crew can change it.
The account screen of a forge you just started speaks to you: no "founder", no "claim" in the address, and Enter sends the first ask #11514
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11510-the-account-screen-of-a-forge-you-just-s → main 5 commits fork 3 threads · 3 unresolved
19 files
changed +228
−27
docs/DESIGN.md +1 −1
| @@ -16,7 +16,7 @@ | ||
| 16 | 16 | |
| 17 | 17 | Budgets: at most three screens before the forge; the forge ready in under 20 s; the first crew activity in under 60 s; the change live within five minutes. |
| 18 | 18 | |
| 19 | Words a stranger is never shown on this path: subscription, autonomy, cell, child, claim. The list lives once, as `STRANGER_NEVER_SEES` in `src/design/first_five_minutes.ts` (with the budgets), for a test to import. | |
| 19 | Words a stranger is never shown on this path: subscription, autonomy, cell, child, claim, founder, autonomous, mode. The list lives once, as `STRANGER_NEVER_SEES` in `src/design/first_five_minutes.ts` (with the budgets), for a test to import. | |
| 20 | 20 | |
| 21 | 21 | **The five rules of attention.** Every pass over a page follows them; the owner's time is the cost. |
| 22 | 22 | Each has a real page as its pair. Enforced by rule 5: `test/attention.test.ts`. |
docs/design/audit.md +1
| @@ -96,6 +96,7 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 96 | 96 | - `/forges/new` — `pages/new-forge.ts` · send a new forge · none · colour 1. |
| 97 | 97 | - `/family` — `pages/family.ts` · parent, this forge and children as a tree · `/forges` (the same children) · colour 1. |
| 98 | 98 | - `/claim` — `pages/claim.ts` · claim a forge that was sent to me · none · colour 1. |
| 99 | - `/welcome` — `pages/claim.ts` (`welcomePage`) · the account screen of a forge you just started: make your account · none · none seen. | |
| 99 | 100 | - `/privacy` — `pages/privacy.ts` · what the forge keeps about a visitor, three plain lines, open to anyone · none · none seen. |
| 100 | 101 | - `/first-admin` — `pages/first-admin.ts` · the first person becomes admin · none · none seen. |
| 101 | 102 | - `/fleet/graph` — `pages/fleet-graph.ts` · the fleet as a graph · `/family`, `/forges` · none seen. |
docs/features/home-plans.md +1 −1
| @@ -12,7 +12,7 @@ Where a visitor sees what a hosted forge is and how to get one (#11139). | ||
| 12 | 12 | |
| 13 | 13 | The plan's name, its price ("Free", or "$19 a month" from the plan's `priceUsd`), the machine it runs on (CPU, memory, disk) and its own sentence. A paid plan with a crew credit adds the credit after that sentence: "$5 of crew credit each month", from the plan's `creditUsd`. The words are `src/billing/plan_words.ts`, shared by both doors. |
| 14 | 14 | |
| 15 | A paid plan whose size has a [](billing-links.md) also carries a "Get <plan>" link to it (on the web an anchor beside the price, in the terminal `Get <plan>: <link>`); the free plan never does, its way in is the one **Get a forge** button. When any plan carries a link, one line follows the plans on both doors: "Your forge is made after payment; an email with the link to open it arrives." (`AFTER_PAYMENT` in the same file). | |
| 15 | A paid plan whose size has a [](billing-links.md) also carries a "Get <plan>" link to it (on the web an anchor beside the price, in the terminal `Get <plan>: <link>`); the free plan never does, its way in is the one **Get a forge** button. When any plan carries a link, one line follows the plans on both doors: "Your forge is made after payment. The link to open it is emailed to you, usually within a few hours." (`AFTER_PAYMENT` in the same file). | |
| 16 | 16 | |
| 17 | 17 | Whether this forge takes new accounts is not said in this section: it is about getting a forge, not an account here. |
| 18 | 18 |
docs/features/welcome.md added +21
| @@ -0,0 +1,21 @@ | ||
| 1 | # The account screen of a forge you just started | |
| 2 | ||
| 3 | Where a person who started a forge from the home arrives, and how their first ask is sent (#11510). | |
| 4 | ||
| 5 | ## `/welcome` | |
| 6 | ||
| 7 | The waiting page (`/forges/<name>/waiting`) sends the person to `/welcome` on their new forge, with the one-time code in the address fragment, which a server never sees. The screen says "Make your account": a username and a password, then **Done**. The person is the admin of that forge and signed in. The words say nothing of a sender, a founder or a claim; the screen is `welcomePage` and `welcomeSubmit` in `src/web/pages/claim.ts`, sharing the code check and the one-link-once rules with `/claim`, which stays for a link someone else sent. | |
| 8 | ||
| 9 | The "Link code" field is filled and hidden by a few lines of script when the address carries the code; without JavaScript it stays visible, with a hint, so the screen still works. | |
| 10 | ||
| 11 | Password only is deliberate: the person is minutes into a forge and has been asked for nothing else; a passkey is offered afterwards, on the first-login next steps (the everyday step). `/claim` keeps its "Use a passkey instead" button because there the new owner may be taking over from the sender. | |
| 12 | ||
| 13 | An unknown, used or already-owned link says so in the same plain words as `/claim` and points back to the home. | |
| 14 | ||
| 15 | ## The first ask | |
| 16 | ||
| 17 | On a new forge's first screen the box under "What do you want built?" sends on Enter; Shift+Enter makes a newline, and so does Enter while composing text or with a modifier held (`firstAskJs`, `src/web/pages/next-steps.ts`). Without JavaScript Enter makes a newline and the **Tell the crew** button sends. No hint is printed about the key, since it would be false without the script. | |
| 18 | ||
| 19 | ## The terminal | |
| 20 | ||
| 21 | The terminal does not reach `/welcome`: it is the screen where the first account is made, and the terminal needs an account to sign in. Once there is one, the first ask is the terminal's own first screen (`crewFirstText`). |
src/core/reserved.ts +1
| @@ -133,6 +133,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 133 | 133 | "feed", |
| 134 | 134 | "review", |
| 135 | 135 | "claim", |
| 136 | "welcome", | |
| 136 | 137 | "first-admin", |
| 137 | 138 | "forges", |
| 138 | 139 | // the forge's own nouns, and the platform's name |
src/design/first_five_minutes.ts +1 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | /** Words never shown to a stranger on the first-five-minutes path. Closed: a change here is a change to the doc. */ |
| 9 | export const STRANGER_NEVER_SEES = ["subscription", "autonomy", "cell", "child", "claim"] as const; | |
| 9 | export const STRANGER_NEVER_SEES = ["subscription", "autonomy", "cell", "child", "claim", "founder", "autonomous", "mode"] as const; | |
| 10 | 10 | |
| 11 | 11 | /** The budgets, in screens and seconds. */ |
| 12 | 12 | export const FIRST_FIVE_MINUTES = { |
src/http/server.ts +3 −1
| @@ -275,7 +275,7 @@ import { | ||
| 275 | 275 | } from "../web/studio/actions.ts"; |
| 276 | 276 | import { forgeReady } from "../web/pages/forge-ready.ts"; |
| 277 | 277 | import { forgeName } from "../web/pages/forge-name.ts"; |
| 278 | import { claimFragmentPage, claimOptions, claimSubmit } from "../web/pages/claim.ts"; | |
| 278 | import { claimFragmentPage, claimOptions, claimSubmit, welcomePage, welcomeSubmit } from "../web/pages/claim.ts"; | |
| 279 | 279 | import { firstAdminOptions, firstAdminPage, firstAdminSubmit } from "../web/pages/first-admin.ts"; |
| 280 | 280 | import { familyPage } from "../web/pages/family.ts"; |
| 281 | 281 | import { forgeLinkRevoke, forgeLinkSubmit, forgeLinkUndo, forgeRemoveConfirm, forgeRemoveSubmit, forgeStopSubmit, |
| @@ -847,6 +847,8 @@ export function buildRouter(): Router<Handler> { | ||
| 847 | 847 | router.add("GET", "/claim", claimFragmentPage); |
| 848 | 848 | router.add("POST", "/claim", claimSubmit); |
| 849 | 849 | router.add("POST", "/claim/options", claimOptions); |
| 850 | router.add("GET", "/welcome", welcomePage); | |
| 851 | router.add("POST", "/welcome", welcomeSubmit); | |
| 850 | 852 | // The operator's first-admin code, redeemed with a passkey (core/first_admin.ts). |
| 851 | 853 | router.add("GET", "/first-admin", firstAdminPage); |
| 852 | 854 | router.add("POST", "/first-admin", firstAdminSubmit); |
src/web/asset.ts +1 −1
| @@ -210,7 +210,7 @@ export const NO_PREFETCH_PATHS = [ | ||
| 210 | 210 | "/assets/*", "/api/*", "/_plat/*", "/_measure*", "/_script-error", "/mcp*", "/oauth/*", "/sw.js", |
| 211 | 211 | "/*/*/raw/*", "/*/*/media/*", "/*/*/archive/*", "/*/*/app/*/open", |
| 212 | 212 | "/logout*", "/signout*", "/sign-out*", "/signin*", "/sign-in*", "/login*", |
| 213 | "/claim*", "/first-admin*", "/verify*", "/go/*", "/-/*", "/apps/open*", | |
| 213 | "/claim*", "/welcome*", "/first-admin*", "/verify*", "/go/*", "/-/*", "/apps/open*", | |
| 214 | 214 | "/settings/*", "/home/*", "/*/-/*", |
| 215 | 215 | ]; |
| 216 | 216 |
src/web/funnel_door.ts +1 −1
| @@ -43,4 +43,4 @@ function p(o){n.sendBeacon("/_funnel",JSON.stringify(o))} | ||
| 43 | 43 | var l=d.querySelector(".door-plans"); |
| 44 | 44 | if(l&&window.IntersectionObserver){var io=new IntersectionObserver(function(e){e.some(function(x){return x.isIntersecting})&&(io.disconnect(),p({step:"plans"}))});io.observe(l)} |
| 45 | 45 | d.addEventListener("click",function(e){var t=e.target,k=t&&t.closest&&t.closest("[data-plan]");k&&p({step:"click",plan:k.getAttribute("data-plan")})},true) |
| 46 | })(navigator,document)`; | |
| 46 | })(navigator,document);`; |
src/web/pages/claim.ts +77 −13
| @@ -93,6 +93,7 @@ function dead( | ||
| 93 | 93 | state: Exclude<ClaimState["state"], "ok">, |
| 94 | 94 | sender?: string, |
| 95 | 95 | endedAt?: string, |
| 96 | own = false, | |
| 96 | 97 | ): Response { |
| 97 | 98 | const ask = sender === undefined ? "whoever sent it" : sender; |
| 98 | 99 | const says = { |
| @@ -122,6 +123,14 @@ function dead( | ||
| 122 | 123 | text: "Someone has already made this forge theirs. If it was you, sign in.", |
| 123 | 124 | }, |
| 124 | 125 | }[state]; |
| 126 | // The person who started this forge themselves has no sender to ask. | |
| 127 | const mine: Partial<Record<typeof state, string>> = { | |
| 128 | unknown: "The link may have been cut short or replaced. Start the forge again from the home page.", | |
| 129 | used: "Each link works once. If it was you, sign in with the account you made. Otherwise start a forge of your own from the home page.", | |
| 130 | expired: "Your link stopped working. Start the forge again from the home page.", | |
| 131 | revoked: "This link has ended. Start the forge again from the home page.", | |
| 132 | }; | |
| 133 | if (own) says.text = mine[state] ?? says.text; | |
| 125 | 134 | return page( |
| 126 | 135 | { |
| 127 | 136 | ctx, |
| @@ -172,7 +181,9 @@ function claimForm( | ||
| 172 | 181 | values: Values, |
| 173 | 182 | errors: Errors, |
| 174 | 183 | expiresAt?: string, |
| 184 | own = false, | |
| 175 | 185 | ): Html { |
| 186 | if (own) return welcomeForm(values, errors); | |
| 176 | 187 | return html`<div class="narrow"> |
| 177 | 188 | ${pageHead("This forge is yours")} |
| 178 | 189 | <section class="settings-block"> |
| @@ -249,6 +260,50 @@ function claimForm( | ||
| 249 | 260 | </div>`; |
| 250 | 261 | } |
| 251 | 262 | |
| 263 | /** The screen for the person who started this forge themselves: a username and a password, nothing about anyone else. */ | |
| 264 | function welcomeForm(values: Values, errors: Errors): Html { | |
| 265 | return html`<div class="narrow"> | |
| 266 | ${pageHead("Make your account")} | |
| 267 | <section class="settings-block"> | |
| 268 | <p class="mb">Choose a username and a password. This account is yours alone, and you are the admin of this forge.</p> | |
| 269 | <form method="post" action="/welcome"> | |
| 270 | <div id="claim-token-field"> | |
| 271 | ${formField({ | |
| 272 | label: "Link code", | |
| 273 | name: "token", | |
| 274 | value: values.token ?? "", | |
| 275 | required: true, | |
| 276 | hint: "The part of your link after the #. Opening the link fills it in.", | |
| 277 | })} | |
| 278 | </div> | |
| 279 | ${formField({ | |
| 280 | label: "Username", | |
| 281 | name: "username", | |
| 282 | value: values.username ?? "", | |
| 283 | autocomplete: "username", | |
| 284 | required: true, | |
| 285 | autofocus: true, | |
| 286 | hint: "Letters, digits, dots and dashes.", | |
| 287 | pattern: "[A-Za-z0-9][A-Za-z0-9._-]*", | |
| 288 | ...(errors.username ? { error: errors.username } : {}), | |
| 289 | })} | |
| 290 | ${formField({ | |
| 291 | label: "Password", | |
| 292 | name: "password", | |
| 293 | type: "password", | |
| 294 | autocomplete: "new-password", | |
| 295 | minlength: MIN_SIGNUP_PASSWORD, | |
| 296 | hint: `At least ${MIN_SIGNUP_PASSWORD} characters.`, | |
| 297 | ...(errors.password ? { error: errors.password } : {}), | |
| 298 | })} | |
| 299 | <div class="form-actions"> | |
| 300 | <button type="submit" class="btn" data-variant="primary">Done</button> | |
| 301 | </div> | |
| 302 | </form> | |
| 303 | </section> | |
| 304 | </div>`; | |
| 305 | } | |
| 306 | ||
| 252 | 307 | /** |
| 253 | 308 | * The fragment link's page script: take the claim code from `` into the |
| 254 | 309 | * form and drop it from the address bar, so it is never in a request line, |
| @@ -270,10 +325,14 @@ const fragmentJs = /* js */ ` | ||
| 270 | 325 | |
| 271 | 326 | // GET /claim — the form for a fragment link: the code arrives in the |
| 272 | 327 | // browser only, so nothing is known or spent here until the form posts it. |
| 273 | export async function claimFragmentPage(ctx: Ctx): Promise<Response> { | |
| 328 | export const claimFragmentPage = (ctx: Ctx): Promise<Response> => fragmentPage(ctx, false); | |
| 329 | /** GET /welcome — the same, for the person who started the forge. */ | |
| 330 | export const welcomePage = (ctx: Ctx): Promise<Response> => fragmentPage(ctx, true); | |
| 331 | ||
| 332 | async function fragmentPage(ctx: Ctx, own: boolean): Promise<Response> { | |
| 274 | 333 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 275 | 334 | const spawned = await timePhaseAsync(ctx.request, "spawned", () => spawnedOrg(ctx.app)); |
| 276 | if (spawned === undefined) return timePhase(ctx.request, "render", () => dead(ctx, "unknown")); | |
| 335 | if (spawned === undefined) return timePhase(ctx.request, "render", () => dead(ctx, "unknown", undefined, undefined, own)); | |
| 277 | 336 | const { spawner } = timePhase(ctx.request, "owners", () => owners(ctx, spawned)); |
| 278 | 337 | // The forge has one link at a time, so its state says nothing about any |
| 279 | 338 | // particular code: a stale or made-up code only ever meets "unknown" on the POST. |
| @@ -282,15 +341,16 @@ export async function claimFragmentPage(ctx: Ctx): Promise<Response> { | ||
| 282 | 341 | return dead( |
| 283 | 342 | ctx, |
| 284 | 343 | link.state === "used" ? "used" : link.revokedAt !== null ? "revoked" : "expired", |
| 285 | spawner?.username, | |
| 344 | own ? undefined : spawner?.username, | |
| 286 | 345 | link.expiresAt, |
| 346 | own, | |
| 287 | 347 | ); |
| 288 | 348 | const rendering = performance.now(); |
| 289 | 349 | const response = page({ |
| 290 | 350 | ctx, |
| 291 | title: "This forge is yours", | |
| 292 | body: claimForm(ctx, spawned, spawner, {}, {}, link?.expiresAt), | |
| 293 | scriptJs: fragmentJs + passkeyJs, | |
| 351 | title: own ? "Make your account" : "This forge is yours", | |
| 352 | body: claimForm(ctx, spawned, spawner, {}, {}, link?.expiresAt, own), | |
| 353 | scriptJs: own ? fragmentJs : fragmentJs + passkeyJs, | |
| 294 | 354 | }); |
| 295 | 355 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 296 | 356 | return response; |
| @@ -343,11 +403,15 @@ export async function claimOptions(ctx: Ctx): Promise<Response> { | ||
| 343 | 403 | } |
| 344 | 404 | |
| 345 | 405 | // POST /claim — make the account, burn the link, sign the person in. |
| 346 | export async function claimSubmit(ctx: Ctx): Promise<Response> { | |
| 406 | export const claimSubmit = (ctx: Ctx): Promise<Response> => submit(ctx, false); | |
| 407 | /** POST /welcome — the same, for the person who started the forge. */ | |
| 408 | export const welcomeSubmit = (ctx: Ctx): Promise<Response> => submit(ctx, true); | |
| 409 | ||
| 410 | async function submit(ctx: Ctx, own: boolean): Promise<Response> { | |
| 347 | 411 | const form = await readForm(ctx); |
| 348 | 412 | if (form instanceof Response) return form; |
| 349 | 413 | const spawned = await spawnedOrg(ctx.app); |
| 350 | if (spawned === undefined) return dead(ctx, "unknown"); | |
| 414 | if (spawned === undefined) return dead(ctx, "unknown", undefined, undefined, own); | |
| 351 | 415 | const orgId = spawned.org.id; |
| 352 | 416 | // From the form's body — never from the path or a query string, which |
| 353 | 417 | // proxies and logs would keep. |
| @@ -355,7 +419,7 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> { | ||
| 355 | 419 | const before = readClaim(ctx.app.db, orgId, token); |
| 356 | 420 | const { spawner } = owners(ctx, spawned); |
| 357 | 421 | if (before.state !== "ok") |
| 358 | return dead(ctx, before.state, spawner?.username, linkOf(ctx.app.db)?.expiresAt); | |
| 422 | return dead(ctx, before.state, own ? undefined : spawner?.username, linkOf(ctx.app.db)?.expiresAt, own); | |
| 359 | 423 | |
| 360 | 424 | const values = { |
| 361 | 425 | username: field(form, "username"), |
| @@ -366,9 +430,9 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> { | ||
| 366 | 430 | page( |
| 367 | 431 | { |
| 368 | 432 | ctx, |
| 369 | title: "This forge is yours", | |
| 370 | body: claimForm(ctx, spawned, spawner, values, e, linkOf(ctx.app.db)?.expiresAt), | |
| 371 | scriptJs: passkeyJs, | |
| 433 | title: own ? "Make your account" : "This forge is yours", | |
| 434 | body: claimForm(ctx, spawned, spawner, values, e, linkOf(ctx.app.db)?.expiresAt, own), | |
| 435 | scriptJs: own ? fragmentJs : passkeyJs, | |
| 372 | 436 | }, |
| 373 | 437 | 422, |
| 374 | 438 | ); |
| @@ -389,7 +453,7 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> { | ||
| 389 | 453 | |
| 390 | 454 | // Spent first, in one statement: of two people on one link, one wins. |
| 391 | 455 | const spent = burnClaim(ctx.app.db, orgId, token); |
| 392 | if (spent.state !== "ok") return dead(ctx, spent.state); | |
| 456 | if (spent.state !== "ok") return dead(ctx, spent.state, undefined, undefined, own); | |
| 393 | 457 | const made = await createUser(ctx.app.db, { |
| 394 | 458 | username: values.username, |
| 395 | 459 | email: values.email.trim() === "" ? noEmail(values.username) : values.email.trim(), |
src/web/pages/forge-waiting.ts +2 −2
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | * forge they just made (#11202). The page is static and holds no claim code: |
| 4 | 4 | * the code rides the fragment of this address (`#<code>`), which a server never |
| 5 | 5 | * sees. A few lines of script read it, clear it from the address bar, poll |
| 6 | * `/forges/<name>/ready` (capped), then go to the child's `/claim#<code>`. | |
| 6 | * `/forges/<name>/ready` (capped), then go to the child's `/welcome#<code>`. | |
| 7 | 7 | */ |
| 8 | 8 | import { isChildName } from "../../core/proposal_token.ts"; |
| 9 | 9 | import type { Ctx } from "../../http/context.ts"; |
| @@ -39,7 +39,7 @@ export const WAITING_JS = /* js */ ` | ||
| 39 | 39 | export function forgeWaitingPage(ctx: Ctx): Response { |
| 40 | 40 | const name = ctx.params.name ?? ""; |
| 41 | 41 | if (!isChildName(name)) return notFoundPage(ctx); |
| 42 | const claim = `${childUrlOf(ctx.app, name).replace(/\/+$/, "")}/claim`; | |
| 42 | const claim = `${childUrlOf(ctx.app, name).replace(/\/+$/, "")}/welcome`; | |
| 43 | 43 | return page({ |
| 44 | 44 | ctx, |
| 45 | 45 | title: "Making your forge", |
src/web/pages/next-steps.ts +14
| @@ -61,6 +61,20 @@ function stepBlock(step: NextStep, primary: boolean, body: Html | null): Html { | ||
| 61 | 61 | ${!step.done && body ? html`<div class="step-body">${body}</div>` : null}`; |
| 62 | 62 | } |
| 63 | 63 | |
| 64 | /** Enter sends the first ask; Shift+Enter (or composing text) makes a newline. */ | |
| 65 | export const firstAskJs = /* js */ ` | |
| 66 | (function () { | |
| 67 | var box = document.querySelector("#composer textarea"); | |
| 68 | if (!box || !box.form) return; | |
| 69 | box.addEventListener("keydown", function (e) { | |
| 70 | if (e.key !== "Enter" || e.shiftKey || e.isComposing || e.metaKey || e.ctrlKey || e.altKey) return; | |
| 71 | e.preventDefault(); | |
| 72 | if (box.value.trim() === "") return; | |
| 73 | if (box.form.requestSubmit) box.form.requestSubmit(); else box.form.submit(); | |
| 74 | }); | |
| 75 | })(); | |
| 76 | `; | |
| 77 | ||
| 64 | 78 | /** Before the first tell: one box, "say what you want built", and one line about what pays for the crew. */ |
| 65 | 79 | function firstTell(ctx: Ctx, user: User, crew: Html[]): Html { |
| 66 | 80 | return html`<section id="next-steps" data-first-tell> |
src/web/pages/start-forge.ts +2 −2
| @@ -32,7 +32,7 @@ if(!/^[a-z0-9][a-z0-9-]*$/.test(n)){say("Use lowercase letters, digits and dashe | ||
| 32 | 32 | t=setTimeout(function(){fetch("/forges/name/"+encodeURIComponent(n),{credentials:"omit"}).then(function(r){ |
| 33 | 33 | return r.status===200?r.json():null}).then(function(j){ |
| 34 | 34 | if(q!==k||!j)return;say(j.verdict==="free"?n+" is free.":j.verdict==="taken"?n+" is taken. Try another.":typeof j.why==="string"?j.why:"")}).catch(function(){})},300)}) |
| 35 | })(document)`; | |
| 35 | })(document);`; | |
| 36 | 36 | |
| 37 | 37 | /** Waits for the forge, then goes to its claim page; after a minute and a half the link is still there. */ |
| 38 | 38 | export const startWaitJs = `(function(d){ |
| @@ -45,7 +45,7 @@ if(j&&j.ready===true){location.replace(u);return}throw 0}).catch(function(){ | ||
| 45 | 45 | if(++tries>60){s.textContent=n+" is taking longer than usual. Use the link to open it.";return} |
| 46 | 46 | setTimeout(poll,1500)})} |
| 47 | 47 | poll() |
| 48 | })(document)`; | |
| 48 | })(document);`; | |
| 49 | 49 | |
| 50 | 50 | /** A forge name the fuller form may be given: 1 to 39 lowercase letters, digits and inner dashes. */ |
| 51 | 51 | const SLUG = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/; |
src/web/studio/home.ts +2 −2
| @@ -90,7 +90,7 @@ import { sinceYouWere } from "./since.ts"; | ||
| 90 | 90 | import { homeTabs, unshippedAsks } from "./home_tabs.ts"; |
| 91 | 91 | import { crewKeyCard } from "./crew_key.ts"; |
| 92 | 92 | import { everydayCard } from "../pages/everyday.ts"; |
| 93 | import { nextStepsCard, nextStepsOf } from "../pages/next-steps.ts"; | |
| 93 | import { firstAskJs, nextStepsCard, nextStepsOf } from "../pages/next-steps.ts"; | |
| 94 | 94 | import { spawnedOrg } from "../../spawn/claim.ts"; |
| 95 | 95 | import { clientApps } from "./client_home.ts"; |
| 96 | 96 | import { passkeyJs } from "../passkey-client.ts"; |
| @@ -338,7 +338,7 @@ export async function studioHome( | ||
| 338 | 338 | title: "Home", |
| 339 | 339 | body: lean, |
| 340 | 340 | // The everyday step makes its account with a passkey (#1890). |
| 341 | ...(firstSteps && !firstTell ? { scriptJs: passkeyJs } : {}), | |
| 341 | ...(firstSteps ? { scriptJs: firstTell ? firstAskJs : passkeyJs } : {}), | |
| 342 | 342 | }); |
| 343 | 343 | } |
| 344 | 344 | // The client's first screen names its own phases (`clientApps`). |
test/forge-looks.test.ts +1
| @@ -48,6 +48,7 @@ const NOT_A_PAGE: Record<string, string> = { | ||
| 48 | 48 | "/audit.csv": "a CSV download of /audit", |
| 49 | 49 | "/maintenance": "no picture: the cap of 24 looks is spent; an admin's page, covered by test/maintenance.test.ts", |
| 50 | 50 | "/claim": "answers 404 to an admin: it exists only for a link-holder", |
| 51 | "/welcome": "the account screen of a forge with no admin yet; answers 404 to an admin", | |
| 51 | 52 | "/first-admin": "the setup form for a forge with no admin yet", |
| 52 | 53 | "/signin": "a sign-in form, not a page of the forge", |
| 53 | 54 | "/signup": "a sign-up form, not a page of the forge", |
test/home-start-forge.test.ts +1 −1
| @@ -188,7 +188,7 @@ function runScript(js: string, els: Record<string, Fake>, answer: (url: string) | ||
| 188 | 188 | }; |
| 189 | 189 | g["setTimeout"] = (fn: () => void) => queueMicrotask(fn); |
| 190 | 190 | const restore = () => Object.assign(g, saved); |
| 191 | new Function("document", `return ${js.replace(/\(document\)\s*$/, "")}`)(doc)(doc); | |
| 191 | new Function("document", `return ${js.replace(/\(document\);?\s*$/, "")}`)(doc)(doc); | |
| 192 | 192 | return { asked, gone, restore }; |
| 193 | 193 | } |
| 194 | 194 | const settle = async () => { |
test/stranger-three-screens.test.ts +1 −1
| @@ -105,7 +105,7 @@ test("the waiting page is static and never contains the code", async () => { | ||
| 105 | 105 | expect(page.status).toBe(200); |
| 106 | 106 | const body = await page.text(); |
| 107 | 107 | expect(body).not.toContain("/claim#"); |
| 108 | expect(body).toContain('data-claim="https://walker--forge.test/claim"'); | |
| 108 | expect(body).toContain('data-claim="https://walker--forge.test/welcome"'); | |
| 109 | 109 | expect((await fetch(`${parentServers.url}/forges/Bad_Name/waiting`)).status).toBe(404); |
| 110 | 110 | }); |
| 111 | 111 |
test/stranger-words.test.ts +1
| @@ -100,6 +100,7 @@ describe("a stranger's way to their own forge", () => { | ||
| 100 | 100 | ["the new-forge form (/forges/new)", await get(servers.url, "/forges/new")], |
| 101 | 101 | ["the start claim screen (/claim/start/:token)", await get(servers.url, `/claim/start/${token}`)], |
| 102 | 102 | ["the claim screen of a new forge (/claim)", await get(fresh.base, "/claim")], |
| 103 | ["the account screen of a new forge (/welcome)", await get(fresh.base, "/welcome")], | |
| 103 | 104 | ["the plan step, leaving (/forges/new)", await plan(false)], |
| 104 | 105 | ["the plan step, keeping access (/forges/new)", await plan(true)], |
| 105 | 106 | ["the first-login next steps (/)", await get(claimed.base, "/", claimed.owner)], |
test/welcome.test.ts added +96
| @@ -0,0 +1,96 @@ | ||
| 1 | /** | |
| 2 | * The person who started a forge arrives at `/welcome` and is asked for a | |
| 3 | * username and a password, nothing about a founder or a sent link (#11510); | |
| 4 | * the sent link keeps `/claim`. Enter sends the first ask; the home's scripts | |
| 5 | * join without a call on the one before. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { firstAskJs } from "../src/web/pages/next-steps.ts"; | |
| 9 | import { funnelJs } from "../src/web/funnel_door.ts"; | |
| 10 | import { startNameJs } from "../src/web/pages/start-forge.ts"; | |
| 11 | import { JS_FLAG_JS } from "../src/web/claim_code.ts"; | |
| 12 | import { WAITING_JS } from "../src/web/pages/forge-waiting.ts"; | |
| 13 | import { startedForge, type StartedForge } from "./helpers/claimed-forge.ts"; | |
| 14 | import { quietLogs } from "./helpers/globals.ts"; | |
| 15 | ||
| 16 | quietLogs(); | |
| 17 | ||
| 18 | let fresh: StartedForge; | |
| 19 | beforeAll(async () => { | |
| 20 | fresh = await startedForge("forge-welcome-"); | |
| 21 | }, 120_000); | |
| 22 | afterAll(async () => { | |
| 23 | await fresh.stop(); | |
| 24 | }); | |
| 25 | ||
| 26 | const text = (html: string) => (/<main\b[\s\S]*?<\/main>/.exec(html)?.[0] ?? html).replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, " ").replace(/<[^>]*>/g, " "); | |
| 27 | ||
| 28 | test("the self-start screen is Make your account: username, password, done", async () => { | |
| 29 | const html = await (await fetch(`${fresh.base}/welcome`)).text(); | |
| 30 | const words = text(html); | |
| 31 | expect(words).toContain("Make your account"); | |
| 32 | expect(html).toContain('action="/welcome"'); | |
| 33 | expect(html).toContain('name="username"'); | |
| 34 | expect(html).toContain('name="password"'); | |
| 35 | expect(html).not.toContain('name="email"'); | |
| 36 | expect(html).not.toContain("passkey"); | |
| 37 | expect(words).toContain("Done"); | |
| 38 | for (const w of [/founder/i, /claim/i, /ask .* to send/i, /email/i]) expect(words).not.toMatch(w); | |
| 39 | expect(html).not.toContain("/claim"); | |
| 40 | }); | |
| 41 | ||
| 42 | test("the sent link keeps /claim and its wording", async () => { | |
| 43 | const html = await (await fetch(`${fresh.base}/claim`)).text(); | |
| 44 | expect(html).toContain('action="/claim"'); | |
| 45 | expect(text(html)).toContain("This forge is yours"); | |
| 46 | }); | |
| 47 | ||
| 48 | test("a refused welcome says nothing of a sender, and a good one signs in", async () => { | |
| 49 | const post = (form: Record<string, string>) => | |
| 50 | fetch(`${fresh.base}/welcome`, { method: "POST", headers: { origin: fresh.base }, body: new URLSearchParams(form), redirect: "manual" }); | |
| 51 | const wrong = await post({ token: "nope", username: "maria", password: "a-long-enough-password" }); | |
| 52 | expect(wrong.status).toBe(404); | |
| 53 | expect(text(await wrong.text())).not.toMatch(/founder|whoever sent/i); | |
| 54 | const short = await post({ token: fresh.token, username: "maria", password: "x" }); | |
| 55 | expect(short.status).toBe(422); | |
| 56 | const ok = await post({ token: fresh.token, username: "maria", password: "a-long-enough-password" }); | |
| 57 | expect(ok.status).toBe(303); | |
| 58 | expect(ok.headers.get("location")).toBe("/"); | |
| 59 | expect(ok.headers.get("set-cookie")).toContain("HttpOnly"); | |
| 60 | const again = await post({ token: fresh.token, username: "maria2", password: "a-long-enough-password" }); | |
| 61 | expect(again.status).toBe(410); | |
| 62 | expect(text(await again.text())).not.toMatch(/founder|whoever sent/i); | |
| 63 | }); | |
| 64 | ||
| 65 | test("the waiting page goes to /welcome, not /claim", () => { | |
| 66 | expect(WAITING_JS).not.toContain("/claim"); | |
| 67 | }); | |
| 68 | ||
| 69 | test("Enter sends the first ask, Shift+Enter makes a newline", () => { | |
| 70 | const sent: string[] = []; | |
| 71 | let handler: ((e: Record<string, unknown>) => void) | undefined; | |
| 72 | const box = { | |
| 73 | value: "a bakery menu", | |
| 74 | form: { requestSubmit: () => sent.push("submit") }, | |
| 75 | addEventListener: (_: string, h: typeof handler) => (handler = h), | |
| 76 | }; | |
| 77 | new Function("document", firstAskJs)({ querySelector: () => box }); | |
| 78 | const press = (o: Record<string, unknown>) => { | |
| 79 | let prevented = false; | |
| 80 | handler!({ key: "Enter", preventDefault: () => (prevented = true), ...o }); | |
| 81 | return prevented; | |
| 82 | }; | |
| 83 | expect(press({})).toBe(true); | |
| 84 | expect(sent).toEqual(["submit"]); | |
| 85 | expect(press({ shiftKey: true })).toBe(false); | |
| 86 | expect(sent).toEqual(["submit"]); | |
| 87 | box.value = " "; | |
| 88 | press({}); | |
| 89 | expect(sent).toEqual(["submit"]); | |
| 90 | }); | |
| 91 | ||
| 92 | test("the home's scripts join without calling the one before", () => { | |
| 93 | const joined = `${funnelJs}\n${startNameJs}\n${JS_FLAG_JS}`; | |
| 94 | expect(() => new Function(joined)).not.toThrow(); | |
| 95 | for (const part of [funnelJs, startNameJs]) expect(part.trimEnd().endsWith(";")).toBe(true); | |
| 96 | }); |
new above
offline
Principle 1: the visible "Link code" field and its hint explain what the fragment script already does. The screen is meant to be a username and a password; hide the field (type=hidden) when the script fills it.