Public: anyone can read this; the crew can change it.
/settings/people says what a guest cannot do, instead of "nothing is refused because of one yet" #7223
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7200-settings-people-says-what-a-guest-cannot → main 2 commits fork 2 threads · 2 unresolved
6 files
changed +108
−10
docs/ROLES.md +6 −1
| @@ -35,12 +35,17 @@ model allowance (dollars a month, default none, through the grant of #1145), | ||
| 35 | 35 | or demotes a member to guest. Demoting leaves any allowance as it is; take it |
| 36 | 36 | back on the account's page. |
| 37 | 37 | |
| 38 | The page states what the roles mean in two sentences built from the table | |
| 39 | (`roleLimits` in `src/core/roles.ts`): what a guest cannot do, every row a | |
| 40 | member has and a guest lacks, and the admin refusal's own words, so it cannot | |
| 41 | say less than the guards refuse (`test/people-role-limits.test.ts`). | |
| 42 | ||
| 38 | 43 | <!-- roles-table --> |
| 39 | 44 | | Ability | guest | member | admin | |
| 40 | 45 | | --- | --- | --- | --- | |
| 41 | 46 | | Read what is public | yes | yes | yes | |
| 42 | 47 | | Comment and open issues | yes | yes | yes | |
| 43 | | Make an app their own | no | yes | yes | | |
| 48 | | Create a repository, fork one or copy an app | no | yes | yes | | |
| 44 | 49 | | Run a crew on a model allowance | no | yes | yes | |
| 45 | 50 | | Administer the forge | no | no | yes | |
| 46 | 51 | <!-- /roles-table --> |
src/core/roles.ts +22 −1
| @@ -15,7 +15,7 @@ export type Role = (typeof ROLES)[number]; | ||
| 15 | 15 | export const ABILITIES = { |
| 16 | 16 | read: "Read what is public", |
| 17 | 17 | comment: "Comment and open issues", |
| 18 | make_it_yours: "Make an app their own", | |
| 18 | make_it_yours: "Create a repository, fork one or copy an app", | |
| 19 | 19 | run_crew: "Run a crew on a model allowance", |
| 20 | 20 | administer: "Administer the forge", |
| 21 | 21 | } as const; |
| @@ -51,6 +51,27 @@ export const NOT_A_MEMBER = "the owner has not made this account a member"; | ||
| 51 | 51 | /** The words a member (or guest) is refused admin settings and releases with. */ |
| 52 | 52 | export const NOT_AN_ADMIN = "only an admin can change the forge's settings or its releases"; |
| 53 | 53 | |
| 54 | const lower = (s: string) => s.charAt(0).toLowerCase() + s.slice(1); | |
| 55 | const capital = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); | |
| 56 | ||
| 57 | /** | |
| 58 | * What a role means, in two sentences built from `CAN` and the refusal | |
| 59 | * words, so the page that explains roles says what the guards enforce: | |
| 60 | * what a guest is refused, and what only an admin may do. `guestLacks` and | |
| 61 | * `adminOnly` name the rows each sentence stands for. | |
| 62 | */ | |
| 63 | export const guestLacks = (): Ability[] => | |
| 64 | (Object.keys(ABILITIES) as Ability[]).filter((a) => !can("guest", a) && can("member", a)); | |
| 65 | export const adminOnly = (): Ability[] => | |
| 66 | (Object.keys(ABILITIES) as Ability[]).filter((a) => can("admin", a) && !can("member", a)); | |
| 67 | ||
| 68 | export function roleLimits(): string { | |
| 69 | const withheld = guestLacks().map((a) => lower(ABILITIES[a])); | |
| 70 | // A label may hold its own "or", so the last item is set off with ", or". | |
| 71 | const list = withheld.length < 2 ? withheld.join("") : `${withheld.slice(0, -1).join(", ")}, or ${withheld.at(-1)}`; | |
| 72 | return `A guest cannot ${list}. ${capital(NOT_AN_ADMIN)}.`; | |
| 73 | } | |
| 74 | ||
| 54 | 75 | /** |
| 55 | 76 | * Whether the table withholds `ability` from this account. Only a person |
| 56 | 77 | * can be a guest: agents and organizations are not accounts the owner |
src/web/pages/people.ts +4 −5
| @@ -1,13 +1,13 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * People — every account and its role in one table: guest, member or admin. |
| 3 | 3 | * An admin promotes a guest to member (with a starter model allowance, none |
| 4 | * by default) or demotes a member, in one same-origin form post. Roles are | |
| 5 | * shown and recorded; nothing is refused because of one yet (`docs/ROLES.md`). | |
| 4 | * by default) or demotes a member, in one same-origin form post. The guards | |
| 5 | * refuse what `CAN` withholds; the card says so in `roleLimits` (`docs/ROLES.md`). | |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { setRole } from "../../actions/roles.ts"; |
| 9 | 9 | import { GRANT_MAX_USD } from "../../core/model_grant.ts"; |
| 10 | import { listPeople, type Person } from "../../core/roles.ts"; | |
| 10 | import { listPeople, roleLimits, type Person } from "../../core/roles.ts"; | |
| 11 | 11 | import { getUserByName, type User } from "../../core/users.ts"; |
| 12 | 12 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 13 | 13 | import type { Ctx } from "../../http/context.ts"; |
| @@ -65,8 +65,7 @@ export function peoplePage(ctx: Ctx): Response { | ||
| 65 | 65 | { |
| 66 | 66 | head: html`<h2>Guests, members and admins</h2> |
| 67 | 67 | <p class="muted">A member may be given a starter model allowance (up to |
| 68 | $${GRANT_MAX_USD} a month). Roles are recorded here; nothing is refused | |
| 69 | because of one yet.</p>`, | |
| 68 | $${GRANT_MAX_USD} a month). ${roleLimits()}</p>`, | |
| 70 | 69 | }, |
| 71 | 70 | ), |
| 72 | 71 | "Account settings", |
test/people-role-limits.test.ts added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | /** `/settings/people` says what a guest cannot do and what only an admin can, in the words of `roles.ts` (#7200). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { ABILITIES, NOT_AN_ADMIN, adminOnly, guestLacks, roleLimits } from "../src/core/roles.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 10 | import { proveAll } from "./helpers/verified.ts"; | |
| 11 | import { quietLogs } from "./helpers/globals.ts"; | |
| 12 | ||
| 13 | quietLogs(); | |
| 14 | ||
| 15 | let root = ""; | |
| 16 | let app: App; | |
| 17 | let servers: Servers; | |
| 18 | ||
| 19 | beforeAll(async () => { | |
| 20 | root = mkdtempSync(join(tmpdir(), "forge-people-limits-")); | |
| 21 | app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge" }); | |
| 22 | const u = await createUser(app.db, { | |
| 23 | origin: "seed", | |
| 24 | trusted: true, | |
| 25 | username: "alice", | |
| 26 | email: "[email protected]", | |
| 27 | password: "a-good-passphrase", | |
| 28 | adminIfFirst: true, | |
| 29 | }); | |
| 30 | if (!u.ok) throw new Error(u.error); | |
| 31 | proveAll(app); | |
| 32 | servers = await serve(app); | |
| 33 | }); | |
| 34 | afterAll(() => { | |
| 35 | servers.stop(); | |
| 36 | app.close(); | |
| 37 | rmSync(root, { recursive: true, force: true }); | |
| 38 | }); | |
| 39 | ||
| 40 | test("the sentences come from the table and the refusal words", () => { | |
| 41 | expect(roleLimits()).toBe( | |
| 42 | "A guest cannot create a repository, fork one or copy an app, or run a crew on a model allowance. " + | |
| 43 | "Only an admin can change the forge's settings or its releases.", | |
| 44 | ); | |
Pins one literal string, so adding an ability passes silently or breaks for the wrong reason. Also assert every ability a guest lacks appears in the page text. |
||
| 45 | }); | |
| 46 | ||
| 47 | test("the guest sentence names every row a guest lacks; the admin sentence is the refusal", () => { | |
| 48 | const [guest, admin] = roleLimits().split(". "); | |
| 49 | expect(guestLacks().length).toBeGreaterThan(0); | |
| 50 | for (const a of guestLacks()) expect(guest!.toLowerCase()).toContain(ABILITIES[a].toLowerCase()); | |
| 51 | expect(adminOnly()).toEqual(["administer"]); | |
| 52 | expect(admin!.toLowerCase()).toBe(`${NOT_AN_ADMIN}.`); | |
| 53 | }); | |
| 54 | ||
| 55 | test("an admin reads what a guest cannot do, and not that nothing is refused", async () => { | |
| 56 | const signin = await fetch(`${servers.url}/signin`, { | |
| 57 | method: "POST", | |
| 58 | headers: { origin: servers.url }, | |
| 59 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 60 | redirect: "manual", | |
| 61 | }); | |
| 62 | const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 63 | const res = await fetch(`${servers.url}/settings/people`, { headers: { cookie } }); | |
| 64 | expect(res.status).toBe(200); | |
| 65 | const text = (await res.text()).replace(/'|'/g, "'").replace(/\s+/g, " "); | |
| 66 | expect(text).toContain( | |
| 67 | "A guest cannot create a repository, fork one or copy an app, or run a crew on a model allowance.", | |
| 68 | ); | |
| 69 | expect(text).toContain("Only an admin can change the forge's settings or its releases."); | |
| 70 | expect(text).toContain(roleLimits()); | |
| 71 | for (const a of guestLacks()) expect(text.toLowerCase()).toContain(ABILITIES[a].toLowerCase()); | |
| 72 | expect(text).not.toContain("nothing is refused"); | |
| 73 | }); |
test/roles.test.ts +1 −1
| @@ -95,7 +95,7 @@ describe("roles on /settings/people", () => { | ||
| 95 | 95 | expect(bob).toMatch(/Administer the forge: <strong>no<\/strong>/); |
| 96 | 96 | setMember(app.db, (getUserByName(app.db, "bob"))!.id, true, "x", new Date()); |
| 97 | 97 | const member = await page(await signIn("bob")); |
| 98 | expect(member).toMatch(/Make an app their own: <strong>yes<\/strong>/); | |
| 98 | expect(member).toMatch(/Create a repository, fork one or copy an app: <strong>yes<\/strong>/); | |
| 99 | 99 | expect(member).toMatch(/Administer the forge: <strong>no<\/strong>/); |
| 100 | 100 | setMember(app.db, getUserByName(app.db, "bob")!.id, false, "x", new Date()); |
| 101 | 101 | const alice = await page(await signIn("alice")); |
test/tui-can.test.ts +2 −2
| @@ -64,11 +64,11 @@ describe("what I can do in the terminal", () => { | ||
| 64 | 64 | test("a guest, a member and an admin each see their own row", async () => { |
| 65 | 65 | const guest = await shown("bob"); |
| 66 | 66 | expect(guest).toContain("You are guest here."); |
| 67 | expect(guest).toContain("Make an app their own: no"); | |
| 67 | expect(guest).toContain("Create a repository, fork one or copy an app: no"); | |
| 68 | 68 | setMember(forge.db, getUserByName(forge.db, "bob")!.id, true, "x", new Date()); |
| 69 | 69 | const member = await shown("bob"); |
| 70 | 70 | expect(member).toContain("You are member here."); |
| 71 | expect(member).toContain("Make an app their own: yes"); | |
| 71 | expect(member).toContain("Create a repository, fork one or copy an app: yes"); | |
| 72 | 72 | expect(member).toContain("Administer the forge: no"); |
| 73 | 73 | const admin = await shown("alice"); |
| 74 | 74 | expect(admin).toContain("You are admin here."); |
new above
offline
Only lists abilities in the table; creating/forking a repository is refused by the guard but absent here, so the page understates what a guest cannot do. Put it in the table.