Story-738 * app lane * Size M * Walkthrough

Clerk-direct invitations and unified members list

The Members & Roles page learned the difference between people already inside the firm and people still holding an invitation.

Pull Request
Built by
Conductor agent on 2026-05-21

The 30-second version

Story-738 fixed the Members & Roles page so it shows both accepted firm members and pending Clerk invitations. Accepted members still come from the PRAMAAN backend. Pending invitations now come straight from Clerk. The page merges those two sources into one list, lets firm admins resend or revoke invitations, and keeps ordinary firm members in a read-only view.

PART ONE - WHAT WE PLANNED TO DO AND WHY

1 Why does this exist?

Before this Story, Settings -> Members could show the people who had already accepted and become PRAMAAN users. It could not show the people who had been invited but had not accepted yet. That made the admin screen feel like it forgot half the job.

The architecture reason is important: a pending invitation is not a PRAMAAN member yet. It is a Clerk organization invitation. PRAMAAN should create its own member row only after the invite is accepted and the Clerk webhook confirms the user really joined.

[door]

Think of a law office reception desk

The employee directory lists people who work in the office. The visitor clipboard at reception lists people who were invited but have not walked in yet. If you shove clipboard names into the employee directory, security, payroll, and seating all get confused. Story-738 kept those lists honest, then showed both of them on one admin desk.

Ask yourself

Why not create a PRAMAAN member row as soon as the admin sends the invite?

Answer: because the invite may never be accepted. A member row means "this person exists in our app and belongs to this firm." A Clerk invitation means "we asked this email to join." Those are different states, like a signed employment contract versus an offer letter still sitting in an inbox.

2 The plan

The spec locked the key decision before code started: pending invitations live only in Clerk. PRAMAAN rows are created after acceptance. So the app needed to read from two places, keep their types separate, and render them together.

The planned shape was a discriminated union: kind: "member" for backend members, and kind: "invitation" for Clerk invitations. That one word, kind, is the table's traffic sign. It tells the UI which row component to use and which actions are legal.

Settings page Members & Roles Backend bridge accepted members Clerk SDK pending invitations Story-738 merge layer STORY-738 firm-invitation.ts + unified.ts kind=invitation before kind=member Unified table rows STORY-738 MemberRow or InvitationRow Firm admin resend / revoke Firm member read-only 1 2 3 4 5 6 read-only 7 actions

The green boxes are the new Story-738-owned shape: the unified item type, the merge helper, and the UI rows that understand both members and invitations. The other boxes already existed or are external services.

What the spec said would ship

The Story asked for invitation types, Clerk-direct list/resend/revoke actions, a page loader that merges backend members with Clerk invitations, UI rows for both kinds, admin-only actions, read-only member behavior, audit events, and tests around actions, merging, and rendering.

Out of scope

Backend cleanup, new invitation webhooks, custom email templates, bulk invite, bulk revoke, invitation expiry policy, and multi-firm transfer logic stayed out. This Story fixed one surface: firm admins managing pending invitations from the Members & Roles page.

PART TWO - HOW WE ACTUALLY DID IT

3 What got built

PR #165 changed 21 files with 1,327 additions and 278 deletions. It added new invitation types, a merge helper, a pending-invitation row, presentation helpers, server-action tests, merge tests, and component tests. It also rewired the page to load backend members and Clerk invitations together.

Reading order

  1. src/types/firm-invitation.ts - learn the two row shapes.
  2. src/lib/firm-members/unified.ts - see how invitations and members are merged.
  3. src/app/[locale]/(app)/settings/firm/members/actions.ts - read the Clerk-direct actions and admin gates.
  4. src/app/[locale]/(app)/settings/firm/members/page.tsx - see the two parallel loads.
  5. src/components/settings/members-roles/members-roles-content.tsx - see refresh, read-only behavior, and action wiring.
  6. src/components/settings/members-roles/members-table.tsx - see the kind switch.
  7. src/components/settings/members-roles/invitation-row.tsx - inspect the new pending-invitation row.
  8. tests/unit/members-actions.test.ts, tests/unit/firm-members-unified.test.ts, and tests/unit/members-roles-content.test.tsx - read the protected behavior.
src/types/firm-invitation.tsType contract

This file names the new data shape. A FirmInvitationListItem is a Clerk invitation adapted into app terms: invitation id, email, firm role, invited-at timestamp, and status.

The important type is FirmMembersUnifiedItem. It is either { kind: "member"; member: ... } or { kind: "invitation"; invitation: ... }. That keeps the row types separate while allowing one table to receive one array.

Ask yourself

Why not flatten members and invitations into one giant object with lots of optional fields?

Because: optional soup hides mistakes. A member has a userId; an invitation has a clerkInvitationId. A member has last activity; an invitation has invited-at age. The kind field makes impossible states harder to write.

src/lib/firm-members/unified.tsMerge adapter

This helper takes two lists: backend members and Clerk invitations. It sorts invitations newest first, puts invitations above members, filters out any legacy backend rows with status invited, and returns the unified array the UI expects.

Read this as the adapter between two worlds. The backend still owns accepted and deactivated members. Clerk still owns pending invites. The table does not need to know how those lists were fetched.

[tray]

Think of a mailroom sorting tray

One pile is signed employment files. One pile is invitation letters waiting for replies. The tray puts urgent letters on top and files below, but it does not pretend the letters are files. That is what this helper does for the UI.

src/app/[locale]/(app)/settings/firm/members/actions.tsServer actions

This file is where Clerk is allowed to be called. listPendingInvitationsAction pages through Clerk organization invitations with status pending. resendFirmMemberInviteAction fetches the old invitation, revokes it, then creates a fresh invitation with the same email and role. revokeFirmMemberInviteAction revokes the invitation in Clerk.

The actions call getClerkActionContext, which checks sign-in, current organization, firm access, admin status when needed, Clerk secret availability, and redirect URL availability when an email must be sent.

Accepted-member actions still use the backend gateway: change role, deactivate, and reactivate remain PRAMAAN member operations. Invitation actions are Clerk operations.

Gotcha

Do not move these Clerk SDK calls into pramaan-functions. ADR-022 keeps Lambda out of Clerk. The app server action is the right wall socket for this plug.

src/app/[locale]/(app)/settings/firm/members/page.tsxPage loader

The server component loads two sources with Promise.allSettled: loadFirmMembers() from the backend and listPendingInvitationsAction() from Clerk. If members load and invitations fail, the page still renders accepted members with a warning.

That fallback matters. Accepted members are the main directory. Pending invitations are useful, but losing Clerk briefly should not blank the whole screen.

Ask yourself

Why use Promise.allSettled instead of Promise.all?

Answer: Promise.all fails the whole load if either source fails. allSettled lets the page say, "I still have the member directory, but the invitation clipboard is unavailable."

members-roles-content.tsx and members-table.tsxUI orchestration

MembersRolesContent owns the interactive state: search text, warning, error, open dialogs, refreshes, and invite sheet. It hides admin controls when readOnly is true.

MembersTable is deliberately simple: switch on item.kind. Members render through MemberRow. Invitations render through InvitationRow. That is the whole row split.

InvitationRow shows email, role badge, pending status, invitation age, and the admin action menu for resend/revoke. For read-only users, the action menu disappears but the row remains visible.

4 Deviations from the plan

The main architecture landed as planned: Clerk owns pending invitations, PRAMAAN owns accepted members, and the app merges both for display.

There were two practical adjustments worth naming. First, the implementation added graceful partial loading: if Clerk invitation loading fails, accepted members still render with the warning "Pending invitations could not be loaded. Accepted members are still shown." The Story asked for a unified list; the implementation made that list more resilient.

Second, the PR verification did not include the full staging Playwright admin and member specs described in the original Story acceptance criteria. The PR body records Storybook visual verification and a Playwright smoke on the Storybook invitation action menu instead. That is not the same as a full staging invite -> resend -> revoke run.

5 Errors hit and how we fixed them

The PR record does not show a long debugging trail, so this walkthrough should not invent one. The important implementation hazards were handled directly in code and tests.

The first hazard was a stale invitation. A user can accept an invitation after the page loads but before an admin clicks resend or revoke. The server action checks Clerk state and maps Clerk 404s to a clear message: Invitation already accepted - refresh to see member.

The second hazard was partial upstream failure. If the backend member load fails, the page shows an error. If only Clerk invitation load fails, the page still shows accepted members and surfaces a warning. That distinction is covered by the refresh result shape and unit tests.

6 Gotchas and surprises

Gotcha

A Clerk invitation id is not a PRAMAAN user id. Use clerkInvitationId for invitation actions and userId for accepted-member actions. Mixing them is like trying to unlock an office door with a visitor badge number.

Gotcha

Resend is not "send the same invitation again." It revokes the old Clerk invitation and creates a new one. That creates a fresh email flow and keeps Clerk as the source of pending-invite truth.

Gotcha

Firm members can see invitation rows, but they cannot act on them. Do not hide the rows for read-only users unless the product decision changes. The admin-only part is the action menu, not the row itself.

Gotcha

Legacy backend rows with status invited are filtered out in the merge helper. If you remove that filter, old bridge-shaped invite data can leak back into a screen that is supposed to trust Clerk for pending invitations.

7 What's still open

The main product slice shipped, but a few edges remain worth remembering:

8 Check yourself

Can you answer these?

  1. Why is a pending invitation not stored as a PRAMAAN member row?
  2. Which code path loads accepted members, and which code path loads pending invitations?
  3. What does kind: "member" versus kind: "invitation" protect the UI from doing by accident?
  4. Why does resend revoke and recreate the Clerk invitation instead of mutating one field?
  5. What should the page do if accepted members load but Clerk invitations fail?
  6. Which actions should disappear for a read-only firm member?
  7. Why would moving Clerk SDK calls to pramaan-functions violate the intended architecture?

If those answers feel fuzzy, reread src/types/firm-invitation.ts, then src/lib/firm-members/unified.ts, then the server actions. That path gives you the shape, the merge, and the side effects in the right order.