diff --git a/doc/spec/invite-flow.md b/doc/spec/invite-flow.md new file mode 100644 index 00000000000..f56d9497b82 --- /dev/null +++ b/doc/spec/invite-flow.md @@ -0,0 +1,178 @@ +# Invite Flow State Map + +Status: Current implementation map +Date: 2026-04-13 + +This document maps the current invite creation and acceptance states implemented in: + +- `ui/src/pages/CompanyInvites.tsx` +- `ui/src/pages/CompanySettings.tsx` +- `ui/src/pages/InviteLanding.tsx` +- `server/src/routes/access.ts` +- `server/src/lib/join-request-dedupe.ts` + +## State Legend + +- Invite state: `active`, `revoked`, `accepted`, `expired` +- Join request status: `pending_approval`, `approved`, `rejected` +- Claim secret state for agent joins: `available`, `consumed`, `expired` +- Invite type: `company_join` or `bootstrap_ceo` +- Join type: `human`, `agent`, or `both` + +## Entity Lifecycle + +```mermaid +flowchart TD + Board[Board user on invite screen] + HumanInvite[Create human company invite] + OpenClawInvite[Generate OpenClaw invite prompt] + Active[Invite state: active] + Revoked[Invite state: revoked] + Expired[Invite state: expired] + Accepted[Invite state: accepted] + BootstrapDone[Bootstrap accepted
no join request] + HumanReuse{Matching human join request
already exists for same user/email?} + HumanPending[Join request
pending_approval] + HumanApproved[Join request
approved] + HumanRejected[Join request
rejected] + AgentPending[Agent join request
pending_approval
+ optional claim secret] + AgentApproved[Agent join request
approved] + AgentRejected[Agent join request
rejected] + ClaimAvailable[Claim secret available] + ClaimConsumed[Claim secret consumed] + ClaimExpired[Claim secret expired] + OpenClawReplay[Special replay path:
accepted invite can be POSTed again
for openclaw_gateway only] + + Board --> HumanInvite --> Active + Board --> OpenClawInvite --> Active + Active --> Revoked: revoke + Active --> Expired: expiresAt passes + + Active --> BootstrapDone: bootstrap_ceo accept + BootstrapDone --> Accepted + + Active --> HumanReuse: human accept + HumanReuse --> HumanPending: reuse existing pending request + HumanReuse --> HumanApproved: reuse existing approved request + HumanReuse --> HumanPending: no reusable request
create new request + HumanPending --> HumanApproved: board approves + HumanPending --> HumanRejected: board rejects + HumanPending --> Accepted + HumanApproved --> Accepted + + Active --> AgentPending: agent accept + AgentPending --> Accepted + AgentPending --> AgentApproved: board approves + AgentPending --> AgentRejected: board rejects + AgentApproved --> ClaimAvailable: createdAgentId + claimSecretHash + ClaimAvailable --> ClaimConsumed: POST claim-api-key succeeds + ClaimAvailable --> ClaimExpired: secret expires + + Accepted --> OpenClawReplay + OpenClawReplay --> AgentPending + OpenClawReplay --> AgentApproved +``` + +## Board-Side Screen States + +```mermaid +stateDiagram-v2 + [*] --> CompanySelection + + CompanySelection --> NoCompany: no company selected + CompanySelection --> LoadingHistory: selectedCompanyId present + LoadingHistory --> HistoryError: listInvites failed + LoadingHistory --> Ready: listInvites succeeded + + state Ready { + [*] --> EmptyHistory + EmptyHistory --> PopulatedHistory: invites exist + PopulatedHistory --> LoadingMore: View more + LoadingMore --> PopulatedHistory: next page loaded + + PopulatedHistory --> RevokePending: Revoke active invite + RevokePending --> PopulatedHistory: revoke succeeded + RevokePending --> PopulatedHistory: revoke failed + + EmptyHistory --> CreatePending: Create invite + PopulatedHistory --> CreatePending: Create invite + CreatePending --> LatestInviteVisible: create succeeded + CreatePending --> Ready: create failed + LatestInviteVisible --> CopiedToast: clipboard copy succeeded + LatestInviteVisible --> Ready: navigate away or refresh + } + + CompanySelection --> OpenClawPromptReady: Company settings prompt generator + OpenClawPromptReady --> OpenClawPromptPending: Generate OpenClaw Invite Prompt + OpenClawPromptPending --> OpenClawSnippetVisible: prompt generated + OpenClawPromptPending --> OpenClawPromptReady: generation failed +``` + +## Invite Landing Screen States + +```mermaid +stateDiagram-v2 + [*] --> TokenGate + + TokenGate --> InvalidToken: token missing + TokenGate --> Loading: token present + Loading --> InviteUnavailable: invite fetch failed or invite not returned + Loading --> CheckingAccess: signed-in session and invite.companyId + Loading --> InviteResolved: invite loaded without membership check + Loading --> AcceptedInviteSummary: invite already consumed
but linked join request still exists + + CheckingAccess --> RedirectToBoard: current user already belongs to company + CheckingAccess --> InviteResolved: membership check finished and no join-request summary state is active + CheckingAccess --> AcceptedInviteSummary: membership check finished and invite has joinRequestStatus + + state InviteResolved { + [*] --> Branch + Branch --> AgentForm: company_join + allowedJoinTypes=agent + Branch --> InlineAuth: authenticated mode + no session + join is not agent-only + Branch --> AcceptReady: bootstrap invite or human-ready session/local_trusted + + InlineAuth --> InlineAuth: toggle sign-up/sign-in + InlineAuth --> InlineAuth: auth validation or auth error message + InlineAuth --> RedirectToBoard: auth succeeded and company membership already exists + InlineAuth --> AcceptPending: auth succeeded and invite still needs acceptance + + AgentForm --> AcceptPending: submit request + AgentForm --> AgentForm: validation or accept error + + AcceptReady --> AcceptPending: Accept invite + AcceptReady --> AcceptReady: accept error + } + + AcceptPending --> BootstrapComplete: bootstrapAccepted=true + AcceptPending --> RedirectToBoard: join status=approved + AcceptPending --> PendingApprovalResult: join status=pending_approval + AcceptPending --> RejectedResult: join status=rejected + + state AcceptedInviteSummary { + [*] --> SummaryBranch + SummaryBranch --> PendingApprovalReload: joinRequestStatus=pending_approval + SummaryBranch --> OpeningCompany: joinRequestStatus=approved
and human invite user is now a member + SummaryBranch --> RejectedReload: joinRequestStatus=rejected + SummaryBranch --> ConsumedReload: approved agent invite or other consumed state + } + + PendingApprovalResult --> PendingApprovalReload: reload after submit + RejectedResult --> RejectedReload: reload after board rejects + RedirectToBoard --> OpeningCompany: brief pre-navigation render when approved membership is detected + OpeningCompany --> RedirectToBoard: navigate to board +``` + +## Notes + +- `GET /api/invites/:token` treats `revoked` and `expired` invites as unavailable. Accepted invites remain resolvable when they already have a linked join request, and the summary now includes `joinRequestStatus` plus `joinRequestType`. +- Human acceptance consumes the invite immediately and then either creates a new join request or reuses an existing `pending_approval` or `approved` human join request for the same user/email. +- The landing page has two layers of post-accept UI: + - immediate mutation-result UI from `POST /api/invites/:token/accept` + - reload-time summary UI from `GET /api/invites/:token` once the invite has already been consumed +- Reload behavior for accepted company invites is now status-sensitive: + - `pending_approval` re-renders the waiting-for-approval panel + - `rejected` renders the "This join request was not approved." error panel + - `approved` only becomes a success path for human invites after membership is visible to the current session; otherwise the page falls through to the generic consumed/unavailable state +- `GET /api/invites/:token/logo` still rejects accepted invites, so accepted-invite reload states may fall back to the generated company icon even though the summary payload still carries `companyLogoUrl`. +- The only accepted-invite replay path in the current implementation is `POST /api/invites/:token/accept` for `agent` requests with `adapterType=openclaw_gateway`, and only when the existing join request is still `pending_approval` or already `approved`. +- `bootstrap_ceo` invites are one-time and do not create join requests. diff --git a/server/src/__tests__/invite-summary-route.test.ts b/server/src/__tests__/invite-summary-route.test.ts index 805373b3a7e..25e3b80ad25 100644 --- a/server/src/__tests__/invite-summary-route.test.ts +++ b/server/src/__tests__/invite-summary-route.test.ts @@ -29,14 +29,13 @@ function createSelectChain(rows: unknown[]) { }; } -function createDbStub(inviteRows: unknown[], companyRows: unknown[], logoRows: unknown[]) { +function createDbStub(...selectResponses: unknown[][]) { let selectCall = 0; return { select() { + const rows = selectResponses[selectCall] ?? []; selectCall += 1; - if (selectCall === 1) return createSelectChain(inviteRows); - if (selectCall === 2) return createSelectChain(companyRows); - return createSelectChain(logoRows); + return createSelectChain(rows); }, }; } @@ -84,18 +83,22 @@ describe("GET /invites/:token", () => { const app = createApp( createDbStub( [invite], - [{ - name: "Acme Robotics", - brandColor: "#114488", - logoAssetId: "logo-1", - }], - [{ - companyId: "company-1", - objectKey: "company-1/assets/companies/logo-1", - contentType: "image/png", - byteSize: 3, - originalFilename: "logo.png", - }], + [ + { + name: "Acme Robotics", + brandColor: "#114488", + logoAssetId: "logo-1", + }, + ], + [ + { + companyId: "company-1", + objectKey: "company-1/assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }, + ], ), ); @@ -129,18 +132,22 @@ describe("GET /invites/:token", () => { const app = createApp( createDbStub( [invite], - [{ - name: "Acme Robotics", - brandColor: "#114488", - logoAssetId: "logo-1", - }], - [{ - companyId: "company-1", - objectKey: "company-1/assets/companies/logo-1", - contentType: "image/png", - byteSize: 3, - originalFilename: "logo.png", - }], + [ + { + name: "Acme Robotics", + brandColor: "#114488", + logoAssetId: "logo-1", + }, + ], + [ + { + companyId: "company-1", + objectKey: "company-1/assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }, + ], ), ); @@ -149,4 +156,50 @@ describe("GET /invites/:token", () => { expect(res.status).toBe(200); expect(res.body.companyLogoUrl).toBeNull(); }); + + it("returns pending join-request status for an already-accepted invite", async () => { + const invite = { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "human", + tokenHash: "hash", + defaultsPayload: null, + expiresAt: new Date("2027-03-07T00:10:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: new Date("2026-03-07T00:05:00.000Z"), + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:05:00.000Z"), + }; + const app = createApp( + createDbStub( + [invite], + [{ requestType: "human", status: "pending_approval" }], + [ + { + name: "Acme Robotics", + brandColor: "#114488", + logoAssetId: "logo-1", + }, + ], + [ + { + companyId: "company-1", + objectKey: "company-1/assets/companies/logo-1", + contentType: "image/png", + byteSize: 3, + originalFilename: "logo.png", + }, + ], + ), + ); + + const res = await request(app).get("/api/invites/pcp_invite_test"); + + expect(res.status).toBe(200); + expect(res.body.joinRequestStatus).toBe("pending_approval"); + expect(res.body.joinRequestType).toBe("human"); + expect(res.body.companyName).toBe("Acme Robotics"); + }); }); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 4e1bbd81973..80586897a5c 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -2529,11 +2529,21 @@ export function accessRoutes( .from(invites) .where(eq(invites.tokenHash, hashToken(token))) .then((rows) => rows[0] ?? null); + const inviteJoinRequest = invite?.acceptedAt + ? await db + .select({ + requestType: joinRequests.requestType, + status: joinRequests.status, + }) + .from(joinRequests) + .where(eq(joinRequests.inviteId, invite.id)) + .then((rows) => rows[0] ?? null) + : null; if ( !invite || invite.revokedAt || - invite.acceptedAt || - inviteExpired(invite) + inviteExpired(invite) || + (invite.acceptedAt && !inviteJoinRequest) ) { throw notFound("Invite not found"); } @@ -2547,6 +2557,8 @@ export function accessRoutes( res.json({ ...toInviteSummaryResponse(req, token, invite, companyBranding), invitedByUserName: inviterName, + joinRequestStatus: inviteJoinRequest?.status ?? null, + joinRequestType: inviteJoinRequest?.requestType ?? null, }); }); diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index 23844538e51..45eac6fcd57 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -21,6 +21,8 @@ type InviteSummary = { skillIndexUrl?: string; inviteMessage?: string | null; invitedByUserName?: string | null; + joinRequestStatus?: JoinRequest["status"] | null; + joinRequestType?: JoinRequest["requestType"] | null; }; type AcceptInviteInput = diff --git a/ui/src/pages/InviteLanding.test.tsx b/ui/src/pages/InviteLanding.test.tsx index 3c9ef113be0..95a52db8ec2 100644 --- a/ui/src/pages/InviteLanding.test.tsx +++ b/ui/src/pages/InviteLanding.test.tsx @@ -420,6 +420,63 @@ describe("InviteLandingPage", () => { }); }); + it("keeps the waiting-for-approval state on refresh for an accepted invite", async () => { + getInviteMock.mockResolvedValue({ + id: "invite-1", + companyId: "company-1", + companyName: "Acme Robotics", + companyLogoUrl: "/api/invites/pcp_invite_test/logo", + companyBrandColor: "#114488", + inviteType: "company_join", + allowedJoinTypes: "both", + humanRole: "operator", + expiresAt: "2027-03-07T00:10:00.000Z", + inviteMessage: "Welcome aboard.", + joinRequestStatus: "pending_approval", + joinRequestType: "human", + }); + getSessionMock.mockResolvedValue({ + session: { id: "session-1", userId: "user-1" }, + user: { + id: "user-1", + name: "Jane Example", + email: "jane@example.com", + image: null, + }, + }); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + + } /> + + + , + ); + }); + await flushReact(); + await flushReact(); + await flushReact(); + + expect(acceptInviteMock).not.toHaveBeenCalled(); + expect(container.querySelector('[data-testid="invite-pending-approval"]')).not.toBeNull(); + expect(container.textContent).toContain("Your request is still awaiting approval."); + expect(container.textContent).toContain( + "Ask them to visit Company Settings → Access to approve your request.", + ); + + await act(async () => { + root.unmount(); + }); + }); + it("redirects straight to the company after sign-in when the user already has access", async () => { getSessionMock.mockResolvedValueOnce(null); getSessionMock.mockResolvedValue({ diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx index 41eea0c274f..e6922e5a221 100644 --- a/ui/src/pages/InviteLanding.tsx +++ b/ui/src/pages/InviteLanding.tsx @@ -119,6 +119,77 @@ function isApprovedHumanJoinPayload(payload: unknown, showsAgentForm: boolean) { return status === "approved"; } +type AwaitingJoinApprovalPanelProps = { + companyDisplayName: string; + companyLogoUrl: string | null; + companyBrandColor: string | null; + invitedByUserName: string | null; + claimSecret?: string | null; + claimApiKeyPath?: string | null; + onboardingTextUrl?: string | null; +}; + +function AwaitingJoinApprovalPanel({ + companyDisplayName, + companyLogoUrl, + companyBrandColor, + invitedByUserName, + claimSecret = null, + claimApiKeyPath = null, + onboardingTextUrl = null, +}: AwaitingJoinApprovalPanelProps) { + const approvalUrl = `${window.location.origin}/company/settings/access`; + const approverLabel = invitedByUserName ?? "A company admin"; + + return ( +
+
+
+ +

Request to join {companyDisplayName}

+
+
+

+ Your request is still awaiting approval. {approverLabel} must approve your request to join. +

+
+

Approval page

+ + Company Settings → Access + +
+

+ Ask them to visit Company Settings → Access to approve your request. +

+

+ Refresh this page after you've been approved — you'll be redirected automatically. +

+
+ {claimSecret && claimApiKeyPath ? ( +
+
Claim secret
+
{claimSecret}
+
POST {claimApiKeyPath}
+
+ ) : null} + {onboardingTextUrl ? ( +
+ Onboarding: {onboardingTextUrl} +
+ ) : null} +
+
+ ); +} + export function InviteLandingPage() { const queryClient = useQueryClient(); const navigate = useNavigate(); @@ -197,6 +268,8 @@ export function InviteLandingPage() { const invitedByUserName = invite?.invitedByUserName?.trim() || null; const inviteMessage = invite?.inviteMessage?.trim() || null; const requestedHumanRole = formatHumanRole(invite?.humanRole); + const inviteJoinRequestStatus = invite?.joinRequestStatus ?? null; + const inviteJoinRequestType = invite?.joinRequestType ?? null; const requiresHumanAccount = healthQuery.data?.deploymentMode === "authenticated" && !sessionQuery.data && @@ -206,6 +279,7 @@ export function InviteLandingPage() { Boolean(sessionQuery.data) && !showsAgentForm && invite?.inviteType !== "bootstrap_ceo" && + !inviteJoinRequestStatus && !isCheckingExistingMembership && !isCurrentMember && !result && @@ -347,6 +421,40 @@ export function InviteLandingPage() { ); } + if ( + inviteJoinRequestStatus === "approved" && + inviteJoinRequestType === "human" && + isCurrentMember + ) { + return
Opening company...
; + } + + if (inviteJoinRequestStatus === "pending_approval") { + return ( + + ); + } + + if (inviteJoinRequestStatus) { + return ( +
+
+

Invite not available

+

+ {inviteJoinRequestStatus === "rejected" + ? "This join request was not approved." + : "This invite has already been used."} +

+
+
+ ); + } + if (result?.kind === "bootstrap") { return (
@@ -372,65 +480,38 @@ export function InviteLandingPage() { const claimApiKeyPath = typeof payload.claimApiKeyPath === "string" ? payload.claimApiKeyPath : null; const onboardingTextUrl = readNestedString(payload.onboarding, ["textInstructions", "url"]); const joinedNow = !showsAgentForm && payload.status === "approved"; - const approvalUrl = `${window.location.origin}/company/settings/access`; - const approverLabel = invitedByUserName ?? "A company admin"; return ( -
-
-
- -

- {joinedNow ? "You joined the company" : `Request to join ${companyDisplayName}`} -

-
- {joinedNow ? ( + joinedNow ? ( +
+
+
+ +

You joined the company

+
- ) : ( -
-

- {approverLabel} must approve your request to join. -

- -

- Ask them to visit Company Settings → Access to approve your request. -

-

- Refresh this page after you've been approved — you'll be redirected automatically. -

-
- )} - {claimSecret && claimApiKeyPath ? ( -
-
Claim secret
-
{claimSecret}
-
POST {claimApiKeyPath}
-
- ) : null} - {onboardingTextUrl ? ( -
- Onboarding: {onboardingTextUrl} -
- ) : null} +
-
+ ) : ( + + ) ); }