mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Map invite flow states and preserve reload status
This commit is contained in:
1 parent
77338cb9ef
commit
4c146ab6e7
6 files changed
+464
-81
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ type InviteSummary = {
|
||||
skillIndexUrl?: string;
|
||||
inviteMessage?: string | null;
|
||||
invitedByUserName?: string | null;
|
||||
joinRequestStatus?: JoinRequest["status"] | null;
|
||||
joinRequestType?: JoinRequest["requestType"] | null;
|
||||
};
|
||||
|
||||
type AcceptInviteInput =
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user