Organizations: create-org flow, public org profile page, and discoverable owner-only management (profile edit + member add/remove) #348

Closed
opened 2026-09-11 18:52:47 +00:00 by crueber · 4 comments
Owner

What's requested

The ability to create an organization and manage it — with an org profile (like the owner profile from #234) that org admins can edit. Managing includes adding/removing users; only an org admin (owner) may add/remove people or edit the profile.

What already exists (verified — this is mostly a UI/reveal ticket)

The backend is substantially built:

  • Org objects: Org{Version, Org, DisplayName, Description, CreatedAt, UpdatedAt} at orgs/<org>/org.json; Members{Version, []Member{Principal, OrgRole, JoinedAt}} at members.json; Team at teams/<slug>.json (internal/identity/orgs.go:16-47).
  • Service surface: CreateOrg (:101, two-step create with creator→owner bootstrap at :115/:167), GetOrg (:221), PutOrg (:233), ListOrgs (:258), GetMembers (:312), SetMember (:319), RemoveMember (:360), plus full Teams CRUD (:425-552).
  • Roles: OrgOwner/OrgMember (internal/identity/identity.go:90-94); CheckOrgOwner gate for org writes (internal/identity/gate.go:79-91).
  • HTTP surface: /api/v1/orgs[/...] (GET/PUT/DELETE org), /orgs/{org}/members (GET collection; PUT/DELETE member), /orgs/{org}/teams CRUD (internal/identity/http.go:296, 378, 434, 530), plus invites (http_invites.go).
  • Settings UI exists: web/src/pages/Org.jsx at /:org/settings — tabs Profile / Members / Teams / Invitations, with role selects, invite-link form, and "org owner required" 403 handling. Team page at /:owner/teams/:slug.
  • Org-owned repos already resolve (isOrgOwner → admin in access resolution, access.go:234-236).

The gaps

  1. No way to create an org. The service and HTTP surface exist, but there is no create-org UI anywhere (no orgs.create call in web/src, no /orgs/new route, no button on any page). The only documented route is /:org/settings — for an org that already exists.
  2. No public org profile page. /:org/settings is the management view; there is no visitor-facing /:org landing that renders the org profile (display name, description) plus its public repos — a visitor clicking an org name in /explore lands on the repo list only. The owner-profile equivalent is #234's /:owner profile work; orgs need the same shape (reuse #234's profile patterns and #247/#283 listing data).
  3. Orgs are invisible in the top-level listing. GET /api/v1/owners and /explore mix orgs and users implicitly (an org is just an owner prefix) — with #283's activity rollup landed, org entries should be distinguishable (an is_org marker or joined org list) so the UI can badge them and link to the org profile rather than a bare repo list.
  4. Membership management exists but is settings-only and undiscoverable: adding users happens via /:org/settings → Members tab (direct PUT) and the Invitations tab (invite links) — functional, but nothing links to it (no "manage" affordance on an org page or in the nav for org owners).

Proposed design

  1. Create-org flow: a "New organization" entry point (button on /explore next to the existing New-repo/Import CTAs, and/or from the user menu) → small form (org name + display name + description) → POST /api/v1/orgs (the create service exists; verify the HTTP route accepts creation and the name-validation rules) → navigate to /:org/settings.
  2. Public org profile at /:org: currently /:org falls through to the repo-list view (route /:owner); extend it to render an org header (display name, description, org badge) above the repo list when the owner is an org — mirror #234's owner-profile shape (GetOrg exists; the profile-vs-user distinction can ride the #283/#345 listing rollup or a ListOrgs membership check). Include a "Manage" link (settings) visible only to org owners.
  3. Discovery: mark orgs in the owners/detailed listing payloads (is_org or a kind field) so /explore badges them (same pattern as the #281 mirror indicator).
  4. Admin-only mutations, already enforced server-side — CheckOrgOwner gates PUT org, member add/remove, and team writes; the UI already surfaces 403s as "org owner required". Keep that; add client-side gating so non-owner members see read-only views rather than 403 forms (use useRole/org membership state, same pattern as repo role gating).
  5. Guardrail interplay: org creation must be bounded (logged-in users only; a user creating an org becomes its owner — CreateOrg already bootstraps that at orgs.go:115) and ties into #346 (orgs a user belongs to become valid repo-owner choices) and #347 (org members' pushes resolve via org membership).

Acceptance criteria

  • A logged-in user can create an org (name/display/description) from the UI; they become its owner and land in /:org/settings.
  • /:org renders a public org profile (display name, description, org badge) above the org's repo list for any visitor; "Manage" link appears only for org owners.
  • Org owners can add/remove members and edit the profile from the settings tabs (existing functionality, now discoverable); non-owner members see read-only views without 403 forms.
  • The owners/detailed listing payload distinguishes orgs from users; /explore badges them and links to the profile view.
  • All mutations remain server-gated by CheckOrgOwner (403 for non-owners — unchanged); client gating is cosmetic-on-top.
  • Invitations and teams keep working through the same settings surface (no regression).
  • Headless tests for the org-vs-user distinction helper and the create-form validation; UI pass light/dark + 390px.
## What's requested The ability to **create an organization** and **manage it** — with an **org profile** (like the owner profile from #234) that org admins can edit. Managing includes **adding/removing users**; only an **org admin (owner)** may add/remove people or edit the profile. ## What already exists (verified — this is mostly a UI/reveal ticket) The backend is substantially built: - **Org objects**: `Org{Version, Org, DisplayName, Description, CreatedAt, UpdatedAt}` at `orgs/<org>/org.json`; `Members{Version, []Member{Principal, OrgRole, JoinedAt}}` at `members.json`; `Team` at `teams/<slug>.json` (`internal/identity/orgs.go:16-47`). - **Service surface**: `CreateOrg` (:101, two-step create with creator→owner bootstrap at :115/:167), `GetOrg` (:221), `PutOrg` (:233), `ListOrgs` (:258), `GetMembers` (:312), `SetMember` (:319), `RemoveMember` (:360), plus full Teams CRUD (:425-552). - **Roles**: `OrgOwner`/`OrgMember` (`internal/identity/identity.go:90-94`); `CheckOrgOwner` gate for org writes (`internal/identity/gate.go:79-91`). - **HTTP surface**: `/api/v1/orgs[/...]` (GET/PUT/DELETE org), `/orgs/{org}/members` (GET collection; PUT/DELETE member), `/orgs/{org}/teams` CRUD (`internal/identity/http.go:296, 378, 434, 530`), plus invites (`http_invites.go`). - **Settings UI exists**: `web/src/pages/Org.jsx` at `/:org/settings` — tabs Profile / Members / Teams / Invitations, with role selects, invite-link form, and "org owner required" 403 handling. Team page at `/:owner/teams/:slug`. - **Org-owned repos already resolve** (`isOrgOwner` → admin in access resolution, `access.go:234-236`). ## The gaps 1. **No way to create an org.** The service and HTTP surface exist, but there is **no create-org UI anywhere** (no `orgs.create` call in `web/src`, no `/orgs/new` route, no button on any page). The only documented route is `/:org/settings` — for an org that already exists. 2. **No public org profile page.** `/:org/settings` is the management view; there is no visitor-facing `/:org` landing that renders the org profile (display name, description) plus its public repos — a visitor clicking an org name in `/explore` lands on the repo list only. The owner-profile equivalent is #234's `/:owner` profile work; orgs need the same shape (reuse #234's profile patterns and #247/#283 listing data). 3. **Orgs are invisible in the top-level listing.** `GET /api/v1/owners` and `/explore` mix orgs and users implicitly (an org is just an owner prefix) — with #283's activity rollup landed, org entries should be distinguishable (an `is_org` marker or joined org list) so the UI can badge them and link to the org profile rather than a bare repo list. 4. **Membership management exists but is settings-only and undiscoverable**: adding users happens via `/:org/settings` → Members tab (direct PUT) and the Invitations tab (invite links) — functional, but nothing links to it (no "manage" affordance on an org page or in the nav for org owners). ## Proposed design 1. **Create-org flow**: a "New organization" entry point (button on `/explore` next to the existing New-repo/Import CTAs, and/or from the user menu) → small form (org name + display name + description) → `POST /api/v1/orgs` (the create service exists; verify the HTTP route accepts creation and the name-validation rules) → navigate to `/:org/settings`. 2. **Public org profile at `/:org`**: currently `/:org` falls through to the repo-list view (route `/:owner`); extend it to render an org header (display name, description, org badge) above the repo list when the owner is an org — mirror #234's owner-profile shape (`GetOrg` exists; the profile-vs-user distinction can ride the #283/#345 listing rollup or a `ListOrgs` membership check). Include a "Manage" link (settings) visible only to org owners. 3. **Discovery**: mark orgs in the owners/detailed listing payloads (`is_org` or a `kind` field) so `/explore` badges them (same pattern as the #281 mirror indicator). 4. **Admin-only mutations, already enforced server-side** — `CheckOrgOwner` gates PUT org, member add/remove, and team writes; the UI already surfaces 403s as "org owner required". Keep that; add client-side gating so non-owner members see read-only views rather than 403 forms (use `useRole`/org membership state, same pattern as repo role gating). 5. **Guardrail interplay**: org creation must be bounded (logged-in users only; a user creating an org becomes its owner — `CreateOrg` already bootstraps that at orgs.go:115) and ties into #346 (orgs a user belongs to become valid repo-owner choices) and #347 (org members' pushes resolve via org membership). ## Acceptance criteria - [ ] A logged-in user can create an org (name/display/description) from the UI; they become its owner and land in `/:org/settings`. - [ ] `/:org` renders a public org profile (display name, description, org badge) above the org's repo list for any visitor; "Manage" link appears only for org owners. - [ ] Org owners can add/remove members and edit the profile from the settings tabs (existing functionality, now discoverable); non-owner members see read-only views without 403 forms. - [ ] The owners/detailed listing payload distinguishes orgs from users; `/explore` badges them and links to the profile view. - [ ] All mutations remain server-gated by `CheckOrgOwner` (403 for non-owners — unchanged); client gating is cosmetic-on-top. - [ ] Invitations and teams keep working through the same settings surface (no regression). - [ ] Headless tests for the org-vs-user distinction helper and the create-form validation; UI pass light/dark + 390px.
crueber added this to the v1 milestone 2026-09-11 18:52:47 +00:00
Author
Owner

This ticket is a prerequisite for #346 and #347 - both now carry a sequencing header pointing here: the member-org half of their ownership guardrails is untestable until orgs can be created and managed through the UI. Implementation order: #348 -> #346 -> #347.

This ticket is a prerequisite for #346 and #347 - both now carry a sequencing header pointing here: the member-org half of their ownership guardrails is untestable until orgs can be created and managed through the UI. Implementation order: #348 -> #346 -> #347.
Author
Owner

Fix PR: #355 (branch fix/issue-348) — create-org UI at /orgs/new, public org profile on /:org, is_org badges on /explore, owner-only management gating. All acceptance criteria covered; server CheckOrgOwner gates unchanged. Not merging — review requested.

Fix PR: https://git.packden.us/crueber/walhub/pulls/355 (branch fix/issue-348) — create-org UI at /orgs/new, public org profile on /:org, is_org badges on /explore, owner-only management gating. All acceptance criteria covered; server CheckOrgOwner gates unchanged. Not merging — review requested.
Author
Owner

REVIEW PR #355 (fix/issue-348, commit 420171e + review fixup c3e091c) — verified in scratch worktree /tmp/pr355 (since removed).

ACCEPTANCE vs #348 (all 4 gaps closed):

  1. Create-org flow ✓ — /orgs/new static route declared before /:owner (web/src/index.jsx:63-66), so it never resolves as repo orgs/new; form → orgs.create → POST /api/v1/orgs → navigate /:org/settings; invalidate('owners') matches Owners.jsx useData('owners') key so /explore refetches. Entry button on /explore next to New-repo/Import under the same logged-in-writer gate — discoverable ✓
  2. Public org profile ✓ — Repos.jsx renders badge + display name + description above the repo list when orgs.get resolves non-null (404→null, users unchanged, no double @handle). Manage link (header + footer) gated on profile can_edit only ✓
  3. Discovery ✓ — owners/detailed rows carry always-present is_org; /explore badges via #281 pill pattern, zero extra GETs ✓
  4. Read-only management ✓ — all four settings tabs key on canManage() from the server can_edit; read-only notices replace forms for non-owners; server 403s untouched (no backend gate change) ✓

SEAM / PRIVILEGE / LAW CHECKS:

  • Law 8 ✓: new OrgLister interface in internal/api/placeholder.go, Env.Orgs field, wired in cmd/walhub/collab.go with compiler assertion; api never imports identity.
  • Law 6 ✓: exactly one ListOrgs per listing regardless of owner count (not per-owner probes, not the #283 catalog aggregate — correct, the catalog knows repos not org namespaces).
  • Fail-open safe ✓: nil seam → all false; list error → 200 all-false. Cannot leak private data (org names already visible as owner prefixes; false-biased).
  • No privilege leak client-side ✓: can_edit computed server-side (profile.go ownerProfileGet via OwnerEditor seam; anon forced false); client only renders.
  • #345 no-regress ✓: repo listing path untouched, still server-filtered; profile fetch is org.json only, never members.json — no member-list exposure to visitors.
  • Route conflicts ✓: org/user namespace collision is a pre-existing service property; null-fallback keeps user pages unchanged.

ONE FIX APPLIED (pushed as c3e091c): web/src/lib/orgs.js shipped myOrgRole (roster-role lookup) with tests, but nothing imported it — Org.jsx gates on server can_edit, not a local roster lookup. Dead code + doc claimed it as an in-use rule. Removed helper + its 2 tests, fixed the 12_web_ui.md decision line. Small, re-tested.

VERIFICATION (scratch worktree, post-fix): go test -race internal/api ✓ + internal/identity ✓; api coverage 95.3% ✓ (≥95 gate); gofmt/vet clean; node --test 692/692 pass (smoke.test.js excluded — see note); orgs.test.js 5/5; vite build + esbuild SDK bundle green; go build ./... clean; go.mod + package.json untouched (no new deps); docs decisions in 01_identity_permissions.md + 07_api.md + 12_web_ui.md (law 12 ✓).
NOTE (no browser, per instructions): web/test/unit/smoke.test.js fails in THIS environment only — something answers :8080/healthz but / returns 503 (setup-only-mode signature), so the smoke assertions trip. Environmental, unrelated to the PR (test self-skips when no server is up; I did not touch the live process). Browser proof (light/dark + 390px) still open as the PR notes.

MERGE RECOMMENDATION: ready to merge (after normal CI; browser pass still open per the PR's own note).

REVIEW PR #355 (fix/issue-348, commit 420171e + review fixup c3e091c) — verified in scratch worktree /tmp/pr355 (since removed). ACCEPTANCE vs #348 (all 4 gaps closed): 1. Create-org flow ✓ — /orgs/new static route declared before /:owner (web/src/index.jsx:63-66), so it never resolves as repo orgs/new; form → orgs.create → POST /api/v1/orgs → navigate /:org/settings; invalidate('owners') matches Owners.jsx useData('owners') key so /explore refetches. Entry button on /explore next to New-repo/Import under the same logged-in-writer gate — discoverable ✓ 2. Public org profile ✓ — Repos.jsx renders badge + display name + description above the repo list when orgs.get resolves non-null (404→null, users unchanged, no double @handle). Manage link (header + footer) gated on profile can_edit only ✓ 3. Discovery ✓ — owners/detailed rows carry always-present is_org; /explore badges via #281 pill pattern, zero extra GETs ✓ 4. Read-only management ✓ — all four settings tabs key on canManage() from the server can_edit; read-only notices replace forms for non-owners; server 403s untouched (no backend gate change) ✓ SEAM / PRIVILEGE / LAW CHECKS: - Law 8 ✓: new OrgLister interface in internal/api/placeholder.go, Env.Orgs field, wired in cmd/walhub/collab.go with compiler assertion; api never imports identity. - Law 6 ✓: exactly one ListOrgs per listing regardless of owner count (not per-owner probes, not the #283 catalog aggregate — correct, the catalog knows repos not org namespaces). - Fail-open safe ✓: nil seam → all false; list error → 200 all-false. Cannot leak private data (org names already visible as owner prefixes; false-biased). - No privilege leak client-side ✓: can_edit computed server-side (profile.go ownerProfileGet via OwnerEditor seam; anon forced false); client only renders. - #345 no-regress ✓: repo listing path untouched, still server-filtered; profile fetch is org.json only, never members.json — no member-list exposure to visitors. - Route conflicts ✓: org/user namespace collision is a pre-existing service property; null-fallback keeps user pages unchanged. ONE FIX APPLIED (pushed as c3e091c): web/src/lib/orgs.js shipped myOrgRole (roster-role lookup) with tests, but nothing imported it — Org.jsx gates on server can_edit, not a local roster lookup. Dead code + doc claimed it as an in-use rule. Removed helper + its 2 tests, fixed the 12_web_ui.md decision line. Small, re-tested. VERIFICATION (scratch worktree, post-fix): go test -race internal/api ✓ + internal/identity ✓; api coverage 95.3% ✓ (≥95 gate); gofmt/vet clean; node --test 692/692 pass (smoke.test.js excluded — see note); orgs.test.js 5/5; vite build + esbuild SDK bundle green; go build ./... clean; go.mod + package.json untouched (no new deps); docs decisions in 01_identity_permissions.md + 07_api.md + 12_web_ui.md (law 12 ✓). NOTE (no browser, per instructions): web/test/unit/smoke.test.js fails in THIS environment only — something answers :8080/healthz but / returns 503 (setup-only-mode signature), so the smoke assertions trip. Environmental, unrelated to the PR (test self-skips when no server is up; I did not touch the live process). Browser proof (light/dark + 390px) still open as the PR notes. MERGE RECOMMENDATION: ready to merge (after normal CI; browser pass still open per the PR's own note).
Author
Owner

Fixed by PR #355 (review clean + one dead-code removal by reviewer; create flow, profile gating, is_org seam, #345 no-regress verified), merged. Closing.

Fixed by PR #355 (review clean + one dead-code removal by reviewer; create flow, profile gating, is_org seam, #345 no-regress verified), merged. Closing.
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#348
No description provided.