History

Rework cloud org flow: onboarding page + live membership check (#234)

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.

Rhys Sullivan committed 7 months ago

Commit 5693f5d1 parent 48eeb909 files · +314 −115Browse files

Unified · Split

9 files
  1. apps/cloud/src/api/protected.ts +2 −3
  2. apps/cloud/src/auth/api.ts +6 −1
  3. apps/cloud/src/auth/authorize-organization.ts +36 −0
  4. apps/cloud/src/auth/handlers.ts +38 −38
  5. apps/cloud/src/auth/resolve-organization.ts +30 −0
  6. apps/cloud/src/routes/__root.tsx +5 −0
  7. apps/cloud/src/web/components/create-organization-form.tsx +91 −0
  8. apps/cloud/src/web/pages/onboarding.tsx +77 −0
  9. apps/cloud/src/web/shell.tsx +29 −73
apps/cloud/src/api/protected.ts+2−3
@@ -10,7 +10,7 @@ import { McpExtensionService } from "@executor/plugin-mcp/api"; · 9 unmodified lines
1010 import { GoogleDiscoveryExtensionService } from "@executor/plugin-google-discovery/api";
1111 import { GraphqlExtensionService } from "@executor/plugin-graphql/api";
1212
13−import { UserStoreService } from "../auth/context";
13+import { authorizeOrganization } from "../auth/authorize-organization";
1414 import { WorkOSAuth } from "../auth/workos";
1515 import { AutumnService } from "../services/autumn";
1616 import { createOrgExecutor } from "../services/executor";
@@ -34,8 +34,7 @@ const lookupOrgForRequest = (request: HttpServerRequest.HttpServerRequest) => · 17 unmodified lines
3434 const session = yield* workos.authenticateRequest(webRequest);
3535 if (!session || !session.organizationId) return null;
3636
37− const users = yield* UserStoreService;
38− return yield* users.use((s) => s.getOrganization(session.organizationId!));
37+ return yield* authorizeOrganization(session.userId, session.organizationId);
3938 });
4039
4140 const createProtectedApp = (organizationId: string, organizationName: string) =>
apps/cloud/src/auth/api.ts+6−1
@@ -66,7 +66,12 @@ export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic") · 65 unmodified lines
6666
6767 /** Session auth endpoints — require a logged-in user, may not have an org */
6868 export class CloudAuthApi extends HttpApiGroup.make("cloudAuth")
69− .add(HttpApiEndpoint.get("me")`/auth/me`.addSuccess(AuthMeResponse).addError(UserStoreError))
69+ .add(
70+ HttpApiEndpoint.get("me")`/auth/me`
71+ .addSuccess(AuthMeResponse)
72+ .addError(UserStoreError)
73+ .addError(WorkOSError),
74+ )
7075 .add(HttpApiEndpoint.post("logout")`/auth/logout`)
7176 .add(
7277 HttpApiEndpoint.get("organizations")`/auth/organizations`
apps/cloud/src/auth/authorize-organization.ts+36−0
@@ -0,0 +1,36 @@
1+// ---------------------------------------------------------------------------
2+// Organization authorization — live membership check against WorkOS.
3+// ---------------------------------------------------------------------------
4+//
5+// The sealed session cookie carries an organizationId that WorkOS signed at
6+// login / refresh time. WorkOS does NOT invalidate existing sessions when a
7+// membership is revoked, and `session.authenticate()` validates the JWT
8+// locally without hitting the API — so a removed user keeps full access
9+// until their access token naturally expires (~10 min).
10+//
11+// To close that gap we verify membership live on every protected request.
12+// `listUserMemberships` is one WorkOS call per request. If this becomes a
13+// hot path we can layer a short per-(user, org) TTL cache underneath, or
14+// swap it for a local memberships table fed by the WorkOS Events API.
15+//
16+// Returns the resolved organization (via resolveOrganization) if the user
17+// currently holds an *active* membership in it, otherwise null. Callers
18+// should treat null as "no access" and route accordingly (onboarding page /
19+// 403).
20+
21+import { Effect } from "effect";
22+
23+import { resolveOrganization } from "./resolve-organization";
24+import { WorkOSAuth } from "./workos";
25+
26+export const authorizeOrganization = (userId: string, organizationId: string) =>
27+ Effect.gen(function* () {
28+ const workos = yield* WorkOSAuth;
29+ const memberships = yield* workos.listUserMemberships(userId);
30+ const active = memberships.data.find(
31+ (m) => m.organizationId === organizationId && m.status === "active",
32+ );
33+ if (!active) return null;
34+
35+ return yield* resolveOrganization(organizationId);
36+ });
apps/cloud/src/auth/handlers.ts+38−38
@@ -5,6 +5,8 @@ import { setCookie, deleteCookie } from "@tanstack/react-start/server"; · 4 unmodified lines
55 import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api";
66 import { SessionContext } from "./middleware";
77 import { UserStoreService } from "./context";
8+import { authorizeOrganization } from "./authorize-organization";
9+import { WorkOSError } from "./errors";
810 import { WorkOSAuth } from "./workos";
911 import { server } from "../env";
1012
@@ -54,40 +56,22 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 43 unmodified lines
5456 yield* users.use((s) => s.ensureAccount(result.user.id));
5557
5658 let sealedSession = result.sealedSession;
57− let organizationId = result.organizationId;
5859
59− // If the auth response doesn't include an org, check if the user
60− // already belongs to one. Only create a new organization if they truly
61− // have no memberships — this prevents duplicate orgs on re-login.
62− if (!organizationId) {
60+ // If the auth response didn't surface an org but the user already
61+ // belongs to one, rehydrate the session with it. If they have no
62+ // memberships at all, leave the session org-less — the frontend
63+ // AuthGate will render the onboarding flow. We never auto-create
64+ // organizations on login.
65+ if (!result.organizationId && sealedSession) {
6366 const memberships = yield* workos.listUserMemberships(result.user.id);
6467 const existing = memberships.data[0];
65−
6668 if (existing) {
67− organizationId = existing.organizationId;
68− } else {
69− const name =
70− [result.user.firstName, result.user.lastName].filter(Boolean).join(" ") ||
71− result.user.email;
72− const org = yield* workos.createOrganization(`${name}'s Organization`);
73− yield* workos.createMembership(org.id, result.user.id, "admin");
74− yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name }));
75− organizationId = org.id;
76− }
77−
78− // Refresh the session so it includes the org context
79− if (sealedSession) {
80− const refreshed = yield* workos.refreshSession(sealedSession, organizationId);
69+ const refreshed = yield* workos.refreshSession(
70+ sealedSession,
71+ existing.organizationId,
72+ );
8173 if (refreshed) sealedSession = refreshed;
8274 }
83− } else {
84− const org = yield* workos.getOrganization(organizationId!);
85− yield* users.use((s) =>
86− s.upsertOrganization({
87− id: organizationId!,
88− name: org.name,
89− }),
90− );
9175 }
9276
9377 if (!sealedSession) {
@@ -96,12 +80,7 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 2 unmodified lines
9680
9781 setCookie("wos-session", sealedSession, COOKIE_OPTIONS);
9882 return HttpServerResponse.redirect("/", { status: 302 });
99− }).pipe(
100− Effect.catchTags({
101− WorkOSError: () => Effect.succeed(HttpServerResponse.redirect("/", { status: 302 })),
102− UserStoreError: () => Effect.succeed(HttpServerResponse.redirect("/", { status: 302 })),
103− }),
104− ),
83+ }),
10584 ),
10685 );
10786
@@ -117,9 +96,8 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 9 unmodified lines
11796 .handle("me", () =>
11897 Effect.gen(function* () {
11998 const session = yield* SessionContext;
120− const users = yield* UserStoreService;
12199 const org = session.organizationId
122− ? yield* users.use((s) => s.getOrganization(session.organizationId!))
100+ ? yield* authorizeOrganization(session.accountId, session.organizationId)
123101 : null;
124102
125103 return {
@@ -184,11 +162,33 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 58 unmodified lines
184162 yield* workos.createMembership(org.id, session.accountId, "admin");
185163 yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name }));
186164
165+ // Try to attach the new org to the current session. This can fail
166+ // (or silently return a session still scoped to the old org) when
167+ // the caller's current session is stale — most commonly after the
168+ // user was removed from the org their cookie is pinned to. In that
169+ // case we can't repair the session in-place, so we clear the
170+ // cookie and fail loudly; the frontend will bounce to login and
171+ // the callback's rehydrate path will pick up the new membership.
187172 const refreshed = yield* workos.refreshSession(session.sealedSession, org.id);
188− if (refreshed) {
189− setCookie("wos-session", refreshed, COOKIE_OPTIONS);
173+ const verified = refreshed
174+ ? yield* workos.authenticateSealedSession(refreshed)
175+ : null;
176+
177+ if (!refreshed || !verified || verified.organizationId !== org.id) {
178+ yield* Effect.logWarning(
179+ "createOrganization: unable to attach new org to current session",
180+ {
181+ userId: session.accountId,
182+ newOrgId: org.id,
183+ refreshReturnedSession: refreshed != null,
184+ verifiedOrgId: verified?.organizationId ?? null,
185+ },
186+ );
187+ deleteCookie("wos-session", { path: "/" });
188+ return yield* new WorkOSError();
190189 }
191190
191+ setCookie("wos-session", refreshed, COOKIE_OPTIONS);
192192 return { id: org.id, name: org.name };
193193 }),
194194 ),
apps/cloud/src/auth/resolve-organization.ts+30−0
@@ -0,0 +1,30 @@
1+// ---------------------------------------------------------------------------
2+// Organization lookup — local mirror with lazy WorkOS fallback.
3+// ---------------------------------------------------------------------------
4+//
5+// We keep a minimal local mirror of organizations so domain tables can
6+// foreign-key against them and so we don't hit WorkOS on every request.
7+// But the mirror can drift: a user's session can reference an org that was
8+// created outside this app (or before the mirror existed). Rather than
9+// proactively mirroring on every login — which was the source of the messy
10+// callback flow we just untangled — we mirror lazily the first time an
11+// unknown org is read. All other callers just do `getOrganization` and get
12+// a self-healing lookup for free.
13+
14+import { Effect } from "effect";
15+
16+import { UserStoreService } from "./context";
17+import { WorkOSAuth } from "./workos";
18+
19+export const resolveOrganization = (organizationId: string) =>
20+ Effect.gen(function* () {
21+ const users = yield* UserStoreService;
22+ const existing = yield* users.use((s) => s.getOrganization(organizationId));
23+ if (existing) return existing;
24+
25+ const workos = yield* WorkOSAuth;
26+ const fresh = yield* workos.getOrganization(organizationId);
27+ return yield* users.use((s) =>
28+ s.upsertOrganization({ id: fresh.id, name: fresh.name }),
29+ );
30+ });
apps/cloud/src/routes/__root.tsx+5−0
@@ -6,6 +6,7 @@ import { ExecutorProvider } from "@executor/react/api/provider"; · 5 unmodified lines
66 import { Toaster } from "@executor/react/components/sonner";
77 import { AuthProvider, useAuth } from "../web/auth";
88 import { LoginPage } from "../web/pages/login";
9+import { OnboardingPage } from "../web/pages/onboarding";
910 import { Shell } from "../web/shell";
1011 import appCss from "@executor/react/globals.css?url";
1112
@@ -82,6 +83,10 @@ function AuthGate() { · 70 unmodified lines
8283 return <LoginPage />;
8384 }
8485
86+ if (auth.organization == null) {
87+ return <OnboardingPage />;
88+ }
89+
8590 return (
8691 <AutumnProvider pathPrefix="/api/autumn">
8792 <ExecutorProvider>
apps/cloud/src/web/components/create-organization-form.tsx+91−0
@@ -0,0 +1,91 @@
1+import { useState } from "react";
2+import { useAtomSet } from "@effect-atom/atom-react";
3+import { Input } from "@executor/react/components/input";
4+import { Label } from "@executor/react/components/label";
5+
6+import { createOrganization } from "../auth";
7+
8+type CreatedOrganization = { id: string; name: string };
9+
10+export function useCreateOrganizationForm(options: {
11+ defaultName?: string;
12+ onSuccess: (org: CreatedOrganization) => void;
13+ onFailure?: () => void;
14+}) {
15+ const doCreate = useAtomSet(createOrganization, { mode: "promiseExit" });
16+ const [name, setName] = useState(options.defaultName ?? "");
17+ const [error, setError] = useState<string | null>(null);
18+ const [creating, setCreating] = useState(false);
19+
20+ const reset = (nextName = options.defaultName ?? "") => {
21+ setName(nextName);
22+ setError(null);
23+ setCreating(false);
24+ };
25+
26+ const submit = async () => {
27+ const trimmed = name.trim();
28+ if (!trimmed) {
29+ setError("Organization name is required.");
30+ return;
31+ }
32+ setCreating(true);
33+ setError(null);
34+ const exit = await doCreate({ payload: { name: trimmed } });
35+ setCreating(false);
36+ if (exit._tag === "Success") {
37+ options.onSuccess(exit.value);
38+ } else {
39+ setError("Failed to create organization.");
40+ options.onFailure?.();
41+ }
42+ };
43+
44+ return {
45+ name,
46+ setName,
47+ error,
48+ setError,
49+ creating,
50+ submit,
51+ reset,
52+ canSubmit: name.trim().length > 0,
53+ };
54+}
55+
56+export function CreateOrganizationFields(props: {
57+ name: string;
58+ onNameChange: (name: string) => void;
59+ error: string | null;
60+ onSubmit: () => void;
61+}) {
62+ return (
63+ <div className="grid gap-4 py-3">
64+ <div className="grid gap-1.5">
65+ <Label
66+ htmlFor="organization-name"
67+ className="text-sm font-medium uppercase tracking-wider text-muted-foreground"
68+ >
69+ Organization name
70+ </Label>
71+ <Input
72+ id="organization-name"
73+ value={props.name}
74+ placeholder="Northwind Labs"
75+ autoFocus
76+ onChange={(event) => props.onNameChange((event.target as HTMLInputElement).value)}
77+ onKeyDown={(event) => {
78+ if (event.key === "Enter") props.onSubmit();
79+ }}
80+ className="h-9 text-sm"
81+ />
82+ </div>
83+
84+ {props.error && (
85+ <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
86+ <p className="text-sm text-destructive">{props.error}</p>
87+ </div>
88+ )}
89+ </div>
90+ );
91+}
apps/cloud/src/web/pages/onboarding.tsx+77−0
@@ -0,0 +1,77 @@
1+import { useAtomRefresh } from "@effect-atom/atom-react";
2+import { Button } from "@executor/react/components/button";
3+
4+import { AUTH_PATHS } from "../../auth/api";
5+import { authAtom, useAuth } from "../auth";
6+import {
7+ CreateOrganizationFields,
8+ useCreateOrganizationForm,
9+} from "../components/create-organization-form";
10+
11+export const OnboardingPage = () => {
12+ const auth = useAuth();
13+ const refreshAuth = useAtomRefresh(authAtom);
14+
15+ const suggestedName =
16+ auth.status === "authenticated" &&
17+ auth.user.name != null &&
18+ auth.user.name.trim() !== ""
19+ ? `${auth.user.name}'s Organization`
20+ : "";
21+
22+ const form = useCreateOrganizationForm({
23+ defaultName: suggestedName,
24+ // On success: the server set a new cookie with the new org; refetch /me
25+ // so AuthGate routes into Shell.
26+ // On failure: the server may have cleared the cookie because the current
27+ // session was too stale to attach the new org. Refetch /me regardless so
28+ // AuthGate can route to LoginPage if that's the case.
29+ onSuccess: () => refreshAuth(),
30+ onFailure: () => refreshAuth(),
31+ });
32+
33+ return (
34+ <div className="flex min-h-screen items-center justify-center bg-background px-4">
35+ <div className="mx-auto flex w-full max-w-sm flex-col gap-6">
36+ <div className="flex flex-col gap-2">
37+ <h1 className="font-serif text-3xl">Create your organization</h1>
38+ <p className="text-sm text-muted-foreground">
39+ Organizations group your sources, secrets, and teammates. You can invite others once
40+ it's set up.
41+ </p>
42+ </div>
43+
44+ <CreateOrganizationFields
45+ name={form.name}
46+ onNameChange={(name) => {
47+ form.setName(name);
48+ if (form.error) form.setError(null);
49+ }}
50+ error={form.error}
51+ onSubmit={() => void form.submit()}
52+ />
53+
54+ <div className="flex items-center justify-between gap-3">
55+ {/* oxlint-disable-next-line react/forbid-elements */}
56+ <button
57+ type="button"
58+ className="text-xs text-muted-foreground hover:text-foreground"
59+ onClick={async () => {
60+ await fetch(AUTH_PATHS.logout, { method: "POST" });
61+ window.location.href = "/";
62+ }}
63+ >
64+ Sign out
65+ </button>
66+ <Button
67+ size="sm"
68+ onClick={() => void form.submit()}
69+ disabled={!form.canSubmit || form.creating}
70+ >
71+ {form.creating ? "Creating…" : "Create organization"}
72+ </Button>
73+ </div>
74+ </div>
75+ </div>
76+ );
77+};
apps/cloud/src/web/shell.tsx+29−73
@@ -24,8 +24,6 @@ import { · 23 unmodified lines
2424 DropdownMenuSubTrigger,
2525 DropdownMenuTrigger,
2626 } from "@executor/react/components/dropdown-menu";
27−import { Input } from "@executor/react/components/input";
28−import { Label } from "@executor/react/components/label";
2927 import { SourceFavicon } from "@executor/react/components/source-favicon";
3028 import { CommandPalette } from "@executor/react/components/command-palette";
3129 import { openApiSourcePlugin } from "@executor/plugin-openapi/react";
@@ -33,12 +31,11 @@ import { mcpSourcePlugin } from "@executor/plugin-mcp/react"; · 1 unmodified line
3331 import { googleDiscoverySourcePlugin } from "@executor/plugin-google-discovery/react";
3432 import { graphqlSourcePlugin } from "@executor/plugin-graphql/react";
3533 import { AUTH_PATHS } from "../auth/api";
34+import { organizationsAtom, switchOrganization, useAuth } from "./auth";
3635 import {
37− createOrganization,
38− organizationsAtom,
39− switchOrganization,
40− useAuth,
41−} from "./auth";
36+ CreateOrganizationFields,
37+ useCreateOrganizationForm,
38+} from "./components/create-organization-form";
4239
4340 const sourcePlugins = [
4441 openApiSourcePlugin,
@@ -198,54 +195,34 @@ function CheckIcon() { · 153 unmodified lines
198195
199196 function UserFooter() {
200197 const auth = useAuth();
201− const doCreateOrganization = useAtomSet(createOrganization, { mode: "promiseExit" });
202198 const [createOrganizationOpen, setCreateOrganizationOpen] = useState(false);
203− const [organizationName, setOrganizationName] = useState("");
204− const [createOrganizationError, setCreateOrganizationError] = useState<string | null>(null);
205− const [creatingOrganization, setCreatingOrganization] = useState(false);
206− if (auth.status !== "authenticated") return null;
207199
208200 const suggestedOrganizationName =
209− auth.user.name?.trim() !== "" && auth.user.name != null
201+ auth.status === "authenticated" &&
202+ auth.user.name?.trim() !== "" &&
203+ auth.user.name != null
210204 ? `${auth.user.name}'s Organization`
211205 : "New Organization";
212206
207+ const form = useCreateOrganizationForm({
208+ defaultName: suggestedOrganizationName,
209+ onSuccess: () => window.location.reload(),
210+ });
211+
212+ if (auth.status !== "authenticated") return null;
213+
213214 const openCreateOrganization = () => {
214− setOrganizationName(suggestedOrganizationName);
215− setCreateOrganizationError(null);
215+ form.reset(suggestedOrganizationName);
216216 setCreateOrganizationOpen(true);
217217 };
218218
219− const handleCreateOrganization = async () => {
220− const name = organizationName.trim();
221− if (!name) {
222− setCreateOrganizationError("Organization name is required.");
223− return;
224− }
225−
226− setCreatingOrganization(true);
227− setCreateOrganizationError(null);
228− const exit = await doCreateOrganization({ payload: { name } });
229− if (exit._tag === "Success") {
230− window.location.reload();
231− } else {
232− setCreateOrganizationError("Failed to create organization.");
233− }
234−
235− setCreatingOrganization(false);
236− };
237−
238219 return (
239220 <div className="shrink-0 border-t border-sidebar-border px-3 py-2.5">
240221 <Dialog
241222 open={createOrganizationOpen}
242223 onOpenChange={(open) => {
243224 setCreateOrganizationOpen(open);
244− if (!open) {
245− setOrganizationName(suggestedOrganizationName);
246− setCreateOrganizationError(null);
247− setCreatingOrganization(false);
248− }
225+ if (!open) form.reset(suggestedOrganizationName);
249226 }}
250227 >
251228 <DropdownMenu>
@@ -342,49 +319,28 @@ function UserFooter() { · 90 unmodified lines
342319 </DialogDescription>
343320 </DialogHeader>
344321
345− <div className="grid gap-4 py-3">
346− <div className="grid gap-1.5">
347− <Label
348− htmlFor="organization-name"
349− className="text-sm font-medium uppercase tracking-wider text-muted-foreground"
350− >
351− Organization name
352− </Label>
353− <Input
354− id="organization-name"
355− value={organizationName}
356− placeholder="Northwind Labs"
357− autoFocus
358− onChange={(event) => {
359− setOrganizationName((event.target as HTMLInputElement).value);
360− if (createOrganizationError) setCreateOrganizationError(null);
361− }}
362− onKeyDown={(event) => {
363− if (event.key === "Enter") void handleCreateOrganization();
364− }}
365− className="h-9 text-sm"
366− />
367− </div>
368−
369− {createOrganizationError && (
370− <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
371− <p className="text-sm text-destructive">{createOrganizationError}</p>
372− </div>
373− )}
374− </div>
322+ <CreateOrganizationFields
323+ name={form.name}
324+ onNameChange={(name) => {
325+ form.setName(name);
326+ if (form.error) form.setError(null);
327+ }}
328+ error={form.error}
329+ onSubmit={() => void form.submit()}
330+ />
375331
376332 <DialogFooter>
377333 <DialogClose asChild>
378− <Button variant="ghost" size="sm" disabled={creatingOrganization}>
334+ <Button variant="ghost" size="sm" disabled={form.creating}>
379335 Cancel
380336 </Button>
381337 </DialogClose>
382338 <Button
383339 size="sm"
384− onClick={() => void handleCreateOrganization()}
385− disabled={!organizationName.trim() || creatingOrganization}
340+ onClick={() => void form.submit()}
341+ disabled={!form.canSubmit || form.creating}
386342 >
387− {creatingOrganization ? "Creating…" : "Create organization"}
343+ {form.creating ? "Creating…" : "Create organization"}
388344 </Button>
389345 </DialogFooter>
390346 </DialogContent>