Owner profile: organizations membership list with explicit empty state (backend membership rail missing) #423

Closed
opened 2026-09-12 22:56:29 +00:00 by crueber · 5 comments
Owner

What's requested

Show the organizations a user belongs to on their profile page, with an explicit empty state when the list is empty ("N/A" / "No organizations" — never a silently absent section). GitHub shows "Organizations" in the profile sidebar for this.

What exists already (verified against the tree)

  • Server: internal/identity/creategate.go — Service.MemberOrgsFor(ctx, p) (line ~113) returns the sorted names of every org whose roster contains the principal (any role), with the #370 email-alias matching. It is not exposed over HTTP: grep -rn MemberOrgs internal/identity/http.go internal/api/*.go → no route, and the SDK (web/sdk/src/orgs.js) has orgs.list/orgs.members.list but no "orgs of principal" call.
  • GET /api/v1/orgs/{org}/members exists (roster per org), but answering "which orgs does user X belong to" from the client would require listing ALL orgs and fetching every roster — a human-rate caller pattern the server already solves internally with MemberOrgsFor.
  • Profile payload: internal/api/profile.go OwnerProfile (owner, display_name, location, timezone, bio_markdown, can_edit) — no orgs field.

Proposed design

  1. Backend (the gap to flag): expose a membership rail. Two shapes — planner's call, note the decision:
    • A dedicated endpoint GET /api/v1/users/{principal}/orgs (names or org summaries), implemented over the existing MemberOrgsFor; or
    • A member_orgs: [...] field on the owner profile doc (mind the profile ETag: profileETag in internal/api/profile.go must cover any new field, and internal/api/profile.go GET is a mutable PUT-able doc — cache class per the #382 law, see the #385 precedent).
      NonRepo endpoints need all three route twins (/api/v1/…, api-browser/v1/…, services/api/… — internal/api/routes.go); if the field lands on the profile, all three twins already exist. SDK: add the call under web/sdk/src/orgs.js (or users.js).
  2. Client: render an Organizations section on the profile page (fits the sidebar from the layout ticket — cross-ref if filed together), linking each org name to /:org. Gate visibility like the rest of the identity surface: list orgs the viewer may see (MemberOrgsFor already returns all rosters containing the principal; whether org listing should respect org visibility/private rosters is a planner decision — note it).
  3. Empty state (explicit requirement): when the list is empty, render an explicit "No organizations" muted line — the section must never silently disappear for users, matching the GitHub profile behavior. For org profiles (the isOrg() branch), show the members roster via the existing GET /api/v1/orgs/{org}/members instead (or omit the section — planner's call).

Acceptance criteria

  • Backend membership surface decided and implemented (endpoint or profile field — decision recorded in the issue/PR).
  • SDK method added; discovery document / API docs updated if a new endpoint lands (the #272 lesson: opt-in RegisterExposed means new surfaces go undocumented by default).
  • Profile page shows the user's orgs with links; empty state renders an explicit "No organizations" line, never an absent section.
  • ETag/cache-class coverage for any new profile field (verification: edit bio → orgs still fresh, no stale-serve window).
  • Headless tests for the new rail + page rendering; vite build green; server tests green.
## What's requested Show the organizations a user belongs to on their profile page, with an explicit empty state when the list is empty ("N/A" / "No organizations" — never a silently absent section). GitHub shows "Organizations" in the profile sidebar for this. ## What exists already (verified against the tree) - Server: `internal/identity/creategate.go` — `Service.MemberOrgsFor(ctx, p)` (line ~113) returns the sorted names of every org whose roster contains the principal (any role), with the #370 email-alias matching. It is **not exposed over HTTP**: `grep -rn MemberOrgs internal/identity/http.go internal/api/*.go` → no route, and the SDK (`web/sdk/src/orgs.js`) has `orgs.list`/`orgs.members.list` but no "orgs of principal" call. - `GET /api/v1/orgs/{org}/members` exists (roster per org), but answering "which orgs does user X belong to" from the client would require listing ALL orgs and fetching every roster — a human-rate caller pattern the server already solves internally with `MemberOrgsFor`. - Profile payload: `internal/api/profile.go` `OwnerProfile` (owner, display_name, location, timezone, bio_markdown, can_edit) — no orgs field. ## Proposed design 1. **Backend (the gap to flag):** expose a membership rail. Two shapes — planner's call, note the decision: - A dedicated endpoint `GET /api/v1/users/{principal}/orgs` (names or org summaries), implemented over the existing `MemberOrgsFor`; or - A `member_orgs: [...]` field on the owner profile doc (mind the profile ETag: `profileETag` in `internal/api/profile.go` must cover any new field, and `internal/api/profile.go` GET is a mutable PUT-able doc — cache class per the #382 law, see the #385 precedent). NonRepo endpoints need all three route twins (`/api/v1/…`, `api-browser/v1/…`, `services/api/…` — `internal/api/routes.go`); if the field lands on the profile, all three twins already exist. SDK: add the call under `web/sdk/src/orgs.js` (or users.js). 2. **Client:** render an Organizations section on the profile page (fits the sidebar from the layout ticket — cross-ref if filed together), linking each org name to `/:org`. Gate visibility like the rest of the identity surface: list orgs the viewer may see (MemberOrgsFor already returns all rosters containing the principal; whether org listing should respect org visibility/private rosters is a planner decision — note it). 3. **Empty state (explicit requirement):** when the list is empty, render an explicit "No organizations" muted line — the section must never silently disappear for users, matching the GitHub profile behavior. For org profiles (the `isOrg()` branch), show the members roster via the existing `GET /api/v1/orgs/{org}/members` instead (or omit the section — planner's call). ## Acceptance criteria - [ ] Backend membership surface decided and implemented (endpoint or profile field — decision recorded in the issue/PR). - [ ] SDK method added; discovery document / API docs updated if a new endpoint lands (the #272 lesson: opt-in `RegisterExposed` means new surfaces go undocumented by default). - [ ] Profile page shows the user's orgs with links; empty state renders an explicit "No organizations" line, never an absent section. - [ ] ETag/cache-class coverage for any new profile field (verification: edit bio → orgs still fresh, no stale-serve window). - [ ] Headless tests for the new rail + page rendering; `vite build` green; server tests green.
crueber added this to the v1 milestone 2026-09-12 22:56:29 +00:00
Author
Owner

Fixed by PR #429 (#429) — decision: dedicated endpoint GET /api/v1/users/{principal}/orgs over MemberOrgsFor (not a profile field; rationale in the PR + docs/features/01 §Decisions). Rail serves sorted names ([]-never-null, 200 for unknown), mutable-collab + content ETag; profile page renders Organizations with links + explicit 'No organizations' empty state; org profiles omit the section (#370 non-routable email spellings). Tests: identity -race green (routeUserOrgs 100%, pkg 95.7%), node 891/0, vite green.

Fixed by PR #429 (https://git.packden.us/crueber/walhub/pulls/429) — decision: dedicated endpoint GET /api/v1/users/{principal}/orgs over MemberOrgsFor (not a profile field; rationale in the PR + docs/features/01 §Decisions). Rail serves sorted names ([]-never-null, 200 for unknown), mutable-collab + content ETag; profile page renders Organizations with links + explicit 'No organizations' empty state; org profiles omit the section (#370 non-routable email spellings). Tests: identity -race green (routeUserOrgs 100%, pkg 95.7%), node 891/0, vite green.
Author
Owner

REVIEW PR #429 (fix/issue-423, +1 commit 1fc8e0c by reviewer — see (3) below). Verified in scratch worktree /tmp/pr429 (since removed); main worktree untouched. No browser (per brief: node tests + reasoning); no docker/compose; live :8080 instance never touched (it answers there — smoke tests bind to it, see (9)).

(1) DECISION — endpoint over profile field: SOUND (law 8). Owner-profile route lives in core internal/api, which must never import identity; a member_orgs field would need a new seam plus a LIST+probes fan-out on every profile GET. Dedicated rail keeps cost on human-rate profile loads, leaves profileETag untouched (internal/api/profile.go not in diff — confirmed). Law 6 holds: LIST + one exact-key members.json GET per org, server-side, never a git hot path.

(2) TWINS + DISCOVERY + DOCS: COMPLETE. Both lanes via Handler.Handle→handleTop (internal/identity/http.go:227); ExposedTemplates += /api/v1/users/{principal}/orgs (http.go:34) flows into discovery through the existing api.RegisterExposed(identity.ExposedTemplates...) in cmd/walhub/collab.go:68 — no composition change needed. Pinned by TestExposedTemplatesExact + TestExposedCoversRoutes (both lanes). No /services/api twin — correct: no identity surface has one (lane rule 14.12.12, top-level twins only). Apidocs.jsx:36 documents the route; SDK users.orgs in web/sdk/src/users.js (right home — path is /users/...) with sdk-identity.test.js case; docs/features/01_identity_permissions.md route table + Decisions entry recorded (law 12).

(3) AUTH — ONE REAL BUG, FIXED + PUSHED (1fc8e0c). routeUserOrgs passed bare auth.Principal{Name: principal} to MemberOrgsFor, so a USERNAME spelling never matched email-held roster rows (matchPrincipal needs Name or Email; invite-accept writes the invite-subject spelling, typically the email — invites.go:494). Repro: seedOrg (email rosters) + ResolveUsername, GET /users/bob/orgs → 200 [] (wrong; profile page passes username slugs). Fix (http.go:375-385): username-shaped targets carry EmailForUsername, mirroring the profile-GET (http.go:292) and avatar (avatar.go:253) precedents; unbound usernames still 200 []. New TestUserOrgsUsernameSpelling fails before (200 []), passes after. Caller-auth gate correct (401 + WWW-Authenticate when anonymous_read off, mirrors profile/members); target validation correct (400 via routeUsers ValidPrincipal before dispatch). RESIDUAL (non-blocking, noted): email-shaped target vs username-held rows has no read-only reverse lookup (alias docs are login-created); primary consumer (username slugs) is covered.

(4) COST: no per-org client fan-out — single rail, server-side LIST+probes. Fine.

(5) UI: CORRECT. Section (Repos.jsx:448-476) sits inside Show when={!isOrg()} (line 405) — users always render it (loading… → list | explicit 'No organizations', never absent); org profiles omit (isOrg branch line 486; member emails aren't routable slugs per #370 — comment says so). Links → /:org. normalizeMemberOrgs (orgs.js:82) coerces/dedupes/sorts, headless-tested (member-orgs.test.js). Nit (non-blocking): memberorgs useData key fires for org pages too (unused fetch, human-rate, tiny) — key-gating would need org-resolution ordering; not worth the churn.

(6) VISIBILITY: recorded planner decision (docs + http.go:340-344) — no roster filtering, anonymous gated by anonymous_read like profile/members. Noted explicitly. OK.

(7) ETAG/CACHE (#382): CORRECT. ccMutable + content ETag over sorted names (fnv); contract test covers it (cacheclass_test.go:73, ccMutable+ETag). Live-verified on scratch server: 200 private,no-cache + Etag user-orgs-v-*; roster change busts tag (TestUserOrgsETag), bio edit leaves rail fresh (TestUserOrgsFreshAfterBioEdit). GET-only (405 otherwise).

(8) profileETag: untouched. Confirmed — no internal/api changes in diff.

(9) TESTS/DEPS: identity go test -race PASS, coverage 95.6-95.7% (≥95 gate); gofmt/vet clean; go build ./... ok; cmd/walhub tests ok (collab discovery). Node: 888 pass / 0 fail / 3 skipped (891 total) with smoke skipped (no server, as designed). vite build + esbuild bundle green. Smoke assertions verified by curl against a scratch server on :18099 (own data-dir, stopped afterward): / + /setup shell (root, hashed asset, dark), asset 200 text/javascript immutable, repos.js ships the rail call, rail live 200 [] + ETag. No new deps (go.mod/npm untouched). NOTE: repo has an ambient live server on :8080, so unconfigured node --test runs its 2 smoke tests against THAT server and fails — environmental, pre-existing, unrelated to this PR. No browser used (per brief).

MERGE RECOMMENDATION: ready to merge (reviewer fix 1fc8e0c pushed to origin/fix/issue-423; all gates green).

REVIEW PR #429 (fix/issue-423, +1 commit 1fc8e0c by reviewer — see (3) below). Verified in scratch worktree /tmp/pr429 (since removed); main worktree untouched. No browser (per brief: node tests + reasoning); no docker/compose; live :8080 instance never touched (it answers there — smoke tests bind to it, see (9)). (1) DECISION — endpoint over profile field: SOUND (law 8). Owner-profile route lives in core internal/api, which must never import identity; a member_orgs field would need a new seam plus a LIST+probes fan-out on every profile GET. Dedicated rail keeps cost on human-rate profile loads, leaves profileETag untouched (internal/api/profile.go not in diff — confirmed). Law 6 holds: LIST + one exact-key members.json GET per org, server-side, never a git hot path. (2) TWINS + DISCOVERY + DOCS: COMPLETE. Both lanes via Handler.Handle→handleTop (internal/identity/http.go:227); ExposedTemplates += /api/v1/users/{principal}/orgs (http.go:34) flows into discovery through the existing api.RegisterExposed(identity.ExposedTemplates...) in cmd/walhub/collab.go:68 — no composition change needed. Pinned by TestExposedTemplatesExact + TestExposedCoversRoutes (both lanes). No /services/api twin — correct: no identity surface has one (lane rule 14.12.12, top-level twins only). Apidocs.jsx:36 documents the route; SDK users.orgs in web/sdk/src/users.js (right home — path is /users/...) with sdk-identity.test.js case; docs/features/01_identity_permissions.md route table + Decisions entry recorded (law 12). (3) AUTH — ONE REAL BUG, FIXED + PUSHED (1fc8e0c). routeUserOrgs passed bare auth.Principal{Name: principal} to MemberOrgsFor, so a USERNAME spelling never matched email-held roster rows (matchPrincipal needs Name or Email; invite-accept writes the invite-subject spelling, typically the email — invites.go:494). Repro: seedOrg (email rosters) + ResolveUsername, GET /users/bob/orgs → 200 [] (wrong; profile page passes username slugs). Fix (http.go:375-385): username-shaped targets carry EmailForUsername, mirroring the profile-GET (http.go:292) and avatar (avatar.go:253) precedents; unbound usernames still 200 []. New TestUserOrgsUsernameSpelling fails before (200 []), passes after. Caller-auth gate correct (401 + WWW-Authenticate when anonymous_read off, mirrors profile/members); target validation correct (400 via routeUsers ValidPrincipal before dispatch). RESIDUAL (non-blocking, noted): email-shaped target vs username-held rows has no read-only reverse lookup (alias docs are login-created); primary consumer (username slugs) is covered. (4) COST: no per-org client fan-out — single rail, server-side LIST+probes. Fine. (5) UI: CORRECT. Section (Repos.jsx:448-476) sits inside Show when={!isOrg()} (line 405) — users always render it (loading… → list | explicit 'No organizations', never absent); org profiles omit (isOrg branch line 486; member emails aren't routable slugs per #370 — comment says so). Links → /:org. normalizeMemberOrgs (orgs.js:82) coerces/dedupes/sorts, headless-tested (member-orgs.test.js). Nit (non-blocking): memberorgs useData key fires for org pages too (unused fetch, human-rate, tiny) — key-gating would need org-resolution ordering; not worth the churn. (6) VISIBILITY: recorded planner decision (docs + http.go:340-344) — no roster filtering, anonymous gated by anonymous_read like profile/members. Noted explicitly. OK. (7) ETAG/CACHE (#382): CORRECT. ccMutable + content ETag over sorted names (fnv); contract test covers it (cacheclass_test.go:73, ccMutable+ETag). Live-verified on scratch server: 200 private,no-cache + Etag user-orgs-v-*; roster change busts tag (TestUserOrgsETag), bio edit leaves rail fresh (TestUserOrgsFreshAfterBioEdit). GET-only (405 otherwise). (8) profileETag: untouched. Confirmed — no internal/api changes in diff. (9) TESTS/DEPS: identity go test -race PASS, coverage 95.6-95.7% (≥95 gate); gofmt/vet clean; go build ./... ok; cmd/walhub tests ok (collab discovery). Node: 888 pass / 0 fail / 3 skipped (891 total) with smoke skipped (no server, as designed). vite build + esbuild bundle green. Smoke assertions verified by curl against a scratch server on :18099 (own data-dir, stopped afterward): / + /setup shell (root, hashed asset, dark), asset 200 text/javascript immutable, repos.js ships the rail call, rail live 200 [] + ETag. No new deps (go.mod/npm untouched). NOTE: repo has an ambient live server on :8080, so unconfigured node --test runs its 2 smoke tests against THAT server and fails — environmental, pre-existing, unrelated to this PR. No browser used (per brief). MERGE RECOMMENDATION: ready to merge (reviewer fix 1fc8e0c pushed to origin/fix/issue-423; all gates green).
Author
Owner

Fixed by PR #429 (review clean + one username/email roster-spelling fix by reviewer; endpoint decision, twins, ETag verified), merged. Closing.

Fixed by PR #429 (review clean + one username/email roster-spelling fix by reviewer; endpoint decision, twins, ETag verified), merged. Closing.
Author
Owner

Superseded in presentation by #430: the landed membership rail stays, but the Organizations surface moves to a dedicated tab (/:owner/organizations) with route-specific rendering. Backend work from this issue (users.orgs, memberorgs:{owner}) is reused unchanged.

Superseded in presentation by #430: the landed membership rail stays, but the Organizations surface moves to a dedicated tab (`/:owner/organizations`) with route-specific rendering. Backend work from this issue (users.orgs, `memberorgs:{owner}`) is reused unchanged.
Author
Owner

Cross-reference: issue #430 ("Owner profile: Organizations becomes a third tab") absorbs the remainder of this ticket — the backend membership endpoint landed here (users.orgs, memberorgs:{owner} key, explicit empty state) stays as-is; only its presentation surface moves from the profile rail to the dedicated /:owner/organizations tab. Fix PR: #434.

Cross-reference: issue #430 ("Owner profile: Organizations becomes a third tab") absorbs the remainder of this ticket — the backend membership endpoint landed here (users.orgs, memberorgs:{owner} key, explicit empty state) stays as-is; only its presentation surface moves from the profile rail to the dedicated /:owner/organizations tab. Fix PR: https://git.packden.us/crueber/walhub/pulls/434.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
crueber/walhub#423
No description provided.