diff --git a/.changeset/v1-no-org-creation.md b/.changeset/v1-no-org-creation.md new file mode 100644 index 0000000000..0a082ea3da --- /dev/null +++ b/.changeset/v1-no-org-creation.md @@ -0,0 +1,5 @@ +--- +"@executor-js/cloud": patch +--- + +Executor Cloud no longer creates organizations: new organizations are created in Executor v2. A signed-in account with no organization is sent to Executor v2's sign-up page, or to a page listing its pending invitations when it has been invited to an existing organization. `/create-org` and the organization switcher's new-organization link open Executor v2, and `POST /api/auth/create-organization` answers `OrganizationCreationMoved` (410) without creating anything. diff --git a/apps/cloud/src/api/layers.ts b/apps/cloud/src/api/layers.ts index c64452e6dd..feeb03e1b2 100644 --- a/apps/cloud/src/api/layers.ts +++ b/apps/cloud/src/api/layers.ts @@ -63,9 +63,9 @@ export const BootSharedServices = Layer.mergeAll( // boot scope and Cloudflare Workers' I/O isolation kills the second // request. // -// `AutumnService.Default` is provided HERE because the `createOrganization` -// handler reads it for the free-organizations-per-user limit gate — one of the -// few app-only billing touchpoints. (It is NOT on the neutral boot core.) +// `AutumnService.Default` is provided HERE because the `deleteOrganization` +// handler cancels the org's billing customer — one of the few app-only billing +// touchpoints. (It is NOT on the neutral boot core.) export const makeNonProtectedApiLive = ( rsLive: Layer.Layer, ) => diff --git a/apps/cloud/src/api/protected.ts b/apps/cloud/src/api/protected.ts index ac97dd5850..4497bb103b 100644 --- a/apps/cloud/src/api/protected.ts +++ b/apps/cloud/src/api/protected.ts @@ -93,8 +93,8 @@ const ExecutionStackMiddleware = makeExecutionStackMiddleware< // // `AutumnService` is provided HERE — the billing service is scoped to the // executor plane that meters, not to the neutral boot core. (`/autumn`, the -// account seat-gate, and the createOrganization free-limit gate each provide it -// where they run.) +// account seat-gate, and org deletion's customer cancel each provide it where +// they run.) export const makeProtectedApiLive = ( rsLive: Layer.Layer, ) => { diff --git a/apps/cloud/src/app.ts b/apps/cloud/src/app.ts index 19b0213ead..9df7b33506 100644 --- a/apps/cloud/src/app.ts +++ b/apps/cloud/src/app.ts @@ -33,7 +33,7 @@ import { WorkerTelemetryLive } from "./observability/telemetry"; // session store (the DO class is exported straight from server.ts), console+Sentry error // capture — and Autumn BILLING entering ONLY as extensions: the engine // metering decorator, the account seat-gate, the `/api/billing/*` proxy route, -// and the createOrganization free-limit gate. `diff` against +// and the org-deletion customer cancel. `diff` against // `apps/host-selfhost/src/app.ts` is the entire product difference. // // `ExecutorApp.make` owns the assembly (the execution-stack middleware wrapping diff --git a/apps/cloud/src/auth/api.ts b/apps/cloud/src/auth/api.ts index 533f750981..2903a19f4c 100644 --- a/apps/cloud/src/auth/api.ts +++ b/apps/cloud/src/auth/api.ts @@ -52,16 +52,6 @@ const V2ImportResponse = Schema.Struct({ moved: Schema.NullOr(V2MovedResponse), }); -const CreateOrganizationBody = Schema.Struct({ - name: Schema.String, -}); - -const CreateOrganizationResponse = Schema.Struct({ - id: Schema.String, - name: Schema.String, - slug: Schema.String, -}); - // Deleting an org requires re-typing its name (the label the UI shows) as a // deliberate, non-automatable confirmation. Verified server-side too. const DeleteOrganizationBody = Schema.Struct({ @@ -192,6 +182,18 @@ export class OrganizationFrozen extends Schema.TaggedErrorClass()( + "OrganizationCreationMoved", + { message: Schema.String, url: Schema.NullOr(Schema.String) }, + { httpApiStatus: 410 }, +) {} + // Whether the organization is moving to v2 could not be read, so a change to // it is refused rather than risked. Retryable. export class OrganizationStateUnavailable extends Schema.TaggedErrorClass()( @@ -272,10 +274,9 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") }), ) .add( + // Refuses every request: new organizations are created in Executor v2. HttpApiEndpoint.post("createOrganization", "/auth/create-organization", { - payload: CreateOrganizationBody, - success: CreateOrganizationResponse, - error: AuthErrors, + error: OrganizationCreationMoved, }), ) .add( diff --git a/apps/cloud/src/auth/doc-gate.ts b/apps/cloud/src/auth/doc-gate.ts index 28b4f4af2e..f62c8e235a 100644 --- a/apps/cloud/src/auth/doc-gate.ts +++ b/apps/cloud/src/auth/doc-gate.ts @@ -7,7 +7,10 @@ // KNOWS who it's serving: // // - signed out → 302 /login (carrying ?returnTo=) before any app HTML exists -// - org-less → 302 /create-org (onboarding owns those sessions) +// - org-less → 302 to Executor v2's sign-up page (v1 creates no +// organizations), or to /join when the session holds a pending invitation +// into an existing organization +// - /create-org → 302 to v2's sign-up page, for every signed-in session // - signed in → the prerendered shell is served, and the auth-hint cookie is // minted when the browser doesn't hold a current one — the client seeds its // auth state from that cookie right after mount (there is no per-request @@ -40,7 +43,7 @@ import { env } from "cloudflare:workers"; import { isAppOwnedPath } from "../app-paths"; import { makeDbLayer } from "../db/db"; -import { v2AppOrigin } from "../edge/marketing"; +import { v2AppOrigin, v2SignUpUrl } from "../edge/marketing"; import { movedDashboardLocation } from "../org/move-to-v2"; import { makeUserStoreLayer, UserStoreService } from "./context"; import { parseCookie } from "./cookies"; @@ -50,8 +53,8 @@ import { makeWorkOsMirrorLayer } from "./workos-mirror"; import { sealedSessionDisplayName } from "./middleware"; import { authorizeOrganization, authorizeOrganizationSelector } from "./organization"; import { loginPath, safeReturnTo } from "./return-to"; -import { ONBOARDING_PATHS, PUBLIC_PATHS } from "./route-paths"; -import { WorkOSClient } from "./workos"; +import { CREATE_ORG_PATH, JOIN_PATH, PUBLIC_PATHS } from "./route-paths"; +import { WorkOSClient, isPendingOrganizationInvitation } from "./workos"; const SESSION_COOKIE = "wos-session"; /** Mirrors the handlers' COOKIE_OPTIONS (path /, HttpOnly, Lax, 7d, Secure). */ @@ -195,6 +198,21 @@ const authorizeLastOrgSlug = async ( return Exit.isSuccess(exit) ? exit.value : null; }; +// ── No organization ────────────────────────────────────────────────────────── + +/** + * Whether this email holds a pending invitation into an organization — the + * same WorkOS read the join page lists them from (`pendingInvitations`). A + * failed read answers `true`: the join page reads again, and an invitee must + * not be sent away from their invitation by a transient failure. + */ +const hasPendingInvitation = async (email: string): Promise => { + const exit = await getRuntime().runPromiseExit( + Effect.flatMap(WorkOSClient.asEffect(), (workos) => workos.listInvitationsByEmail(email)), + ); + return !Exit.isSuccess(exit) || exit.value.data.some(isPendingOrganizationInvitation); +}; + // ── Moved to v2 ────────────────────────────────────────────────────────────── /** @@ -305,13 +323,25 @@ export const authGateMiddleware = createMiddleware({ type: "request" }).server( return redirect("/", { refreshedSession: session.refreshedSession }); } - // A session with no organization belongs in onboarding — same decision - // the client AuthGate makes mid-session, made here before the document - // exists so the app shell is never painted for an org-less session. - if (!session.organizationId && !ONBOARDING_PATHS.has(pathname)) { - return redirect("/create-org", { - refreshedSession: session.refreshedSession, - }); + // v1 creates no organizations: its create-an-organization address opens + // v2's sign-up page, whatever organizations the session already has. + const signUpUrl = v2SignUpUrl(env); + if (pathname === CREATE_ORG_PATH && signUpUrl !== null) { + return redirect(signUpUrl.href, { refreshedSession: session.refreshedSession }); + } + + // A session with no organization is sent on before the document exists, + // so the app shell is never painted for it — the client AuthGate does the + // same mid-session. New organizations are created in v2, so it goes to + // v2's sign-up page, unless it holds a pending invitation into an existing + // organization: that is accepted on the join page. Where this deployment + // has no v2, the join page is the only place it can go. + if (!session.organizationId && pathname !== JOIN_PATH) { + const location = + signUpUrl !== null && !(await hasPendingInvitation(session.email)) + ? signUpUrl.href + : JOIN_PATH; + return redirect(location, { refreshedSession: session.refreshedSession }); } // A BARE console path (no org slug in the URL) canonicalizes onto the org @@ -331,7 +361,7 @@ export const authGateMiddleware = createMiddleware({ type: "request" }).server( lastOrgSlug && isValidOrgSlug(lastOrgSlug) && !isValidOrgSlug(firstSegment) && - !ONBOARDING_PATHS.has(pathname) + pathname !== JOIN_PATH ) { const sessionOrgSlug = (await organizationDisplay(session.organizationId)).slug; if (lastOrgSlug !== sessionOrgSlug) { @@ -348,7 +378,7 @@ export const authGateMiddleware = createMiddleware({ type: "request" }).server( } // An organization that has moved to v2 is opened there instead. - if (!ONBOARDING_PATHS.has(pathname)) { + if (pathname !== JOIN_PATH) { const movedTo = await movedToV2Location(session, firstSegment); if (movedTo) return redirect(movedTo, { refreshedSession: session.refreshedSession }); } diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index 67e761804d..b8d76aa524 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -11,9 +11,11 @@ import { McpExecutionNotFoundError, McpSessionForbiddenError, OrganizationDeletionForbidden, + OrganizationCreationMoved, OrganizationDeletionIncomplete, OrganizationFrozen, OrganizationStateUnavailable, + ORGANIZATION_CREATION_MOVED_MESSAGE, } from "./api"; import { MemberDirectory, NoOrganization } from "@executor-js/api/server"; // Pure constants/codec module (no React) — safe in the backend graph. @@ -25,15 +27,9 @@ import { UserStoreService } from "./context"; import { mirrorMembership, mirrorSignIn } from "./mirror-feeders"; import { env } from "cloudflare:workers"; import { WorkOSError } from "./errors"; -import { WorkOSClient } from "./workos"; +import { WorkOSClient, isPendingOrganizationInvitation } from "./workos"; import { AutumnService, autumnStatusOf } from "../extensions/billing/service"; import { forkReportMemberSeats } from "../extensions/billing/member-seats"; -import { captureCauseEffect } from "../observability"; -import { - hasPaidOrganizationSubscription, - isOverFreeOrganizationLimit, - shouldApplyFreeOrganizationLimit, -} from "../extensions/billing/plans"; import { LAST_ORG_COOKIE } from "./last-org-cookie"; import { ORG_SELECTOR_HEADER, @@ -47,6 +43,7 @@ import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; import { liveV2ImportSource, v2ImportAnswer } from "./v2-import"; import { liveV2MovedSource } from "./v2-moved"; import { assertOrgWritable, orgFreezeRefusal } from "../org/move-to-v2"; +import { v2SignUpUrl } from "../edge/marketing"; const COOKIE_OPTIONS = { path: "/", @@ -456,110 +453,15 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( }); }), ) - .handle("createOrganization", ({ payload }) => - Effect.gen(function* () { - const workos = yield* WorkOSClient; - const users = yield* UserStoreService; - const session = yield* SessionContext; - const autumn = yield* AutumnService; - - const name = payload.name.trim(); - // The free-organizations-per-user limit counts the caller's ACTIVE - // memberships, read from the local mirror. - const directory = yield* MemberDirectory; - const activeMemberships = yield* directory.membershipsOf(session.accountId, ["active"]); - - if (isOverFreeOrganizationLimit(activeMemberships)) { - const paidOrganizationIds = yield* Effect.all( - activeMemberships.map((membership) => - autumn - .use((client) => - client.customers.getOrCreate({ - customerId: membership.organizationId, - }), - ) - .pipe( - Effect.map((customer) => - hasPaidOrganizationSubscription(customer.subscriptions) - ? membership.organizationId - : null, - ), - ), - ), - { concurrency: 3 }, - ).pipe( - // Any Autumn failure here (outage or missing customer) leaves the - // paid/free split unknown, and the limit must fail closed. - Effect.mapError(() => new WorkOSError()), - Effect.map((ids) => new Set(ids.filter(Predicate.isNotNull))), - ); - - if (shouldApplyFreeOrganizationLimit(activeMemberships, paidOrganizationIds)) { - return yield* new WorkOSError(); - } - } - - const org = yield* workos.createOrganization(name); - const membership = yield* workos.createMembership(org.id, session.accountId, "admin"); - // `upsertOrganization` mints the slug at insert — no separate heal step. - const mirrored = yield* users.use("upsertOrganization", (s) => - s.upsertOrganization({ - id: org.id, - name: org.name, - updatedAt: new Date(org.updatedAt), - }), - ); - // Write-through: the creator's admin membership, from the create - // response, lands in the mirror before anything reads it. - yield* mirrorMembership(membership); - - // Provision the org's billing customer while we're the ones creating - // the org. Without this the first billing call an org ever makes is a - // non-creating one (balance check / usage track), which 404s and keeps - // 404ing — unlimited unbilled executions. Non-fatal: a billing blip - // must not block signup, and the billing seam heals a customer that - // is still missing later. - yield* autumn.ensureCustomer(org.id).pipe( - Effect.catch((error) => - Effect.gen(function* () { - yield* Effect.logWarning( - "createOrganization: could not provision the Autumn customer", - { organizationId: org.id }, - ); - yield* captureCauseEffect(error); - }), - ), - ); - // Seed the new org's billed seat count (the creator's seat). - yield* forkReportMemberSeats(org.id); - - // Try to attach the new org to the current session. This can fail - // (or silently return a session still scoped to the old org) when - // the caller's current session is stale — most commonly after the - // user was removed from the org their cookie is pinned to. In that - // case we can't repair the session in-place, so we clear the - // cookie and fail loudly; the frontend will bounce to login and - // the callback's rehydrate path will pick up the new membership. - const refreshed = yield* workos.refreshSession(session.sealedSession, org.id); - const verified = refreshed ? yield* workos.authenticateSealedSession(refreshed) : null; - - if (!refreshed || !verified || verified.organizationId !== org.id) { - yield* Effect.logWarning( - "createOrganization: unable to attach new org to current session", - { - userId: session.accountId, - newOrgId: org.id, - refreshReturnedSession: refreshed != null, - verifiedOrgId: verified?.organizationId ?? null, - }, - ); - (yield* SessionCookies).set("wos-session", "", DELETE_COOKIE_OPTIONS); - return yield* new WorkOSError(); - } - - (yield* SessionCookies).set("wos-session", refreshed, RESPONSE_COOKIE_OPTIONS); - return { id: org.id, name: org.name, slug: mirrored.slug }; - }), + // v1 creates no organizations; Executor v2 does. Nothing is written to + // WorkOS, the mirror or billing: the request is answered with where to go. + .handle("createOrganization", () => + Effect.fail( + new OrganizationCreationMoved({ + message: ORGANIZATION_CREATION_MOVED_MESSAGE, + url: v2SignUpUrl(env)?.href ?? null, + }), + ), ) .handle("deleteOrganization", ({ payload }) => Effect.gen(function* () { @@ -721,7 +623,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( // The caller's session is pinned to the now-deleted org — clear it so // the browser bounces to login and rehydrates to another membership - // (or the create-org screen when they have none left). + // (or, with none left, is sent on to Executor v2). (yield* SessionCookies).set("wos-session", "", DELETE_COOKIE_OPTIONS); return { success: true }; @@ -733,9 +635,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( const session = yield* SessionContext; const invitations = yield* workos.listInvitationsByEmail(session.email); - const pending = invitations.data.filter( - (i) => i.state === "pending" && i.organizationId !== null, - ); + const pending = invitations.data.filter(isPendingOrganizationInvitation); // Resolve org names + inviter identities in parallel. Treat // individual failures as "skip the field" rather than failing the @@ -747,7 +647,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( pending.map((inv) => Effect.gen(function* () { const org = yield* workos - .getOrganization(inv.organizationId!) + .getOrganization(inv.organizationId) .pipe(Effect.orElseSucceed(() => null)); if (!org) return null; const inviter = inv.inviterUserId @@ -827,10 +727,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( // seat count now. yield* forkReportMemberSeats(org.id); - // Attach the just-accepted org to the current session. Same shape - // as createOrganization: refresh + verify; if we can't pin the - // session in-place, clear the cookie and let the user bounce - // through login again. The acceptance has already succeeded + // Attach the just-accepted org to the current session: refresh + + // verify; if we can't pin the session in-place, clear the cookie + // and let the user bounce through login again. The acceptance has already succeeded // server-side, so the next login will pick up the membership. const refreshed = yield* workos.refreshSession(session.sealedSession, org.id); const verified = refreshed ? yield* workos.authenticateSealedSession(refreshed) : null; diff --git a/apps/cloud/src/auth/middleware-live.ts b/apps/cloud/src/auth/middleware-live.ts index 16ac3a4ff6..454be8934f 100644 --- a/apps/cloud/src/auth/middleware-live.ts +++ b/apps/cloud/src/auth/middleware-live.ts @@ -35,8 +35,8 @@ export const SessionAuthLive = Layer.effect( } // Per-request cookie queue. Typed `.handle()` session handlers (the - // WorkOS session-refresh on switchOrganization / createOrganization / - // acceptInvitation) return DATA, so they can't attach a Set-Cookie + // WorkOS session-refresh on acceptInvitation, the cookie clear on + // deleteOrganization) return DATA, so they can't attach a Set-Cookie // themselves — they `yield* SessionCookies` and queue writes, which we // drain onto the response below. This is what lets `handlers.ts` drop // the `@tanstack/react-start/server` `setCookie` import (the sole thing diff --git a/apps/cloud/src/auth/middleware.ts b/apps/cloud/src/auth/middleware.ts index cecd1a64f4..bba281f7e7 100644 --- a/apps/cloud/src/auth/middleware.ts +++ b/apps/cloud/src/auth/middleware.ts @@ -46,8 +46,8 @@ export class SessionContext extends Context.Service()( // A request-scoped cookie setter, provided ALONGSIDE `SessionContext` by // `SessionAuth` (see its `provides` below). Typed `.handle()` handlers — the -// WorkOS session-refresh on switchOrganization / createOrganization / -// acceptInvitation — return DATA, not an `HttpServerResponse`, so they can't +// WorkOS session-refresh on acceptInvitation, the cookie clear on +// deleteOrganization — return DATA, not an `HttpServerResponse`, so they can't // attach a `Set-Cookie` directly. They `yield* SessionCookies` and queue writes; // `SessionAuthLive` drains the queue onto the outgoing response. It's a SEPARATE // service, not a field on `Session`, so the session DATA stays pure — `OrgAuth` diff --git a/apps/cloud/src/auth/route-paths.ts b/apps/cloud/src/auth/route-paths.ts index 967a74dc18..95dc9c2110 100644 --- a/apps/cloud/src/auth/route-paths.ts +++ b/apps/cloud/src/auth/route-paths.ts @@ -6,5 +6,17 @@ /** Pages that render for SIGNED-OUT visitors (the gate lets them through). */ export const PUBLIC_PATHS = new Set(["/login"]); -/** Pages an authenticated-but-org-less user is FOR (everything else redirects to onboarding). */ -export const ONBOARDING_PATHS = new Set(["/create-org", "/setup-mcp"]); +/** + * The page a signed-in session with no organization is FOR: accepting a + * pending invitation into an existing organization. Every other page sends an + * org-less session on — here when it holds a pending invitation, else to + * Executor v2, where new organizations are created. + */ +export const JOIN_PATH = "/join"; + +/** + * v1's create-an-organization address. v1 creates no organizations: the + * document auth gate answers it with v2's sign-up page, for every signed-in + * session, so links to it are plain document navigations, never router links. + */ +export const CREATE_ORG_PATH = "/create-org"; diff --git a/apps/cloud/src/auth/workos.ts b/apps/cloud/src/auth/workos.ts index af37f69218..472d50d6be 100644 --- a/apps/cloud/src/auth/workos.ts +++ b/apps/cloud/src/auth/workos.ts @@ -535,22 +535,6 @@ const make = Effect.gen(function* () { }), ), - /** Create a new organization in WorkOS. */ - createOrganization: (name: string) => - use("organizations.createOrganization", (wos) => - wos.organizations.createOrganization({ name }), - ), - - /** Add a user to an organization. */ - createMembership: (organizationId: string, userId: string, roleSlug?: string) => - use("userManagement.createOrganizationMembership", (wos) => - wos.userManagement.createOrganizationMembership({ - organizationId, - userId, - ...(roleSlug ? { roleSlug } : {}), - }), - ), - /** List organization memberships for a user. */ listUserMemberships: (userId: string) => use("userManagement.listOrganizationMemberships", async (wos) => @@ -894,6 +878,15 @@ const make = Effect.gen(function* () { export type WorkOSClientService = Effect.Success; +/** An invitation (from `listInvitationsByEmail`) still waiting to be accepted + * into an organization. */ +export const isPendingOrganizationInvitation = < + Invitation extends { readonly state: string; readonly organizationId: string | null }, +>( + invitation: Invitation, +): invitation is Invitation & { readonly organizationId: string } => + invitation.state === "pending" && invitation.organizationId !== null; + export class WorkOSClient extends Context.Service()( "@executor-js/cloud/WorkOSClient", ) { diff --git a/apps/cloud/src/edge/marketing.ts b/apps/cloud/src/edge/marketing.ts index 283791b154..72e628a89b 100644 --- a/apps/cloud/src/edge/marketing.ts +++ b/apps/cloud/src/edge/marketing.ts @@ -309,9 +309,19 @@ export const parseV2Edge = (env: V2EdgeEnv): V2Edge | string | null => { * of v2's sign-up page, the same host the edge sends v2 dashboard links to. */ export const v2AppOrigin = (env: V2EdgeEnv): URL | null => { + const signUpUrl = v2SignUpUrl(env); + return signUpUrl === null ? null : new URL(signUpUrl.origin); +}; + +/** + * v2's sign-up page (the `V2_SIGN_UP_URL` var), where new organizations are + * created, or `null` where v1 does not hand requests to v2 (see + * {@link v2AppOrigin}). + */ +export const v2SignUpUrl = (env: V2EdgeEnv): URL | null => { const edge = parseV2Edge(env); if (edge === null || typeof edge === "string") return null; - return new URL(edge.signUpUrl.origin); + return new URL(edge.signUpUrl); }; /** Whether a connected-account callback carries v2's state prefix. Reads the diff --git a/apps/cloud/src/extensions/billing/plans.ts b/apps/cloud/src/extensions/billing/plans.ts index d3f1ce2aab..eeeaf9c4f0 100644 --- a/apps/cloud/src/extensions/billing/plans.ts +++ b/apps/cloud/src/extensions/billing/plans.ts @@ -1,60 +1,25 @@ -// Paid plan IDs, mirroring the Autumn plan definitions in the repo-root +// Plan IDs mirror the Autumn plan definitions in the repo-root // `autumn.config.ts` (the deploy-time source synced via `atmn`). These IDs are // stable, so we keep them as literals here rather than importing the config // across the app boundary. -export const PAID_AUTUMN_PLAN_IDS = new Set(["team", "enterprise"]); export const ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES = new Set(["active", "trialing"]); -// --------------------------------------------------------------------------- -// Free-tier organization-creation limit — the createOrganization gate. -// -// These predicates read the Autumn plan config above, so they live with the -// billing config (NOT in `auth/organization.ts`, which the billing-free MCP -// session DO bundle reaches). Used only by `auth/handlers.ts`'s -// `createOrganization` handler. -// --------------------------------------------------------------------------- - -export const FREE_ORGANIZATIONS_PER_USER_LIMIT = 3; - -export type OrganizationLimitSubscriptionSummary = { - readonly planId?: string | null; - readonly status?: string | null; -}; - -export type OrganizationLimitMembershipSummary = { - readonly organizationId: string; - readonly status?: string | null; -}; - -export const isPaidOrganizationSubscription = ( - subscription: OrganizationLimitSubscriptionSummary, -): boolean => - subscription.planId != null && - PAID_AUTUMN_PLAN_IDS.has(subscription.planId) && - ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES.has(subscription.status ?? ""); - -export const hasPaidOrganizationSubscription = ( - subscriptions: ReadonlyArray, -): boolean => subscriptions.some(isPaidOrganizationSubscription); - // --------------------------------------------------------------------------- // Execution rate-limit exemption. // // The hourly execution backstop exists for FREE-tier abuse, so the only orgs -// it may cap are those with nothing but the Free plan. This is deliberately -// NOT `hasPaidOrganizationSubscription`: that set names only the plans sold -// today, and every other plan an org can legitimately hold — grandfathered -// (`hobby`, `professional`), pay-as-you-go, or one added later and not yet -// listed here — fell through to the cap. A paying customer blocked by an -// abuse backstop is the worse failure, so the exemption is "anything but -// Free" rather than "one of the plans we remembered to list". +// it may cap are those with nothing but the Free plan. The exemption is +// "anything but Free", not a list of the plans sold today: every other plan +// an org can legitimately hold — grandfathered (`hobby`, `professional`), +// pay-as-you-go, or one added later — must stay uncapped. A paying customer +// blocked by an abuse backstop is the worse failure. // --------------------------------------------------------------------------- export const FREE_AUTUMN_PLAN_ID = "free"; export const hasNonFreeOrganizationSubscription = ( - subscriptions: ReadonlyArray, + subscriptions: ReadonlyArray, ): boolean => subscriptions.some( (subscription) => @@ -63,16 +28,6 @@ export const hasNonFreeOrganizationSubscription = ( ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES.has(subscription.status ?? ""), ); -export const shouldApplyFreeOrganizationLimit = ( - activeMemberships: ReadonlyArray, - paidOrganizationIds: ReadonlySet, -): boolean => - !activeMemberships.some((membership) => paidOrganizationIds.has(membership.organizationId)); - -export const isOverFreeOrganizationLimit = ( - activeMemberships: ReadonlyArray, -): boolean => activeMemberships.length >= FREE_ORGANIZATIONS_PER_USER_LIMIT; - // --------------------------------------------------------------------------- // Per-plan member seat limits — the org member seat-gate (reserveMemberSlot). // Reads the same Autumn plan config. Used by the account provider seat-gate. diff --git a/apps/cloud/src/extensions/routes.ts b/apps/cloud/src/extensions/routes.ts index 9892af061d..3b3d434599 100644 --- a/apps/cloud/src/extensions/routes.ts +++ b/apps/cloud/src/extensions/routes.ts @@ -74,10 +74,9 @@ const spec = OpenApi.fromApi(CloudOpenApi); * Build cloud's app-only extension routes. `rsLive` is the per-request DB layer * the session handlers read; passed in so tests can swap a counting fake. * - * `AutumnService.Default` is provided to the session + org groups because the - * `createOrganization` free-limit gate and the domain-verification-link gate - * read it — the few app-only billing touchpoints. It is NOT on the neutral boot - * core. + * `AutumnService.Default` is provided to the session + org groups because org + * deletion's customer cancel and the domain-verification-link gate read it — + * the few app-only billing touchpoints. It is NOT on the neutral boot core. */ export const makeCloudExtensionRoutes = ( rsLive: Layer.Layer, diff --git a/apps/cloud/src/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts index e0ca33dbdf..ff312ab5aa 100644 --- a/apps/cloud/src/routeTree.gen.ts +++ b/apps/cloud/src/routeTree.gen.ts @@ -9,9 +9,8 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' -import { Route as CreateOrgRouteImport } from './routes/bare/create-org' +import { Route as JoinRouteImport } from './routes/bare/join' import { Route as LoginRouteImport } from './routes/bare/login' -import { Route as SetupMcpRouteImport } from './routes/bare/setup-mcp' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index' import { Route as ApiKeysRouteImport } from './routes/app/api-keys' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts' @@ -31,9 +30,9 @@ import { Route as ResumeDotexecutionIdRouteImport } from './routes/app/resume.$e import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsDottoolkitSlugRouteImport } from './../../../packages/react/src/routes/toolkits.$toolkitSlug' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRouteImport } from './../../../packages/react/src/routes/integrations.add.$pluginKey' -const CreateOrgRoute = CreateOrgRouteImport.update({ - id: '/create-org', - path: '/create-org', +const JoinRoute = JoinRouteImport.update({ + id: '/join', + path: '/join', getParentRoute: () => rootRouteImport, } as any) const LoginRoute = LoginRouteImport.update({ @@ -41,11 +40,6 @@ const LoginRoute = LoginRouteImport.update({ path: '/login', getParentRoute: () => rootRouteImport, } as any) -const SetupMcpRoute = SetupMcpRouteImport.update({ - id: '/setup-mcp', - path: '/setup-mcp', - getParentRoute: () => rootRouteImport, -} as any) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport.update({ id: '/{-$orgSlug}/', @@ -164,9 +158,8 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginK ) export interface FileRoutesByFullPath { - '/create-org': typeof CreateOrgRoute + '/join': typeof JoinRoute '/login': typeof LoginRoute - '/setup-mcp': typeof SetupMcpRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/billing': typeof BillingRoute @@ -187,9 +180,8 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/integrations/add/$pluginKey': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotaddDotpluginKeyRoute } export interface FileRoutesByTo { - '/create-org': typeof CreateOrgRoute + '/join': typeof JoinRoute '/login': typeof LoginRoute - '/setup-mcp': typeof SetupMcpRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/billing': typeof BillingRoute @@ -211,9 +203,8 @@ export interface FileRoutesByTo { } export interface FileRoutesById { __root__: typeof rootRouteImport - '/create-org': typeof CreateOrgRoute + '/join': typeof JoinRoute '/login': typeof LoginRoute - '/setup-mcp': typeof SetupMcpRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren '/{-$orgSlug}/billing': typeof BillingRoute @@ -236,9 +227,8 @@ export interface FileRoutesById { export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: - | '/create-org' + | '/join' | '/login' - | '/setup-mcp' | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/billing' @@ -259,9 +249,8 @@ export interface FileRouteTypes { | '/{-$orgSlug}/integrations/add/$pluginKey' fileRoutesByTo: FileRoutesByTo to: - | '/create-org' + | '/join' | '/login' - | '/setup-mcp' | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/billing' @@ -282,9 +271,8 @@ export interface FileRouteTypes { | '/{-$orgSlug}/integrations/add/$pluginKey' id: | '__root__' - | '/create-org' + | '/join' | '/login' - | '/setup-mcp' | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' | '/{-$orgSlug}/billing' @@ -306,9 +294,8 @@ export interface FileRouteTypes { fileRoutesById: FileRoutesById } export interface RootRouteChildren { - CreateOrgRoute: typeof CreateOrgRoute + JoinRoute: typeof JoinRoute LoginRoute: typeof LoginRoute - SetupMcpRoute: typeof SetupMcpRoute ApiKeysRoute: typeof ApiKeysRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren BillingRoute: typeof BillingRoute @@ -329,11 +316,11 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { - '/create-org': { - id: '/create-org' - path: '/create-org' - fullPath: '/create-org' - preLoaderRoute: typeof CreateOrgRouteImport + '/join': { + id: '/join' + path: '/join' + fullPath: '/join' + preLoaderRoute: typeof JoinRouteImport parentRoute: typeof rootRouteImport } '/login': { @@ -343,13 +330,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LoginRouteImport parentRoute: typeof rootRouteImport } - '/setup-mcp': { - id: '/setup-mcp' - path: '/setup-mcp' - fullPath: '/setup-mcp' - preLoaderRoute: typeof SetupMcpRouteImport - parentRoute: typeof rootRouteImport - } '/{-$orgSlug}/': { id: '/{-$orgSlug}/' path: '/{-$orgSlug}' @@ -510,9 +490,8 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren = ) const rootRouteChildren: RootRouteChildren = { - CreateOrgRoute: CreateOrgRoute, + JoinRoute: JoinRoute, LoginRoute: LoginRoute, - SetupMcpRoute: SetupMcpRoute, ApiKeysRoute: ApiKeysRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren, diff --git a/apps/cloud/src/routes/__root.tsx b/apps/cloud/src/routes/__root.tsx index 40d88322f1..428be38c3d 100644 --- a/apps/cloud/src/routes/__root.tsx +++ b/apps/cloud/src/routes/__root.tsx @@ -25,7 +25,7 @@ import { ArtifactRendererProvider } from "@executor-js/react/api/artifact-render import { plugins as clientPlugins } from "virtual:executor/plugins-client"; import { AuthProvider, useAuth } from "../web/auth"; import { loginPath } from "../auth/return-to"; -import { ONBOARDING_PATHS, PUBLIC_PATHS } from "../auth/route-paths"; +import { JOIN_PATH, PUBLIC_PATHS } from "../auth/route-paths"; import { SupportOptions } from "../web/components/support-options"; import { minimizeSentryEvent } from "../observability/sentry-privacy"; import { Shell } from "../web/shell"; @@ -191,7 +191,7 @@ function AuthGate() { const auth = useAuth(); const location = useLocation(); const navigate = useNavigate(); - const isOnboardingRoute = ONBOARDING_PATHS.has(location.pathname); + const isJoinRoute = location.pathname === JOIN_PATH; const isPublicRoute = PUBLIC_PATHS.has(location.pathname); // The org the URL names (the `{-$orgSlug}` segment), if any. `/account/me` // is scoped to it, so `auth.organization` IS this org when the caller is a @@ -206,21 +206,22 @@ function AuthGate() { const firstSegment = location.pathname.split("/")[1] ?? ""; const pathnameOrgSlug = isValidOrgSlug(firstSegment) ? firstSegment : null; - // The SSR gate already bounced fresh org-less document requests to - // /create-org; this catches the MID-SESSION transitions (org deleted, - // membership revoked → /account/me now reports no org). Only for BARE paths: - // an org-less result on a slugged URL is a wrong address (404 below), not a - // reason to send the user to onboarding. + // The SSR gate already sent fresh org-less document requests on (to v2, or + // to the join page with a pending invitation); this catches the MID-SESSION + // transitions (org deleted, membership revoked → /account/me now reports no + // org). The join page shows any pending invitation and otherwise sends the + // user to v2. Only for BARE paths: an org-less result on a slugged URL is a + // wrong address (404 below), not a reason to leave the app. const needsOrgRedirect = auth.status === "authenticated" && auth.organization == null && !urlOrgSlug && - !isOnboardingRoute && + !isJoinRoute && !isPublicRoute; React.useEffect(() => { if (needsOrgRedirect) { - void navigate({ to: "/create-org", replace: true }); + void navigate({ to: JOIN_PATH, replace: true }); } }, [needsOrgRedirect, navigate]); @@ -248,15 +249,16 @@ function AuthGate() { return ; } - if (isOnboardingRoute) { + if (isJoinRoute) { return ; } if (auth.organization == null) { // A URL naming an org this session can't access (`/account/me` returned no // org for its slug) is a wrong address → the route 404, framed by nothing - // (the user isn't "in" any org here). A bare path with no org is a new - // user — the redirect effect above is taking them to onboarding. + // (the user isn't "in" any org here). A bare path with no org is a user + // with no organization — the redirect effect above is taking them to the + // join page. return urlOrgSlug ? : ; } diff --git a/apps/cloud/src/routes/app/org.tsx b/apps/cloud/src/routes/app/org.tsx index 15af2e2f7e..88eccfc1e4 100644 --- a/apps/cloud/src/routes/app/org.tsx +++ b/apps/cloud/src/routes/app/org.tsx @@ -119,8 +119,8 @@ function DangerZoneSection() { trackEvent("org_deleted", { success: Exit.isSuccess(exit) }); if (Exit.isSuccess(exit)) { // The org (and the caller's session) are gone. A full navigation resets - // all app state and lets the auth gate rehydrate to another membership or - // the create-org screen. + // all app state and lets the auth gate rehydrate to another membership, + // or with none left send the user on to Executor v2. toast.success(`Deleted ${organizationName}`); window.location.href = "/"; return; diff --git a/apps/cloud/src/routes/bare/create-org.tsx b/apps/cloud/src/routes/bare/create-org.tsx deleted file mode 100644 index 8a8e6133dc..0000000000 --- a/apps/cloud/src/routes/bare/create-org.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { CreateOrgPage } from "../../web/pages/create-org"; - -export const Route = createFileRoute("/create-org")({ - component: CreateOrgPage, -}); diff --git a/apps/cloud/src/routes/bare/join.tsx b/apps/cloud/src/routes/bare/join.tsx new file mode 100644 index 0000000000..eec2a29635 --- /dev/null +++ b/apps/cloud/src/routes/bare/join.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { JoinPage } from "../../web/pages/join"; + +export const Route = createFileRoute("/join")({ + component: JoinPage, +}); diff --git a/apps/cloud/src/routes/bare/setup-mcp.tsx b/apps/cloud/src/routes/bare/setup-mcp.tsx deleted file mode 100644 index b249fbfbfe..0000000000 --- a/apps/cloud/src/routes/bare/setup-mcp.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { SetupMcpPage } from "../../web/pages/setup-mcp"; - -export const Route = createFileRoute("/setup-mcp")({ - component: SetupMcpPage, -}); diff --git a/apps/cloud/src/web/auth.tsx b/apps/cloud/src/web/auth.tsx index 10070a026c..9d4f85dc95 100644 --- a/apps/cloud/src/web/auth.tsx +++ b/apps/cloud/src/web/auth.tsx @@ -18,7 +18,7 @@ import { CloudApiClient } from "./client"; // shared `onIdentify` callback. Identity comes from the provider-neutral // account surface, identical to self-host. // -// Cloud-only multi-org bits (org switcher, create-org, pending invites) stay +// Cloud-only multi-org bits (org switcher, pending invites) stay // here as cloud-local atoms over CloudApiClient — they are NOT part of the // shared account contract and coexist with the shared `/account/*` atoms. // --------------------------------------------------------------------------- @@ -41,8 +41,6 @@ export const v2ImportAtom = CloudApiClient.query("cloudAuth", "v2Import", { reactivityKeys: [ReactivityKey.auth], }); -export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization"); - export const deleteOrganization = CloudApiClient.mutation("cloudAuth", "deleteOrganization"); export const pendingInvitationsAtom = CloudApiClient.query("cloudAuth", "pendingInvitations", { diff --git a/apps/cloud/src/web/components/create-organization-form.tsx b/apps/cloud/src/web/components/create-organization-form.tsx deleted file mode 100644 index 768b125279..0000000000 --- a/apps/cloud/src/web/components/create-organization-form.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useState } from "react"; -import { useAtomSet } from "@effect/atom-react"; -import * as Exit from "effect/Exit"; -import { authWriteKeys } from "@executor-js/react/api/reactivity-keys"; -import { trackEvent } from "@executor-js/react/api/analytics"; -import { Input } from "@executor-js/react/components/input"; -import { Label } from "@executor-js/react/components/label"; - -import { createOrganization } from "../auth"; - -type CreatedOrganization = { id: string; name: string; slug: string }; - -export function useCreateOrganizationForm(options: { - defaultName?: string; - onSuccess: (org: CreatedOrganization) => void; - onFailure?: () => void; -}) { - const doCreate = useAtomSet(createOrganization, { mode: "promiseExit" }); - const [name, setName] = useState(options.defaultName ?? ""); - const [error, setError] = useState(null); - const [creating, setCreating] = useState(false); - - const reset = (nextName = options.defaultName ?? "") => { - setName(nextName); - setError(null); - setCreating(false); - }; - - const submit = async () => { - const trimmed = name.trim(); - if (!trimmed) { - setError("Organization name is required."); - return; - } - setCreating(true); - setError(null); - const exit = await doCreate({ payload: { name: trimmed }, reactivityKeys: authWriteKeys }); - setCreating(false); - trackEvent("org_created", { success: Exit.isSuccess(exit) }); - if (Exit.isSuccess(exit)) { - options.onSuccess(exit.value); - } else { - setError("Failed to create organization."); - options.onFailure?.(); - } - }; - - return { - name, - setName, - error, - setError, - creating, - submit, - reset, - canSubmit: name.trim().length > 0, - }; -} - -export function CreateOrganizationFields(props: { - name: string; - onNameChange: (name: string) => void; - error: string | null; - onSubmit: () => void; -}) { - return ( -
-
- - props.onNameChange((event.target as HTMLInputElement).value)} - onKeyDown={(event) => { - if (event.key === "Enter") props.onSubmit(); - }} - className="h-9 text-sm" - /> -
- - {props.error && ( -
-

{props.error}

-
- )} -
- ); -} diff --git a/apps/cloud/src/web/components/org-menu-slot.tsx b/apps/cloud/src/web/components/org-menu-slot.tsx index 085656616a..b419c5f602 100644 --- a/apps/cloud/src/web/components/org-menu-slot.tsx +++ b/apps/cloud/src/web/components/org-menu-slot.tsx @@ -1,17 +1,6 @@ -import { useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { trackEvent } from "@executor-js/react/api/analytics"; -import { Button } from "@executor-js/react/components/button"; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@executor-js/react/components/dialog"; import { DropdownMenuItem, DropdownMenuLabel, @@ -20,18 +9,18 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, } from "@executor-js/react/components/dropdown-menu"; +import { CREATE_ORG_PATH } from "../../auth/route-paths"; import { useAuth } from "../auth"; import { organizationsAtom } from "../auth"; -import { CreateOrganizationFields, useCreateOrganizationForm } from "./create-organization-form"; // --------------------------------------------------------------------------- // Cloud-only org-switcher slot for the shared shell's account dropdown. // // The shared `Shell` renders this `orgMenuSlot` ABOVE its API-keys link. Cloud -// is the only product with multiple organizations, so the switcher + create-org -// dialog live here and are injected, keeping the shared shell provider-neutral. -// The create-org dialog is controlled by local state so its `DialogContent` -// can live outside the dropdown menu while the trigger sits inside it. +// is the only product with multiple organizations, so the switcher lives here +// and is injected, keeping the shared shell provider-neutral. v1 creates no +// organizations: the switcher's last item opens Executor v2, where new ones +// are created, in a new tab like the sidebar's other links to v2. // --------------------------------------------------------------------------- function CheckIcon() { @@ -92,29 +81,9 @@ function OrganizationSwitcherItems(props: { activeOrganizationId: string | null export function OrgMenuSlot() { const auth = useAuth(); - const [createOrganizationOpen, setCreateOrganizationOpen] = useState(false); - - const suggestedOrganizationName = - auth.status === "authenticated" && auth.user.name?.trim() !== "" && auth.user.name != null - ? `${auth.user.name}'s Organization` - : "New Organization"; - - const form = useCreateOrganizationForm({ - defaultName: suggestedOrganizationName, - // Land on the new org's URL root — a reload would keep the old slug and - // the slug gate would switch the session right back. - onSuccess: (org) => { - window.location.href = `/${org.slug}`; - }, - }); if (auth.status !== "authenticated") return null; - const openCreateOrganization = () => { - form.reset(suggestedOrganizationName); - setCreateOrganizationOpen(true); - }; - return ( <> @@ -129,60 +98,21 @@ export function OrgMenuSlot() { - { - event.preventDefault(); - openCreateOrganization(); - }} - > - Create organization + {/* A document navigation: the auth gate answers /create-org with + v2's sign-up page. */} + + trackEvent("v2_new_org_opened")} + > + New organization in Executor v2 + - - { - setCreateOrganizationOpen(open); - if (!open) form.reset(suggestedOrganizationName); - }} - > - - - Create organization - - Add another organization under your current account and switch into it immediately. - - - - { - form.setName(name); - if (form.error) form.setError(null); - }} - error={form.error} - onSubmit={() => void form.submit()} - /> - - - - - - - - - ); } diff --git a/apps/cloud/src/web/pages/create-org.tsx b/apps/cloud/src/web/pages/join.tsx similarity index 56% rename from apps/cloud/src/web/pages/create-org.tsx rename to apps/cloud/src/web/pages/join.tsx index 446ec7b586..e49f9f243a 100644 --- a/apps/cloud/src/web/pages/create-org.tsx +++ b/apps/cloud/src/web/pages/join.tsx @@ -1,6 +1,5 @@ -import { useState } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { useAtomValue, useAtomSet } from "@effect/atom-react"; -import { useNavigate } from "@tanstack/react-router"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import * as Exit from "effect/Exit"; import { authWriteKeys } from "@executor-js/react/api/reactivity-keys"; @@ -8,11 +7,17 @@ import { trackEvent } from "@executor-js/react/api/analytics"; import { Button } from "@executor-js/react/components/button"; import { AUTH_PATHS } from "../../auth/api"; +import { CREATE_ORG_PATH } from "../../auth/route-paths"; import { acceptInvitation, pendingInvitationsAtom, useAuth } from "../auth"; -import { - CreateOrganizationFields, - useCreateOrganizationForm, -} from "../components/create-organization-form"; + +// --------------------------------------------------------------------------- +// The page for a signed-in user with no organization who holds a pending +// invitation into an existing one. v1 creates no organizations (they are +// created in Executor v2), so this page only accepts invitations: accepting +// one opens that organization, and with none to accept the user is sent on +// to v2 through `/create-org`, which the document auth gate answers with v2's +// sign-up page. +// --------------------------------------------------------------------------- type PendingInvitation = { id: string; @@ -40,14 +45,16 @@ const formatRelativeTime = (iso: string): string => { return `${Math.floor(days / 365)}y ago`; }; -export const CreateOrgPage = () => { +export const JoinPage = () => { const auth = useAuth(); - const navigate = useNavigate(); const invitationsResult = useAtomValue(pendingInvitationsAtom); const doAccept = useAtomSet(acceptInvitation, { mode: "promiseExit" }); const [acceptingId, setAcceptingId] = useState(null); const [errorByInvitationId, setErrorByInvitationId] = useState>({}); + // Set once an invitation is accepted and the page is navigating into its + // organization, so the refetched (now empty) list cannot send the user to v2. + const joining = useRef(false); const handleAccept = async (invitation: PendingInvitation) => { setAcceptingId(invitation.id); @@ -61,97 +68,113 @@ export const CreateOrgPage = () => { payload: { invitationId: invitation.id }, reactivityKeys: authWriteKeys, }); - setAcceptingId(null); trackEvent("org_invitation_accepted", { success: Exit.isSuccess(exit) }); - if (!Exit.isSuccess(exit)) { - setErrorByInvitationId((prev) => ({ - ...prev, - [invitation.id]: "Couldn't accept this invitation. Try again or ask the inviter to resend.", - })); + if (Exit.isSuccess(exit)) { + joining.current = true; + window.location.href = `/${exit.value.slug}`; + return; } + setAcceptingId(null); + setErrorByInvitationId((prev) => ({ + ...prev, + [invitation.id]: "Couldn't accept this invitation. Try again or ask the inviter to resend.", + })); }; - const suggestedName = - auth.status === "authenticated" && auth.user.name != null && auth.user.name.trim() !== "" - ? `${auth.user.name}'s Organization` - : ""; + const invitations = AsyncResult.isSuccess(invitationsResult) + ? invitationsResult.value.invitations + : null; + const organizationSlug = + auth.status === "authenticated" ? (auth.organization?.slug ?? null) : null; - const form = useCreateOrganizationForm({ - defaultName: suggestedName, - onSuccess: () => { - void navigate({ to: "/setup-mcp" }); - }, - }); + // Nothing to accept: a member goes back to their organization, anyone else + // on to v2 (a document navigation, so the auth gate answers it). + const nothingToAccept = invitations !== null && invitations.length === 0; + useEffect(() => { + if (!nothingToAccept || joining.current) return; + window.location.replace(organizationSlug ? `/${organizationSlug}` : CREATE_ORG_PATH); + }, [nothingToAccept, organizationSlug]); - // Almost no new user has a pending invitation, so we never show a loading - // placeholder for them — the create form renders immediately and stays put. - // Invitations (when they exist) just appear above it once the fetch resolves; - // a refetch (the create-org submit invalidates the auth-keyed atom) keeps the - // last value, so nothing flickers. - const invitations = AsyncResult.match(invitationsResult, { - onInitial: () => [] as readonly PendingInvitation[], - onFailure: () => [] as readonly PendingInvitation[], - onSuccess: ({ value }) => value.invitations, - }); + if (AsyncResult.isFailure(invitationsResult)) return ; + if (invitations === null || invitations.length === 0) { + return
; + } - const count = invitations.length; - const sole = count === 1 ? invitations[0]! : null; + const sole = invitations.length === 1 ? invitations[0]! : null; return ( -
-
-
-

- Step 1 of 2 -

-

- {count > 0 ? "You've been invited" : "Create your organization"} -

- {count === 0 && ( -

- Organizations group your sources, secrets, and teammates. You can invite others once - it's set up. -

- )} -
+ + {sole && ( + void handleAccept(sole)} + /> + )} + + {invitations.length > 1 && ( + void handleAccept(inv)} + /> + )} + +

+ New organizations are created in{" "} + + Executor v2 + + . +

+
+ ); +}; - {sole && ( - void handleAccept(sole)} - /> - )} +/** The invitations could not be read: say where organizations are created + * now, and let the user go there or sign out. */ +const MovedNotice = () => ( + +

+ New organizations are created in Executor v2. If you were invited to an organization here, + reload this page to see the invitation. +

+ +
+); - {count > 1 && ( - void handleAccept(inv)} - /> - )} +const JoinLayout = (props: { title: string; children: ReactNode }) => ( +
+
+
+

{props.title}

+
- + {props.children} -
- {/* oxlint-disable-next-line react/forbid-elements */} - -
-
+
+ {/* oxlint-disable-next-line react/forbid-elements */} + +
- ); -}; +
+); // --------------------------------------------------------------------------- @@ -246,53 +269,3 @@ const MultiInvitationsView = ({ ); - -// --------------------------------------------------------------------------- - -const CreateOrgSection = ({ - isPrimary, - form, -}: { - isPrimary: boolean; - form: ReturnType; -}) => { - const [expanded, setExpanded] = useState(isPrimary); - - if (!isPrimary && !expanded) { - return ( -
- {/* oxlint-disable-next-line react/forbid-elements */} - -
- ); - } - - return ( -
- { - form.setName(name); - if (form.error) form.setError(null); - }} - error={form.error} - onSubmit={() => void form.submit()} - /> -
- -
-
- ); -}; diff --git a/apps/cloud/src/web/pages/setup-mcp.tsx b/apps/cloud/src/web/pages/setup-mcp.tsx deleted file mode 100644 index a9e7bd2660..0000000000 --- a/apps/cloud/src/web/pages/setup-mcp.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { useEffect, useState } from "react"; -import { useNavigate } from "@tanstack/react-router"; -import { trackEvent } from "@executor-js/react/api/analytics"; -import { Button } from "@executor-js/react/components/button"; -import { CodeBlock } from "@executor-js/react/components/code-block"; -import { - buildMcpHttpEndpoint, - buildMcpInstallCommand, - type McpElicitationMode, -} from "@executor-js/react/components/mcp-install-card"; -import { CopyButton } from "@executor-js/react/components/copy-button"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@executor-js/react/components/collapsible"; -import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select"; - -import { useAuth } from "../auth"; - -export const SetupMcpPage = () => { - const navigate = useNavigate(); - const auth = useAuth(); - const organizationSlug = - auth.status === "authenticated" ? (auth.organization?.slug ?? null) : null; - // Land DIRECTLY on the org's canonical URL. Navigating to the bare - // `/{-$orgSlug}` would mount the shell at `/`, then OrgSlugGate would fire a - // SECOND navigation to canonicalize `/` → `/` — that double hop is the - // window where the shell paints over this still-mounted onboarding page. - const goToApp = () => - navigate({ to: "/{-$orgSlug}", params: { orgSlug: organizationSlug ?? undefined } }); - const [origin, setOrigin] = useState(null); - const [advancedOpen, setAdvancedOpen] = useState(false); - const [elicitationMode, setElicitationMode] = useState("model"); - - useEffect(() => { - setOrigin(window.location.origin); - }, []); - - const endpoint = origin - ? buildMcpHttpEndpoint({ - origin, - desktop: null, - elicitationMode, - organizationSlug, - }) - : ""; - const command = origin - ? buildMcpInstallCommand({ - mode: "http", - isDev: false, - origin, - elicitationMode, - organizationSlug, - }) - : ""; - - return ( -
-
-
-

- Step 2 of 2 -

-

Connect your MCP client

-

- Executor exposes your sources, secrets, and tools to any MCP-compatible agent. Copy the - URL into your client, or run the install command. -

-
- -
-

- MCP server URL -

-
- - {endpoint || "…"} - - {endpoint && ( - - trackEvent("mcp_install_command_copied", { - transport: "http", - elicitation_mode: elicitationMode, - surface: "setup_mcp", - }) - } - /> - )} -
-

Paste this into your MCP client config.

-
- - - - Advanced - - - -
-
-
Resume approvals
-
- Select how tool approvals are handled for this MCP connection. -
-
- setElicitationMode(event.target.value as McpElicitationMode)} - aria-label="Elicitation mode" - className="min-w-44" - > - Browser approval - Model resume tool - Native elicitation - -
-
-
- -
-
- or -
-
- -
-

- Install command -

- - trackEvent("mcp_install_command_copied", { - transport: "http", - elicitation_mode: elicitationMode, - surface: "setup_mcp", - }) - } - /> -

Adds the server to a supported agent.

-
- -
- {/* oxlint-disable-next-line react/forbid-elements */} - - -
-
-
- ); -}; diff --git a/apps/cloud/src/web/shell.tsx b/apps/cloud/src/web/shell.tsx index 8cb2265204..c7dfa96330 100644 --- a/apps/cloud/src/web/shell.tsx +++ b/apps/cloud/src/web/shell.tsx @@ -14,7 +14,7 @@ import { V2MovedNotice } from "./components/v2-moved-notice"; // cloud-only bits injected through its slots: // - sign-out navigate through cloud's WorkOS logout, land home // - nav items defaults + Organization + Billing (cloud-only sections) -// - org menu slot multi-org switcher + create-org dialog (cloud-only) +// - org menu slot multi-org switcher + link to create one in v2 (cloud-only) // - support slot the "Move to Executor v2" link, shown only to users // v2 would let import this org ("Open in Executor v2" // once it has moved, with the moved notice), and the diff --git a/apps/cloud/tsr.routes.ts b/apps/cloud/tsr.routes.ts index 58b1913aaa..3df5e42bf2 100644 --- a/apps/cloud/tsr.routes.ts +++ b/apps/cloud/tsr.routes.ts @@ -14,8 +14,8 @@ import { consoleRoutes } from "@executor-js/react/console-routes"; // // Org-scoped vs bare: console routes and most app routes live INSIDE the // optional `{-$orgSlug}` scope; src/routes/bare stays OUTSIDE it — /login is -// for signed-out visitors and /create-org and /setup-mcp are reached -// precisely when the user has no organization yet. +// for signed-out visitors and /join is reached precisely when the user has no +// organization yet. export const routes = rootRoute("__root.tsx", [ ...consoleRoutes({ dir: "../../../../packages/react/src/routes", diff --git a/packages/core/api/src/account/org-slug.ts b/packages/core/api/src/account/org-slug.ts index 5b83758805..5d6af8f689 100644 --- a/packages/core/api/src/account/org-slug.ts +++ b/packages/core/api/src/account/org-slug.ts @@ -21,8 +21,9 @@ const ORG_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){1,47}$/; * toolkits, artifacts, resume, plugins (the shared * contract, i.e. every entry in `CONSOLE_ROUTE_PATHS`), * plus host extras: - * api-keys, org, billing, create-org, setup-mcp (cloud), - * admin, join, docs (selfhost), login (Better Auth + * api-keys, org, billing, create-org, join (cloud; + * setup-mcp was a cloud route), admin, join, docs + * (selfhost), login (Better Auth * `mcp({ loginPage })` + cloud/selfhost login UX) * - Marketing worker: home, setup, privacy, terms, blog, pricing, careers, * changelog, _astro (executor.sh edge routes; the diff --git a/packages/react/src/api/analytics.tsx b/packages/react/src/api/analytics.tsx index b281d20a33..77d6de85b5 100644 --- a/packages/react/src/api/analytics.tsx +++ b/packages/react/src/api/analytics.tsx @@ -166,7 +166,7 @@ export interface AnalyticsEvents { mcp_install_command_copied: { transport: "http" | "stdio"; elicitation_mode?: string; - surface: "integrations" | "setup_mcp"; + surface: "integrations"; }; mcp_install_transport_switched: { transport: "http" | "stdio" }; mcp_install_elicitation_mode_changed: { elicitation_mode: string }; @@ -186,13 +186,12 @@ export interface AnalyticsEvents { // ── Cloud: auth & onboarding ───────────────────────────────────────────── login_cta_clicked: {}; signed_out: {}; - org_created: { success: boolean }; org_deleted: { success: boolean }; org_switched: { success: boolean }; org_invitation_accepted: { success: boolean }; - setup_mcp_completed: {}; - setup_mcp_skipped: {}; v2_import_opened: {}; + /** The org switcher's link to create an organization in Executor v2. */ + v2_new_org_opened: {}; // The "moved to Executor v2" notice, for an org v2 has already imported. // The org rides on the posthog group, like every other event. v2_moved_modal_shown: {}; diff --git a/packages/react/src/multiplayer/shell.tsx b/packages/react/src/multiplayer/shell.tsx index 2ee203b25e..da5caa8d50 100644 --- a/packages/react/src/multiplayer/shell.tsx +++ b/packages/react/src/multiplayer/shell.tsx @@ -34,7 +34,7 @@ import { useCanCreateWorkspaceConnections } from "./use-admin-nav"; // Provider-neutral: identity comes from the shared `useAuth()` seam. The bits // that genuinely differ per product are injected: // - `onSignOut` how the session is ended (WorkOS logout vs Better Auth) -// - `orgMenuSlot` org switcher / create-org (cloud only) +// - `orgMenuSlot` org switcher (cloud only) // - `supportSlot` support dialog button (cloud only) // - `navItems` which sections show (e.g. cloud adds Billing) // Everything visual is identical so both products look the same. @@ -103,7 +103,7 @@ export interface ShellProps { readonly onSignOut: () => void | Promise; /** Nav sections; defaults to {@link defaultShellNavItems}. */ readonly navItems?: ReadonlyArray; - /** Injected into the account dropdown — cloud's org switcher / create-org. */ + /** Injected into the account dropdown — cloud's org switcher. */ readonly orgMenuSlot?: ReactNode; /** Injected support button above the account footer (cloud). */ readonly supportSlot?: ReactNode;