Map invite flow states and preserve reload status

This commit is contained in:
Dotta committed 2026-04-13 09:12:32 -05:00
1 parent 77338cb9ef
commit 4c146ab6e7
6 files changed
+464 -81

No files matched your search

+178
View File
@@ -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<br/>no join request]
HumanReuse{Matching human join request<br/>already exists for same user/email?}
HumanPending[Join request<br/>pending_approval]
HumanApproved[Join request<br/>approved]
HumanRejected[Join request<br/>rejected]
AgentPending[Agent join request<br/>pending_approval<br/>+ optional claim secret]
AgentApproved[Agent join request<br/>approved]
AgentRejected[Agent join request<br/>rejected]
ClaimAvailable[Claim secret available]
ClaimConsumed[Claim secret consumed]
ClaimExpired[Claim secret expired]
OpenClawReplay[Special replay path:<br/>accepted invite can be POSTed again<br/>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<br/>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<br/>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<br/>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.
@@ -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");
});
});
+14 -2
View File
@@ -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,
});
});
+2
View File
@@ -21,6 +21,8 @@ type InviteSummary = {
skillIndexUrl?: string;
inviteMessage?: string | null;
invitedByUserName?: string | null;
joinRequestStatus?: JoinRequest["status"] | null;
joinRequestType?: JoinRequest["requestType"] | null;
};
type AcceptInviteInput =
+57
View File
@@ -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(
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/invite/:token" element={<InviteLandingPage />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>,
);
});
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({
+132 -51
View File
@@ -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 (
<div className="min-h-screen bg-zinc-950 px-6 py-12 text-zinc-100">
<div className="mx-auto max-w-md border border-zinc-800 bg-zinc-950 p-6" data-testid="invite-pending-approval">
<div className="flex items-center gap-3">
<CompanyPatternIcon
companyName={companyDisplayName}
logoUrl={companyLogoUrl}
brandColor={companyBrandColor}
className="h-12 w-12 border border-zinc-800 rounded-none"
/>
<h1 className="text-lg font-semibold">Request to join {companyDisplayName}</h1>
</div>
<div className="mt-4 space-y-3">
<p className="text-sm text-zinc-400">
Your request is still awaiting approval. {approverLabel} must approve your request to join.
</p>
<div className="border border-zinc-800 p-3">
<p className="text-xs text-zinc-500 mb-1">Approval page</p>
<a
href={approvalUrl}
className="text-sm text-zinc-200 underline underline-offset-2 hover:text-zinc-100"
>
Company Settings → Access
</a>
</div>
<p className="text-sm text-zinc-400">
Ask them to visit <a href={approvalUrl} className="text-zinc-200 underline underline-offset-2 hover:text-zinc-100">Company Settings → Access</a> to approve your request.
</p>
<p className="text-xs text-zinc-500">
Refresh this page after you've been approved — you'll be redirected automatically.
</p>
</div>
{claimSecret && claimApiKeyPath ? (
<div className="mt-4 space-y-1 border border-zinc-800 p-3 text-xs text-zinc-400">
<div className="text-zinc-200">Claim secret</div>
<div className="font-mono break-all">{claimSecret}</div>
<div className="font-mono break-all">POST {claimApiKeyPath}</div>
</div>
) : null}
{onboardingTextUrl ? (
<div className="mt-4 text-xs text-zinc-400">
Onboarding: <span className="font-mono break-all">{onboardingTextUrl}</span>
</div>
) : null}
</div>
</div>
);
}
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 <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Opening company...</div>;
}
if (inviteJoinRequestStatus === "pending_approval") {
return (
<AwaitingJoinApprovalPanel
companyDisplayName={companyDisplayName}
companyLogoUrl={companyLogoUrl}
companyBrandColor={companyBrandColor}
invitedByUserName={invitedByUserName}
/>
);
}
if (inviteJoinRequestStatus) {
return (
<div className="mx-auto max-w-xl py-10">
<div className="border border-border bg-card p-6" data-testid="invite-error">
<h1 className="text-lg font-semibold">Invite not available</h1>
<p className="mt-2 text-sm text-muted-foreground">
{inviteJoinRequestStatus === "rejected"
? "This join request was not approved."
: "This invite has already been used."}
</p>
</div>
</div>
);
}
if (result?.kind === "bootstrap") {
return (
<div className="min-h-screen bg-zinc-950 px-6 py-12 text-zinc-100">
@@ -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 (
<div className="min-h-screen bg-zinc-950 px-6 py-12 text-zinc-100">
<div className="mx-auto max-w-md border border-zinc-800 bg-zinc-950 p-6">
<div className="flex items-center gap-3">
<CompanyPatternIcon
companyName={companyDisplayName}
logoUrl={companyLogoUrl}
brandColor={companyBrandColor}
className="h-12 w-12 border border-zinc-800 rounded-none"
/>
<h1 className="text-lg font-semibold">
{joinedNow ? "You joined the company" : `Request to join ${companyDisplayName}`}
</h1>
</div>
{joinedNow ? (
joinedNow ? (
<div className="min-h-screen bg-zinc-950 px-6 py-12 text-zinc-100">
<div className="mx-auto max-w-md border border-zinc-800 bg-zinc-950 p-6">
<div className="flex items-center gap-3">
<CompanyPatternIcon
companyName={companyDisplayName}
logoUrl={companyLogoUrl}
brandColor={companyBrandColor}
className="h-12 w-12 border border-zinc-800 rounded-none"
/>
<h1 className="text-lg font-semibold">You joined the company</h1>
</div>
<div className="mt-4">
<Button asChild className="w-full rounded-none">
<Link to="/">Open board</Link>
</Button>
</div>
) : (
<div className="mt-4 space-y-3">
<p className="text-sm text-zinc-400">
{approverLabel} must approve your request to join.
</p>
<div className="border border-zinc-800 p-3">
<p className="text-xs text-zinc-500 mb-1">Approval page</p>
<a
href={approvalUrl}
className="text-sm text-zinc-200 underline underline-offset-2 hover:text-zinc-100"
>
Company Settings → Access
</a>
</div>
<p className="text-sm text-zinc-400">
Ask them to visit <a href={approvalUrl} className="text-zinc-200 underline underline-offset-2 hover:text-zinc-100">Company Settings → Access</a> to approve your request.
</p>
<p className="text-xs text-zinc-500">
Refresh this page after you've been approved — you'll be redirected automatically.
</p>
</div>
)}
{claimSecret && claimApiKeyPath ? (
<div className="mt-4 space-y-1 border border-zinc-800 p-3 text-xs text-zinc-400">
<div className="text-zinc-200">Claim secret</div>
<div className="font-mono break-all">{claimSecret}</div>
<div className="font-mono break-all">POST {claimApiKeyPath}</div>
</div>
) : null}
{onboardingTextUrl ? (
<div className="mt-4 text-xs text-zinc-400">
Onboarding: <span className="font-mono break-all">{onboardingTextUrl}</span>
</div>
) : null}
</div>
</div>
</div>
) : (
<AwaitingJoinApprovalPanel
companyDisplayName={companyDisplayName}
companyLogoUrl={companyLogoUrl}
companyBrandColor={companyBrandColor}
invitedByUserName={invitedByUserName}
claimSecret={claimSecret}
claimApiKeyPath={claimApiKeyPath}
onboardingTextUrl={onboardingTextUrl}
/>
)
);
}