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

The /oauth/authorize consent page speaks the home page's language #9808

Merged opened this

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

Merged

scout/forge:scout/9799-the-oauth-authorize-consent-page-speaks → main 6 commits fork 3 threads · 3 unresolved

  • Merged. scout/9799-the-oauth-authorize-consent-page-speaks was merged into main as c0545dd.

Review

Sign in to review this change.
3 files changed +145 −65
docs/design/areas/settings.md +2
@@ -94,6 +94,8 @@ accounts are `row()`s in `<ul class="list setting-list" id="accounts-list">` und
9494 (Sign-ins, Verify, Suspend, Remove) in the row's `after` slot; the mail, ownerless-organization, sign-in reach and
9595 confirm/refusal notices are plain `<section class="settings-block">` blocks with an `<h2>`. `test/accounts-rows.test.ts` holds the shape.
9696
97 `/oauth/authorize` (`src/web/pages/oauth.ts`) adds no component class. The consent, sign-in and can't-connect parts are each a `<section class="settings-block">` with an `<h2>` (What it gets, What it learns; the can't-connect part is the message alone) under the `pageHead`, no `.card`. The client's name, the return host, the token kind, name and lifetime fields are as they were. `test/oauth-page-shape.test.ts` holds the shape.
98
9799 Settings › Cloud (`src/web/pages/cloud.ts`) adds no component class. As on the home page it is headings over rows: Cloud accounts,
98100 Add an account and Every read are each a `<section class="settings-block">` with an `<h2>`; each account is a `row()` in
99101 `<ul class="list setting-list">`; with none, one plain sentence says how to add one. `/settings/cloud/audit` is unchanged.
src/web/pages/oauth.ts +65 −65
@@ -20,7 +20,7 @@ import { pageHead, SIGN_IN_TO_DO, forbiddenPage } from "../chrome.ts";
2020 import { field, readForm } from "../form.ts";
2121 import { html, type Html } from "../html.ts";
2222 import { page, redirect } from "../layout.ts";
23 import { btn, card, field as formField } from "../ui.ts";
23 import { btn, field as formField } from "../ui.ts";
2424
2525 const PARAMS = [
2626 "client_id",
@@ -64,12 +64,10 @@ const refused = (ctx: Ctx, message: string): Response =>
6464 title: "Can't connect",
6565 body: html`<div class="narrow">
6666 ${pageHead("This connection can't be made")}
67 ${card(
68 html`<p>${message}</p>
69 <p class="muted small">
70 Nothing was granted. Go back to the app and start again.
71 </p>`,
72 )}
67 <section class="settings-block">
68 <p>${message}</p>
outdated

Principle 1: "Nothing was granted" restates the head and the message below; one sentence is enough.

69 <p class="muted small">Go back to the app and start again.</p>
70 </section>
7371 </div>`,
7472 },
7573 400,
@@ -100,20 +98,21 @@ function signInConsent(request: AuthRequest, values: Params): Html {
10098 const host = new URL(request.redirectUri).host;
10199 return html`<div class="narrow">
102100 ${pageHead(`Sign in to ${host}`)}
103 ${card(
104 html`<p class="mb">
105 Sign in to <strong>${host}</strong> as you. It learns only what is
106 listed below, and gets no access to this forge.
107 </p>
108 <form method="post" action="/oauth/authorize">
109 ${PARAMS.map((name) => hidden(name, values[name]))}
110 ${identity(request)}
111 <div class="form-actions">
112 ${btn("Sign in", { variant: "primary", name: "decision", value: "allow" })}
113 ${btn("Deny", { name: "decision", value: "deny" })}
114 </div>
115 </form>`,
116 )}
101 <section class="settings-block">
102 <h2>What it learns</h2>
103 <p class="mb">
outdated

Principle 1: this h2 repeats the pageHead "Sign in to host". Drop it or say something the head does not. Also untested: add a test for this page.

104 Sign in to <strong>${host}</strong> as you. It learns only what is
105 listed below, and gets no access to this forge.
106 </p>
107 <form method="post" action="/oauth/authorize">
108 ${PARAMS.map((name) => hidden(name, values[name]))}
109 ${identity(request)}
110 <div class="form-actions">
111 ${btn("Sign in", { variant: "primary", name: "decision", value: "allow" })}
112 ${btn("Deny", { name: "decision", value: "deny" })}
113 </div>
114 </form>
115 </section>
117116 </div>`;
118117 }
119118
@@ -122,50 +121,51 @@ function consent(ctx: Ctx, request: AuthRequest, values: Params): Html {
122121 const host = new URL(request.redirectUri).host;
123122 return html`<div class="narrow">
124123 ${pageHead(`Connect ${request.client.name}`)}
125 ${card(
126 html`<p class="mb">
127 <strong>${request.client.name}</strong> wants to use the forge as you,
128 through a token. When you allow it, you are sent back to
129 <strong>${host}</strong>.
130 </p>
131 <form method="post" action="/oauth/authorize">
132 ${PARAMS.map((name) => hidden(name, values[name]))}
133 ${identity(request)}
134 <fieldset class="field">
135 <legend>What it may do</legend>
136 <label
137 ><input type="radio" name="grant" value="ro"
138 ${request.scope === "ro" ? "checked" : ""} />
139 Read: see your apps, issues and runs</label
140 >
141 <label
142 ><input type="radio" name="grant" value="rw"
143 ${request.scope === "rw" ? "checked" : ""} />
144 Read and act: tell the crew, plan, and change things as you</label
145 >
146 </fieldset>
147 ${formField({
148 label: "Token name",
149 name: "name",
150 value: request.client.name,
151 required: true,
152 hint: "How it is listed under Settings, where you can revoke it.",
153 })}
154 <div class="field">
155 <label for="f-lifetime">Lasts</label>
156 <select class="input" id="f-lifetime" name="lifetime">
157 <option value="never" selected>Until I revoke it</option>
158 <option value="year">A year</option>
159 <option value="month">30 days</option>
160 <option value="day">A day</option>
161 </select>
162 </div>
163 <div class="form-actions">
164 ${btn("Allow", { variant: "primary", name: "decision", value: "allow" })}
165 ${btn("Deny", { name: "decision", value: "deny" })}
166 </div>
167 </form>`,
168 )}
124 <section class="settings-block">
125 <h2>What it gets</h2>
126 <p class="mb">
outdated

Principle 1: "Allow this connection" repeats the head "Connect <client>". Also the <form> below is not re-indented inside the section.

127 <strong>${request.client.name}</strong> wants to use the forge as you,
128 through a token. When you allow it, you are sent back to
129 <strong>${host}</strong>.
130 </p>
131 <form method="post" action="/oauth/authorize">
132 ${PARAMS.map((name) => hidden(name, values[name]))}
133 ${identity(request)}
134 <fieldset class="field">
135 <legend>What it may do</legend>
136 <label
137 ><input type="radio" name="grant" value="ro"
138 ${request.scope === "ro" ? "checked" : ""} />
139 Read: see your apps, issues and runs</label
140 >
141 <label
142 ><input type="radio" name="grant" value="rw"
143 ${request.scope === "rw" ? "checked" : ""} />
144 Read and act: tell the crew, plan, and change things as you</label
145 >
146 </fieldset>
147 ${formField({
148 label: "Token name",
149 name: "name",
150 value: request.client.name,
151 required: true,
152 hint: "How it is listed under Settings, where you can revoke it.",
153 })}
154 <div class="field">
155 <label for="f-lifetime">Lasts</label>
156 <select class="input" id="f-lifetime" name="lifetime">
157 <option value="never" selected>Until I revoke it</option>
158 <option value="year">A year</option>
159 <option value="month">30 days</option>
160 <option value="day">A day</option>
161 </select>
162 </div>
163 <div class="form-actions">
164 ${btn("Allow", { variant: "primary", name: "decision", value: "allow" })}
165 ${btn("Deny", { name: "decision", value: "deny" })}
166 </div>
167 </form>
168 </section>
169169 </div>`;
170170 }
171171
test/oauth-page-shape.test.ts added +78
@@ -0,0 +1,78 @@
1 /** /oauth/authorize speaks the home page's language: headed sections, 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, type App } from "../src/app.ts";
7 import { createSession } from "../src/core/sessions.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10
11 const REDIRECT = "https://claude.ai/api/mcp/auth_callback";
12 let dir = "";
13 let app: App;
14 let servers: Servers;
15 let cookie = "";
16 let clientId = "";
17
18 beforeAll(async () => {
19 dir = await mkdtemp(join(tmpdir(), "forge-oauth-shape-"));
20 app = createApp({ dataDir: dir, port: 0 });
21 servers = await serve(app);
22 const made = await createUser(app.db, {
23 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
24 password: "a-good-passphrase", adminIfFirst: true,
25 });
26 if (!made.ok) throw new Error(made.error);
27 cookie = `forge_session=${createSession(app.db, made.value.id).token}`;
28 const res = await fetch(`${servers.url}/oauth/register`, {
29 method: "POST",
30 headers: { "content-type": "application/json" },
31 body: JSON.stringify({ client_name: "Shape Client", redirect_uris: [REDIRECT] }),
32 });
33 clientId = ((await res.json()) as any).client_id;
34 });
35 afterAll(async () => {
36 servers.stop();
37 await rm(dir, { recursive: true, force: true });
38 });
39
40 const get = (over: Record<string, string> = {}) =>
41 fetch(
42 `${servers.url}/oauth/authorize?${new URLSearchParams({
43 response_type: "code", client_id: clientId, redirect_uri: REDIRECT,
44 code_challenge: "c".repeat(43), code_challenge_method: "S256", state: "s", ...over,
45 })}`,
46 { redirect: "manual", headers: { cookie } },
47 );
48
49 test("the consent page is a headed section with no card", async () => {
50 const res = await get();
51 expect(res.status).toBe(200);
52 const body = await res.text();
53 expect(body).toContain("<h2>What it gets</h2>");
54 expect(body).toContain("Shape Client");
55 expect(body).toContain('name="grant"');
56 expect(body).toContain('name="name"');
57 expect(body).not.toMatch(/class="[^"]*\bcard\b/);
58 });
59
60 test("the can't-connect page is a headed section with no card, and is shown, not followed", async () => {
61 const res = await get({ redirect_uri: "https://evil.example/cb" });
62 expect(res.status).toBe(400);
63 expect(res.headers.get("location")).toBeNull();
64 const body = await res.text();
65 expect(body).toContain("<section");
66 expect(body).not.toContain("Nothing was granted");
67 expect(body).not.toMatch(/class="[^"]*\bcard\b/);
68 });
69
70 test("the sign-in-only page is a headed section with no card and no token form", async () => {
71 const res = await get({ scope: "openid profile" });
72 expect(res.status).toBe(200);
73 const body = await res.text();
74 expect(body).toContain("<h2>What it learns</h2>");
75 expect(body).toContain("Sign in to <strong>claude.ai</strong>");
76 expect(body).not.toContain('name="grant"');
77 expect(body).not.toMatch(/class="[^"]*\bcard\b/);
78 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs