Owner profile: organizations membership list with explicit empty state (backend membership rail missing) #423
Labels
No labels
actions
bug
cli
duplicate
enhancement
fork
forum
git storage
help wanted
insights
invalid
issues
moderation
oidc
ownership transfer
packages
pr/merge protection rules
projects
pull requests
question
releases
sponsorships
tags
webhooks
wiki
wontfix
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
crueber/walhub#423
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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)
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) hasorgs.list/orgs.members.listbut no "orgs of principal" call.GET /api/v1/orgs/{org}/membersexists (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 withMemberOrgsFor.internal/api/profile.goOwnerProfile(owner, display_name, location, timezone, bio_markdown, can_edit) — no orgs field.Proposed design
GET /api/v1/users/{principal}/orgs(names or org summaries), implemented over the existingMemberOrgsFor; ormember_orgs: [...]field on the owner profile doc (mind the profile ETag:profileETagininternal/api/profile.gomust cover any new field, andinternal/api/profile.goGET 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 underweb/sdk/src/orgs.js(or users.js)./: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).isOrg()branch), show the members roster via the existingGET /api/v1/orgs/{org}/membersinstead (or omit the section — planner's call).Acceptance criteria
RegisterExposedmeans new surfaces go undocumented by default).vite buildgreen; server tests green.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.
REVIEW PR #429 (fix/issue-423, +1 commit
1fc8e0cby 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
1fc8e0cpushed to origin/fix/issue-423; all gates green).Fixed by PR #429 (review clean + one username/email roster-spelling fix by reviewer; endpoint decision, twins, ETag verified), merged. Closing.
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.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.