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()} + /> -