Public: anyone can read this; the crew can change it.
Click your own name in the studio and you land in dev's repository list #207
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/185-click-your-own-name-in-the-studio-and-yo → main 1 commit fork
2 files
changed +104
−3
src/web/pages/org.ts +58 −3
| @@ -27,6 +27,7 @@ import { | ||
| 27 | 27 | type Permission, |
| 28 | 28 | type Team, |
| 29 | 29 | } from "../../core/orgs.ts"; |
| 30 | import { appsForRepos } from "../../core/apps.ts"; | |
| 30 | 31 | import { listReposByOwner, type Repo } from "../../core/repos.ts"; |
| 31 | 32 | import { getUserByName, type User } from "../../core/users.ts"; |
| 32 | 33 | import type { Ctx } from "../../http/context.ts"; |
| @@ -35,6 +36,7 @@ import { field, readForm, type Form } from "../form.ts"; | ||
| 35 | 36 | import { html, when, type Html } from "../html.ts"; |
| 36 | 37 | import { icon } from "../icons.ts"; |
| 37 | 38 | import { flashTo, page } from "../layout.ts"; |
| 39 | import { shelfRows } from "../studio/shelf.ts"; | |
| 38 | 40 | import { |
| 39 | 41 | avatar, |
| 40 | 42 | badge, |
| @@ -130,6 +132,52 @@ function repoSection(ctx: Ctx, owner: User, isSelf: boolean): Html { | ||
| 130 | 132 | )}`; |
| 131 | 133 | } |
| 132 | 134 | |
| 135 | /** | |
| 136 | * The owner's apps in the studio, one shelf row each — the same row the | |
| 137 | * studio home's *Your apps* card draws (`shelfRows`, `studio/shelf.ts`), | |
| 138 | * never a second copy of it. Visibility is `listReposByOwner`'s own, the | |
| 139 | * same rule the repository list below uses, so a stranger never sees a | |
| 140 | * private app here either. | |
| 141 | */ | |
| 142 | async function appsSection(ctx: Ctx, owner: User, isSelf: boolean): Promise<Html> { | |
| 143 | const repos = listReposByOwner(ctx.app.db, owner.username, ctx.auth.user, { | |
| 144 | limit: 200, | |
| 145 | }); | |
| 146 | const records = appsForRepos( | |
| 147 | ctx.app.db, | |
| 148 | repos.map((r) => r.id), | |
| 149 | ); | |
| 150 | const appRepos = repos.filter((r) => records.has(r.id)); | |
| 151 | const newAppHref = `/apps/new?owner=${owner.username}`; | |
| 152 | ||
| 153 | if (appRepos.length === 0) | |
| 154 | return empty({ | |
| 155 | icon: "rocket", | |
| 156 | title: "No apps yet", | |
| 157 | note: isSelf | |
| 158 | ? "Make one, tell the crew what it is, and watch it come up." | |
| 159 | : `${owner.username} has no apps you can see.`, | |
| 160 | ...(isSelf | |
| 161 | ? { action: btn("New app", { href: newAppHref, variant: "primary" }) } | |
| 162 | : {}), | |
| 163 | }); | |
| 164 | ||
| 165 | const rows = await shelfRows(ctx, appRepos); | |
| 166 | return listCard(rows, { | |
| 167 | head: html`${icon("rocket")} | |
| 168 | <h2>Apps</h2> | |
| 169 | ${when( | |
| 170 | isSelf, | |
| 171 | () => | |
| 172 | html`<span class="spacer"></span>${btn("New app", { | |
| 173 | href: newAppHref, | |
| 174 | size: "sm", | |
| 175 | icon: "plus", | |
| 176 | })}`, | |
| 177 | )}`, | |
| 178 | }); | |
| 179 | } | |
| 180 | ||
| 133 | 181 | /** Everyone on any of the org's teams, once each. */ |
| 134 | 182 | function orgMembers(ctx: Ctx, teams: Team[]): User[] { |
| 135 | 183 | const seen = new Map<string, User>(); |
| @@ -207,8 +255,11 @@ function teamsSection(ctx: Ctx, org: User, canManage: boolean): Html { | ||
| 207 | 255 | )}`; |
| 208 | 256 | } |
| 209 | 257 | |
| 210 | // GET /:owner — a person or an org, with their repos. | |
| 211 | export function ownerProfile(ctx: Ctx): Response { | |
| 258 | // GET /:owner — a person or an org, with their repos. In the studio this | |
| 259 | // leads with the owner's apps, drawn by the same shelf the studio's own | |
| 260 | // home draws its *Your apps* card from; dev mode is byte-identical to what | |
| 261 | // this page has always rendered. | |
| 262 | export async function ownerProfile(ctx: Ctx): Promise<Response> { | |
| 212 | 263 | const owner = getUserByName(ctx.app.db, ctx.params.owner ?? ""); |
| 213 | 264 | if (!owner) return notFoundPage(ctx); |
| 214 | 265 | |
| @@ -219,6 +270,9 @@ export function ownerProfile(ctx: Ctx): Response { | ||
| 219 | 270 | const canManage = isOrg && accessAllows(access, "admin"); |
| 220 | 271 | const isSelf = |
| 221 | 272 | ctx.auth.user !== undefined && (ctx.auth.user.id === owner.id || canManage); |
| 273 | const studio = ctx.mode === "studio"; | |
| 274 | ||
| 275 | const apps = studio ? await appsSection(ctx, owner, isSelf) : null; | |
| 222 | 276 | |
| 223 | 277 | const body = html`<div class="page-head"> |
| 224 | 278 | ${avatar(owner.username, "lg")} |
| @@ -232,12 +286,13 @@ export function ownerProfile(ctx: Ctx): Response { | ||
| 232 | 286 | ${when(isSelf, () => |
| 233 | 287 | btn("New repository", { |
| 234 | 288 | href: `/new?owner=${owner.username}`, |
| 235 | variant: "primary", | |
| 289 | variant: studio ? "ghost" : "primary", | |
| 236 | 290 | size: "sm", |
| 237 | 291 | icon: "plus", |
| 238 | 292 | }), |
| 239 | 293 | )} |
| 240 | 294 | </div> |
| 295 | ${apps} | |
| 241 | 296 | ${repoSection(ctx, owner, isSelf)} |
| 242 | 297 | ${when(isOrg && accessAllows(access, "read"), () => |
| 243 | 298 | teamsSection(ctx, owner, canManage), |
test/studio.test.ts +46
| @@ -1350,6 +1350,52 @@ describe("the studio", () => { | ||
| 1350 | 1350 | expect(dev).not.toContain('href="/apps?live=1"'); |
| 1351 | 1351 | }); |
| 1352 | 1352 | |
| 1353 | test("clicking your own name in the studio lands on /:owner, which leads with your apps on the shelf and New app as the primary action; dev mode is unchanged, and a stranger never sees a private one", async () => { | |
| 1354 | const studio = await (await get("/alice")).text(); | |
| 1355 | expect(studio).toContain("<h2>Apps</h2>"); | |
| 1356 | expect(studio).toContain('href="/alice/notes"'); | |
| 1357 | expect(studio).toContain("not running"); | |
| 1358 | expect(studio).toContain('href="/apps/new?owner=alice"'); | |
| 1359 | // The repository list stays reachable, filter and all — nothing disappears. | |
| 1360 | expect(studio).toContain('data-search-input'); | |
| 1361 | expect(studio).toContain("notes"); | |
| 1362 | ||
| 1363 | const dev = await ( | |
| 1364 | await get("/alice", { cookie: `${cookie}; forge_mode=dev` }) | |
| 1365 | ).text(); | |
| 1366 | expect(dev).not.toContain("<h2>Apps</h2>"); | |
| 1367 | expect(dev).not.toContain('href="/apps/new?owner=alice"'); | |
| 1368 | expect(dev).not.toContain("not running"); | |
| 1369 | ||
| 1370 | const carol = await createUser(app.db, { | |
| 1371 | username: "carol", | |
| 1372 | email: "[email protected]", | |
| 1373 | password: "a-good-passphrase", | |
| 1374 | }); | |
| 1375 | if (!carol.ok) throw new Error(carol.error); | |
| 1376 | const carolCookie = ( | |
| 1377 | ( | |
| 1378 | await fetch(`${base}/signin`, { | |
| 1379 | method: "POST", | |
| 1380 | body: new URLSearchParams({ | |
| 1381 | username: "carol", | |
| 1382 | password: "a-good-passphrase", | |
| 1383 | }), | |
| 1384 | redirect: "manual", | |
| 1385 | }) | |
| 1386 | ).headers.get("set-cookie") ?? "" | |
| 1387 | ).split(";")[0]!; | |
| 1388 | const stranger = await ( | |
| 1389 | await fetch(`${base}/alice`, { | |
| 1390 | headers: { cookie: `${carolCookie}; forge_mode=studio` }, | |
| 1391 | redirect: "manual", | |
| 1392 | }) | |
| 1393 | ).text(); | |
| 1394 | expect(stranger).not.toContain('href="/alice/notes"'); | |
| 1395 | expect(stranger).toContain("No apps yet"); | |
| 1396 | expect(stranger).not.toContain('href="/apps/new?owner=alice"'); | |
| 1397 | }); | |
| 1398 | ||
| 1353 | 1399 | test("signed out in studio, / leads with the public apps that are serving — their look, not a private one's", async () => { |
| 1354 | 1400 | // A second app, public, with a picture of its production. "notes" stays |
| 1355 | 1401 | // private, so its presence (or absence) is the negative case below. |
new above
offline