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(
+
+ 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. +
++ {inviteJoinRequestStatus === "rejected" + ? "This join request was not approved." + : "This invite has already been used."} +
+- {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. -
-