Skip to content

feat(member-profile): self-service Linked Domains card (Stage 2 of #321)#4179

Merged
bokelley merged 4 commits into
mainfrom
bokelley/member-linked-domains-ui
May 7, 2026
Merged

feat(member-profile): self-service Linked Domains card (Stage 2 of #321)#4179
bokelley merged 4 commits into
mainfrom
bokelley/member-linked-domains-ui

Conversation

@bokelley

@bokelley bokelley commented May 7, 2026

Copy link
Copy Markdown
Contributor

Summary

  • New endpoints under /api/me/organization/domains: GET (list) and PUT /:domain/primary (set primary). Member self-service mirror of the admin "Linked Domains" card.
  • The PUT writes BOTH organization_domains.is_primary AND member_profiles.primary_brand_domain in one transaction. This is the bit the admin set-primary path doesn't do — and the cause of the Media.net escalation Hide billing button until user subscribes, move membership to top #321.
  • Linked Domains card added to member-profile.html for company orgs.

Closes #4158 (Stage 2 of the domain-cleanup series).

Why this PR's PUT is dual-write

In the admin Set-Primary path (admin-account-detail.htmlPUT /api/admin/organizations/:orgId/domains/:domain/primary), only organization_domains.is_primary and organizations.email_domain get written. member_profiles.primary_brand_domain is a separate column with a separate write path (the brand-claim verification flow). That's the two-primary surprise documented in #4159.

For a member self-service surface, we don't want to inflict that distinction on the user. So PUT writes both — when the domain is claimable (assertClaimableBrandDomain), primary_brand_domain follows. Existing brand-claim values are still preserved by the auto-populate path's IS NULL guard at the webhook (PR #4157); this PR only writes through an explicit member action.

Out of scope

  • POST (add a new domain, kicks off a WorkOS verification challenge): WorkOS-side wiring is materially more work; deferring to a follow-up.
  • DELETE (remove a domain): same — needs WorkOS coordination.
  • The four-column rationalization (Rationalize the four overlapping "domain" columns #4159): Stage 3, design question pending Brian's steer.

Test plan

  • 5 new integration tests:
    • lists verified domains with is_primary + is_brand_primary flags
    • PUT primary updates both organization_domains AND member_profiles.primary_brand_domain (asserts the dual-write)
    • rejects PUT primary from a non-owner/admin member (403)
    • rejects PUT primary on an unverified domain (400)
    • 404 on a domain not on the org
  • Typecheck clean
  • Manual smoke after merge: load member-profile.html as a multi-domain user; verify the Linked Domains card renders + Set Primary works.

🤖 Generated with Claude Code

bokelley and others added 4 commits May 7, 2026 05:13
GET /api/me/organization/domains lists verified org domains; PUT
/api/me/organization/domains/:domain/primary lets owners/admins switch the
primary. The PUT writes BOTH organization_domains.is_primary AND
member_profiles.primary_brand_domain in one transaction so members don't have
to know about the two-primary distinction (Media.net escalation #321 root cause).

Adds a Linked Domains card to the member-profile UI for company orgs, mirroring
what admins see in admin-account-detail.html. Closes #4158 (Stage 2 of the
domain-cleanup series).

POST (add domain → WorkOS verification challenge) and DELETE deferred to a
follow-up since they require WorkOS-side wiring that's materially more work
than the read+set paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s card

E2E caught: section never rendered because the conditional always
evaluated false (the /api/me/member-profile response does not include
is_personal). The empty-state path in loadLinkedDomains already handles
personal orgs (zero verified domains keeps the section hidden), so the
gate was unnecessary anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Restrict brand-primary dual-write to source='workos' rows only —
  admin-imported / manual 'verified' rows aren't DNS-proven and shouldn't
  seed brand identity. Org-membership inference primary still flips for
  any verified domain. Matches the trust boundary the
  backfill-primary-brand-domain script applies. (security-reviewer)
- Add SELECT 1 ... FOR UPDATE on the organizations row to serialize
  against concurrent WorkOS upsertOrganizationDomain webhooks that take
  the same lock. (code-reviewer)
- Canonicalize input :domain via canonicalizeBrandDomain instead of
  lowercase+trim — rejects malformed shapes early and matches the GET
  path's claimable check. (code-reviewer)
- Length-cap input domain at 253 chars (RFC 1035). (code-reviewer)
- Log ROLLBACK failure instead of silently swallowing. (code-reviewer)
- UI: keep the section hidden when 0 domains rather than rendering an
  empty-state copy that's confusing for personal orgs. (code-reviewer)
- Test cleanup: drop redundant inline auth middleware in buildApp; the
  vi.mock of requireAuth is what makes auth bypass work.
- Test coverage: add a case asserting source='import' rows can flip the
  org-membership primary but not the brand-identity primary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(semgrep)

Caught by CI semgrep rule error-message-leak. Log the underlying error
server-side, return only the error code to the caller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley
bokelley merged commit 1d97176 into main May 7, 2026
13 checks passed
@bokelley
bokelley deleted the bokelley/member-linked-domains-ui branch May 7, 2026 10:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Member-facing Linked Domains UI (self-service)

1 participant