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
10import { GoogleDiscoveryExtensionService } from "@executor/plugin-google-discovery/api";10import { GoogleDiscoveryExtensionService } from "@executor/plugin-google-discovery/api";
11import { GraphqlExtensionService } from "@executor/plugin-graphql/api";11import { GraphqlExtensionService } from "@executor/plugin-graphql/api";
12 12
13import { UserStoreService } from "../auth/context";13import { authorizeOrganization } from "../auth/authorize-organization";
14import { WorkOSAuth } from "../auth/workos";14import { WorkOSAuth } from "../auth/workos";
15import { AutumnService } from "../services/autumn";15import { AutumnService } from "../services/autumn";
16import { createOrgExecutor } from "../services/executor";16import { createOrgExecutor } from "../services/executor";
@@ -34,8 +34,7 @@ const lookupOrgForRequest = (request: HttpServerRequest.HttpServerRequest) => · 17 unmodified lines
34 const session = yield* workos.authenticateRequest(webRequest);34 const session = yield* workos.authenticateRequest(webRequest);
35 if (!session || !session.organizationId) return null;35 if (!session || !session.organizationId) return null;
36 36
37 const users = yield* UserStoreService;37 return yield* authorizeOrganization(session.userId, session.organizationId);
38 return yield* users.use((s) => s.getOrganization(session.organizationId!));
39 });38 });
40 39
41const createProtectedApp = (organizationId: string, organizationName: string) =>40const 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
66 66
67/** Session auth endpoints — require a logged-in user, may not have an org */67/** Session auth endpoints — require a logged-in user, may not have an org */
68export class CloudAuthApi extends HttpApiGroup.make("cloudAuth")68export 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 )
70 .add(HttpApiEndpoint.post("logout")`/auth/logout`)75 .add(HttpApiEndpoint.post("logout")`/auth/logout`)
71 .add(76 .add(
72 HttpApiEndpoint.get("organizations")`/auth/organizations`77 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
21import { Effect } from "effect";
22
23import { resolveOrganization } from "./resolve-organization";
24import { WorkOSAuth } from "./workos";
25
26export 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
5import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api";5import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api";
6import { SessionContext } from "./middleware";6import { SessionContext } from "./middleware";
7import { UserStoreService } from "./context";7import { UserStoreService } from "./context";
8import { authorizeOrganization } from "./authorize-organization";
9import { WorkOSError } from "./errors";
8import { WorkOSAuth } from "./workos";10import { WorkOSAuth } from "./workos";
9import { server } from "../env";11import { server } from "../env";
10 12
@@ -54,40 +56,22 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 43 unmodified lines
54 yield* users.use((s) => s.ensureAccount(result.user.id));56 yield* users.use((s) => s.ensureAccount(result.user.id));
55 57
56 let sealedSession = result.sealedSession;58 let sealedSession = result.sealedSession;
57 let organizationId = result.organizationId;
58 59
59 // If the auth response doesn't include an org, check if the user60 // If the auth response didn't surface an org but the user already
60 // already belongs to one. Only create a new organization if they truly61 // belongs to one, rehydrate the session with it. If they have no
61 // have no memberships — this prevents duplicate orgs on re-login.62 // memberships at all, leave the session org-less — the frontend
62 if (!organizationId) {63 // AuthGate will render the onboarding flow. We never auto-create
64 // organizations on login.
65 if (!result.organizationId && sealedSession) {
63 const memberships = yield* workos.listUserMemberships(result.user.id);66 const memberships = yield* workos.listUserMemberships(result.user.id);
64 const existing = memberships.data[0];67 const existing = memberships.data[0];
65
66 if (existing) {68 if (existing) {
67 organizationId = existing.organizationId;69 const refreshed = yield* workos.refreshSession(
68 } else {70 sealedSession,
69 const name =71 existing.organizationId,
70 [result.user.firstName, result.user.lastName].filter(Boolean).join(" ") ||72 );
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);
81 if (refreshed) sealedSession = refreshed;73 if (refreshed) sealedSession = refreshed;
82 }74 }
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 );
91 }75 }
92 76
93 if (!sealedSession) {77 if (!sealedSession) {
@@ -96,12 +80,7 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 2 unmodified lines
96 80
97 setCookie("wos-session", sealedSession, COOKIE_OPTIONS);81 setCookie("wos-session", sealedSession, COOKIE_OPTIONS);
98 return HttpServerResponse.redirect("/", { status: 302 });82 return HttpServerResponse.redirect("/", { status: 302 });
99 }).pipe(83 }),
100 Effect.catchTags({
101 WorkOSError: () => Effect.succeed(HttpServerResponse.redirect("/", { status: 302 })),
102 UserStoreError: () => Effect.succeed(HttpServerResponse.redirect("/", { status: 302 })),
103 }),
104 ),
105 ),84 ),
106);85);
107 86
@@ -117,9 +96,8 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 9 unmodified lines
117 .handle("me", () =>96 .handle("me", () =>
118 Effect.gen(function* () {97 Effect.gen(function* () {
119 const session = yield* SessionContext;98 const session = yield* SessionContext;
120 const users = yield* UserStoreService;
121 const org = session.organizationId99 const org = session.organizationId
122 ? yield* users.use((s) => s.getOrganization(session.organizationId!))100 ? yield* authorizeOrganization(session.accountId, session.organizationId)
123 : null;101 : null;
124 102
125 return {103 return {
@@ -184,11 +162,33 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 58 unmodified lines
184 yield* workos.createMembership(org.id, session.accountId, "admin");162 yield* workos.createMembership(org.id, session.accountId, "admin");
185 yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name }));163 yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name }));
186 164
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.
187 const refreshed = yield* workos.refreshSession(session.sealedSession, org.id);172 const refreshed = yield* workos.refreshSession(session.sealedSession, org.id);
188 if (refreshed) {173 const verified = refreshed
189 setCookie("wos-session", refreshed, COOKIE_OPTIONS);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();
190 }189 }
191 190
191 setCookie("wos-session", refreshed, COOKIE_OPTIONS);
192 return { id: org.id, name: org.name };192 return { id: org.id, name: org.name };
193 }),193 }),
194 ),194 ),
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
14import { Effect } from "effect";
15
16import { UserStoreService } from "./context";
17import { WorkOSAuth } from "./workos";
18
19export 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
6import { Toaster } from "@executor/react/components/sonner";6import { Toaster } from "@executor/react/components/sonner";
7import { AuthProvider, useAuth } from "../web/auth";7import { AuthProvider, useAuth } from "../web/auth";
8import { LoginPage } from "../web/pages/login";8import { LoginPage } from "../web/pages/login";
9import { OnboardingPage } from "../web/pages/onboarding";
9import { Shell } from "../web/shell";10import { Shell } from "../web/shell";
10import appCss from "@executor/react/globals.css?url";11import appCss from "@executor/react/globals.css?url";
11 12
@@ -82,6 +83,10 @@ function AuthGate() { · 70 unmodified lines
82 return <LoginPage />;83 return <LoginPage />;
83 }84 }
84 85
86 if (auth.organization == null) {
87 return <OnboardingPage />;
88 }
89
85 return (90 return (
86 <AutumnProvider pathPrefix="/api/autumn">91 <AutumnProvider pathPrefix="/api/autumn">
87 <ExecutorProvider>92 <ExecutorProvider>
apps/cloud/src/web/components/create-organization-form.tsx+91−0
@@ -0,0 +1,91 @@
1import { useState } from "react";
2import { useAtomSet } from "@effect-atom/atom-react";
3import { Input } from "@executor/react/components/input";
4import { Label } from "@executor/react/components/label";
5
6import { createOrganization } from "../auth";
7
8type CreatedOrganization = { id: string; name: string };
9
10export 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
56export 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 @@
1import { useAtomRefresh } from "@effect-atom/atom-react";
2import { Button } from "@executor/react/components/button";
3
4import { AUTH_PATHS } from "../../auth/api";
5import { authAtom, useAuth } from "../auth";
6import {
7 CreateOrganizationFields,
8 useCreateOrganizationForm,
9} from "../components/create-organization-form";
10
11export 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
24 DropdownMenuSubTrigger,24 DropdownMenuSubTrigger,
25 DropdownMenuTrigger,25 DropdownMenuTrigger,
26} from "@executor/react/components/dropdown-menu";26} from "@executor/react/components/dropdown-menu";
27import { Input } from "@executor/react/components/input";
28import { Label } from "@executor/react/components/label";
29import { SourceFavicon } from "@executor/react/components/source-favicon";27import { SourceFavicon } from "@executor/react/components/source-favicon";
30import { CommandPalette } from "@executor/react/components/command-palette";28import { CommandPalette } from "@executor/react/components/command-palette";
31import { openApiSourcePlugin } from "@executor/plugin-openapi/react";29import { openApiSourcePlugin } from "@executor/plugin-openapi/react";
@@ -33,12 +31,11 @@ import { mcpSourcePlugin } from "@executor/plugin-mcp/react"; · 1 unmodified line
33import { googleDiscoverySourcePlugin } from "@executor/plugin-google-discovery/react";31import { googleDiscoverySourcePlugin } from "@executor/plugin-google-discovery/react";
34import { graphqlSourcePlugin } from "@executor/plugin-graphql/react";32import { graphqlSourcePlugin } from "@executor/plugin-graphql/react";
35import { AUTH_PATHS } from "../auth/api";33import { AUTH_PATHS } from "../auth/api";
34import { organizationsAtom, switchOrganization, useAuth } from "./auth";
36import {35import {
37 createOrganization,36 CreateOrganizationFields,
38 organizationsAtom,37 useCreateOrganizationForm,
39 switchOrganization,38} from "./components/create-organization-form";
40 useAuth,
41} from "./auth";
42 39
43const sourcePlugins = [40const sourcePlugins = [
44 openApiSourcePlugin,41 openApiSourcePlugin,
@@ -198,54 +195,34 @@ function CheckIcon() { · 153 unmodified lines
198 195
199function UserFooter() {196function UserFooter() {
200 const auth = useAuth();197 const auth = useAuth();
201 const doCreateOrganization = useAtomSet(createOrganization, { mode: "promiseExit" });
202 const [createOrganizationOpen, setCreateOrganizationOpen] = useState(false);198 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;
207 199
208 const suggestedOrganizationName =200 const suggestedOrganizationName =
209 auth.user.name?.trim() !== "" && auth.user.name != null201 auth.status === "authenticated" &&
202 auth.user.name?.trim() !== "" &&
203 auth.user.name != null
210 ? `${auth.user.name}'s Organization`204 ? `${auth.user.name}'s Organization`
211 : "New Organization";205 : "New Organization";
212 206
207 const form = useCreateOrganizationForm({
208 defaultName: suggestedOrganizationName,
209 onSuccess: () => window.location.reload(),
210 });
211
212 if (auth.status !== "authenticated") return null;
213
213 const openCreateOrganization = () => {214 const openCreateOrganization = () => {
214 setOrganizationName(suggestedOrganizationName);215 form.reset(suggestedOrganizationName);
215 setCreateOrganizationError(null);
216 setCreateOrganizationOpen(true);216 setCreateOrganizationOpen(true);
217 };217 };
218 218
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
238 return (219 return (
239 <div className="shrink-0 border-t border-sidebar-border px-3 py-2.5">220 <div className="shrink-0 border-t border-sidebar-border px-3 py-2.5">
240 <Dialog221 <Dialog
241 open={createOrganizationOpen}222 open={createOrganizationOpen}
242 onOpenChange={(open) => {223 onOpenChange={(open) => {
243 setCreateOrganizationOpen(open);224 setCreateOrganizationOpen(open);
244 if (!open) {225 if (!open) form.reset(suggestedOrganizationName);
245 setOrganizationName(suggestedOrganizationName);
246 setCreateOrganizationError(null);
247 setCreatingOrganization(false);
248 }
249 }}226 }}
250 >227 >
251 <DropdownMenu>228 <DropdownMenu>
@@ -342,49 +319,28 @@ function UserFooter() { · 90 unmodified lines
342 </DialogDescription>319 </DialogDescription>
343 </DialogHeader>320 </DialogHeader>
344 321
345 <div className="grid gap-4 py-3">322 <CreateOrganizationFields
346 <div className="grid gap-1.5">323 name={form.name}
347 <Label324 onNameChange={(name) => {
348 htmlFor="organization-name"325 form.setName(name);
349 className="text-sm font-medium uppercase tracking-wider text-muted-foreground"326 if (form.error) form.setError(null);
350 >327 }}
351 Organization name328 error={form.error}
352 </Label>329 onSubmit={() => void form.submit()}
353 <Input330 />
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>
375 331
376 <DialogFooter>332 <DialogFooter>
377 <DialogClose asChild>333 <DialogClose asChild>
378 <Button variant="ghost" size="sm" disabled={creatingOrganization}>334 <Button variant="ghost" size="sm" disabled={form.creating}>
379 Cancel335 Cancel
380 </Button>336 </Button>
381 </DialogClose>337 </DialogClose>
382 <Button338 <Button
383 size="sm"339 size="sm"
384 onClick={() => void handleCreateOrganization()}340 onClick={() => void form.submit()}
385 disabled={!organizationName.trim() || creatingOrganization}341 disabled={!form.canSubmit || form.creating}
386 >342 >
387 {creatingOrganization ? "Creating…" : "Create organization"}343 {form.creating ? "Creating…" : "Create organization"}
388 </Button>344 </Button>
389 </DialogFooter>345 </DialogFooter>
390 </DialogContent>346 </DialogContent>