Public: anyone can read this; the crew can change it.
small: a repository's Settings and App settings open each section on its control, with the explanations behind one `?` #11276
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11210-small-a-repository-s-settings-and-app-se → main 4 commits fork
3 files
changed +120
−54
src/web/pages/settings.ts +35 −53
| @@ -97,6 +97,7 @@ import { NOT_CHANGED } from "../edit_words.ts"; | ||
| 97 | 97 | import { STOP_APP } from "../danger_words.ts"; |
| 98 | 98 | import { icon } from "../icons.ts"; |
| 99 | 99 | import { flashTo, page, redirect } from "../layout.ts"; |
| 100 | import { helpAsked, pageHelp } from "../page_help.ts"; | |
| 100 | 101 | import type { Flash } from "../flash.ts"; |
| 101 | 102 | import { |
| 102 | 103 | badge, |
| @@ -330,12 +331,6 @@ export async function settingsPage( | ||
| 330 | 331 | const wave = html`<section class="settings-block"> |
| 331 | 332 | <h2>Cut a wave</h2> |
| 332 | 333 | <form method="post" action="${base}/settings/wave"> |
| 333 | <p class="muted"> | |
| 334 | Opens a pull that adds the next <code>waves/<version>.md</code>, | |
| 335 | listing every pull merged since the last wave. The headline and | |
| 336 | highlights are left as TODO for you to write. | |
| 337 | <a href="${base}/waves">See the waves so far</a>. | |
| 338 | </p> | |
| 339 | 334 | <div class="form-actions">${btn("Cut a wave", { variant: "primary" })}</div> |
| 340 | 335 | </form> |
| 341 | 336 | </section>`; |
| @@ -345,11 +340,7 @@ export async function settingsPage( | ||
| 345 | 340 | <h2>Archive</h2> |
| 346 | 341 | <form method="post" action="${base}/settings/archive"> |
| 347 | 342 | <input type="hidden" name="archived" value="${repo.archivedAt === null ? "1" : "0"}" /> |
| 348 | <p class="muted"> | |
| 349 | ${repo.archivedAt === null | |
| 350 | ? "Put a finished repository away without deleting it. Its page says when it was archived; links, clone and push keep working." | |
| 351 | : html`Archived on ${repo.archivedAt.slice(0, 10)}. Restore it to take the label off.`} | |
| 352 | </p> | |
| 343 | ${repo.archivedAt === null ? null : html`<p class="muted small">Archived on ${repo.archivedAt.slice(0, 10)}.</p>`} | |
| 353 | 344 | <div class="form-actions"> |
| 354 | 345 | ${btn(repo.archivedAt === null ? "Archive" : "Restore", { |
| 355 | 346 | variant: "primary", |
| @@ -359,12 +350,9 @@ export async function settingsPage( | ||
| 359 | 350 | </section>`; |
| 360 | 351 | const move = html`<section class="settings-block" id="move"> |
| 361 | 352 | <h2>Move to another owner</h2> |
| 353 | <p class="muted small">Hands the repository to someone else.</p> | |
| 362 | 354 | <form method="get" action="/-/confirm"> |
| 363 | 355 | <input type="hidden" name="to" value="${base}/settings/transfer" /> |
| 364 | <p class="muted"> | |
| 365 | Give the repository to an account or organisation you can create | |
| 366 | repositories under. | |
| 367 | </p> | |
| 368 | 356 | ${formField({ |
| 369 | 357 | label: "New owner", |
| 370 | 358 | name: "owner", |
| @@ -388,9 +376,7 @@ export async function settingsPage( | ||
| 388 | 376 | : html`<section class="settings-block"> |
| 389 | 377 | <h2>Mirror</h2> |
| 390 | 378 | <p class="muted" data-mirror-line>${mirrorLine(held)}</p> |
| 391 | <p class="muted small"> | |
| 392 | Stopping leaves ${held.fullName} as it is; nothing more is pushed. | |
| 393 | </p> | |
| 379 | <p class="muted small">Stopping leaves ${held.fullName} as it is; nothing more is pushed.</p> | |
| 394 | 380 | <div class="form-actions"> |
| 395 | 381 | ${confirmAction({ |
| 396 | 382 | action: `${base}/settings/mirror/stop`, |
| @@ -398,12 +384,22 @@ export async function settingsPage( | ||
| 398 | 384 | })} |
| 399 | 385 | </div> |
| 400 | 386 | </section>`; |
| 387 | const trailHelp = getProvenance(ctx.app.db, repo.id); | |
| 388 | const help = pageHelp( | |
| 389 | [ | |
| 390 | ["Cut a wave", html`Opens a pull that adds the next <code>waves/<version>.md</code>, listing every pull merged since the last wave. The headline and highlights are left as TODO for you to write. <a href="${base}/waves">See the waves so far</a>.`], | |
| 391 | ["Archive", "Puts a finished repository away without deleting it. Its page says when it was archived; links, clone and push keep working. Restore takes the label off."], | |
| 392 | ["Move to another owner", "Gives the repository to an account or organisation you can create repositories under. You are asked to confirm before it moves."], | |
| 393 | ...(trailHelp === undefined ? [] : ([["Where this came from", "Where this repository was migrated from. Shown, it appears under the repository's name and in the API. Hidden, nobody else sees it, but the record is kept, so you can show it again."]] as const)), | |
| 394 | ], | |
| 395 | helpAsked(ctx.request.url), | |
| 396 | ); | |
| 401 | 397 | const response = settingsPageResponse( |
| 402 | 398 | ctx, |
| 403 | 399 | repo, |
| 404 | 400 | "general", |
| 405 | 401 | "Settings", |
| 406 | html`${lead}${mirror ? html`<div class="mt">${mirror}</div>` : null}<div class="mt">${body}</div>${trail | |
| 402 | html`${help}${lead}${mirror ? html`<div class="mt">${mirror}</div>` : null}<div class="mt">${body}</div>${trail | |
| 407 | 403 | ? html`<div class="mt">${trail}</div>` |
| 408 | 404 | : null}${upstream ? html`<div class="mt">${upstream}</div>` : null}${sources ? html`<div class="mt">${sources}</div>` : null}<div class="mt">${wave}</div><div class="mt">${archive}</div><div class="mt">${move}</div>${proposals |
| 409 | 405 | ? html`<div class="mt">${proposals}</div>` |
| @@ -563,7 +559,13 @@ function provenanceCard(ctx: Ctx, repo: Repo): Html | null { | ||
| 563 | 559 | <h2>Show where this came from</h2> |
| 564 | 560 | <form method="post" action="${base}/settings/provenance"> |
| 565 | 561 | <input type="hidden" name="shown" value="${trail.shown ? "0" : "1"}" /> |
| 566 | <p class="muted"> | |
| 562 | <p class="muted small">${trail.shown ? "Shown under the repository's name." : "Hidden; the record is kept."}</p> | |
| 563 | <div class="form-actions"> | |
| 564 | ${btn(trail.shown ? "Hide where this came from" : "Show where this came from", { | |
| 565 | variant: "outline", | |
| 566 | })} | |
| 567 | </div> | |
| 568 | <p class="muted small mt"> | |
| 567 | 569 | Migrated from |
| 568 | 570 | <a href="${trail.sourceUrl}" rel="noopener noreferrer" |
| 569 | 571 | >${sourceLabel(trail.sourceUrl)}</a |
| @@ -575,15 +577,7 @@ function provenanceCard(ctx: Ctx, repo: Repo): Html | null { | ||
| 575 | 577 | >`, |
| 576 | 578 | )} |
| 577 | 579 | on ${trail.migratedAt.slice(0, 10)}. |
| 578 | ${trail.shown | |
| 579 | ? "Shown under the repository's name on every page and in the API. Hiding it keeps the record, so it can come back." | |
| 580 | : "Hidden: nobody else sees it, on a page or in the API. The record is kept."} | |
| 581 | 580 | </p> |
| 582 | <div class="form-actions"> | |
| 583 | ${btn(trail.shown ? "Hide where this came from" : "Show where this came from", { | |
| 584 | variant: "outline", | |
| 585 | })} | |
| 586 | </div> | |
| 587 | 581 | </form> |
| 588 | 582 | </section>`; |
| 589 | 583 | } |
| @@ -766,7 +760,16 @@ function appSettingsView( | ||
| 766 | 760 | const vitals = timePhase(ctx.request, "vitals", () => |
| 767 | 761 | appVitals(ctx.app.db, record.id), |
| 768 | 762 | ); |
| 769 | return html`${form} | |
| 763 | const help = pageHelp( | |
| 764 | [ | |
| 765 | ["Visitors and speed", "Counted by the beacon, with no address kept; the 7 day figure adds the days up."], | |
| 766 | ["Environment", "What the app reads at start-up; values are write-only, and one set for production only or previews only wins there over one set everywhere."], | |
| 767 | ["Who may enter", "Named people may open the app and its previews, and nothing else: not the code, the crew, the settings or the terminal. Removing a name shuts them out at once. What the app itself shows them is the app's to decide: it runs with your read-only token, so anything it shows visitors from the forge, they see too."], | |
| 768 | ["Domains", html`Serve the app at a name of your own — <span class="mono">app.example.com</span>, or an apex. Add it, publish the two records it shows, and press Check now. The forge serves the app there as soon as the record is verified; the app itself learns its new address at its next deploy.`], | |
| 769 | ], | |
| 770 | helpAsked(ctx.request.url), | |
| 771 | ); | |
| 772 | return html`${help}${form} | |
| 770 | 773 | ${when(listed?.ok === true, () => html`<div class="mt">${appMembersView(base, values.access, named)}</div>`)} |
| 771 | 774 | <div class="mt">${appVisitorsView(vitals)}</div> |
| 772 | 775 | <div class="mt">${statement}</div> |
| @@ -811,10 +814,7 @@ export function appVisitorsView(v: AppVitals): Html { | ||
| 811 | 814 | ); |
| 812 | 815 | return html`<section class="settings-block" id="visitors"> |
| 813 | 816 | <h2>Visitors and speed</h2> |
| 814 | <p class="muted small mb"> | |
| 815 | Counted by the beacon, with no address kept; the 7 day figure adds | |
| 816 | the days up. <a href="app/traffic">Where visitors come from</a> | |
| 817 | </p> | |
| 817 | <p class="small mb"><a href="app/traffic">Where visitors come from</a></p> | |
| 818 | 818 | <dl class="stats"> |
| 819 | 819 | <div><dt>Visitors today</dt><dd>${v.today}</dd></div> |
| 820 | 820 | <div><dt>Visitors, 7 days</dt><dd>${v.week}</dd></div> |
| @@ -856,10 +856,6 @@ export function appEnvView(base: string, names: AppEnvName[]): Html { | ||
| 856 | 856 | ); |
| 857 | 857 | return html`<section class="settings-block" id="environment"> |
| 858 | 858 | <h2>Environment</h2> |
| 859 | <p class="muted small"> | |
| 860 | What the app reads at start-up; values are write-only, and one set | |
| 861 | for production only or previews only wins there over one set everywhere. | |
| 862 | </p> | |
| 863 | 859 | ${when( |
| 864 | 860 | rows.length > 0, |
| 865 | 861 | () => |
| @@ -933,11 +929,7 @@ function appMembersView( | ||
| 933 | 929 | > |
| 934 | 930 | ${named.join("\n")}</textarea |
| 935 | 931 | > |
| 936 | <p class="hint"> | |
| 937 | ${access === "private" | |
| 938 | ? "They may open the app and its previews, and nothing else: not the code, the crew, the settings or the terminal. Removing a name shuts them out at once. What the app itself shows them is the app's to decide: it runs with your read-only token, so anything it shows visitors from the forge, they see too." | |
| 939 | : "The app is public, so anyone may open it now; these names matter once it is private."} | |
| 940 | </p> | |
| 932 | ${access === "private" ? null : html`<p class="hint">The app is public, so anyone may open it now; these names matter once it is private.</p>`} | |
| 941 | 933 | </div> |
| 942 | 934 | <div class="form-actions">${btn("Save people", { variant: "primary" })}</div> |
| 943 | 935 | </form> |
| @@ -984,11 +976,6 @@ function appDomainsView( | ||
| 984 | 976 | ); |
| 985 | 977 | return html`<section class="settings-block" id="domains"> |
| 986 | 978 | <h2>${icon("globe")} Domains</h2> |
| 987 | <p class="muted small mb"> | |
| 988 | Serve the app at a name of your own — | |
| 989 | <span class="mono">app.example.com</span>, or an apex. Add it, publish | |
| 990 | the two records it shows, and press Check now. | |
| 991 | </p> | |
| 992 | 979 | ${when( |
| 993 | 980 | rows.length > 0, |
| 994 | 981 | () => |
| @@ -1001,12 +988,7 @@ function appDomainsView( | ||
| 1001 | 988 | Nothing is serving apps on this forge, so a domain would have |
| 1002 | 989 | nowhere to point. |
| 1003 | 990 | </p>` |
| 1004 | : html`<p class="muted small mb"> | |
| 1005 | The forge serves the app there as soon as the record is | |
| 1006 | verified. The app itself learns its new address at its next | |
| 1007 | deploy. | |
| 1008 | </p> | |
| 1009 | <form | |
| 991 | : html`<form | |
| 1010 | 992 | class="edit-row" |
| 1011 | 993 | method="post" |
| 1012 | 994 | action="${base}/settings/app/domains" |
test/migrate.test.ts +2 −1
| @@ -663,7 +663,8 @@ describe("migrate", () => { | ||
| 663 | 663 | shown: false, |
| 664 | 664 | }); |
| 665 | 665 | const settings = await (await browser(aliceCookie, "/alice/hush/settings")).text(); |
| 666 | expect(settings).toContain("Hidden: nobody else sees it"); | |
| 666 | expect(settings).toContain("Hidden; the record is kept."); | |
| 667 | expect(settings.indexOf("nobody else sees it")).toBeGreaterThan(settings.indexOf("data-page-help")); | |
| 667 | 668 | expect(settings).not.toContain("Hide where this came from"); |
| 668 | 669 | expect(settings).toMatch(/<button[^>]*>\s*Show where this came from/); |
| 669 | 670 |
test/settings-help.test.ts added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | /** A repository's Settings and App settings open each section on its control; the explanations sit behind one `?` (#11210). */ | |
| 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, type App } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 9 | import { setMirrorTarget, deleteMirrorTarget } from "../src/core/migrate_mirror.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 12 | ||
| 13 | let dataDir = ""; | |
| 14 | let app: App, servers: Servers, bearer = "", repoId = ""; | |
| 15 | ||
| 16 | beforeAll(async () => { | |
| 17 | dataDir = await mkdtemp(join(tmpdir(), "forge-sethelp-")); | |
| 18 | app = createApp({ dataDir, port: 0 }); | |
| 19 | servers = await serve(app); | |
| 20 | const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" }); | |
| 21 | if (!admin.ok) throw new Error(admin.error); | |
| 22 | const made = await createRepo(app, admin.value, { name: "notes", visibility: "public", description: "" }); | |
| 23 | if (!made.ok) throw new Error(String(made.error)); | |
| 24 | repoId = made.value.id; | |
| 25 | const row = insertApp(app.db, { repoId: made.value.id, slug: "notes", access: "public" }); | |
| 26 | if (!row.ok) throw new Error(row.error); | |
| 27 | const minted = await fetch(`${servers.url}/api/v1/tokens`, { | |
| 28 | method: "POST", | |
| 29 | headers: { "content-type": "application/json" }, | |
| 30 | body: JSON.stringify({ adminConfirm: true, username: "alice", password: "correct-horse-battery", name: "cli", scope: "admin" }), | |
| 31 | }); | |
| 32 | bearer = ((await minted.json()) as { token: string }).token; | |
| 33 | }); | |
| 34 | ||
| 35 | afterAll(async () => { | |
| 36 | servers.stop(); | |
| 37 | app.close(); | |
| 38 | await rm(dataDir, { recursive: true, force: true }); | |
| 39 | }); | |
| 40 | ||
| 41 | const get = async (path: string) => { | |
| 42 | const res = await fetch(`${servers.url}${path}`, { headers: { authorization: `Bearer ${bearer}` } }); | |
| 43 | expect(res.status).toBe(200); | |
| 44 | return res.text(); | |
| 45 | }; | |
| 46 | ||
| 47 | test("Settings keeps its explanations in the one help, open on ?help", async () => { | |
| 48 | const closed = await get("/alice/notes/settings"); | |
| 49 | expect(closed.match(/data-page-help/g)?.length).toBe(1); | |
| 50 | expect(closed).not.toContain("data-page-help open"); | |
| 51 | expect(closed).toContain("Opens a pull that adds the next"); | |
| 52 | expect(closed.indexOf("Opens a pull that adds the next")).toBeGreaterThan(closed.indexOf("data-page-help")); | |
| 53 | expect(closed).not.toContain('<p class="muted">\n Opens a pull'); | |
| 54 | expect((await get("/alice/notes/settings?help")).includes("data-page-help open")).toBe(true); | |
| 55 | }); | |
| 56 | ||
| 57 | test("App settings keeps traffic, environment, access and domains words in the one help", async () => { | |
| 58 | const page = await get("/alice/notes/settings/app"); | |
| 59 | expect(page.match(/data-page-help/g)?.length).toBe(1); | |
| 60 | const help = page.slice(page.indexOf("data-page-help"), page.indexOf("</details>", page.indexOf("data-page-help"))); | |
| 61 | for (const w of ["Counted by the beacon", "write-only", "read-only token", "publish the two records"]) { | |
| 62 | expect(help).toContain(w); | |
| 63 | expect(page.split(w).length - 1).toBe(1); | |
| 64 | } | |
| 65 | expect(await get("/alice/notes/settings/app?help")).toContain("data-page-help open"); | |
| 66 | }); | |
| 67 | ||
| 68 | test("the mirror warning sits once, beside Stop mirroring; a repository with no mirror or trail has neither entry", async () => { | |
| 69 | const bare = await get("/alice/notes/settings?help"); | |
| 70 | expect(bare).not.toContain("Stopping leaves"); | |
| 71 | expect(bare).not.toContain("Where this came from"); | |
| 72 | expect(bare).not.toContain("Stop mirroring"); | |
| 73 | const repo = { id: repoId }; | |
| 74 | app.db.run("PRAGMA foreign_keys = OFF"); | |
| 75 | setMirrorTarget(app.db, { repoId: repo.id, originId: "o1", fullName: "octo/notes", cloneUrl: "https://code.example/octo/notes.git", tips: {} }); | |
| 76 | try { | |
| 77 | const page = await get("/alice/notes/settings"); | |
| 78 | expect(page.split("Stopping leaves octo/notes as it is").length - 1).toBe(1); | |
| 79 | expect(page.indexOf("Stopping leaves")).toBeLessThan(page.indexOf("Stop mirroring")); | |
| 80 | } finally { | |
| 81 | deleteMirrorTarget(app.db, repo.id); | |
| 82 | } | |
| 83 | }); |