Public: anyone can read this; the crew can change it.
An account's model grant and spend ceilings, set by an admin, speak the home page's language #9900
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9897-an-account-s-model-grant-and-spend-ceili → main 4 commits fork 1 thread · 1 unresolved
2 files
changed +119
−28
src/web/pages/model.ts +19 −28
| @@ -23,7 +23,7 @@ import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; | ||
| 23 | 23 | import { field, readForm } from "../form.ts"; |
| 24 | 24 | import { html, type Html } from "../html.ts"; |
| 25 | 25 | import { flashTo, page } from "../layout.ts"; |
| 26 | import { btn, card, row } from "../ui.ts"; | |
| 26 | import { btn, row } from "../ui.ts"; | |
| 27 | 27 | import { accountOnly, accountSections } from "./origins.ts"; |
| 28 | 28 | |
| 29 | 29 | // GET /settings/model |
| @@ -101,17 +101,18 @@ export async function modelAdd(ctx: Ctx): Promise<Response> { | ||
| 101 | 101 | ); |
| 102 | 102 | } |
| 103 | 103 | |
| 104 | /** A heading over its content, no box: `card` with a head, minus the box. */ | |
| 105 | function settingsBlock(body: Html, opts: { head: Html }): Html { | |
| 106 | return html`<section class="settings-block">${opts.head}${body}</section>`; | |
| 107 | } | |
| 108 | ||
| 109 | 104 | /** The admin's card for an account's spend ceilings, on a person's or an organization's page. */ |
| 110 | export function ceilingCard(ctx: Ctx, user: User, at: string, plain = false): Html { | |
| 105 | export function ceilingCard(ctx: Ctx, user: User, at: string): Html { | |
| 111 | 106 | const ceiling = getAccountCeiling(ctx.app.db, user.id); |
| 112 | 107 | const left = dailyLeft(ctx.app.db, user.id, new Date()); |
| 113 | return (plain ? settingsBlock : card)( | |
| 114 | html`<form class="stack" method="post" action="${at}/ceiling"> | |
| 108 | return html`<section class="settings-block"> | |
| 109 | <h2>Spend ceilings</h2> | |
| 110 | <p class="muted" id="ceiling-line"> | |
| 111 | ${ceiling === undefined || left === undefined | |
| 112 | ? "No ceiling set." | |
| 113 | : `$${ceiling.dailyUsd.toFixed(2)} a day, $${ceiling.perRunUsd.toFixed(2)} a run; $${left.toFixed(2)} left today.`} | |
| 114 | </p> | |
| 115 | <form class="stack" method="post" action="${at}/ceiling"> | |
| 115 | 116 | <div class="field"> |
| 116 | 117 | <label for="f-daily">Daily ceiling, dollars</label> |
| 117 | 118 | <input class="input" id="f-daily" name="daily" inputmode="decimal" |
| @@ -126,24 +127,18 @@ export function ceilingCard(ctx: Ctx, user: User, at: string, plain = false): Ht | ||
| 126 | 127 | ceiling.</span> |
| 127 | 128 | </div> |
| 128 | 129 | <div>${btn("Save", { variant: "primary", size: "sm" })}</div> |
| 129 | </form>`, | |
| 130 | { | |
| 131 | head: html`<h2>Spend ceilings</h2> | |
| 132 | <p class="muted" id="ceiling-line"> | |
| 133 | ${ceiling === undefined || left === undefined | |
| 134 | ? "No ceiling set." | |
| 135 | : `$${ceiling.dailyUsd.toFixed(2)} a day, $${ceiling.perRunUsd.toFixed(2)} a run; $${left.toFixed(2)} left today.`} | |
| 136 | </p>`, | |
| 137 | }, | |
| 138 | ); | |
| 130 | </form> | |
| 131 | </section>`; | |
| 139 | 132 | } |
| 140 | 133 | |
| 141 | 134 | /** The admin's card on a person's page: what they have, and the grant form. */ |
| 142 | 135 | export function grantCard(ctx: Ctx, user: User, at: string): Html { |
| 143 | 136 | const grant = getModelGrant(ctx.app.db, user.id); |
| 144 | const ceilings = ceilingCard(ctx, user, at, true); | |
| 145 | const grantForm = settingsBlock( | |
| 146 | html`<form class="stack" method="post" action="${at}/model"> | |
| 137 | const ceilings = ceilingCard(ctx, user, at); | |
| 138 | const grantForm = html`<section class="settings-block"> | |
| 139 | <h2>Model budget</h2> | |
| 140 | <p class="muted">${budgetLine(ctx.app.db, user, new Date())}</p> | |
| 141 | <form class="stack" method="post" action="${at}/model"> | |
| 147 | 142 | <div class="field"> |
| 148 | 143 | <label for="f-grant">Grant, dollars a month</label> |
| 149 | 144 | <input class="input" id="f-grant" name="usd" inputmode="decimal" |
| @@ -153,12 +148,8 @@ export function grantCard(ctx: Ctx, user: User, at: string): Html { | ||
| 153 | 148 | $${GRANT_MAX_USD} a month; a run past the cap stops.</span> |
| 154 | 149 | </div> |
| 155 | 150 | <div>${btn("Save", { variant: "primary", size: "sm" })}</div> |
| 156 | </form>`, | |
| 157 | { | |
| 158 | head: html`<h2>Model budget</h2> | |
| 159 | <p class="muted">${budgetLine(ctx.app.db, user, new Date())}</p>`, | |
| 160 | }, | |
| 161 | ); | |
| 151 | </form> | |
| 152 | </section>`; | |
| 162 | 153 | return html`${grantForm}${ceilings}`; |
| 163 | 154 | } |
| 164 | 155 |
test/model-language.test.ts added +100
| @@ -0,0 +1,100 @@ | ||
| 1 | /** An account's model grant and spend ceilings speak the home page's language (#9897): sections under h2, no card. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp } from "../src/app.ts"; | |
| 7 | import { createOrg } from "../src/core/orgs.ts"; | |
| 8 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 9 | import { serve } from "../src/http/server.ts"; | |
| 10 | ||
| 11 | let dir = ""; | |
| 12 | let app: ReturnType<typeof createApp>; | |
| 13 | let servers: Awaited<ReturnType<typeof serve>>; | |
| 14 | const cookies: Record<string, string> = {}; | |
| 15 | ||
| 16 | beforeAll(async () => { | |
| 17 | dir = await mkdtemp(join(tmpdir(), "forge-model-lang-")); | |
| 18 | app = createApp({ dataDir: dir, port: 0 }); | |
| 19 | servers = await serve(app); | |
| 20 | for (const [username, first] of [["alice", true], ["bob", false]] as const) { | |
| 21 | await createUser(app.db, { | |
| 22 | origin: "seed", trusted: true, username, email: `${username}@forge.local`, | |
| 23 | password: "a-good-passphrase", adminIfFirst: first, | |
| 24 | }); | |
| 25 | const res = await fetch(servers.url + "/signin", { | |
| 26 | method: "POST", | |
| 27 | redirect: "manual", | |
| 28 | headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url }, | |
| 29 | body: new URLSearchParams({ username, password: "a-good-passphrase" }), | |
| 30 | }); | |
| 31 | cookies[username] = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 32 | } | |
| 33 | }); | |
| 34 | afterAll(async () => { | |
| 35 | servers.stop(); | |
| 36 | app.close(); | |
| 37 | await rm(dir, { recursive: true, force: true }); | |
| 38 | }); | |
| 39 | ||
| 40 | const main = (page: string): string => page.slice(page.indexOf("<main"), page.indexOf("</main>")); | |
| 41 | ||
| 42 | test("the grant and the ceilings are plain sections under h2, posting where they did", async () => { | |
| 43 | const res = await fetch(servers.url + "/settings/accounts/bob", { headers: { cookie: cookies.alice! } }); | |
| 44 | expect(res.status).toBe(200); | |
| 45 | const page = main(await res.text()); | |
| 46 | const at = page.indexOf("<h2>Model budget</h2>"); | |
| 47 | expect(at).toBeGreaterThan(-1); | |
| 48 | const mine = page.slice(at); | |
| 49 | expect(mine).toContain("<h2>Spend ceilings</h2>"); | |
| 50 | expect(mine).toContain('action="/settings/accounts/bob/model"'); | |
| 51 | expect(mine).toContain('action="/settings/accounts/bob/ceiling"'); | |
| 52 | expect(mine).toContain('name="usd"'); | |
| 53 | expect(mine).toContain('name="daily"'); | |
| 54 | // Each heading opens a plain section, not a card. | |
| 55 | for (const h of ["<h2>Model budget</h2>", "<h2>Spend ceilings</h2>"]) | |
| 56 | expect(page.slice(page.indexOf(h) - 60, page.indexOf(h)).replace(/\s+/g, " ")).toContain('<section class="settings-block">'); | |
| 57 | // The grant and ceiling sections hold no card around their forms. | |
| 58 | const grant = page.slice(at, page.indexOf("</section>", at)); | |
| 59 | expect(grant).not.toContain("card"); | |
| 60 | const ceilingAt = page.indexOf("<h2>Spend ceilings</h2>"); | |
| 61 | expect(page.slice(ceilingAt, page.indexOf("</section>", ceilingAt))).not.toContain("card"); | |
| 62 | }); | |
| 63 | ||
| 64 | test("an organization's page is its spend ceilings, a plain section under h2", async () => { | |
| 65 | const founder = getUserByName(app.db, "alice")!; | |
| 66 | expect(createOrg(app.db, "acme", founder.id).ok).toBe(true); | |
| 67 | const res = await fetch(servers.url + "/settings/accounts/acme", { headers: { cookie: cookies.alice! } }); | |
| 68 | expect(res.status).toBe(200); | |
| 69 | const page = main(await res.text()); | |
| 70 | const at = page.indexOf("<h2>Spend ceilings</h2>"); | |
| 71 | expect(at).toBeGreaterThan(-1); | |
| 72 | expect(page.slice(Math.max(0, at - 60), at).replace(/\s+/g, " ")).toContain('<section class="settings-block">'); | |
| 73 | expect(page).toContain('action="/settings/accounts/acme/ceiling"'); | |
| 74 | expect(page).not.toContain("card"); | |
| 75 | }); | |
| 76 | ||
| 77 | test("a ceiling still posts for an admin only, behind the same-origin check", async () => { | |
| 78 | const body = new URLSearchParams({ daily: "5", perRun: "1" }); | |
| 79 | const headers = { "content-type": "application/x-www-form-urlencoded" }; | |
| 80 | const url = servers.url + "/settings/accounts/bob/ceiling"; | |
| 81 | const asBob = await fetch(url, { | |
| 82 | method: "POST", redirect: "manual", body, headers: { ...headers, cookie: cookies.bob!, origin: servers.url }, | |
| 83 | }); | |
| 84 | expect(asBob.status).toBe(404); | |
| 85 | const foreign = await fetch(url, { | |
| 86 | method: "POST", redirect: "manual", body, headers: { ...headers, cookie: cookies.alice!, origin: "https://evil.test" }, | |
| 87 | }); | |
| 88 | expect(foreign.status).toBeGreaterThanOrEqual(400); | |
| 89 | const ok = await fetch(url, { | |
| 90 | method: "POST", redirect: "manual", body, headers: { ...headers, cookie: cookies.alice!, origin: servers.url }, | |
| 91 | }); | |
| 92 | expect(ok.status).toBe(303); | |
| 93 | }); | |
| 94 | ||
| 95 | test("the person's own model page still takes a token and shows none back", async () => { | |
| 96 | const res = await fetch(servers.url + "/settings/model", { headers: { cookie: cookies.bob! } }); | |
| 97 | const page = main(await res.text()); | |
| 98 | expect(page).toContain("<h2>Your model budget</h2>"); | |
| 99 | expect(page).toContain('type="password"'); | |
| 100 | }); |
This assertion passes on main too, because
grantCardalready passedplain=true. Add the organization page (ceilingCardwithoutplain), which is the only output this change alters.