From 551ba870cfa78148f7d35776e5b3d643580fd87a Mon Sep 17 00:00:00 2001
From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com>
Date: Mon, 13 Apr 2026 15:02:29 -0700
Subject: [PATCH] Rework cloud org flow: onboarding page + live membership
check
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace the callback's auto-org-creation with an explicit OnboardingPage
that AuthGate renders when a user has a session but no org. Drops the
duplicated create-org logic, the login-time upsertOrganization calls,
and the '"{name}'s Organization"' default. Users name their own org.
Add resolveOrganization helper that reads the local mirror first and
lazily falls back to WorkOS on miss, so /me and protected API routes
self-heal when the mirror is stale instead of silently returning a null
org (the bug that was routing existing users to onboarding).
Add authorizeOrganization helper that performs a live
listUserMemberships check on every protected request. A user removed
from their active org now loses access on the next request, not up to
~10 minutes later when the JWT access token naturally expires. The
extra WorkOS call is acceptable for now; can be swapped for a local
memberships table fed by the Events API as a follow-up.
Fix silent refresh failure in createOrganization: if refreshSession
returns null or yields a session still scoped to the previous org
(happens when the current session is stale — e.g. the caller was
removed from the org their cookie is pinned to), clear the cookie and
fail loudly so the frontend bounces to login instead of getting stuck
on onboarding with a successful 200 and a cookie that will never
match.
Extract CreateOrganizationForm hook + fields into a shared component
so the shell dialog and the new onboarding page reuse the same state
and styling.
---
apps/cloud/src/api/protected.ts | 5 +-
apps/cloud/src/auth/api.ts | 7 +-
apps/cloud/src/auth/authorize-organization.ts | 36 +++++++
apps/cloud/src/auth/handlers.ts | 76 ++++++-------
apps/cloud/src/auth/resolve-organization.ts | 30 ++++++
apps/cloud/src/routes/__root.tsx | 5 +
.../components/create-organization-form.tsx | 91 ++++++++++++++++
apps/cloud/src/web/pages/onboarding.tsx | 77 +++++++++++++
apps/cloud/src/web/shell.tsx | 102 +++++-------------
9 files changed, 314 insertions(+), 115 deletions(-)
create mode 100644 apps/cloud/src/auth/authorize-organization.ts
create mode 100644 apps/cloud/src/auth/resolve-organization.ts
create mode 100644 apps/cloud/src/web/components/create-organization-form.tsx
create mode 100644 apps/cloud/src/web/pages/onboarding.tsx
diff --git a/apps/cloud/src/api/protected.ts b/apps/cloud/src/api/protected.ts
index 213982161..57a55031f 100644
--- a/apps/cloud/src/api/protected.ts
+++ b/apps/cloud/src/api/protected.ts
@@ -10,7 +10,7 @@ import { McpExtensionService } from "@executor/plugin-mcp/api";
import { GoogleDiscoveryExtensionService } from "@executor/plugin-google-discovery/api";
import { GraphqlExtensionService } from "@executor/plugin-graphql/api";
-import { UserStoreService } from "../auth/context";
+import { authorizeOrganization } from "../auth/authorize-organization";
import { WorkOSAuth } from "../auth/workos";
import { AutumnService } from "../services/autumn";
import { createOrgExecutor } from "../services/executor";
@@ -34,8 +34,7 @@ const lookupOrgForRequest = (request: HttpServerRequest.HttpServerRequest) =>
const session = yield* workos.authenticateRequest(webRequest);
if (!session || !session.organizationId) return null;
- const users = yield* UserStoreService;
- return yield* users.use((s) => s.getOrganization(session.organizationId!));
+ return yield* authorizeOrganization(session.userId, session.organizationId);
});
const createProtectedApp = (organizationId: string, organizationName: string) =>
diff --git a/apps/cloud/src/auth/api.ts b/apps/cloud/src/auth/api.ts
index 4e6d4bd3e..77fc53539 100644
--- a/apps/cloud/src/auth/api.ts
+++ b/apps/cloud/src/auth/api.ts
@@ -66,7 +66,12 @@ export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic")
/** Session auth endpoints — require a logged-in user, may not have an org */
export class CloudAuthApi extends HttpApiGroup.make("cloudAuth")
- .add(HttpApiEndpoint.get("me")`/auth/me`.addSuccess(AuthMeResponse).addError(UserStoreError))
+ .add(
+ HttpApiEndpoint.get("me")`/auth/me`
+ .addSuccess(AuthMeResponse)
+ .addError(UserStoreError)
+ .addError(WorkOSError),
+ )
.add(HttpApiEndpoint.post("logout")`/auth/logout`)
.add(
HttpApiEndpoint.get("organizations")`/auth/organizations`
diff --git a/apps/cloud/src/auth/authorize-organization.ts b/apps/cloud/src/auth/authorize-organization.ts
new file mode 100644
index 000000000..43a2cd449
--- /dev/null
+++ b/apps/cloud/src/auth/authorize-organization.ts
@@ -0,0 +1,36 @@
+// ---------------------------------------------------------------------------
+// Organization authorization — live membership check against WorkOS.
+// ---------------------------------------------------------------------------
+//
+// The sealed session cookie carries an organizationId that WorkOS signed at
+// login / refresh time. WorkOS does NOT invalidate existing sessions when a
+// membership is revoked, and `session.authenticate()` validates the JWT
+// locally without hitting the API — so a removed user keeps full access
+// until their access token naturally expires (~10 min).
+//
+// To close that gap we verify membership live on every protected request.
+// `listUserMemberships` is one WorkOS call per request. If this becomes a
+// hot path we can layer a short per-(user, org) TTL cache underneath, or
+// swap it for a local memberships table fed by the WorkOS Events API.
+//
+// Returns the resolved organization (via resolveOrganization) if the user
+// currently holds an *active* membership in it, otherwise null. Callers
+// should treat null as "no access" and route accordingly (onboarding page /
+// 403).
+
+import { Effect } from "effect";
+
+import { resolveOrganization } from "./resolve-organization";
+import { WorkOSAuth } from "./workos";
+
+export const authorizeOrganization = (userId: string, organizationId: string) =>
+ Effect.gen(function* () {
+ const workos = yield* WorkOSAuth;
+ const memberships = yield* workos.listUserMemberships(userId);
+ const active = memberships.data.find(
+ (m) => m.organizationId === organizationId && m.status === "active",
+ );
+ if (!active) return null;
+
+ return yield* resolveOrganization(organizationId);
+ });
diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts
index 442dd4add..70e0d57be 100644
--- a/apps/cloud/src/auth/handlers.ts
+++ b/apps/cloud/src/auth/handlers.ts
@@ -5,6 +5,8 @@ import { setCookie, deleteCookie } from "@tanstack/react-start/server";
import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api";
import { SessionContext } from "./middleware";
import { UserStoreService } from "./context";
+import { authorizeOrganization } from "./authorize-organization";
+import { WorkOSError } from "./errors";
import { WorkOSAuth } from "./workos";
import { server } from "../env";
@@ -54,40 +56,22 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group(
yield* users.use((s) => s.ensureAccount(result.user.id));
let sealedSession = result.sealedSession;
- let organizationId = result.organizationId;
- // If the auth response doesn't include an org, check if the user
- // already belongs to one. Only create a new organization if they truly
- // have no memberships — this prevents duplicate orgs on re-login.
- if (!organizationId) {
+ // If the auth response didn't surface an org but the user already
+ // belongs to one, rehydrate the session with it. If they have no
+ // memberships at all, leave the session org-less — the frontend
+ // AuthGate will render the onboarding flow. We never auto-create
+ // organizations on login.
+ if (!result.organizationId && sealedSession) {
const memberships = yield* workos.listUserMemberships(result.user.id);
const existing = memberships.data[0];
-
if (existing) {
- organizationId = existing.organizationId;
- } else {
- const name =
- [result.user.firstName, result.user.lastName].filter(Boolean).join(" ") ||
- result.user.email;
- const org = yield* workos.createOrganization(`${name}'s Organization`);
- yield* workos.createMembership(org.id, result.user.id, "admin");
- yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name }));
- organizationId = org.id;
- }
-
- // Refresh the session so it includes the org context
- if (sealedSession) {
- const refreshed = yield* workos.refreshSession(sealedSession, organizationId);
+ const refreshed = yield* workos.refreshSession(
+ sealedSession,
+ existing.organizationId,
+ );
if (refreshed) sealedSession = refreshed;
}
- } else {
- const org = yield* workos.getOrganization(organizationId!);
- yield* users.use((s) =>
- s.upsertOrganization({
- id: organizationId!,
- name: org.name,
- }),
- );
}
if (!sealedSession) {
@@ -96,12 +80,7 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group(
setCookie("wos-session", sealedSession, COOKIE_OPTIONS);
return HttpServerResponse.redirect("/", { status: 302 });
- }).pipe(
- Effect.catchTags({
- WorkOSError: () => Effect.succeed(HttpServerResponse.redirect("/", { status: 302 })),
- UserStoreError: () => Effect.succeed(HttpServerResponse.redirect("/", { status: 302 })),
- }),
- ),
+ }),
),
);
@@ -117,9 +96,8 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
.handle("me", () =>
Effect.gen(function* () {
const session = yield* SessionContext;
- const users = yield* UserStoreService;
const org = session.organizationId
- ? yield* users.use((s) => s.getOrganization(session.organizationId!))
+ ? yield* authorizeOrganization(session.accountId, session.organizationId)
: null;
return {
@@ -184,11 +162,33 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
yield* workos.createMembership(org.id, session.accountId, "admin");
yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name }));
+ // 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);
- if (refreshed) {
- setCookie("wos-session", refreshed, COOKIE_OPTIONS);
+ 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,
+ },
+ );
+ deleteCookie("wos-session", { path: "/" });
+ return yield* new WorkOSError();
}
+ setCookie("wos-session", refreshed, COOKIE_OPTIONS);
return { id: org.id, name: org.name };
}),
),
diff --git a/apps/cloud/src/auth/resolve-organization.ts b/apps/cloud/src/auth/resolve-organization.ts
new file mode 100644
index 000000000..e59f9ff3c
--- /dev/null
+++ b/apps/cloud/src/auth/resolve-organization.ts
@@ -0,0 +1,30 @@
+// ---------------------------------------------------------------------------
+// Organization lookup — local mirror with lazy WorkOS fallback.
+// ---------------------------------------------------------------------------
+//
+// We keep a minimal local mirror of organizations so domain tables can
+// foreign-key against them and so we don't hit WorkOS on every request.
+// But the mirror can drift: a user's session can reference an org that was
+// created outside this app (or before the mirror existed). Rather than
+// proactively mirroring on every login — which was the source of the messy
+// callback flow we just untangled — we mirror lazily the first time an
+// unknown org is read. All other callers just do `getOrganization` and get
+// a self-healing lookup for free.
+
+import { Effect } from "effect";
+
+import { UserStoreService } from "./context";
+import { WorkOSAuth } from "./workos";
+
+export const resolveOrganization = (organizationId: string) =>
+ Effect.gen(function* () {
+ const users = yield* UserStoreService;
+ const existing = yield* users.use((s) => s.getOrganization(organizationId));
+ if (existing) return existing;
+
+ const workos = yield* WorkOSAuth;
+ const fresh = yield* workos.getOrganization(organizationId);
+ return yield* users.use((s) =>
+ s.upsertOrganization({ id: fresh.id, name: fresh.name }),
+ );
+ });
diff --git a/apps/cloud/src/routes/__root.tsx b/apps/cloud/src/routes/__root.tsx
index d735fe8b7..4cd57be22 100644
--- a/apps/cloud/src/routes/__root.tsx
+++ b/apps/cloud/src/routes/__root.tsx
@@ -6,6 +6,7 @@ import { ExecutorProvider } from "@executor/react/api/provider";
import { Toaster } from "@executor/react/components/sonner";
import { AuthProvider, useAuth } from "../web/auth";
import { LoginPage } from "../web/pages/login";
+import { OnboardingPage } from "../web/pages/onboarding";
import { Shell } from "../web/shell";
import appCss from "@executor/react/globals.css?url";
@@ -82,6 +83,10 @@ function AuthGate() {
return ;
}
+ if (auth.organization == null) {
+ return ;
+ }
+
return (
diff --git a/apps/cloud/src/web/components/create-organization-form.tsx b/apps/cloud/src/web/components/create-organization-form.tsx
new file mode 100644
index 000000000..5630685f7
--- /dev/null
+++ b/apps/cloud/src/web/components/create-organization-form.tsx
@@ -0,0 +1,91 @@
+import { useState } from "react";
+import { useAtomSet } from "@effect-atom/atom-react";
+import { Input } from "@executor/react/components/input";
+import { Label } from "@executor/react/components/label";
+
+import { createOrganization } from "../auth";
+
+type CreatedOrganization = { id: string; name: 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 } });
+ setCreating(false);
+ if (exit._tag === "Success") {
+ 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 (
+
+ );
+}
diff --git a/apps/cloud/src/web/pages/onboarding.tsx b/apps/cloud/src/web/pages/onboarding.tsx
new file mode 100644
index 000000000..d6c7d7c99
--- /dev/null
+++ b/apps/cloud/src/web/pages/onboarding.tsx
@@ -0,0 +1,77 @@
+import { useAtomRefresh } from "@effect-atom/atom-react";
+import { Button } from "@executor/react/components/button";
+
+import { AUTH_PATHS } from "../../auth/api";
+import { authAtom, useAuth } from "../auth";
+import {
+ CreateOrganizationFields,
+ useCreateOrganizationForm,
+} from "../components/create-organization-form";
+
+export const OnboardingPage = () => {
+ const auth = useAuth();
+ const refreshAuth = useAtomRefresh(authAtom);
+
+ const suggestedName =
+ auth.status === "authenticated" &&
+ auth.user.name != null &&
+ auth.user.name.trim() !== ""
+ ? `${auth.user.name}'s Organization`
+ : "";
+
+ const form = useCreateOrganizationForm({
+ defaultName: suggestedName,
+ // On success: the server set a new cookie with the new org; refetch /me
+ // so AuthGate routes into Shell.
+ // On failure: the server may have cleared the cookie because the current
+ // session was too stale to attach the new org. Refetch /me regardless so
+ // AuthGate can route to LoginPage if that's the case.
+ onSuccess: () => refreshAuth(),
+ onFailure: () => refreshAuth(),
+ });
+
+ return (
+
+
+
+
Create your organization
+
+ Organizations group your sources, secrets, and teammates. You can invite others once
+ it's set up.
+