Keep the organization's header and tabs on its packages pages (#30)
An organization's packages list and package pages render the same header and tab bar as its other tabs, with Packages selected. The live test checks it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ruyx3Qr4jYrZEpPi466deb
Canberk Demirci authored · GitHub committed signed1 parent 5018b53commit 0f675dab20972c887c99144e79e403c413302b86Browse files
4 files changed+86−35
Files (4)
modified.github/workflows/live-test.yml+11−0View file
| @@ -258,6 +258,17 @@ | ||
| 258 | 258 | grep -F "buildx ok 120000000 bytes" <<<"$log" || { echo "::error::the image pushed by docker/build-push-action did not come back from the registry"; exit 1; } |
| 259 | 259 | api "/users/$OWNER/packages/container/${REPO,,}/versions" | jq -c '.[] | {tags: .metadata.container.tags, size, platforms}' |
| 260 | 260 | |
| 261 | + - name: An organization's packages page keeps its tabs | |
| 262 | + if: steps.token.outputs.skip != 'true' | |
| 263 | + run: | | |
| 264 | + set -euo pipefail | |
| 265 | + org=$(curl -fsS "$DIFFINERY/api/v3/user/orgs" -H "Authorization: Bearer $TOKEN" | jq -r '.[0].login // empty') | |
| 266 | + [ -n "$org" ] || { echo "$OWNER is in no organization; skipped"; exit 0; } | |
| 267 | + html=$(curl -fsS "$DIFFINERY/$org/-/packages" -H "Authorization: Bearer $TOKEN") | |
| 268 | + grep -qF "<a href=\"/$org/-/packages\" class=\"on\">" <<<"$html" || { echo "::error::$org's packages page lost the organization's tab bar"; exit 1; } | |
| 269 | + grep -qF "href=\"/$org?tab=repositories\"" <<<"$html" || { echo "::error::$org's packages page has no Repositories tab"; exit 1; } | |
| 270 | + echo "$org's packages page shows the organization's tabs with Packages selected" | |
| 271 | + | |
| 261 | 272 | - name: Git LFS round trip |
| 262 | 273 | if: steps.token.outputs.skip != 'true' |
| 263 | 274 | run: | |
modifiedapps/diffinery/src/web/account.tsx+39−27View file
| @@ -1476,6 +1476,44 @@ | ||
| 1476 | 1476 | |
| 1477 | 1477 | type OrgTab = "repositories" | "people" | "agents" | "settings"; |
| 1478 | 1478 | |
| 1479 | +/** An organization's name and tab bar, shared by its profile tabs and its packages pages. */ | |
| 1480 | +export function OrgHeader({ org, role, isOwner, tab }: { org: Account; role: "owner" | "admin" | "member" | null; isOwner: boolean; tab: OrgTab | "packages" }) { | |
| 1481 | + const base = `/${org.login}`; | |
| 1482 | + return ( | |
| 1483 | + <> | |
| 1484 | + <div class="row mb" style="gap:16px"> | |
| 1485 | + <Avatar login={org.login} size="lg" version={org.avatar_version} /> | |
| 1486 | + <div> | |
| 1487 | + <h1 class="m0">{org.name ?? org.login}</h1> | |
| 1488 | + <div class="muted small"> | |
| 1489 | + Organization · {org.login} | |
| 1490 | + {role ? ` · you are ${role === "owner" ? "an owner" : `a${role === "admin" ? "n" : ""} ${role}`}` : ""} | |
| 1491 | + </div> | |
| 1492 | + </div> | |
| 1493 | + </div> | |
| 1494 | + <nav class="tabs mb" style="border-bottom:1px solid var(--border)"> | |
| 1495 | + {( | |
| 1496 | + [ | |
| 1497 | + ["repositories", "Repositories", true], | |
| 1498 | + ["people", "People", true], | |
| 1499 | + ["agents", "Agents", true], | |
| 1500 | + ["settings", "Settings", isOwner], | |
| 1501 | + ] as const | |
| 1502 | + ) | |
| 1503 | + .filter((x) => x[2]) | |
| 1504 | + .map(([k, label]) => ( | |
| 1505 | + <a href={`${base}?tab=${k}`} class={tab === k ? "on" : ""}> | |
| 1506 | + {label} | |
| 1507 | + </a> | |
| 1508 | + ))} | |
| 1509 | + <a href={`${base}/-/packages`} class={tab === "packages" ? "on" : ""}> | |
| 1510 | + Packages | |
| 1511 | + </a> | |
| 1512 | + </nav> | |
| 1513 | + </> | |
| 1514 | + ); | |
| 1515 | +} | |
| 1516 | + | |
| 1479 | 1517 | const ROLE_LABEL: Record<string, string> = { owner: "Owner", admin: "Admin", member: "Member" }; |
| 1480 | 1518 | |
| 1481 | 1519 | /** The invitation link right after inviting someone by email (it's also in their inbox). */ |
| @@ -1788,33 +1826,7 @@ | ||
| 1788 | 1826 | c, |
| 1789 | 1827 | { title: acct.login }, |
| 1790 | 1828 | <> |
| 1791 | - <div class="row mb" style="gap:16px"> | |
| 1792 | - <Avatar login={acct.login} size="lg" version={acct.avatar_version} /> | |
| 1793 | - <div> | |
| 1794 | - <h1 class="m0">{acct.name ?? acct.login}</h1> | |
| 1795 | - <div class="muted small"> | |
| 1796 | - Organization · {acct.login} | |
| 1797 | - {role ? ` · you are ${role === "owner" ? "an owner" : `a${role === "admin" ? "n" : ""} ${role}`}` : ""} | |
| 1798 | - </div> | |
| 1799 | - </div> | |
| 1800 | - </div> | |
| 1801 | - <nav class="tabs mb" style="border-bottom:1px solid var(--border)"> | |
| 1802 | - {( | |
| 1803 | - [ | |
| 1804 | - ["repositories", "Repositories", true], | |
| 1805 | - ["people", "People", true], | |
| 1806 | - ["agents", "Agents", true], | |
| 1807 | - ["settings", "Settings", isOwner], | |
| 1808 | - ] as const | |
| 1809 | - ) | |
| 1810 | - .filter((x) => x[2]) | |
| 1811 | - .map(([k, label]) => ( | |
| 1812 | - <a href={`${base}?tab=${k}`} class={tab === k ? "on" : ""}> | |
| 1813 | - {label} | |
| 1814 | - </a> | |
| 1815 | - ))} | |
| 1816 | - <a href={`${base}/-/packages`}>Packages</a> | |
| 1817 | - </nav> | |
| 1829 | + <OrgHeader org={acct} role={role} isOwner={isOwner} tab={tab} /> | |
| 1818 | 1830 | {body} |
| 1819 | 1831 | </>, |
| 1820 | 1832 | ); |
modifiedapps/diffinery/src/web/packages.tsx+26−8View file
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | import { RegistryStore, type ManifestRow, type PackageRow } from "../registry/store.js"; |
| 10 | 10 | import { ICONS } from "./assets.js"; |
| 11 | 11 | import { KSelect } from "./kumo.js"; |
| 12 | +import { OrgHeader } from "./account.js"; | |
| 12 | 13 | import { csrfToken } from "./csrf.js"; |
| 13 | 14 | import { Avatar, CopyButton, Form, Time, act, fmtBytes, post, principalAccount, prm, render, repoUrl, type WebApp } from "./ui.js"; |
| 14 | 15 | |
| @@ -27,6 +28,13 @@ | ||
| 27 | 28 | return owner; |
| 28 | 29 | } |
| 29 | 30 | |
| 31 | +/** An organization's packages pages keep its header and tab bar, with Packages selected. */ | |
| 32 | +async function orgShell(c: AppContext, org: Account) { | |
| 33 | + const me = principalAccount(c); | |
| 34 | + const role = me ? await c.get("dir").orgRole(org.id, me.kind === "agent" ? (me.owner_id ?? me.id) : me.id) : null; | |
| 35 | + return <OrgHeader org={org} role={role} isOwner={!!me?.site_admin || role === "owner"} tab="packages" />; | |
| 36 | +} | |
| 37 | + | |
| 30 | 38 | async function visible(c: AppContext, owner: Account, rows: PackageRow[]): Promise<PackageRow[]> { |
| 31 | 39 | const out: PackageRow[] = []; |
| 32 | 40 | for (const p of rows) if (allows((await packageAccess(c, owner, p.name, p)).role, "pull")) out.push(p); |
| @@ -117,17 +125,26 @@ | ||
| 117 | 125 | c, |
| 118 | 126 | { title: `${owner.login} packages` }, |
| 119 | 127 | <> |
| 120 | - <div class="row mb" style="gap:12px"> | |
| 121 | - <Avatar login={owner.login} size="lg" version={owner.avatar_version} /> | |
| 122 | - <div> | |
| 123 | - <h1 class="m0"> | |
| 124 | - <a href={`/${owner.login}`}>{owner.login}</a> <span class="faint">/</span> Packages | |
| 125 | - </h1> | |
| 126 | - <div class="muted small"> | |
| 128 | + {owner.kind === "org" ? ( | |
| 129 | + <> | |
| 130 | + {await orgShell(c, owner)} | |
| 131 | + <p class="muted small"> | |
| 127 | 132 | Container images at <span class="mono">{host(c)}/{owner.login.toLowerCase()}/…</span> |
| 133 | + </p> | |
| 134 | + </> | |
| 135 | + ) : ( | |
| 136 | + <div class="row mb" style="gap:12px"> | |
| 137 | + <Avatar login={owner.login} size="lg" version={owner.avatar_version} /> | |
| 138 | + <div> | |
| 139 | + <h1 class="m0"> | |
| 140 | + <a href={`/${owner.login}`}>{owner.login}</a> <span class="faint">/</span> Packages | |
| 141 | + </h1> | |
| 142 | + <div class="muted small"> | |
| 143 | + Container images at <span class="mono">{host(c)}/{owner.login.toLowerCase()}/…</span> | |
| 144 | + </div> | |
| 128 | 145 | </div> |
| 129 | 146 | </div> |
| 130 | - </div> | |
| 147 | + )} | |
| 131 | 148 | <div class="box"> |
| 132 | 149 | <PackageList |
| 133 | 150 | c={c} |
| @@ -159,6 +176,7 @@ | ||
| 159 | 176 | c, |
| 160 | 177 | { title: `${owner.login}/${pkg.name}`, description: pkg.description ?? `Container image ${image}` }, |
| 161 | 178 | <> |
| 179 | + {owner.kind === "org" ? await orgShell(c, owner) : null} | |
| 162 | 180 | <div class="mb"> |
| 163 | 181 | <h1 class="m0"> |
| 164 | 182 | <VisIcon v={pkg.visibility} /> |
modifiedapps/diffinery/test/e2e/registry.test.mjs+10−0View file
| @@ -373,6 +373,16 @@ | ||
| 373 | 373 | // ...but not to the members' unlinked packages, which he can't see. |
| 374 | 374 | assert.equal((await reg("GET", "crew/mira-tool/manifests/v1", { tok: CREW.otto })).status, 404); |
| 375 | 375 | assert.deepEqual((await ok("GET", "/orgs/crew/packages", undefined, CREW.otto)).map((p) => p.name), ["app"]); |
| 376 | + | |
| 377 | + // The organization's packages pages keep its header and tab bar, with Packages selected. | |
| 378 | + for (const path of ["/crew/-/packages", "/crew/-/packages/container/mira-tool"]) { | |
| 379 | + const page = await fetch(`${base}${path}`, { headers: { authorization: `Bearer ${CREW.mira}` } }); | |
| 380 | + const html = await page.text(); | |
| 381 | + assert.equal(page.status, 200, path); | |
| 382 | + assert.match(html, /Organization · crew · you are a member/, path); | |
| 383 | + assert.match(html, /<a href="\/crew\/-\/packages" class="on">/, path); | |
| 384 | + assert.match(html, /href="\/crew\?tab=people"/, path); | |
| 385 | + } | |
| 376 | 386 | }); |
| 377 | 387 | |
| 378 | 388 | function git(cwd, args) { |