Public: anyone can read this; the crew can change it.

A repository's Settings page moves it to another owner, as the API and terminal already do #10118

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/10099-a-repository-s-settings-page-moves-it-to → main 3 commits fork 1 thread · 1 unresolved

  • Merged. mason/10099-a-repository-s-settings-page-moves-it-to was merged into main as dc5d766.

Review

Sign in to review this change.
5 files changed +210 −4
docs/features/repo-transfer.md +5 −1
@@ -1,6 +1,10 @@
11 # Moving a repository to another owner
22
3 From the terminal: on a repository's screen, `:` then "Move this repository
3 From the web: a repository's Settings page has a "Move to another owner" block
4 (an admin's, beside Archive): type the new owner, confirm, and you land on
5 Settings at the new path. A refusal comes back beside the field as its
6 sentence, the field kept; anyone who does not administer the repository gets
7 `404`. From the terminal: on a repository's screen, `:` then "Move this repository
48 to another owner", and type the new owner. From the API:
59 `POST /api/v1/repos/:owner/:name/transfer` with `{"newOwner": "<account or org>"}`
610 moves a repository (an `rw` token; the caller administers the repository and
src/http/server.ts +2
@@ -379,6 +379,7 @@ import {
379379 settingsUpdate,
380380 waveCut,
381381 archiveSubmit,
382 transferSubmit,
382383 provenanceSubmit,
383384 mirrorStop,
384385 } from "../web/pages/settings.ts";
@@ -1415,6 +1416,7 @@ export function buildRouter(): Router<Handler> {
14151416 router.add("POST", "/:owner/:name/settings", settingsUpdate);
14161417 router.add("POST", "/:owner/:name/settings/wave", waveCut);
14171418 router.add("POST", "/:owner/:name/settings/archive", archiveSubmit);
1419 router.add("POST", "/:owner/:name/settings/transfer", transferSubmit);
14181420 router.add("POST", "/:owner/:name/settings/provenance", provenanceSubmit);
14191421 router.add("POST", "/:owner/:name/settings/mirror/stop", mirrorStop);
14201422 router.add("POST", "/:owner/:name/settings/proposal-tokens", proposalTokenMint);
src/web/pages/search.ts +16 −1
@@ -550,6 +550,8 @@ interface Destructive {
550550 detail: string;
551551 /** A phrase the visitor must type back before the button will work. */
552552 phrase: string;
553 /** A form value the settings page typed that the confirmed POST needs back. */
554 carries?: string;
553555 }
554556
555557 /**
@@ -571,6 +573,17 @@ const DESTRUCTIVE: {
571573 phrase: `${m[1]}/${m[2]}`,
572574 }),
573575 },
576 {
577 pattern: /^\/([^/]+)\/([^/]+)\/settings\/transfer$/,
578 describe: (m) => ({
579 verb: "Move",
580 title: `Move ${m[1]}/${m[2]} to another owner?`,
581 detail:
582 "The repository, its issues and pulls move to the new owner and its address changes; the old address redirects. You must be able to create repositories under the new owner.",
583 phrase: "",
584 carries: "owner",
585 }),
586 },
574587 {
575588 pattern: /^\/([^/]+)\/([^/]+)\/settings\/detach$/,
576589 describe: (m) => ({
@@ -912,7 +925,8 @@ function confirmBody(ctx: Ctx): Response {
912925 // link therefore cannot invent reassuring words for a dangerous button, and
913926 // cannot aim the form at an endpoint that was never meant for it.
914927 if (!known) return notFoundPage(ctx);
915 const { verb, title, detail, phrase } = known;
928 const { verb, title, detail, phrase, carries } = known;
929 const carried = carries === undefined ? "" : (params.get(carries) ?? "").slice(0, 200);
916930
917931 const referer = ctx.request.headers.get("referer");
918932 let back = "/";
@@ -926,6 +940,7 @@ function confirmBody(ctx: Ctx): Response {
926940 ${pageHead(title)}
927941 <p>${detail}</p>
928942 <form method="post" action="${to}" class="mt">
943 ${carries === undefined ? null : html`<input type="hidden" name="${carries}" value="${carried}" />`}
929944 ${when(phrase !== "", () =>
930945 formField({
931946 label: `Type ${phrase} to continue`,
src/web/pages/settings.ts +55 −2
@@ -51,6 +51,7 @@ import {
5151 SCOPE_WORDS,
5252 } from "../../actions/app_env_words.ts";
5353 import { AUTONOMY_SAID, checkAutonomy, isDecisionsBy } from "../../core/repos.ts";
54 import { TRANSFER_SAID, TRANSFER_STATUS, transferRepository } from "../../actions/transfer.ts";
5455 import { updateRepoSettings } from "../../actions/repos.ts";
5556 import { appsRuntime } from "../../apps/runtime.ts";
5657 import { nameFrame, notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
@@ -227,7 +228,11 @@ export async function adminForm(
227228 * gates and the secrets, with the rest — the app, prompts, labels,
228229 * webhooks, the danger zone — one quiet section over in the same frame.
229230 */
230 export async function settingsPage(ctx: Ctx): Promise<Response> {
231 export async function settingsPage(
232 ctx: Ctx,
233 /** A refused move: the owner as typed and the sentence to show beside it. */
234 refused?: { owner: string; said: string; status: number },
235 ): Promise<Response> {
231236 nameFrame(ctx.request);
232237 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
233238 const [readable, repo] = timePhase(ctx.request, "repo", () => {
@@ -411,6 +416,28 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
411416 </div>
412417 </form>
413418 </section>`;
419 const move = html`<section class="settings-block">
420 <h2>Move to another owner</h2>
421 <form method="get" action="/-/confirm">
422 <input type="hidden" name="to" value="${base}/settings/transfer" />
423 <p class="muted">

Principle 1: this paragraph and the confirm page's detail say the same thing (address changes, old one redirects). Say it once; drop it here or shorten to one sentence.

424 Give the repository to an account or organisation you can create
425 repositories under.
426 </p>
427 ${formField({
428 label: "New owner",
429 name: "owner",
430 value: refused?.owner ?? "",
431 required: true,
432 maxlength: 64,
433 autocomplete: "off",
434 spellcheck: false,
435 mono: true,
436 ...(refused ? { error: refused.said } : {}),
437 })}
438 <div class="form-actions">${btn("Move", { variant: "danger" })}</div>
439 </form>
440 </section>`;
414441 renderMs += performance.now() - since;
415442 const held = timePhase(ctx.request, "mirror", () => getMirrorTarget(ctx.app.db, repo.id));
416443 since = performance.now();
@@ -437,11 +464,13 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
437464 "Settings",
438465 html`${lead}${mirror ? html`<div class="mt">${mirror}</div>` : null}<div class="mt">${body}</div>${trail
439466 ? html`<div class="mt">${trail}</div>`
440 : 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>${proposals
467 : 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
441468 ? html`<div class="mt">${proposals}</div>`
442469 : null}${secrets
443470 ? html`<div class="mt">${secrets}</div>`
444471 : null}`,
472 undefined,
473 refused?.status,
445474 );
446475 notePhase(ctx.request, "render", renderMs + performance.now() - since);
447476 return response;
@@ -545,6 +574,30 @@ export async function archiveSubmit(ctx: Ctx): Promise<Response> {
545574 );
546575 }
547576
577 /**
578 * POST /:owner/:name/settings/transfer — move the repository to another
579 * owner, through the action (never core). A refusal is the settings page
580 * again, with the sentence beside the field and the field as typed.
581 */
582 export async function transferSubmit(ctx: Ctx): Promise<Response> {
583 const gate = await adminForm(ctx);
584 if (gate instanceof Response) return gate;
585 const { repo, form } = gate;
586 const owner = (field(form, "owner") ?? "").trim();
587 const refuse = (said: string, status: number) =>
588 settingsPage(ctx, { owner: owner.slice(0, 64), said, status });
589 if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(owner))
590 return refuse(TRANSFER_SAID.unknown_owner, TRANSFER_STATUS.unknown_owner);
591 const moved = await transferRepository(ctx.app, ctx.auth.user, repo, owner);
592 if (!moved.ok) {
593 if (moved.error === "not_found") return repoNotFound(ctx);
594 return refuse(TRANSFER_SAID[moved.error], TRANSFER_STATUS[moved.error]);
595 }
596 return flashTo(ctx, `${repoBase(moved.value)}/settings`, {
597 message: `Moved to ${moved.value.ownerName}/${moved.value.name}.`,
598 });
599 }
600
548601 /**
549602 * "Show where this came from" (#3293), drawn only for a repository that
550603 * has a trail to show: the line as it reads, and the one switch for it.
test/repo-transfer-web.test.ts added +132
@@ -0,0 +1,132 @@
1 /**
2 * The Settings page moves a repository to another owner (#10099): the block is
3 * an admin's, the confirm step carries the field, the POST calls the action,
4 * and each refusal comes back on the page as its sentence with the field kept.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createOrg } from "../src/actions/orgs.ts";
12 import { createRepo } from "../src/actions/repos.ts";
13 import { TRANSFER_SAID } from "../src/actions/transfer.ts";
14 import { createRepo as makeRepo } from "../src/core/repos.ts";
15 import { createUser, type User } from "../src/core/users.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18
19 quietLogs();
20
21 const PASSWORD = "a-long-transfer-web-passphrase";
22
23 describe("moving a repository from Settings", () => {
24 let dataDir = "";
25 let app: App;
26 let servers: Servers;
27 let alice: User;
28 let bob: User;
29 const cookies: Record<string, string> = {};
30
31 const post = (path: string, who: string, fields: Record<string, string>, origin: string | false = servers.url) =>
32 fetch(`${servers.url}${path}`, {
33 method: "POST",
34 redirect: "manual",
35 headers: { cookie: cookies[who]!, ...(origin ? { origin } : {}) },
36 body: new URLSearchParams(fields),
37 });
38 const get = (path: string, who: string) =>
39 fetch(`${servers.url}${path}`, { redirect: "manual", headers: { cookie: cookies[who]! } });
40
41 beforeAll(async () => {
42 dataDir = await mkdtemp(join(tmpdir(), "forge-transfer-web-"));
43 app = createApp({ dataDir, port: 0 });
44 servers = await serve(app);
45 for (const username of ["alice", "bob", "carol"]) {
46 const u = await createUser(app.db, {
47 origin: "seed",
48 trusted: true,
49 username,
50 email: `${username}@forge.local`,
51 password: PASSWORD,
52 adminIfFirst: username === "alice",
53 });
54 if (!u.ok) throw new Error(u.error);
55 if (username === "alice") alice = u.value;
56 if (username === "bob") bob = u.value;
57 const res = await fetch(`${servers.url}/signin`, {
58 method: "POST",
59 body: new URLSearchParams({ username, password: PASSWORD }),
60 redirect: "manual",
61 });
62 cookies[username] = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
63 }
64 const org = createOrg(app, alice, "lab");
65 if (!org.ok) throw new Error(org.error);
66 for (const name of ["notes", "taken", "other"]) {
67 const r = await createRepo(app, alice, { name, visibility: "public" });
68 if (!r.ok) throw new Error(String(r.error));
69 }
70 const mine = await makeRepo(app.db, app.reposRoot, { ownerId: bob.id, name: "mine", visibility: "public" });
71 if (!mine.ok) throw new Error(String(mine.error));
72 const dup = await createRepo(app, alice, { name: "taken", visibility: "public", owner: "lab" });
73 if (!dup.ok) throw new Error(String(dup.error));
74 });
75
76 afterAll(async () => {
77 await servers.stop();
78 app.db.close();
79 await rm(dataDir, { recursive: true, force: true });
80 });
81
82 test("the block shows to the admin only, and its confirm step carries the field", async () => {
83 const page = await (await get("/alice/notes/settings", "alice")).text();
84 expect(page).toContain("Move to another owner");
85 const reader = await (await get("/alice/notes/settings", "bob")).text();
86 expect(reader).not.toContain("Move to another owner");
87 const confirm = await (
88 await get(`/-/confirm?to=${encodeURIComponent("/alice/notes/settings/transfer")}&owner=lab`, "alice")
89 ).text();
90 expect(confirm).toContain('name="owner" value="lab"');
91 const other = await (
92 await get(`/-/confirm?to=${encodeURIComponent("/alice/notes/settings/transfer")}&owner=lab&evil=x&confirm=y`, "alice")
93 ).text();
94 expect(other).not.toContain('name="evil"');
95 expect(other).not.toContain('value="x"');
96 expect(other.match(/<input type="hidden"/g)?.length).toBe(1);
97 });
98
99 test("a reader gets 404, and a cross-origin post is refused", async () => {
100 expect((await post("/alice/notes/settings/transfer", "bob", { owner: "bob" })).status).toBe(404);
101 const res = await post("/alice/notes/settings/transfer", "alice", { owner: "lab" }, "https://evil.example");
102 expect(res.status).toBe(403);
103 expect((await get("/alice/notes", "alice")).status).toBe(200);
104 });
105
106 const refusals: [string, string, string, number][] = [
107 ["name taken", "taken", "name_taken", 409],
108 ["same owner", "alice", "same_owner", 409],
109 ["unknown owner", "nobody", "unknown_owner", 422],
110 ["forbidden owner", "carol", "forbidden_owner", 403],
111 ];
112 for (const [label, owner, key, status] of refusals) {
113 test(`refusal: ${label}`, async () => {
114 const repo = key === "name_taken" ? "taken" : "other";
115 const target = key === "name_taken" ? "lab" : owner;
116 const who = key === "forbidden_owner" ? "bob" : "alice";
117 const res = await post(`/${who}/${key === "forbidden_owner" ? "mine" : repo}/settings/transfer`, who, { owner: target });
118 expect(res.status).toBe(status);
119 const text = await res.text();
120 expect(text).toContain(TRANSFER_SAID[key as keyof typeof TRANSFER_SAID].replace(/'/g, "&#39;"));
121 expect(text).toContain(`value="${target}"`);
122 });
123 }
124
125 test("a move redirects to Settings at the new path", async () => {
126 const res = await post("/alice/notes/settings/transfer", "alice", { owner: "lab" });
127 expect(res.status).toBe(303);
128 expect(res.headers.get("location")).toContain("/lab/notes/settings");
129 expect((await get("/lab/notes/settings", "alice")).status).toBe(200);
130 expect((await get("/alice/notes", "alice")).status).toBe(308);
131 });
132 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs