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 ( +
+
+ + 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/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. +

+
+ + { + form.setName(name); + if (form.error) form.setError(null); + }} + error={form.error} + onSubmit={() => void form.submit()} + /> + +
+ {/* oxlint-disable-next-line react/forbid-elements */} + + +
+
+
+ ); +}; diff --git a/apps/cloud/src/web/shell.tsx b/apps/cloud/src/web/shell.tsx index b25c63cbe..813540683 100644 --- a/apps/cloud/src/web/shell.tsx +++ b/apps/cloud/src/web/shell.tsx @@ -24,8 +24,6 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@executor/react/components/dropdown-menu"; -import { Input } from "@executor/react/components/input"; -import { Label } from "@executor/react/components/label"; import { SourceFavicon } from "@executor/react/components/source-favicon"; import { CommandPalette } from "@executor/react/components/command-palette"; import { openApiSourcePlugin } from "@executor/plugin-openapi/react"; @@ -33,12 +31,11 @@ import { mcpSourcePlugin } from "@executor/plugin-mcp/react"; import { googleDiscoverySourcePlugin } from "@executor/plugin-google-discovery/react"; import { graphqlSourcePlugin } from "@executor/plugin-graphql/react"; import { AUTH_PATHS } from "../auth/api"; +import { organizationsAtom, switchOrganization, useAuth } from "./auth"; import { - createOrganization, - organizationsAtom, - switchOrganization, - useAuth, -} from "./auth"; + CreateOrganizationFields, + useCreateOrganizationForm, +} from "./components/create-organization-form"; const sourcePlugins = [ openApiSourcePlugin, @@ -198,54 +195,34 @@ function CheckIcon() { function UserFooter() { const auth = useAuth(); - const doCreateOrganization = useAtomSet(createOrganization, { mode: "promiseExit" }); const [createOrganizationOpen, setCreateOrganizationOpen] = useState(false); - const [organizationName, setOrganizationName] = useState(""); - const [createOrganizationError, setCreateOrganizationError] = useState(null); - const [creatingOrganization, setCreatingOrganization] = useState(false); - if (auth.status !== "authenticated") return null; const suggestedOrganizationName = - auth.user.name?.trim() !== "" && auth.user.name != null + auth.status === "authenticated" && + auth.user.name?.trim() !== "" && + auth.user.name != null ? `${auth.user.name}'s Organization` : "New Organization"; + const form = useCreateOrganizationForm({ + defaultName: suggestedOrganizationName, + onSuccess: () => window.location.reload(), + }); + + if (auth.status !== "authenticated") return null; + const openCreateOrganization = () => { - setOrganizationName(suggestedOrganizationName); - setCreateOrganizationError(null); + form.reset(suggestedOrganizationName); setCreateOrganizationOpen(true); }; - const handleCreateOrganization = async () => { - const name = organizationName.trim(); - if (!name) { - setCreateOrganizationError("Organization name is required."); - return; - } - - setCreatingOrganization(true); - setCreateOrganizationError(null); - const exit = await doCreateOrganization({ payload: { name } }); - if (exit._tag === "Success") { - window.location.reload(); - } else { - setCreateOrganizationError("Failed to create organization."); - } - - setCreatingOrganization(false); - }; - return (
{ setCreateOrganizationOpen(open); - if (!open) { - setOrganizationName(suggestedOrganizationName); - setCreateOrganizationError(null); - setCreatingOrganization(false); - } + if (!open) form.reset(suggestedOrganizationName); }} > @@ -342,49 +319,28 @@ function UserFooter() { -
-
- - { - setOrganizationName((event.target as HTMLInputElement).value); - if (createOrganizationError) setCreateOrganizationError(null); - }} - onKeyDown={(event) => { - if (event.key === "Enter") void handleCreateOrganization(); - }} - className="h-9 text-sm" - /> -
- - {createOrganizationError && ( -
-

{createOrganizationError}

-
- )} -
+ { + form.setName(name); + if (form.error) form.setError(null); + }} + error={form.error} + onSubmit={() => void form.submit()} + /> -