Skip to content

Commit a3d70be

Browse files
committed
Link eligible orgs to Executor v2's import from the sidebar
1 parent a54b513 commit a3d70be

8 files changed

Lines changed: 196 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@executor-js/cloud": patch
3+
---
4+
5+
Link eligible single-member free organizations to Executor v2's workspace import from the sidebar.

‎apps/cloud/src/auth/api.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ const AuthOrganizationsResponse = Schema.Struct({
3434
activeOrganizationId: Schema.NullOr(Schema.String),
3535
});
3636

37+
// Executor v2's workspace import for the selected org: its page URL when v2
38+
// would let the signed-in user import the org, otherwise null. Nothing else
39+
// about the membership or billing reads behind it is returned.
40+
const V2ImportResponse = Schema.Struct({
41+
url: Schema.NullOr(Schema.String),
42+
});
43+
3744
const CreateOrganizationBody = Schema.Struct({
3845
name: Schema.String,
3946
});
@@ -231,6 +238,12 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth")
231238
error: [WorkOSError, UserStoreError, MemberDirectoryError],
232239
}),
233240
)
241+
.add(
242+
HttpApiEndpoint.get("v2Import", "/auth/v2-import", {
243+
success: V2ImportResponse,
244+
error: NoOrganization,
245+
}),
246+
)
234247
.add(
235248
HttpApiEndpoint.post("createOrganization", "/auth/create-organization", {
236249
payload: CreateOrganizationBody,

‎apps/cloud/src/auth/handlers.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
type AuthorizeOrganizationOptions,
4343
} from "./organization";
4444
import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";
45+
import { liveV2ImportSource, v2ImportEligible, v2ImportUrl } from "./v2-import";
4546

4647
const COOKIE_OPTIONS = {
4748
path: "/",
@@ -439,6 +440,19 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
439440
};
440441
}),
441442
)
443+
.handle("v2Import", () =>
444+
Effect.gen(function* () {
445+
const session = yield* requireSelectedOrganization;
446+
const url = v2ImportUrl(env);
447+
if (url === null) return { url: null };
448+
const eligible = yield* v2ImportEligible(
449+
yield* liveV2ImportSource,
450+
session.accountId,
451+
session.organizationId,
452+
);
453+
return { url: eligible ? url.href : null };
454+
}),
455+
)
442456
.handle("createOrganization", ({ payload }) =>
443457
Effect.gen(function* () {
444458
const workos = yield* WorkOSClient;

‎apps/cloud/src/auth/v2-import.ts‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// ---------------------------------------------------------------------------
2+
// The dashboard's link to Executor v2's workspace import.
3+
//
4+
// v2 imports a v1 organization only when all of these hold (v2's
5+
// `V1Import.choices`), and v1 shows the link under exactly the same rules:
6+
// 1. the signed-in user's email is verified,
7+
// 2. the organization has exactly one active member, and it is that user,
8+
// 3. the organization is not on a paid v1 plan: no Autumn subscription to a
9+
// plan outside v2's free list that has not expired.
10+
// Every failure to read one of them hides the link. Only the yes/no answer
11+
// leaves the server; memberships and billing never do.
12+
// ---------------------------------------------------------------------------
13+
14+
import { Effect } from "effect";
15+
16+
import { parseV2Edge, type V2EdgeEnv } from "../edge/marketing";
17+
import { AutumnService } from "../extensions/billing/service";
18+
import { WorkOSClient } from "./workos";
19+
20+
/** v2's import page, on v2's app host. */
21+
const V2_IMPORT_PATH = "/v1-import";
22+
23+
/**
24+
* The absolute URL of v2's import page, or `null` where v1 does not hand
25+
* requests to v2 (local dev without the v2 settings, test workers, or broken
26+
* settings). v2's app host is the origin of its sign-up page, the same host
27+
* the edge sends v2 dashboard links to.
28+
*/
29+
export const v2ImportUrl = (env: V2EdgeEnv): URL | null => {
30+
const edge = parseV2Edge(env);
31+
if (edge === null || typeof edge === "string") return null;
32+
return new URL(V2_IMPORT_PATH, edge.signUpUrl.origin);
33+
};
34+
35+
/** v1's plans that cost nothing, as v2's import counts them (v2's
36+
* `freePlans`). Every other plan, including one added later, is paid. */
37+
export const V2_IMPORT_FREE_PLAN_IDS: ReadonlyArray<string> = ["free", "free-pay-as-you-go"];
38+
39+
export type V2ImportSubscription = { readonly planId: string; readonly status: string };
40+
41+
/** Whether v2 treats the organization as on a paid v1 plan. */
42+
export const isPaidForV2Import = (subscriptions: ReadonlyArray<V2ImportSubscription>): boolean =>
43+
subscriptions.some(
44+
(subscription) =>
45+
!V2_IMPORT_FREE_PLAN_IDS.includes(subscription.planId) && subscription.status !== "expired",
46+
);
47+
48+
/** The reads the eligibility check makes, each against the system of record. */
49+
export interface V2ImportSource {
50+
readonly emailVerified: (userId: string) => Effect.Effect<boolean, unknown>;
51+
/** The user ids of the organization's active members. */
52+
readonly activeMemberIds: (
53+
organizationId: string,
54+
) => Effect.Effect<ReadonlyArray<string>, unknown>;
55+
readonly subscriptions: (
56+
organizationId: string,
57+
) => Effect.Effect<ReadonlyArray<V2ImportSubscription>, unknown>;
58+
}
59+
60+
/**
61+
* Whether v2 would let this user import this organization. Reads in order and
62+
* stops at the first refusal, so most refusals cost one or two calls. Never
63+
* fails: an unavailable read answers `false`.
64+
*/
65+
export const v2ImportEligible = (
66+
source: V2ImportSource,
67+
userId: string,
68+
organizationId: string,
69+
): Effect.Effect<boolean> =>
70+
Effect.gen(function* () {
71+
if (!(yield* source.emailVerified(userId))) return false;
72+
const members = yield* source.activeMemberIds(organizationId);
73+
if (members.length !== 1 || members[0] !== userId) return false;
74+
return !isPaidForV2Import(yield* source.subscriptions(organizationId));
75+
}).pipe(
76+
Effect.orElseSucceed(() => false),
77+
Effect.withSpan("v2Import.eligible"),
78+
);
79+
80+
/** The reads over v1's WorkOS (live, not the membership mirror, as v2 reads
81+
* them) and Autumn. An organization Autumn has never seen is provisioned on
82+
* the free plan, the same answer v2 gives for a missing customer. */
83+
export const liveV2ImportSource = Effect.gen(function* () {
84+
const workos = yield* WorkOSClient;
85+
const autumn = yield* AutumnService;
86+
return {
87+
emailVerified: (userId) =>
88+
workos.getUser(userId).pipe(Effect.map((user) => user.emailVerified)),
89+
activeMemberIds: (organizationId) =>
90+
workos
91+
.listOrgMembers(organizationId, ["active"])
92+
.pipe(
93+
Effect.map((list) =>
94+
list.data
95+
.filter((membership) => membership.status === "active")
96+
.map((membership) => membership.userId),
97+
),
98+
),
99+
subscriptions: (organizationId) =>
100+
autumn
101+
.use((client) => client.customers.getOrCreate({ customerId: organizationId }))
102+
.pipe(Effect.map((customer) => customer.subscriptions)),
103+
} satisfies V2ImportSource;
104+
});

‎apps/cloud/src/web/auth.tsx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ export const organizationsAtom = Atom.refreshOnWindowFocus(
3434
}),
3535
);
3636

37+
// Whether the selected org can move to Executor v2, and where. Eligibility
38+
// reads WorkOS and Autumn, so it is fetched once per dashboard load and kept.
39+
export const v2ImportAtom = CloudApiClient.query("cloudAuth", "v2Import", {
40+
timeToLive: "5 minutes",
41+
reactivityKeys: [ReactivityKey.auth],
42+
});
43+
3744
export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization");
3845

3946
export const deleteOrganization = CloudApiClient.mutation("cloudAuth", "deleteOrganization");
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { useAtomValue } from "@effect/atom-react";
2+
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3+
import { trackEvent } from "@executor-js/react/api/analytics";
4+
import { v2ImportAtom } from "../auth";
5+
6+
// ---------------------------------------------------------------------------
7+
// Cloud-only sidebar link to Executor v2's workspace import. The server
8+
// decides who sees it (`auth/v2-import.ts`) and answers with the page URL only
9+
// for users v2 would let import the selected org; everyone else, and every
10+
// failure, renders nothing. Opens in a new tab, like the sidebar's Docs link.
11+
// ---------------------------------------------------------------------------
12+
13+
function MoveIcon({ className }: { className?: string }) {
14+
return (
15+
<svg viewBox="0 0 16 16" fill="none" className={className} aria-hidden>
16+
<path
17+
d="M3 8h9M8.5 4.5L12 8l-3.5 3.5"
18+
stroke="currentColor"
19+
strokeWidth="1.3"
20+
strokeLinecap="round"
21+
strokeLinejoin="round"
22+
/>
23+
</svg>
24+
);
25+
}
26+
27+
export function V2ImportLink() {
28+
const result = useAtomValue(v2ImportAtom);
29+
if (!AsyncResult.isSuccess(result) || result.value.url === null) return null;
30+
return (
31+
<a
32+
href={result.value.url}
33+
target="_blank"
34+
rel="noopener noreferrer"
35+
onClick={() => trackEvent("v2_import_opened")}
36+
className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm text-sidebar-foreground transition-colors hover:bg-sidebar-active/60 hover:text-foreground"
37+
>
38+
<MoveIcon className="size-3.5 text-muted-foreground" />
39+
Move to Executor v2
40+
</a>
41+
);
42+
}

‎apps/cloud/src/web/shell.tsx‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,17 @@ import { trackEvent } from "@executor-js/react/api/analytics";
66
import { AUTH_PATHS } from "../auth/api";
77
import { OrgMenuSlot } from "./components/org-menu-slot";
88
import { SupportSlot } from "./components/support-slot";
9+
import { V2ImportLink } from "./components/v2-import-link";
910

1011
// ---------------------------------------------------------------------------
1112
// Cloud shell — the SHARED multiplayer shell, identical to self-host, with
1213
// cloud-only bits injected through its slots:
1314
// - sign-out navigate through cloud's WorkOS logout, land home
1415
// - nav items defaults + Organization + Billing (cloud-only sections)
1516
// - org menu slot multi-org switcher + create-org dialog (cloud-only)
16-
// - support slot the "Get support" dialog button (cloud-only)
17+
// - support slot the "Move to Executor v2" link, shown only to users
18+
// v2 would let import this org, and the "Get support"
19+
// dialog button (cloud-only)
1720
// API keys live in the main sidebar nav (via `defaultShellNavItems`); the
1821
// shared shell renders the account dropdown frame and sign-out, with
1922
// `orgMenuSlot` injected at the top of the dropdown.
@@ -52,7 +55,12 @@ export function Shell(props: { readonly content?: React.ReactNode }) {
5255
onSignOut={signOut}
5356
navItems={items}
5457
orgMenuSlot={<OrgMenuSlot />}
55-
supportSlot={<SupportSlot />}
58+
supportSlot={
59+
<>
60+
<V2ImportLink />
61+
<SupportSlot />
62+
</>
63+
}
5664
content={props.content}
5765
/>
5866
);

‎packages/react/src/api/analytics.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ export interface AnalyticsEvents {
189189
org_invitation_accepted: { success: boolean };
190190
setup_mcp_completed: {};
191191
setup_mcp_skipped: {};
192+
v2_import_opened: {};
192193

193194
// ── Cloud: billing & support ─────────────────────────────────────────────
194195
billing_plan_selected: {

0 commit comments

Comments
 (0)