History

Move web routes under /:org and API routes under /api/:org

The active org moves off the WorkOS session cookie and onto the URL — the cookie still proves login identity, but membership is verified per request against the URL-resolved org. This unblocks "in same browser tab, switch orgs by changing the URL" without a full session refresh. Web side: - All protected routes move to `apps/cloud/src/routes/$org/...`. The bracket-escape `[-]` convention keeps `[-].billing` and `[-].settings` as literal `-` URL segments under `/$org/-/...`. - A new `OrgRouteProvider` resolves the URL handle via `auth.organizations` and exposes `{ orgId, orgName, orgHandle }` to descendants. Unknown handles redirect to the first membership. - `/` index redirects to the first membership's `/$handle`. AuthGate routes `organizations.length === 0` to onboarding. - The org switcher is now a `<Link>` to `/$org`; `switchOrganization`, `organizationsAtom`, and the related session-refresh path are gone. - Cloud's NavItem wrapper goes away — sidebar links use typed `<Link>` directly with a `navItemClassName(active)` helper. API side: - Cloud's protected API mounts under `/api/:org/...` via a `HttpRouter.prefixed("/api/:org")` layer. - `ExecutionStackMiddleware` reads the URL `:org` segment, looks up the org row by handle, and verifies membership against WorkOS — independent of `session.organizationId`. - The frontend executor client sets its base URL to `${origin}/api/${orgHandle}` once the org layout resolves. - `/auth/me` returns `organizations: Array<{id, handle, name}>` instead of a single org. `createOrganization` and `acceptInvitation` no longer refresh the session — the URL is the source of truth, the client just navigates to the new handle. Test harness: - The harness mirrors the prefix layer, lazily seeds an organizations row whose handle equals the requested orgId, and rewrites outgoing request URLs to include `/api/${orgId}`. The `x-test-org-id` header is gone; `x-test-user-id` remains for tests that need a non-default user.

Rhys Sullivan committed 6 months ago

Commit b7508931 parent 0a99d1723 files · +792 −510Browse files

Unified · Split

23 files
  1. apps/cloud/src/api.request-scope.node.test.ts +6 −5
  2. apps/cloud/src/api/protected.ts +50 −5
  3. apps/cloud/src/auth/api.ts +5 −19
  4. apps/cloud/src/auth/handlers.ts +64 −77
  5. apps/cloud/src/routeTree.gen.ts +216 −167
  6. apps/cloud/src/routes/$org.tsx +61 −0
  7. apps/cloud/src/routes/$org/[-].billing.tsx +5 −2
  8. apps/cloud/src/routes/$org/[-].billing_.plans.tsx +7 −4
  9. apps/cloud/src/routes/$org/[-].settings.tsx +10 −10
  10. apps/cloud/src/routes/$org/connections.tsx +1 −1
  11. apps/cloud/src/routes/$org/index.tsx +6 −0
  12. apps/cloud/src/routes/$org/policies.tsx +1 −1
  13. apps/cloud/src/routes/$org/secrets.tsx +1 −1
  14. apps/cloud/src/routes/$org/sources.$namespace.tsx +1 −1
  15. apps/cloud/src/routes/$org/sources.add.$pluginKey.tsx +1 −1
  16. apps/cloud/src/routes/$org/tools.tsx +1 −1
  17. apps/cloud/src/routes/__root.tsx +4 −80
  18. apps/cloud/src/routes/index.tsx +23 −3
  19. apps/cloud/src/services/__test-harness__/api-harness.ts +93 −24
  20. apps/cloud/src/web/auth.tsx +23 −17
  21. apps/cloud/src/web/components/create-organization-form.tsx +1 −1
  22. apps/cloud/src/web/org-route.tsx +31 −0
  23. apps/cloud/src/web/shell.tsx +181 −90
apps/cloud/src/api.request-scope.node.test.ts+6−5
@@ -183,11 +183,12 @@ describe("makeApiLive (prod handler factory) request scoping", () => { · 182 unmodified lines
183 }).handler;183 }).handler;
184 184
185 // Hit a protected route. ExecutionStackMiddleware short-circuits with185 // Hit a protected route. ExecutionStackMiddleware short-circuits with
186 // 403 (no session cookie) but not before `requestScopedMiddleware`186 // 401/404 (no session cookie / unknown org) but not before
187 // has built the per-request layer. We don't care about the response —187 // `requestScopedMiddleware` has built the per-request layer. We don't
188 // only that the layer was built once per request.188 // care about the response — only that the layer was built once per
189 await handler(new Request("http://test.local/scope"));189 // request. The protected API mounts under `/api/:org/...`.
190 await handler(new Request("http://test.local/scope"));190 await handler(new Request("http://test.local/api/test_org/scope"));
191 await handler(new Request("http://test.local/api/test_org/scope"));
191 192
192 expect(counts.acquires).toBe(2);193 expect(counts.acquires).toBe(2);
193 expect(counts.releases).toBe(2);194 expect(counts.releases).toBe(2);
apps/cloud/src/api/protected.ts+50−5
@@ -21,11 +21,22 @@ import { WorkOSAuth } from "../auth/workos"; · 20 unmodified lines
21import { AutumnService } from "../services/autumn";21import { AutumnService } from "../services/autumn";
22import { DbService } from "../services/db";22import { DbService } from "../services/db";
23import { makeExecutionStack } from "../services/execution-stack";23import { makeExecutionStack } from "../services/execution-stack";
24import { makeUserStore } from "../services/user-store";
24import { HttpResponseError } from "./error-response";25import { HttpResponseError } from "./error-response";
25import { RequestScopedServicesLive } from "./layers";26import { RequestScopedServicesLive } from "./layers";
26import { ProtectedCloudApi, ProtectedCloudApiLive, RouterConfig } from "./protected-layers";27import { ProtectedCloudApi, ProtectedCloudApiLive, RouterConfig } from "./protected-layers";
27import { requestScopedMiddleware } from "./request-scoped";28import { requestScopedMiddleware } from "./request-scoped";
28 29
30// Pull the URL `:org` segment from a request path. The protected API mounts
31// under `/api/:org/...` — anything else is a programming error and surfaces as
32// a typed `no_organization` response so the framework's error pipeline can
33// render it.
34const orgHandleFromPath = (pathname: string): string | null => {
35 const parts = pathname.split("/").filter((part) => part.length > 0);
36 if (parts.length < 2 || parts[0] !== "api") return null;
37 return parts[1] ?? null;
38};
39
29// Pre-compute the per-plugin `Effect.provideService(extensionService,40// Pre-compute the per-plugin `Effect.provideService(extensionService,
30// executor[id])` chain. The plugin spec carries the Service tag so41// executor[id])` chain. The plugin spec carries the Service tag so
31// this file doesn't import each plugin's `*/api` directly.42// this file doesn't import each plugin's `*/api` directly.
@@ -72,19 +83,42 @@ const ExecutionStackMiddleware = HttpRouter.middleware<{ · 40 unmodified lines
72 const webRequest = yield* HttpServerRequest.toWeb(request);83 const webRequest = yield* HttpServerRequest.toWeb(request);
73 const workos = yield* WorkOSAuth;84 const workos = yield* WorkOSAuth;
74 const session = yield* workos.authenticateRequest(webRequest);85 const session = yield* workos.authenticateRequest(webRequest);
75 if (!session || !session.organizationId) {86 if (!session) {
76 return yield* new HttpResponseError({87 return yield* new HttpResponseError({
77 status: 403,88 status: 401,
89 code: "unauthorized",
90 message: "Unauthorized",
91 });
92 }
93 // The URL is the source of truth for active org. Pull the handle
94 // off the request path, resolve it to an org row, and verify
95 // membership against WorkOS — independent of `session.organizationId`.
96 const url = new URL(webRequest.url);
97 const handle = orgHandleFromPath(url.pathname);
98 if (!handle) {
99 return yield* new HttpResponseError({
100 status: 404,
78 code: "no_organization",101 code: "no_organization",
79 message: "No organization in session",102 message: "Missing organization in URL",
80 });103 });
81 }104 }
82 const org = yield* authorizeOrganization(session.userId, session.organizationId);105 const { db } = yield* DbService;
106 const resolved = yield* Effect.promise(() =>
107 makeUserStore(db).getOrganizationByHandle(handle),
108 );
109 if (!resolved) {
110 return yield* new HttpResponseError({
111 status: 404,
112 code: "no_organization",
113 message: `Organization "${handle}" not found`,
114 });
115 }
116 const org = yield* authorizeOrganization(session.userId, resolved.id);
83 if (!org) {117 if (!org) {
84 return yield* new HttpResponseError({118 return yield* new HttpResponseError({
85 status: 403,119 status: 403,
86 code: "no_organization",120 code: "no_organization",
87 message: "No organization in session",121 message: "Not a member of this organization",
88 });122 });
89 }123 }
90 const auth = AuthContext.of({124 const auth = AuthContext.of({
@@ -105,6 +139,16 @@ const ExecutionStackMiddleware = HttpRouter.middleware<{ · 14 unmodified lines
105 }),139 }),
106);140);
107 141
142// Layer that swaps the boot router with a `:org`-prefixed view, so every
143// route registered by `ProtectedCloudApiLive` mounts under `/api/:org/*`.
144// `HttpRouter.prefixed` returns a wrapper that delegates to the underlying
145// router state — the outer router-config layer still owns the actual
146// FindMyWay instance, so non-protected routes (auth, autumn, swagger) keep
147// their unprefixed paths.
148const PrefixedRouterLayer = Layer.effect(HttpRouter.HttpRouter)(
149 Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed("/api/:org")),
150);
151
108// `rsLive` is the per-request DB layer. Combining it into the auth152// `rsLive` is the per-request DB layer. Combining it into the auth
109// middleware collapses `requires: DbService | UserStoreService` to153// middleware collapses `requires: DbService | UserStoreService` to
110// never (so `.layer` is a real Layer instead of the "Need to combine"154// never (so `.layer` is a real Layer instead of the "Need to combine"
@@ -118,6 +162,7 @@ export const makeProtectedApiLive = (rsLive: Layer.Layer<DbService | UserStoreSe · 7 unmodified lines
118 ).layer;162 ).layer;
119 return ProtectedCloudApiLive.pipe(163 return ProtectedCloudApiLive.pipe(
120 Layer.provide(protectedMiddleware),164 Layer.provide(protectedMiddleware),
165 Layer.provide(PrefixedRouterLayer),
121 Layer.provideMerge(HttpApiSwagger.layer(ProtectedCloudApi, { path: "/docs" })),166 Layer.provideMerge(HttpApiSwagger.layer(ProtectedCloudApi, { path: "/docs" })),
122 Layer.provideMerge(RouterConfig),167 Layer.provideMerge(RouterConfig),
123 );168 );
apps/cloud/src/auth/api.ts+5−19
@@ -12,26 +12,18 @@ const AuthUser = Schema.Struct({ · 11 unmodified lines
12 12
13const AuthOrganization = Schema.Struct({13const AuthOrganization = Schema.Struct({
14 id: Schema.String,14 id: Schema.String,
15 handle: Schema.String,
15 name: Schema.String,16 name: Schema.String,
16});17});
17 18
18const AuthMeResponse = Schema.Struct({19const AuthMeResponse = Schema.Struct({
19 user: AuthUser,20 user: AuthUser,
20 organization: Schema.NullOr(AuthOrganization),21 /** Memberships, with the org `handle` URL routes use. Sorted alphabetically. */
21});22 organizations: Schema.Array(AuthOrganization),
22
23const AuthOrganizationSummary = Schema.Struct({
24 id: Schema.String,
25 name: Schema.String,
26});23});
27 24
28const AuthOrganizationsResponse = Schema.Struct({25const AuthOrganizationsResponse = Schema.Struct({
29 organizations: Schema.Array(AuthOrganizationSummary),26 organizations: Schema.Array(AuthOrganization),
30 activeOrganizationId: Schema.NullOr(Schema.String),
31});
32
33const SwitchOrganizationBody = Schema.Struct({
34 organizationId: Schema.String,
35});27});
36 28
37const CreateOrganizationBody = Schema.Struct({29const CreateOrganizationBody = Schema.Struct({
@@ -40,6 +32,7 @@ const CreateOrganizationBody = Schema.Struct({ · 2 unmodified lines
40 32
41const CreateOrganizationResponse = Schema.Struct({33const CreateOrganizationResponse = Schema.Struct({
42 id: Schema.String,34 id: Schema.String,
35 handle: Schema.String,
43 name: Schema.String,36 name: Schema.String,
44});37});
45 38
@@ -81,7 +74,6 @@ export const AUTH_PATHS = { · 35 unmodified lines
81 login: "/api/auth/login",74 login: "/api/auth/login",
82 logout: "/api/auth/logout",75 logout: "/api/auth/logout",
83 callback: "/api/auth/callback",76 callback: "/api/auth/callback",
84 switchOrganization: "/api/auth/switch-organization",
85} as const;77} as const;
86 78
87const AuthErrors = [UserStoreError, WorkOSError] as const;79const AuthErrors = [UserStoreError, WorkOSError] as const;
@@ -111,12 +103,6 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") · 23 unmodified lines
111 error: WorkOSError,103 error: WorkOSError,
112 }),104 }),
113 )105 )
114 .add(
115 HttpApiEndpoint.post("switchOrganization", "/auth/switch-organization", {
116 payload: SwitchOrganizationBody,
117 error: WorkOSError,
118 }),
119 )
120 .add(106 .add(
121 HttpApiEndpoint.post("createOrganization", "/auth/create-organization", {107 HttpApiEndpoint.post("createOrganization", "/auth/create-organization", {
122 payload: CreateOrganizationBody,108 payload: CreateOrganizationBody,
apps/cloud/src/auth/handlers.ts+64−77
@@ -1,12 +1,11 @@
1import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi";1import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi";
2import { HttpServerResponse } from "effect/unstable/http";2import { HttpServerResponse } from "effect/unstable/http";
3import { Duration, Effect } from "effect";3import { Duration, Effect } from "effect";
4import { setCookie, deleteCookie } from "@tanstack/react-start/server";4import { deleteCookie } from "@tanstack/react-start/server";
5 5
6import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api";6import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api";
7import { SessionContext } from "./middleware";7import { SessionContext } from "./middleware";
8import { UserStoreService } from "./context";8import { UserStoreService } from "./context";
9import { authorizeOrganization } from "./authorize-organization";
10import { env } from "cloudflare:workers";9import { env } from "cloudflare:workers";
11import { WorkOSError } from "./errors";10import { WorkOSError } from "./errors";
12import { WorkOSAuth } from "./workos";11import { WorkOSAuth } from "./workos";
@@ -181,9 +180,37 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 168 unmodified lines
181 .handle("me", () =>180 .handle("me", () =>
182 Effect.gen(function* () {181 Effect.gen(function* () {
183 const session = yield* SessionContext;182 const session = yield* SessionContext;
184 const org = session.organizationId183 const users = yield* UserStoreService;
185 ? yield* authorizeOrganization(session.accountId, session.organizationId)184 const workos = yield* WorkOSAuth;
186 : null;185
186 const memberships = yield* workos.listUserMemberships(session.accountId);
187 // Mirror each org locally so the local handle exists; ignore mirror
188 // failures (the directory layer already has the canonical name +
189 // membership — a transient db error here shouldn't blank the user's
190 // org list).
191 const orgs = yield* Effect.all(
192 memberships.data.map((m) =>
193 workos.getOrganization(m.organizationId).pipe(
194 Effect.flatMap((org) =>
195 users
196 .use((s) => s.upsertOrganization({ id: org.id, name: org.name }))
197 .pipe(
198 Effect.map((mirror) => ({
199 id: mirror.id,
200 handle: mirror.handle,
201 name: org.name,
202 })),
203 ),
204 ),
205 Effect.orElseSucceed(() => null),
206 ),
207 ),
208 { concurrency: "unbounded" },
209 );
210
211 const organizations = orgs
212 .filter(<T>(v: T | null): v is T => v !== null)
213 .sort((a, b) => a.name.localeCompare(b.name));
187 214
188 return {215 return {
189 user: {216 user: {
@@ -192,7 +219,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 2 unmodified lines
192 name: session.name,219 name: session.name,
193 avatarUrl: session.avatarUrl,220 avatarUrl: session.avatarUrl,
194 },221 },
195 organization: org ? { id: org.id, name: org.name } : null,222 organizations,
196 };223 };
197 }),224 }),
198 )225 )
@@ -203,39 +230,35 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 4 unmodified lines
203 .handle("organizations", () =>230 .handle("organizations", () =>
204 Effect.gen(function* () {231 Effect.gen(function* () {
205 const workos = yield* WorkOSAuth;232 const workos = yield* WorkOSAuth;
233 const users = yield* UserStoreService;
206 const session = yield* SessionContext;234 const session = yield* SessionContext;
207 235
208 const memberships = yield* workos.listUserMemberships(session.accountId);236 const memberships = yield* workos.listUserMemberships(session.accountId);
209 const organizations = yield* Effect.all(237 const orgs = yield* Effect.all(
210 memberships.data.map((m) =>238 memberships.data.map((m) =>
211 workos.getOrganization(m.organizationId).pipe(239 workos.getOrganization(m.organizationId).pipe(
212 Effect.map((org) => ({ id: org.id, name: org.name })),240 Effect.flatMap((org) =>
241 users
242 .use((s) => s.upsertOrganization({ id: org.id, name: org.name }))
243 .pipe(
244 Effect.map((mirror) => ({
245 id: mirror.id,
246 handle: mirror.handle,
247 name: org.name,
248 })),
249 ),
250 ),
213 Effect.orElseSucceed(() => null),251 Effect.orElseSucceed(() => null),
214 ),252 ),
215 ),253 ),
216 { concurrency: "unbounded" },254 { concurrency: "unbounded" },
217 );255 );
218 256
219 return {257 const organizations = orgs
220 organizations: organizations.filter(258 .filter(<T>(v: T | null): v is T => v !== null)
221 (org): org is NonNullable<typeof org> => org !== null,259 .sort((a, b) => a.name.localeCompare(b.name));
222 ),
223 activeOrganizationId: session.organizationId,
224 };
225 }),
226 )
227 .handle("switchOrganization", ({ payload }) =>
228 Effect.gen(function* () {
229 const workos = yield* WorkOSAuth;
230 const session = yield* SessionContext;
231 260
232 const refreshed = yield* workos.refreshSession(261 return { organizations };
233 session.sealedSession,
234 payload.organizationId,
235 );
236 if (refreshed) {
237 setCookie("wos-session", refreshed, COOKIE_OPTIONS);
238 }
239 }),262 }),
240 )263 )
241 .handle("createOrganization", ({ payload }) =>264 .handle("createOrganization", ({ payload }) =>
@@ -247,34 +270,15 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 5 unmodified lines
247 const name = payload.name.trim();270 const name = payload.name.trim();
248 const org = yield* workos.createOrganization(name);271 const org = yield* workos.createOrganization(name);
249 yield* workos.createMembership(org.id, session.accountId, "admin");272 yield* workos.createMembership(org.id, session.accountId, "admin");
250 yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name }));273 const mirrored = yield* users.use((s) =>
251 274 s.upsertOrganization({ id: org.id, name: org.name }),
252 // Try to attach the new org to the current session. This can fail275 );
253 // (or silently return a session still scoped to the old org) when276 // No session refresh — the URL is the source of truth for active
254 // the caller's current session is stale — most commonly after the277 // org now, so the client just navigates to /:handle/... after
255 // user was removed from the org their cookie is pinned to. In that278 // create. The WorkOS session's `organizationId` only proves login
256 // case we can't repair the session in-place, so we clear the279 // identity; it doesn't gate per-org access (that goes through the
257 // cookie and fail loudly; the frontend will bounce to login and280 // membership check on the URL-resolved org).
258 // the callback's rehydrate path will pick up the new membership.281 return { id: org.id, handle: mirrored.handle, name: org.name };
259 const refreshed = yield* workos.refreshSession(session.sealedSession, org.id);
260 const verified = refreshed ? yield* workos.authenticateSealedSession(refreshed) : null;
261
262 if (!refreshed || !verified || verified.organizationId !== org.id) {
263 yield* Effect.logWarning(
264 "createOrganization: unable to attach new org to current session",
265 {
266 userId: session.accountId,
267 newOrgId: org.id,
268 refreshReturnedSession: refreshed != null,
269 verifiedOrgId: verified?.organizationId ?? null,
270 },
271 );
272 deleteCookie("wos-session", { path: "/" });
273 return yield* new WorkOSError();
274 }
275
276 setCookie("wos-session", refreshed, COOKIE_OPTIONS);
277 return { id: org.id, name: org.name };
278 }),282 }),
279 )283 )
280 .handle("pendingInvitations", () =>284 .handle("pendingInvitations", () =>
@@ -330,7 +334,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 49 unmodified lines
330 Effect.gen(function* () {334 Effect.gen(function* () {
331 const workos = yield* WorkOSAuth;335 const workos = yield* WorkOSAuth;
332 const users = yield* UserStoreService;336 const users = yield* UserStoreService;
333 const session = yield* SessionContext;
334 337
335 const invitation = yield* workos.acceptInvitation(payload.invitationId);338 const invitation = yield* workos.acceptInvitation(payload.invitationId);
336 339
@@ -343,30 +346,14 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 6 unmodified lines
343 return yield* new WorkOSError();346 return yield* new WorkOSError();
344 }347 }
345 348
346 // Mirror the org locally so domain tables can FK against it.349 // Mirror the org locally so domain tables can FK against it and
350 // the URL handle exists by the time the client navigates.
347 const org = yield* workos.getOrganization(invitation.organizationId);351 const org = yield* workos.getOrganization(invitation.organizationId);
348 yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name }));352 yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name }));
349 353
350 // Attach the just-accepted org to the current session. Same shape354 // No session refresh — the URL is the source of truth for active
351 // as createOrganization: refresh + verify; if we can't pin the355 // org. The client refreshes the auth atom and navigates to the
352 // session in-place, clear the cookie and let the user bounce356 // new org's `/$handle` after this resolves.
353 // through login again. The acceptance has already succeeded
354 // server-side, so the next login will pick up the membership.
355 const refreshed = yield* workos.refreshSession(session.sealedSession, org.id);
356 const verified = refreshed ? yield* workos.authenticateSealedSession(refreshed) : null;
357
358 if (!refreshed || !verified || verified.organizationId !== org.id) {
359 yield* Effect.logWarning("acceptInvitation: unable to attach org to current session", {
360 userId: session.accountId,
361 orgId: org.id,
362 refreshReturnedSession: refreshed != null,
363 verifiedOrgId: verified?.organizationId ?? null,
364 });
365 deleteCookie("wos-session", { path: "/" });
366 return yield* new WorkOSError();
367 }
368
369 setCookie("wos-session", refreshed, COOKIE_OPTIONS);
370 return { id: org.id, name: org.name };357 return { id: org.id, name: org.name };
371 }),358 }),
372 ),359 ),
apps/cloud/src/routeTree.gen.ts+216−167
@@ -9,201 +9,181 @@ · 8 unmodified lines
9// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.9// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
10 10
11import { Route as rootRouteImport } from './routes/__root'11import { Route as rootRouteImport } from './routes/__root'
12import { Route as ToolsRouteImport } from './routes/tools'12import { Route as OrgRouteImport } from './routes/$org'
13import { Route as SecretsRouteImport } from './routes/secrets'
14import { Route as PoliciesRouteImport } from './routes/policies'
15import { Route as OrgRouteImport } from './routes/org'
16import { Route as ConnectionsRouteImport } from './routes/connections'
17import { Route as BillingRouteImport } from './routes/billing'
18import { Route as IndexRouteImport } from './routes/index'13import { Route as IndexRouteImport } from './routes/index'
19import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace'14import { Route as OrgIndexRouteImport } from './routes/$org/index'
20import { Route as BillingPlansRouteImport } from './routes/billing_.plans'15import { Route as OrgToolsRouteImport } from './routes/$org/tools'
21import { Route as SourcesAddPluginKeyRouteImport } from './routes/sources.add.$pluginKey'16import { Route as OrgSecretsRouteImport } from './routes/$org/secrets'
17import { Route as OrgPoliciesRouteImport } from './routes/$org/policies'
18import { Route as OrgConnectionsRouteImport } from './routes/$org/connections'
19import { Route as OrgSourcesNamespaceRouteImport } from './routes/$org/sources.$namespace'
20import { Route as OrgChar91Char93SettingsRouteImport } from './routes/$org/[-].settings'
21import { Route as OrgChar91Char93BillingRouteImport } from './routes/$org/[-].billing'
22import { Route as OrgSourcesAddPluginKeyRouteImport } from './routes/$org/sources.add.$pluginKey'
23import { Route as OrgChar91Char93BillingPlansRouteImport } from './routes/$org/[-].billing_.plans'
22 24
23const ToolsRoute = ToolsRouteImport.update({25const OrgRoute = OrgRouteImport.update({
26 id: '/$org',
27 path: '/$org',
28 getParentRoute: () => rootRouteImport,
29} as any)
30const IndexRoute = IndexRouteImport.update({
31 id: '/',
32 path: '/',
33 getParentRoute: () => rootRouteImport,
34} as any)
35const OrgIndexRoute = OrgIndexRouteImport.update({
36 id: '/',
37 path: '/',
38 getParentRoute: () => OrgRoute,
39} as any)
40const OrgToolsRoute = OrgToolsRouteImport.update({
24 id: '/tools',41 id: '/tools',
25 path: '/tools',42 path: '/tools',
26 getParentRoute: () => rootRouteImport,43 getParentRoute: () => OrgRoute,
27} as any)44} as any)
28const SecretsRoute = SecretsRouteImport.update({45const OrgSecretsRoute = OrgSecretsRouteImport.update({
29 id: '/secrets',46 id: '/secrets',
30 path: '/secrets',47 path: '/secrets',
31 getParentRoute: () => rootRouteImport,48 getParentRoute: () => OrgRoute,
32} as any)49} as any)
33const PoliciesRoute = PoliciesRouteImport.update({50const OrgPoliciesRoute = OrgPoliciesRouteImport.update({
34 id: '/policies',51 id: '/policies',
35 path: '/policies',52 path: '/policies',
36 getParentRoute: () => rootRouteImport,53 getParentRoute: () => OrgRoute,
37} as any)
38const OrgRoute = OrgRouteImport.update({
39 id: '/org',
40 path: '/org',
41 getParentRoute: () => rootRouteImport,
42} as any)54} as any)
43const ConnectionsRoute = ConnectionsRouteImport.update({55const OrgConnectionsRoute = OrgConnectionsRouteImport.update({
44 id: '/connections',56 id: '/connections',
45 path: '/connections',57 path: '/connections',
46 getParentRoute: () => rootRouteImport,58 getParentRoute: () => OrgRoute,
47} as any)
48const BillingRoute = BillingRouteImport.update({
49 id: '/billing',
50 path: '/billing',
51 getParentRoute: () => rootRouteImport,
52} as any)
53const IndexRoute = IndexRouteImport.update({
54 id: '/',
55 path: '/',
56 getParentRoute: () => rootRouteImport,
57} as any)59} as any)
58const SourcesNamespaceRoute = SourcesNamespaceRouteImport.update({60const OrgSourcesNamespaceRoute = OrgSourcesNamespaceRouteImport.update({
59 id: '/sources/$namespace',61 id: '/sources/$namespace',
60 path: '/sources/$namespace',62 path: '/sources/$namespace',
61 getParentRoute: () => rootRouteImport,63 getParentRoute: () => OrgRoute,
62} as any)64} as any)
63const BillingPlansRoute = BillingPlansRouteImport.update({65const OrgChar91Char93SettingsRoute = OrgChar91Char93SettingsRouteImport.update({
64 id: '/billing_/plans',66 id: '/-/settings',
65 path: '/billing/plans',67 path: '/-/settings',
66 getParentRoute: () => rootRouteImport,68 getParentRoute: () => OrgRoute,
69} as any)
70const OrgChar91Char93BillingRoute = OrgChar91Char93BillingRouteImport.update({
71 id: '/-/billing',
72 path: '/-/billing',
73 getParentRoute: () => OrgRoute,
67} as any)74} as any)
68const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({75const OrgSourcesAddPluginKeyRoute = OrgSourcesAddPluginKeyRouteImport.update({
69 id: '/sources/add/$pluginKey',76 id: '/sources/add/$pluginKey',
70 path: '/sources/add/$pluginKey',77 path: '/sources/add/$pluginKey',
71 getParentRoute: () => rootRouteImport,78 getParentRoute: () => OrgRoute,
72} as any)79} as any)
80const OrgChar91Char93BillingPlansRoute =
81 OrgChar91Char93BillingPlansRouteImport.update({
82 id: '/-/billing_/plans',
83 path: '/-/billing/plans',
84 getParentRoute: () => OrgRoute,
85 } as any)
73 86
74export interface FileRoutesByFullPath {87export interface FileRoutesByFullPath {
75 '/': typeof IndexRoute88 '/': typeof IndexRoute
76 '/billing': typeof BillingRoute89 '/$org': typeof OrgRouteWithChildren
77 '/connections': typeof ConnectionsRoute90 '/$org/connections': typeof OrgConnectionsRoute
78 '/org': typeof OrgRoute91 '/$org/policies': typeof OrgPoliciesRoute
79 '/policies': typeof PoliciesRoute92 '/$org/secrets': typeof OrgSecretsRoute
80 '/secrets': typeof SecretsRoute93 '/$org/tools': typeof OrgToolsRoute
81 '/tools': typeof ToolsRoute94 '/$org/': typeof OrgIndexRoute
82 '/billing/plans': typeof BillingPlansRoute95 '/$org/-/billing': typeof OrgChar91Char93BillingRoute
83 '/sources/$namespace': typeof SourcesNamespaceRoute96 '/$org/-/settings': typeof OrgChar91Char93SettingsRoute
84 '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute97 '/$org/sources/$namespace': typeof OrgSourcesNamespaceRoute
98 '/$org/-/billing/plans': typeof OrgChar91Char93BillingPlansRoute
99 '/$org/sources/add/$pluginKey': typeof OrgSourcesAddPluginKeyRoute
85}100}
86export interface FileRoutesByTo {101export interface FileRoutesByTo {
87 '/': typeof IndexRoute102 '/': typeof IndexRoute
88 '/billing': typeof BillingRoute103 '/$org/connections': typeof OrgConnectionsRoute
89 '/connections': typeof ConnectionsRoute104 '/$org/policies': typeof OrgPoliciesRoute
90 '/org': typeof OrgRoute105 '/$org/secrets': typeof OrgSecretsRoute
91 '/policies': typeof PoliciesRoute106 '/$org/tools': typeof OrgToolsRoute
92 '/secrets': typeof SecretsRoute107 '/$org': typeof OrgIndexRoute
93 '/tools': typeof ToolsRoute108 '/$org/-/billing': typeof OrgChar91Char93BillingRoute
94 '/billing/plans': typeof BillingPlansRoute109 '/$org/-/settings': typeof OrgChar91Char93SettingsRoute
95 '/sources/$namespace': typeof SourcesNamespaceRoute110 '/$org/sources/$namespace': typeof OrgSourcesNamespaceRoute
96 '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute111 '/$org/-/billing/plans': typeof OrgChar91Char93BillingPlansRoute
112 '/$org/sources/add/$pluginKey': typeof OrgSourcesAddPluginKeyRoute
97}113}
98export interface FileRoutesById {114export interface FileRoutesById {
99 __root__: typeof rootRouteImport115 __root__: typeof rootRouteImport
100 '/': typeof IndexRoute116 '/': typeof IndexRoute
101 '/billing': typeof BillingRoute117 '/$org': typeof OrgRouteWithChildren
102 '/connections': typeof ConnectionsRoute118 '/$org/connections': typeof OrgConnectionsRoute
103 '/org': typeof OrgRoute119 '/$org/policies': typeof OrgPoliciesRoute
104 '/policies': typeof PoliciesRoute120 '/$org/secrets': typeof OrgSecretsRoute
105 '/secrets': typeof SecretsRoute121 '/$org/tools': typeof OrgToolsRoute
106 '/tools': typeof ToolsRoute122 '/$org/': typeof OrgIndexRoute
107 '/billing_/plans': typeof BillingPlansRoute123 '/$org/-/billing': typeof OrgChar91Char93BillingRoute
108 '/sources/$namespace': typeof SourcesNamespaceRoute124 '/$org/-/settings': typeof OrgChar91Char93SettingsRoute
109 '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute125 '/$org/sources/$namespace': typeof OrgSourcesNamespaceRoute
126 '/$org/-/billing_/plans': typeof OrgChar91Char93BillingPlansRoute
127 '/$org/sources/add/$pluginKey': typeof OrgSourcesAddPluginKeyRoute
110}128}
111export interface FileRouteTypes {129export interface FileRouteTypes {
112 fileRoutesByFullPath: FileRoutesByFullPath130 fileRoutesByFullPath: FileRoutesByFullPath
113 fullPaths:131 fullPaths:
114 | '/'132 | '/'
115 | '/billing'133 | '/$org'
116 | '/connections'134 | '/$org/connections'
117 | '/org'135 | '/$org/policies'
118 | '/policies'136 | '/$org/secrets'
119 | '/secrets'137 | '/$org/tools'
120 | '/tools'138 | '/$org/'
121 | '/billing/plans'139 | '/$org/-/billing'
122 | '/sources/$namespace'140 | '/$org/-/settings'
123 | '/sources/add/$pluginKey'141 | '/$org/sources/$namespace'
142 | '/$org/-/billing/plans'
143 | '/$org/sources/add/$pluginKey'
124 fileRoutesByTo: FileRoutesByTo144 fileRoutesByTo: FileRoutesByTo
125 to:145 to:
126 | '/'146 | '/'
127 | '/billing'147 | '/$org/connections'
128 | '/connections'148 | '/$org/policies'
129 | '/org'149 | '/$org/secrets'
130 | '/policies'150 | '/$org/tools'
131 | '/secrets'151 | '/$org'
132 | '/tools'152 | '/$org/-/billing'
133 | '/billing/plans'153 | '/$org/-/settings'
134 | '/sources/$namespace'154 | '/$org/sources/$namespace'
135 | '/sources/add/$pluginKey'155 | '/$org/-/billing/plans'
156 | '/$org/sources/add/$pluginKey'
136 id:157 id:
137 | '__root__'158 | '__root__'
138 | '/'159 | '/'
139 | '/billing'160 | '/$org'
140 | '/connections'161 | '/$org/connections'
141 | '/org'162 | '/$org/policies'
142 | '/policies'163 | '/$org/secrets'
143 | '/secrets'164 | '/$org/tools'
144 | '/tools'165 | '/$org/'
145 | '/billing_/plans'166 | '/$org/-/billing'
146 | '/sources/$namespace'167 | '/$org/-/settings'
147 | '/sources/add/$pluginKey'168 | '/$org/sources/$namespace'
169 | '/$org/-/billing_/plans'
170 | '/$org/sources/add/$pluginKey'
148 fileRoutesById: FileRoutesById171 fileRoutesById: FileRoutesById
149}172}
150export interface RootRouteChildren {173export interface RootRouteChildren {
151 IndexRoute: typeof IndexRoute174 IndexRoute: typeof IndexRoute
152 BillingRoute: typeof BillingRoute175 OrgRoute: typeof OrgRouteWithChildren
153 ConnectionsRoute: typeof ConnectionsRoute
154 OrgRoute: typeof OrgRoute
155 PoliciesRoute: typeof PoliciesRoute
156 SecretsRoute: typeof SecretsRoute
157 ToolsRoute: typeof ToolsRoute
158 BillingPlansRoute: typeof BillingPlansRoute
159 SourcesNamespaceRoute: typeof SourcesNamespaceRoute
160 SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute
161}176}
162 177
163declare module '@tanstack/react-router' {178declare module '@tanstack/react-router' {
164 interface FileRoutesByPath {179 interface FileRoutesByPath {
165 '/tools': {180 '/$org': {
166 id: '/tools'181 id: '/$org'
167 path: '/tools'182 path: '/$org'
168 fullPath: '/tools'183 fullPath: '/$org'
169 preLoaderRoute: typeof ToolsRouteImport
170 parentRoute: typeof rootRouteImport
171 }
172 '/secrets': {
173 id: '/secrets'
174 path: '/secrets'
175 fullPath: '/secrets'
176 preLoaderRoute: typeof SecretsRouteImport
177 parentRoute: typeof rootRouteImport
178 }
179 '/policies': {
180 id: '/policies'
181 path: '/policies'
182 fullPath: '/policies'
183 preLoaderRoute: typeof PoliciesRouteImport
184 parentRoute: typeof rootRouteImport
185 }
186 '/org': {
187 id: '/org'
188 path: '/org'
189 fullPath: '/org'
190 preLoaderRoute: typeof OrgRouteImport184 preLoaderRoute: typeof OrgRouteImport
191 parentRoute: typeof rootRouteImport185 parentRoute: typeof rootRouteImport
192 }186 }
193 '/connections': {
194 id: '/connections'
195 path: '/connections'
196 fullPath: '/connections'
197 preLoaderRoute: typeof ConnectionsRouteImport
198 parentRoute: typeof rootRouteImport
199 }
200 '/billing': {
201 id: '/billing'
202 path: '/billing'
203 fullPath: '/billing'
204 preLoaderRoute: typeof BillingRouteImport
205 parentRoute: typeof rootRouteImport
206 }
207 '/': {187 '/': {
208 id: '/'188 id: '/'
209 path: '/'189 path: '/'
@@ -211,41 +191,110 @@ declare module '@tanstack/react-router' { · 1 unmodified line
211 preLoaderRoute: typeof IndexRouteImport191 preLoaderRoute: typeof IndexRouteImport
212 parentRoute: typeof rootRouteImport192 parentRoute: typeof rootRouteImport
213 }193 }
214 '/sources/$namespace': {194 '/$org/': {
215 id: '/sources/$namespace'195 id: '/$org/'
196 path: '/'
197 fullPath: '/$org/'
198 preLoaderRoute: typeof OrgIndexRouteImport
199 parentRoute: typeof OrgRoute
200 }
201 '/$org/tools': {
202 id: '/$org/tools'
203 path: '/tools'
204 fullPath: '/$org/tools'
205 preLoaderRoute: typeof OrgToolsRouteImport
206 parentRoute: typeof OrgRoute
207 }
208 '/$org/secrets': {
209 id: '/$org/secrets'
210 path: '/secrets'
211 fullPath: '/$org/secrets'
212 preLoaderRoute: typeof OrgSecretsRouteImport
213 parentRoute: typeof OrgRoute
214 }
215 '/$org/policies': {
216 id: '/$org/policies'
217 path: '/policies'
218 fullPath: '/$org/policies'
219 preLoaderRoute: typeof OrgPoliciesRouteImport
220 parentRoute: typeof OrgRoute
221 }
222 '/$org/connections': {
223 id: '/$org/connections'
224 path: '/connections'
225 fullPath: '/$org/connections'
226 preLoaderRoute: typeof OrgConnectionsRouteImport
227 parentRoute: typeof OrgRoute
228 }
229 '/$org/sources/$namespace': {
230 id: '/$org/sources/$namespace'
216 path: '/sources/$namespace'231 path: '/sources/$namespace'
217 fullPath: '/sources/$namespace'232 fullPath: '/$org/sources/$namespace'
218 preLoaderRoute: typeof SourcesNamespaceRouteImport233 preLoaderRoute: typeof OrgSourcesNamespaceRouteImport
219 parentRoute: typeof rootRouteImport234 parentRoute: typeof OrgRoute
220 }235 }
221 '/billing_/plans': {236 '/$org/-/settings': {
222 id: '/billing_/plans'237 id: '/$org/-/settings'
223 path: '/billing/plans'238 path: '/-/settings'
224 fullPath: '/billing/plans'239 fullPath: '/$org/-/settings'
225 preLoaderRoute: typeof BillingPlansRouteImport240 preLoaderRoute: typeof OrgChar91Char93SettingsRouteImport
226 parentRoute: typeof rootRouteImport241 parentRoute: typeof OrgRoute
242 }
243 '/$org/-/billing': {
244 id: '/$org/-/billing'
245 path: '/-/billing'
246 fullPath: '/$org/-/billing'
247 preLoaderRoute: typeof OrgChar91Char93BillingRouteImport
248 parentRoute: typeof OrgRoute
227 }249 }
228 '/sources/add/$pluginKey': {250 '/$org/sources/add/$pluginKey': {
229 id: '/sources/add/$pluginKey'251 id: '/$org/sources/add/$pluginKey'
230 path: '/sources/add/$pluginKey'252 path: '/sources/add/$pluginKey'
231 fullPath: '/sources/add/$pluginKey'253 fullPath: '/$org/sources/add/$pluginKey'
232 preLoaderRoute: typeof SourcesAddPluginKeyRouteImport254 preLoaderRoute: typeof OrgSourcesAddPluginKeyRouteImport
233 parentRoute: typeof rootRouteImport255 parentRoute: typeof OrgRoute
256 }
257 '/$org/-/billing_/plans': {
258 id: '/$org/-/billing_/plans'
259 path: '/-/billing/plans'
260 fullPath: '/$org/-/billing/plans'
261 preLoaderRoute: typeof OrgChar91Char93BillingPlansRouteImport
262 parentRoute: typeof OrgRoute
234 }263 }
235 }264 }
236}265}
237 266
267interface OrgRouteChildren {
268 OrgConnectionsRoute: typeof OrgConnectionsRoute
269 OrgPoliciesRoute: typeof OrgPoliciesRoute
270 OrgSecretsRoute: typeof OrgSecretsRoute
271 OrgToolsRoute: typeof OrgToolsRoute
272 OrgIndexRoute: typeof OrgIndexRoute
273 OrgChar91Char93BillingRoute: typeof OrgChar91Char93BillingRoute
274 OrgChar91Char93SettingsRoute: typeof OrgChar91Char93SettingsRoute
275 OrgSourcesNamespaceRoute: typeof OrgSourcesNamespaceRoute
276 OrgChar91Char93BillingPlansRoute: typeof OrgChar91Char93BillingPlansRoute
277 OrgSourcesAddPluginKeyRoute: typeof OrgSourcesAddPluginKeyRoute
278}
279
280const OrgRouteChildren: OrgRouteChildren = {
281 OrgConnectionsRoute: OrgConnectionsRoute,
282 OrgPoliciesRoute: OrgPoliciesRoute,
283 OrgSecretsRoute: OrgSecretsRoute,
284 OrgToolsRoute: OrgToolsRoute,
285 OrgIndexRoute: OrgIndexRoute,
286 OrgChar91Char93BillingRoute: OrgChar91Char93BillingRoute,
287 OrgChar91Char93SettingsRoute: OrgChar91Char93SettingsRoute,
288 OrgSourcesNamespaceRoute: OrgSourcesNamespaceRoute,
289 OrgChar91Char93BillingPlansRoute: OrgChar91Char93BillingPlansRoute,
290 OrgSourcesAddPluginKeyRoute: OrgSourcesAddPluginKeyRoute,
291}
292
293const OrgRouteWithChildren = OrgRoute._addFileChildren(OrgRouteChildren)
294
238const rootRouteChildren: RootRouteChildren = {295const rootRouteChildren: RootRouteChildren = {
239 IndexRoute: IndexRoute,296 IndexRoute: IndexRoute,
240 BillingRoute: BillingRoute,297 OrgRoute: OrgRouteWithChildren,
241 ConnectionsRoute: ConnectionsRoute,
242 OrgRoute: OrgRoute,
243 PoliciesRoute: PoliciesRoute,
244 SecretsRoute: SecretsRoute,
245 ToolsRoute: ToolsRoute,
246 BillingPlansRoute: BillingPlansRoute,
247 SourcesNamespaceRoute: SourcesNamespaceRoute,
248 SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute,
249}298}
250export const routeTree = rootRouteImport299export const routeTree = rootRouteImport
251 ._addFileChildren(rootRouteChildren)300 ._addFileChildren(rootRouteChildren)
apps/cloud/src/routes/$org.tsx+61−0
@@ -0,0 +1,61 @@
1import { createFileRoute, useNavigate, useParams } from "@tanstack/react-router";
2import { useEffect } from "react";
3import { AutumnProvider } from "autumn-js/react";
4import { ExecutorProvider } from "@executor-js/react/api/provider";
5import { setBaseUrl } from "@executor-js/react/api/base-url";
6import { Toaster } from "@executor-js/react/components/sonner";
7import { ExecutorPluginsProvider } from "@executor-js/sdk/client";
8import { plugins as clientPlugins } from "virtual:executor/plugins-client";
9
10import { findOrgByHandle, useAuth } from "../web/auth";
11import { OrgRouteProvider } from "../web/org-route";
12import { Shell, ShellSkeleton } from "../web/shell";
13
14export const Route = createFileRoute("/$org")({
15 component: OrgLayout,
16});
17
18function OrgLayout() {
19 const auth = useAuth();
20 const navigate = useNavigate();
21 const { org: handle } = useParams({ from: Route.id });
22
23 // Redirect to the first membership when the URL handle is unknown. We only
24 // run the redirect once auth resolves to authenticated; loading/unauth are
25 // already handled by AuthGate in __root.
26 const matched = auth.status === "authenticated" ? findOrgByHandle(auth, handle) : null;
27 const fallback = auth.status === "authenticated" ? (auth.organizations[0] ?? null) : null;
28
29 useEffect(() => {
30 if (auth.status !== "authenticated") return;
31 if (matched) return;
32 if (!fallback) return;
33 void navigate({ to: "/$org", params: { org: fallback.handle }, replace: true });
34 }, [auth.status, matched, fallback, navigate]);
35
36 if (auth.status !== "authenticated") return null;
37 if (!matched) return null;
38
39 // Point the executor API client at this org's prefixed routes. Done before
40 // first render of the executor providers so all queries see the right URL.
41 // The cloud app is single-tenant per page, so a one-shot setter is fine —
42 // when the URL handle changes, this re-runs at the start of the next render.
43 if (typeof window !== "undefined") {
44 setBaseUrl(`${window.location.origin}/api/${matched.handle}`);
45 }
46
47 return (
48 <OrgRouteProvider
49 value={{ orgId: matched.id, orgName: matched.name, orgHandle: matched.handle }}
50 >
51 <AutumnProvider pathPrefix="/autumn">
52 <ExecutorProvider fallback={<ShellSkeleton />}>
53 <ExecutorPluginsProvider plugins={clientPlugins}>
54 <Shell />
55 <Toaster />
56 </ExecutorPluginsProvider>
57 </ExecutorProvider>
58 </AutumnProvider>
59 </OrgRouteProvider>
60 );
61}
apps/cloud/src/routes/$org/[-].billing.tsxrenamed from apps/cloud/src/routes/billing.tsx+5−2
@@ -2,10 +2,11 @@ import { createFileRoute, Link } from "@tanstack/react-router"; · 1 unmodified line
2import { useCustomer, useListPlans } from "autumn-js/react";2import { useCustomer, useListPlans } from "autumn-js/react";
3import { Button } from "@executor-js/react/components/button";3import { Button } from "@executor-js/react/components/button";
4import { Badge } from "@executor-js/react/components/badge";4import { Badge } from "@executor-js/react/components/badge";
5import { useOrgRoute } from "../../web/org-route";
5 6
6type Plan = NonNullable<ReturnType<typeof useListPlans>["data"]>[number];7type Plan = NonNullable<ReturnType<typeof useListPlans>["data"]>[number];
7 8
8export const Route = createFileRoute("/billing")({9export const Route = createFileRoute("/$org/-/billing")({
9 component: BillingPage,10 component: BillingPage,
10});11});
11 12
@@ -15,6 +16,7 @@ const PLAN_TAGLINES: Record<string, string> = { · 3 unmodified lines
15};16};
16 17
17function BillingPage() {18function BillingPage() {
19 const { orgHandle } = useOrgRoute();
18 const { data: customer, openCustomerPortal, isLoading: customerLoading } = useCustomer();20 const { data: customer, openCustomerPortal, isLoading: customerLoading } = useCustomer();
19 const { data: plans, isLoading: plansLoading } = useListPlans();21 const { data: plans, isLoading: plansLoading } = useListPlans();
20 22
@@ -99,7 +101,8 @@ function BillingPage() { · 78 unmodified lines
99 </Button>101 </Button>
100 )}102 )}
101 <Link103 <Link
102 to="/billing/plans"104 to="/$org/-/billing/plans"
105 params={{ org: orgHandle }}
103 className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"106 className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
104 >107 >
105 Manage108 Manage
apps/cloud/src/routes/$org/[-].billing_.plans.tsxrenamed from apps/cloud/src/routes/billing_.plans.tsx+7−4
@@ -1,6 +1,6 @@
1import { useEffect, useState } from "react";1import { useEffect, useState } from "react";
2import { createFileRoute, Link } from "@tanstack/react-router";2import { createFileRoute, Link } from "@tanstack/react-router";
3import { useAuth } from "../web/auth";3import { useAuth } from "../../web/auth";
4import { useCustomer, useListPlans } from "autumn-js/react";4import { useCustomer, useListPlans } from "autumn-js/react";
5import { Button } from "@executor-js/react/components/button";5import { Button } from "@executor-js/react/components/button";
6import { Badge } from "@executor-js/react/components/badge";6import { Badge } from "@executor-js/react/components/badge";
@@ -16,10 +16,11 @@ import { · 9 unmodified lines
16} from "@executor-js/react/components/dialog";16} from "@executor-js/react/components/dialog";
17import { Input } from "@executor-js/react/components/input";17import { Input } from "@executor-js/react/components/input";
18import { Label } from "@executor-js/react/components/label";18import { Label } from "@executor-js/react/components/label";
19import { useOrgRoute } from "../../web/org-route";
19 20
20type Plan = NonNullable<ReturnType<typeof useListPlans>["data"]>[number];21type Plan = NonNullable<ReturnType<typeof useListPlans>["data"]>[number];
21 22
22export const Route = createFileRoute("/billing_/plans")({23export const Route = createFileRoute("/$org/-/billing_/plans")({
23 component: PlansPage,24 component: PlansPage,
24});25});
25 26
@@ -77,6 +78,7 @@ const ENTERPRISE_MAILTO = `mailto:rhys@executor.sh?subject=${encodeURIComponent( · 51 unmodified lines
77)}`;78)}`;
78 79
79function PlansPage() {80function PlansPage() {
81 const { orgHandle } = useOrgRoute();
80 const { attach, openCustomerPortal, isLoading: customerLoading } = useCustomer();82 const { attach, openCustomerPortal, isLoading: customerLoading } = useCustomer();
81 const { data: plans, isLoading: plansLoading, isFetching } = useListPlans();83 const { data: plans, isLoading: plansLoading, isFetching } = useListPlans();
82 const [loadingPlan, setLoadingPlan] = useState<string | null>(null);84 const [loadingPlan, setLoadingPlan] = useState<string | null>(null);
@@ -92,7 +94,8 @@ function PlansPage() { · 9 unmodified lines
92 <div className="mx-auto max-w-5xl px-6 py-10 lg:px-10 lg:py-14">94 <div className="mx-auto max-w-5xl px-6 py-10 lg:px-10 lg:py-14">
93 <div className="mb-8">95 <div className="mb-8">
94 <Link96 <Link
95 to="/billing"97 to="/$org/-/billing"
98 params={{ org: orgHandle }}
96 className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors mb-4"99 className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors mb-4"
97 >100 >
98 <svg viewBox="0 0 16 16" fill="none" className="size-3.5">101 <svg viewBox="0 0 16 16" fill="none" className="size-3.5">
@@ -307,9 +310,9 @@ function PlansPage() { · 208 unmodified lines
307 310
308function SlackContactCta() {311function SlackContactCta() {
309 const auth = useAuth();312 const auth = useAuth();
313 const { orgName } = useOrgRoute();
310 const signedIn = auth.status === "authenticated" ? auth : null;314 const signedIn = auth.status === "authenticated" ? auth : null;
311 const prefillEmail = signedIn?.user.email ?? "";315 const prefillEmail = signedIn?.user.email ?? "";
312 const orgName = signedIn?.organization?.name ?? "";
313 316
314 const [open, setOpen] = useState(false);317 const [open, setOpen] = useState(false);
315 const [email, setEmail] = useState(prefillEmail);318 const [email, setEmail] = useState(prefillEmail);
apps/cloud/src/routes/$org/[-].settings.tsxrenamed from apps/cloud/src/routes/org.tsx+10−10
@@ -1,6 +1,6 @@
1import { useReducer, useState } from "react";1import { useReducer, useState } from "react";
2import { Cause, Exit, Result } from "effect";2import { Cause, Exit, Result } from "effect";
3import { Forbidden } from "../org/api";3import { Forbidden } from "../../org/api";
4import { createFileRoute, Link } from "@tanstack/react-router";4import { createFileRoute, Link } from "@tanstack/react-router";
5import { useAtomValue, useAtomSet } from "@effect/atom-react";5import { useAtomValue, useAtomSet } from "@effect/atom-react";
6import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";6import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
@@ -52,10 +52,10 @@ import { · 45 unmodified lines
52 getDomainVerificationLink,52 getDomainVerificationLink,
53 deleteDomain,53 deleteDomain,
54 updateOrgName,54 updateOrgName,
55} from "../web/org-atoms";55} from "../../web/org-atoms";
56import { useAuth } from "../web/auth";56import { useOrgRoute } from "../../web/org-route";
57 57
58export const Route = createFileRoute("/org")({58export const Route = createFileRoute("/$org/-/settings")({
59 component: OrgPage,59 component: OrgPage,
60});60});
61 61
@@ -110,9 +110,8 @@ function formatLastActive(lastActiveAt: string | null): string { · 48 unmodified lines
110}110}
111 111
112function OrgPage() {112function OrgPage() {
113 const auth = useAuth();113 const { orgName: routeOrgName, orgHandle } = useOrgRoute();
114 const orgName =114 const orgName = routeOrgName ?? "Organization";
115 auth.status === "authenticated" ? (auth.organization?.name ?? "Organization") : "Organization";
116 const membersResult = useAtomValue(orgMembersAtom);115 const membersResult = useAtomValue(orgMembersAtom);
117 const rolesResult = useAtomValue(orgRolesAtom);116 const rolesResult = useAtomValue(orgRolesAtom);
118 const domainsResult = useAtomValue(orgDomainsAtom);117 const domainsResult = useAtomValue(orgDomainsAtom);
@@ -262,7 +261,7 @@ function OrgPage() { · 143 unmodified lines
262 <p className="text-sm text-muted-foreground">261 <p className="text-sm text-muted-foreground">
263 Join by domain is available on the Team plan.262 Join by domain is available on the Team plan.
264 </p>263 </p>
265 <Link to="/billing/plans">264 <Link to="/$org/-/billing/plans" params={{ org: orgHandle }}>
266 <Button size="sm" variant="outline">265 <Button size="sm" variant="outline">
267 Upgrade266 Upgrade
268 </Button>267 </Button>
@@ -322,7 +321,7 @@ function OrgPage() { · 53 unmodified lines
322 Invite member321 Invite member
323 </Button>322 </Button>
324 ) : (323 ) : (
325 <Link to="/billing/plans">324 <Link to="/$org/-/billing/plans" params={{ org: orgHandle }}>
326 <Button size="sm" className="min-w-32">325 <Button size="sm" className="min-w-32">
327 Upgrade326 Upgrade
328 </Button>327 </Button>
@@ -608,6 +607,7 @@ function DomainCard({ domain: d, onDelete }: { domain: DomainData; onDelete: () · 279 unmodified lines
608}607}
609 608
610function InviteErrorAlert({ cause }: { cause: Cause.Cause<unknown> }) {609function InviteErrorAlert({ cause }: { cause: Cause.Cause<unknown> }) {
610 const { orgHandle } = useOrgRoute();
611 const failure = Cause.findError(cause);611 const failure = Cause.findError(cause);
612 const error = Result.isSuccess(failure) ? failure.success : null;612 const error = Result.isSuccess(failure) ? failure.success : null;
613 613
@@ -617,7 +617,7 @@ function InviteErrorAlert({ cause }: { cause: Cause.Cause<unknown> }) { · 3 unmodified lines
617 <p className="text-sm text-destructive">617 <p className="text-sm text-destructive">
618 You've reached your member limit. Upgrade to Team to invite more.618 You've reached your member limit. Upgrade to Team to invite more.
619 </p>619 </p>
620 <Link to="/billing/plans">620 <Link to="/$org/-/billing/plans" params={{ org: orgHandle }}>
621 <Button size="sm" variant="outline">621 <Button size="sm" variant="outline">
622 Upgrade622 Upgrade
623 </Button>623 </Button>
apps/cloud/src/routes/$org/connections.tsxrenamed from apps/cloud/src/routes/connections.tsx+1−1
@@ -1,6 +1,6 @@
1import { createFileRoute } from "@tanstack/react-router";1import { createFileRoute } from "@tanstack/react-router";
2import { ConnectionsPage } from "@executor-js/react/pages/connections";2import { ConnectionsPage } from "@executor-js/react/pages/connections";
3 3
4export const Route = createFileRoute("/connections")({4export const Route = createFileRoute("/$org/connections")({
5 component: () => <ConnectionsPage />,5 component: () => <ConnectionsPage />,
6});6});
apps/cloud/src/routes/$org/index.tsx+6−0
@@ -0,0 +1,6 @@
1import { createFileRoute } from "@tanstack/react-router";
2import { SourcesPage } from "@executor-js/react/pages/sources";
3
4export const Route = createFileRoute("/$org/")({
5 component: SourcesPage,
6});
apps/cloud/src/routes/$org/policies.tsxrenamed from apps/cloud/src/routes/policies.tsx+1−1
@@ -1,6 +1,6 @@
1import { createFileRoute } from "@tanstack/react-router";1import { createFileRoute } from "@tanstack/react-router";
2import { PoliciesPage } from "@executor-js/react/pages/policies";2import { PoliciesPage } from "@executor-js/react/pages/policies";
3 3
4export const Route = createFileRoute("/policies")({4export const Route = createFileRoute("/$org/policies")({
5 component: () => <PoliciesPage />,5 component: () => <PoliciesPage />,
6});6});
apps/cloud/src/routes/$org/secrets.tsxrenamed from apps/cloud/src/routes/secrets.tsx+1−1
@@ -1,7 +1,7 @@
1import { createFileRoute } from "@tanstack/react-router";1import { createFileRoute } from "@tanstack/react-router";
2import { SecretsPage } from "@executor-js/react/pages/secrets";2import { SecretsPage } from "@executor-js/react/pages/secrets";
3 3
4export const Route = createFileRoute("/secrets")({4export const Route = createFileRoute("/$org/secrets")({
5 component: () => (5 component: () => (
6 <SecretsPage6 <SecretsPage
7 addSecretDescription="Store a credential or API key for this organization."7 addSecretDescription="Store a credential or API key for this organization."
apps/cloud/src/routes/$org/sources.$namespace.tsxrenamed from apps/cloud/src/routes/sources.$namespace.tsx+1−1
@@ -1,7 +1,7 @@
1import { createFileRoute } from "@tanstack/react-router";1import { createFileRoute } from "@tanstack/react-router";
2import { SourceDetailPage } from "@executor-js/react/pages/source-detail";2import { SourceDetailPage } from "@executor-js/react/pages/source-detail";
3 3
4export const Route = createFileRoute("/sources/$namespace")({4export const Route = createFileRoute("/$org/sources/$namespace")({
5 component: () => {5 component: () => {
6 const { namespace } = Route.useParams();6 const { namespace } = Route.useParams();
7 return <SourceDetailPage namespace={namespace} />;7 return <SourceDetailPage namespace={namespace} />;
apps/cloud/src/routes/$org/sources.add.$pluginKey.tsxrenamed from apps/cloud/src/routes/sources.add.$pluginKey.tsx+1−1
@@ -10,7 +10,7 @@ const SearchParams = Schema.toStandardSchemaV1( · 9 unmodified lines
10 }),10 }),
11);11);
12 12
13export const Route = createFileRoute("/sources/add/$pluginKey")({13export const Route = createFileRoute("/$org/sources/add/$pluginKey")({
14 validateSearch: SearchParams,14 validateSearch: SearchParams,
15 component: () => {15 component: () => {
16 const { pluginKey } = Route.useParams();16 const { pluginKey } = Route.useParams();
apps/cloud/src/routes/$org/tools.tsxrenamed from apps/cloud/src/routes/tools.tsx+1−1
@@ -1,6 +1,6 @@
1import { createFileRoute } from "@tanstack/react-router";1import { createFileRoute } from "@tanstack/react-router";
2import { ToolsPage } from "@executor-js/react/pages/tools";2import { ToolsPage } from "@executor-js/react/pages/tools";
3 3
4export const Route = createFileRoute("/tools")({4export const Route = createFileRoute("/$org/tools")({
5 component: ToolsPage,5 component: ToolsPage,
6});6});
apps/cloud/src/routes/__root.tsx+4−80
@@ -1,18 +1,12 @@
1import React from "react";1import React from "react";
2import * as Sentry from "@sentry/react";2import * as Sentry from "@sentry/react";
3import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router";3import { HeadContent, Outlet, Scripts, createRootRoute } from "@tanstack/react-router";
4import { AutumnProvider } from "autumn-js/react";
5import posthog from "posthog-js";4import posthog from "posthog-js";
6import { PostHogProvider } from "posthog-js/react";5import { PostHogProvider } from "posthog-js/react";
7import { ExecutorProvider } from "@executor-js/react/api/provider";
8import { Skeleton } from "@executor-js/react/components/skeleton";
9import { Toaster } from "@executor-js/react/components/sonner";
10import { ExecutorPluginsProvider } from "@executor-js/sdk/client";
11import { plugins as clientPlugins } from "virtual:executor/plugins-client";
12import { AuthProvider, useAuth } from "../web/auth";6import { AuthProvider, useAuth } from "../web/auth";
13import { LoginPage } from "../web/pages/login";7import { LoginPage } from "../web/pages/login";
14import { OnboardingPage } from "../web/pages/onboarding";8import { OnboardingPage } from "../web/pages/onboarding";
15import { Shell } from "../web/shell";9import { ShellSkeleton } from "../web/shell";
16import appCss from "@executor-js/react/globals.css?url";10import appCss from "@executor-js/react/globals.css?url";
17 11
18if (typeof window !== "undefined" && import.meta.env.VITE_PUBLIC_SENTRY_DSN) {12if (typeof window !== "undefined" && import.meta.env.VITE_PUBLIC_SENTRY_DSN) {
@@ -90,67 +84,6 @@ function RootComponent() { · 71 unmodified lines
90 );84 );
91}85}
92 86
93function ShellSkeleton() {
94 return (
95 <div className="flex h-screen overflow-hidden">
96 {/* Desktop sidebar skeleton */}
97 <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56">
98 <div className="flex h-12 shrink-0 items-center border-b border-sidebar-border px-4">
99 <Skeleton className="h-4 w-20" />
100 </div>
101 <nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-2">
102 <Skeleton className="h-7 w-full rounded-md" />
103 <Skeleton className="h-7 w-full rounded-md" />
104 <Skeleton className="h-7 w-full rounded-md" />
105 <Skeleton className="h-7 w-full rounded-md" />
106 <div className="mt-5 mb-2 px-2.5">
107 <Skeleton className="h-3 w-14" />
108 </div>
109 <div className="flex flex-col gap-1">
110 <Skeleton className="h-7 w-11/12 rounded-md" />
111 <Skeleton className="h-7 w-10/12 rounded-md" />
112 <Skeleton className="h-7 w-9/12 rounded-md" />
113 </div>
114 </nav>
115 <div className="shrink-0 border-t border-sidebar-border px-3 py-2.5">
116 <div className="flex items-center gap-2.5">
117 <Skeleton className="size-7 rounded-full" />
118 <div className="flex min-w-0 flex-1 flex-col gap-1">
119 <Skeleton className="h-3 w-24" />
120 <Skeleton className="h-3 w-16" />
121 </div>
122 </div>
123 </div>
124 </aside>
125
126 {/* Main content skeleton */}
127 <main className="flex min-h-0 flex-1 flex-col overflow-hidden">
128 {/* Mobile top bar */}
129 <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background px-4 md:hidden">
130 <Skeleton className="size-7 rounded-md" />
131 <Skeleton className="h-4 w-20" />
132 <div className="w-7 shrink-0" />
133 </div>
134
135 <div className="flex min-h-0 flex-1 flex-col gap-6 px-6 py-8">
136 <div className="flex items-center justify-between">
137 <div className="flex flex-col gap-2">
138 <Skeleton className="h-6 w-40" />
139 <Skeleton className="h-4 w-64" />
140 </div>
141 <Skeleton className="h-8 w-28 rounded-md" />
142 </div>
143 <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
144 {Array.from({ length: 6 }).map((_, i) => (
145 <Skeleton key={i} className="h-24 w-full rounded-lg" />
146 ))}
147 </div>
148 </div>
149 </main>
150 </div>
151 );
152}
153
154function AuthGate() {87function AuthGate() {
155 const auth = useAuth();88 const auth = useAuth();
156 89
@@ -162,18 +95,9 @@ function AuthGate() { · 5 unmodified lines
162 return <LoginPage />;95 return <LoginPage />;
163 }96 }
164 97
165 if (auth.organization == null) {98 if (auth.organizations.length === 0) {
166 return <OnboardingPage />;99 return <OnboardingPage />;
167 }100 }
168 101
169 return (102 return <Outlet />;
170 <AutumnProvider pathPrefix="/api/autumn">
171 <ExecutorProvider fallback={<ShellSkeleton />}>
172 <ExecutorPluginsProvider plugins={clientPlugins}>
173 <Shell />
174 <Toaster />
175 </ExecutorPluginsProvider>
176 </ExecutorProvider>
177 </AutumnProvider>
178 );
179}103}
apps/cloud/src/routes/index.tsx+23−3
@@ -1,6 +1,26 @@
1import { createFileRoute } from "@tanstack/react-router";1import { createFileRoute, useNavigate } from "@tanstack/react-router";
2import { SourcesPage } from "@executor-js/react/pages/sources";2import { useEffect } from "react";
3
4import { useAuth } from "../web/auth";
3 5
4export const Route = createFileRoute("/")({6export const Route = createFileRoute("/")({
5 component: SourcesPage,7 component: IndexRedirect,
6});8});
9
10function IndexRedirect() {
11 const auth = useAuth();
12 const navigate = useNavigate();
13 const firstHandle =
14 auth.status === "authenticated" ? (auth.organizations[0]?.handle ?? null) : null;
15
16 useEffect(() => {
17 if (!firstHandle) return;
18 void navigate({ to: "/$org", params: { org: firstHandle }, replace: true });
19 }, [firstHandle, navigate]);
20
21 return (
22 <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
23 Loading…
24 </div>
25 );
26}
apps/cloud/src/services/__test-harness__/api-harness.ts+93−24
@@ -5,14 +5,16 @@ · 4 unmodified lines
5// two test-only swaps:5// two test-only swaps:
6//6//
7// - `OrgAuthLive` is replaced with `FakeOrgAuthLive`, which reads7// - `OrgAuthLive` is replaced with `FakeOrgAuthLive`, which reads
8// the scope id off `x-test-org-id` instead of the WorkOS cookie.8// the org handle from the URL `/api/:org/...` prefix instead of
9// the WorkOS cookie.
9// - `workos-vault` is configured with an in-memory `WorkOSVaultClient`10// - `workos-vault` is configured with an in-memory `WorkOSVaultClient`
10// so secret writes never reach WorkOS's real API.11// so secret writes never reach WorkOS's real API.
11//12//
12// Tests get a `fetchForOrg(orgId)` they can hand to `FetchHttpClient`13// Tests get a `fetchForOrg(orgId)` they can hand to `FetchHttpClient`
13// and then call `HttpApiClient.make(ProtectedCloudApi)` against it.14// and then call `HttpApiClient.make(ProtectedCloudApi)` against it.
14// Each test picks its own org id (usually a random UUID) so rows don't15// Each test picks its own org id (usually a random UUID) so rows don't
15// collide across tests.16// collide across tests. The harness seeds an organizations row whose
17// `handle` equals the org id so `resolveOrgContext(orgId)` succeeds.
16 18
17import { Effect, Layer } from "effect";19import { Effect, Layer } from "effect";
18import { HttpApiBuilder, HttpApiClient, HttpApiSwagger } from "effect/unstable/httpapi";20import { HttpApiBuilder, HttpApiClient, HttpApiSwagger } from "effect/unstable/httpapi";
@@ -38,9 +40,13 @@ import { · 19 unmodified lines
38 RouterConfig,40 RouterConfig,
39} from "../../api/protected-layers";41} from "../../api/protected-layers";
40import { DbService } from "../db";42import { DbService } from "../db";
43import { organizations } from "../schema";
41 44
42export const TEST_BASE_URL = "http://test.local";45export const TEST_BASE_URL = "http://test.local";
43export const TEST_ORG_HEADER = "x-test-org-id";46/**
47 * Optional header for tests that need to act as a specific user. The org
48 * id always comes from the URL prefix; only the user is opt-in.
49 */
44export const TEST_USER_HEADER = "x-test-user-id";50export const TEST_USER_HEADER = "x-test-user-id";
45 51
46// Mirrors apps/cloud/src/services/executor.ts#createScopedExecutor — the52// Mirrors apps/cloud/src/services/executor.ts#createScopedExecutor — the
@@ -89,16 +95,43 @@ const createTestScopedExecutor = (userId: string, orgId: string, orgName: string · 42 unmodified lines
89 });95 });
90 });96 });
91 97
98// Seed a test organization row whose handle equals the supplied id so the
99// production middleware resolution path (`resolveOrgContext(handle)`) works
100// against the test db. Uses `onConflictDoNothing` so repeated `asOrg(orgId,
101// …)` calls within a test don't fight each other. Lives inside the request
102// pipeline (so DbService is already provided) instead of at factory time
103// — bringing up its own DbService.Live in a Node test process leaks a
104// postgres.js socket that ECONNRESETs across test files.
105const seedTestOrg = (orgId: string) =>
106 Effect.gen(function* () {
107 const { db } = yield* DbService;
108 yield* Effect.promise(() =>
109 db
110 .insert(organizations)
111 .values({ id: orgId, handle: orgId, name: `Org ${orgId}` })
112 .onConflictDoNothing(),
113 );
114 });
115
92// ---------------------------------------------------------------------------116// ---------------------------------------------------------------------------
93// HTTP plumbing117// HTTP plumbing
94// ---------------------------------------------------------------------------118// ---------------------------------------------------------------------------
95 119
120// Pull the URL `:org` segment from a request path. The protected API mounts
121// under `/api/:org/...`. Returning `null` for a malformed prefix forces the
122// downstream handler to surface a typed error rather than panicking.
123const orgHandleFromPath = (pathname: string): string | null => {
124 const parts = pathname.split("/").filter((part) => part.length > 0);
125 if (parts.length < 2 || parts[0] !== "api") return null;
126 return parts[1] ?? null;
127};
128
96// Test version of the production `ExecutionStackMiddleware` — reads the129// Test version of the production `ExecutionStackMiddleware` — reads the
97// `x-test-org-id` (and optional `x-test-user-id`) header, builds a130// org handle from the URL `/api/:org/...` prefix (matching production),
98// test-scoped executor against the live postgres test db with a fake131// builds a test-scoped executor against the live postgres test db with a
99// WorkOS vault, and provides `AuthContext` + the executor services to the132// fake WorkOS vault, and provides `AuthContext` + the executor services
100// handler. Mirrors prod's HttpRouter middleware but with test-mode133// to the handler. The optional `x-test-user-id` header overrides the
101// constructors.134// default per-org user.
102const TestExecutionStackMiddleware = HttpRouter.middleware<{135const TestExecutionStackMiddleware = HttpRouter.middleware<{
103 provides:136 provides:
104 | AuthContext137 | AuthContext
@@ -115,11 +148,20 @@ const TestExecutionStackMiddleware = HttpRouter.middleware<{ · 10 unmodified lines
115 return (httpEffect) =>148 return (httpEffect) =>
116 Effect.gen(function* () {149 Effect.gen(function* () {
117 const request = yield* HttpServerRequest.HttpServerRequest;150 const request = yield* HttpServerRequest.HttpServerRequest;
118 const orgId = request.headers[TEST_ORG_HEADER];151 const webRequest = yield* HttpServerRequest.toWeb(request);
119 if (!orgId || typeof orgId !== "string") {152 const url = new URL(webRequest.url);
120 // oxlint-disable-next-line executor/no-effect-escape-hatch, executor/no-error-constructor -- boundary: test HTTP harness has no request context without x-test-org-id153 const orgId = orgHandleFromPath(url.pathname);
121 return yield* Effect.die(new Error("missing x-test-org-id"));154 if (!orgId) {
155 // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: test HTTP harness has no request context without /api/:org prefix
156 return yield* Effect.die(
157 // oxlint-disable-next-line executor/no-error-constructor -- boundary: test HTTP harness invariant on missing prefix
158 new Error(`missing /api/:org prefix in ${url.pathname}`),
159 );
122 }160 }
161 // Lazily seed the org row so production-mode `resolveOrgContext` (used
162 // anywhere that takes the URL handle as truth) finds it. The test
163 // harness can't pre-seed at factory time without leaking sockets.
164 yield* seedTestOrg(orgId);
123 const userHeader = request.headers[TEST_USER_HEADER];165 const userHeader = request.headers[TEST_USER_HEADER];
124 const userId =166 const userId =
125 typeof userHeader === "string" && userHeader.length > 0167 typeof userHeader === "string" && userHeader.length > 0
@@ -150,9 +192,18 @@ const TestExecutionStackMiddleware = HttpRouter.middleware<{ · 24 unmodified lines
150 }),192 }),
151).layer;193).layer;
152 194
195// Mirror the production setup — the protected API mounts under `/api/:org`
196// via a prefixed router view. The outer `HttpRouter` from
197// `HttpServer.layerServices` is the underlying state; the prefix wrapper
198// rewrites added paths only.
199const PrefixedRouterLayer = Layer.effect(HttpRouter.HttpRouter)(
200 Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed("/api/:org")),
201);
202
153const TestApiLive = HttpApiBuilder.layer(ProtectedCloudApi).pipe(203const TestApiLive = HttpApiBuilder.layer(ProtectedCloudApi).pipe(
154 Layer.provide(ProtectedCloudApiHandlers),204 Layer.provide(ProtectedCloudApiHandlers),
155 Layer.provide(TestExecutionStackMiddleware),205 Layer.provide(TestExecutionStackMiddleware),
206 Layer.provide(PrefixedRouterLayer),
156 Layer.provideMerge(HttpApiSwagger.layer(ProtectedCloudApi, { path: "/docs" })),207 Layer.provideMerge(HttpApiSwagger.layer(ProtectedCloudApi, { path: "/docs" })),
157 Layer.provideMerge(RouterConfig),208 Layer.provideMerge(RouterConfig),
158 Layer.provideMerge(DbService.Live),209 Layer.provideMerge(DbService.Live),
@@ -161,25 +212,43 @@ const TestApiLive = HttpApiBuilder.layer(ProtectedCloudApi).pipe( · 2 unmodified lines
161 212
162const handler = HttpRouter.toWebHandler(TestApiLive, { disableLogger: true }).handler;213const handler = HttpRouter.toWebHandler(TestApiLive, { disableLogger: true }).handler;
163 214
215// Rewrite outgoing request URLs to `/api/${orgId}${path}` so the prefixed
216// router matches. Tests construct `HttpApiClient.make(...)` against
217// `TEST_BASE_URL` and call endpoint methods that build paths like
218// `/scopes/.../sources` — we splice the org segment in front before the
219// request reaches the in-process handler.
220const rewriteRequestForOrg = async (
221 base: Request,
222 orgId: string,
223 extraHeaders: Record<string, string> = {},
224): Promise<Request> => {
225 const url = new URL(base.url);
226 if (!url.pathname.startsWith(`/api/${orgId}/`) && url.pathname !== `/api/${orgId}`) {
227 url.pathname = `/api/${orgId}${url.pathname.startsWith("/") ? "" : "/"}${url.pathname}`;
228 }
229 // Buffer the body — Node's `RequestInit` rejects stream bodies without
230 // `duplex: "half"`, and forwarding a Request through `new Request(url, {...})`
231 // is fragile across runtimes. ArrayBuffer survives the round-trip cleanly.
232 const body =
233 base.method === "GET" || base.method === "HEAD" ? undefined : await base.arrayBuffer();
234 return new Request(url.toString(), {
235 method: base.method,
236 headers: { ...Object.fromEntries(base.headers), ...extraHeaders },
237 body,
238 });
239};
240
164export const fetchForOrg = (orgId: string): typeof globalThis.fetch =>241export const fetchForOrg = (orgId: string): typeof globalThis.fetch =>
165 ((input: RequestInfo | URL, init?: RequestInit) => {242 (async (input: RequestInfo | URL, init?: RequestInit) => {
166 const base = input instanceof Request ? input : new Request(input, init);243 const base = input instanceof Request ? input : new Request(input, init);
167 const req = new Request(base, {244 const req = await rewriteRequestForOrg(base, orgId);
168 headers: { ...Object.fromEntries(base.headers), [TEST_ORG_HEADER]: orgId },
169 });
170 return handler(req);245 return handler(req);
171 }) as typeof globalThis.fetch;246 }) as typeof globalThis.fetch;
172 247
173export const fetchForUser = (userId: string, orgId: string): typeof globalThis.fetch =>248export const fetchForUser = (userId: string, orgId: string): typeof globalThis.fetch =>
174 ((input: RequestInfo | URL, init?: RequestInit) => {249 (async (input: RequestInfo | URL, init?: RequestInit) => {
175 const base = input instanceof Request ? input : new Request(input, init);250 const base = input instanceof Request ? input : new Request(input, init);
176 const req = new Request(base, {251 const req = await rewriteRequestForOrg(base, orgId, { [TEST_USER_HEADER]: userId });
177 headers: {
178 ...Object.fromEntries(base.headers),
179 [TEST_ORG_HEADER]: orgId,
180 [TEST_USER_HEADER]: userId,
181 },
182 });
183 return handler(req);252 return handler(req);
184 }) as typeof globalThis.fetch;253 }) as typeof globalThis.fetch;
185 254
apps/cloud/src/web/auth.tsx+23−17
@@ -1,5 +1,4 @@
1import React, { createContext, useContext, useEffect } from "react";1import React, { createContext, useContext, useEffect } from "react";
2import * as Atom from "effect/unstable/reactivity/Atom";
3import { useAtomValue } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import { usePostHog } from "posthog-js/react";4import { usePostHog } from "posthog-js/react";
@@ -18,8 +17,9 @@ type AuthUser = { · 12 unmodified lines
18 avatarUrl: string | null;17 avatarUrl: string | null;
19};18};
20 19
21type AuthOrganization = {20export type AuthOrganization = {
22 id: string;21 id: string;
22 handle: string;
23 name: string;23 name: string;
24};24};
25 25
@@ -32,14 +32,6 @@ export const authAtom = CloudApiClient.query("cloudAuth", "me", { · 6 unmodified lines
32 reactivityKeys: [ReactivityKey.auth],32 reactivityKeys: [ReactivityKey.auth],
33});33});
34 34
35export const organizationsAtom = Atom.refreshOnWindowFocus(
36 CloudApiClient.query("cloudAuth", "organizations", {
37 timeToLive: "1 minute",
38 reactivityKeys: [ReactivityKey.auth],
39 }),
40);
41
42export const switchOrganization = CloudApiClient.mutation("cloudAuth", "switchOrganization");
43export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization");35export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization");
44 36
45export const pendingInvitationsAtom = CloudApiClient.query("cloudAuth", "pendingInvitations", {37export const pendingInvitationsAtom = CloudApiClient.query("cloudAuth", "pendingInvitations", {
@@ -56,7 +48,11 @@ export const acceptInvitation = CloudApiClient.mutation("cloudAuth", "acceptInvi · 10 unmodified lines
56type AuthState =48type AuthState =
57 | { status: "loading" }49 | { status: "loading" }
58 | { status: "unauthenticated" }50 | { status: "unauthenticated" }
59 | { status: "authenticated"; user: AuthUser; organization: AuthOrganization | null };51 | {
52 status: "authenticated";
53 user: AuthUser;
54 organizations: ReadonlyArray<AuthOrganization>;
55 };
60 56
61const AuthContext = createContext<AuthState>({ status: "loading" });57const AuthContext = createContext<AuthState>({ status: "loading" });
62 58
@@ -71,7 +67,7 @@ const AuthProviderClient = ({ children }: { children: React.ReactNode }) => { · 8 unmodified lines
71 onSuccess: ({ value }) => ({67 onSuccess: ({ value }) => ({
72 status: "authenticated" as const,68 status: "authenticated" as const,
73 user: value.user,69 user: value.user,
74 organization: value.organization,70 organizations: value.organizations,
75 }),71 }),
76 onFailure: () => ({ status: "unauthenticated" as const }),72 onFailure: () => ({ status: "unauthenticated" as const }),
77 });73 });
@@ -79,21 +75,25 @@ const AuthProviderClient = ({ children }: { children: React.ReactNode }) => { · 1 unmodified line
79 const userId = state.status === "authenticated" ? state.user.id : null;75 const userId = state.status === "authenticated" ? state.user.id : null;
80 const email = state.status === "authenticated" ? state.user.email : null;76 const email = state.status === "authenticated" ? state.user.email : null;
81 const name = state.status === "authenticated" ? state.user.name : null;77 const name = state.status === "authenticated" ? state.user.name : null;
82 const orgId = state.status === "authenticated" ? (state.organization?.id ?? null) : null;78 // PostHog org grouping uses the first membership; the user can navigate
83 const orgName = state.status === "authenticated" ? (state.organization?.name ?? null) : null;79 // between orgs in-session. If we want richer grouping later we can
80 // re-emit on URL change.
81 const firstOrgId = state.status === "authenticated" ? (state.organizations[0]?.id ?? null) : null;
82 const firstOrgName =
83 state.status === "authenticated" ? (state.organizations[0]?.name ?? null) : null;
84 const isUnauthenticated = state.status === "unauthenticated";84 const isUnauthenticated = state.status === "unauthenticated";
85 85
86 useEffect(() => {86 useEffect(() => {
87 if (!posthog) return;87 if (!posthog) return;
88 if (userId) {88 if (userId) {
89 posthog.identify(userId, { email, name });89 posthog.identify(userId, { email, name });
90 if (orgId) {90 if (firstOrgId) {
91 posthog.group("organization", orgId, { name: orgName });91 posthog.group("organization", firstOrgId, { name: firstOrgName });
92 }92 }
93 } else if (isUnauthenticated) {93 } else if (isUnauthenticated) {
94 posthog.reset();94 posthog.reset();
95 }95 }
96 }, [posthog, userId, email, name, orgId, orgName, isUnauthenticated]);96 }, [posthog, userId, email, name, firstOrgId, firstOrgName, isUnauthenticated]);
97 97
98 return <AuthContext.Provider value={state}>{children}</AuthContext.Provider>;98 return <AuthContext.Provider value={state}>{children}</AuthContext.Provider>;
99};99};
@@ -104,3 +104,9 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { · 4 unmodified lines
104 }104 }
105 return <AuthProviderClient>{children}</AuthProviderClient>;105 return <AuthProviderClient>{children}</AuthProviderClient>;
106};106};
107
108/** Find the organization in the auth state matching a given URL handle. */
109export const findOrgByHandle = (state: AuthState, handle: string): AuthOrganization | null => {
110 if (state.status !== "authenticated") return null;
111 return state.organizations.find((o) => o.handle === handle) ?? null;
112};
apps/cloud/src/web/components/create-organization-form.tsx+1−1
@@ -7,7 +7,7 @@ import { Label } from "@executor-js/react/components/label"; · 6 unmodified lines
7 7
8import { createOrganization } from "../auth";8import { createOrganization } from "../auth";
9 9
10type CreatedOrganization = { id: string; name: string };10type CreatedOrganization = { id: string; handle: string; name: string };
11 11
12export function useCreateOrganizationForm(options: {12export function useCreateOrganizationForm(options: {
13 defaultName?: string;13 defaultName?: string;
apps/cloud/src/web/org-route.tsx+31−0
@@ -0,0 +1,31 @@
1import React, { createContext, useContext } from "react";
2
3// ---------------------------------------------------------------------------
4// OrgRouteContext — provided by the `/$org` layout, consumed by descendants
5// that need to know the URL-active organization. The handle drives all link
6// generation; the id flows to API calls when one slips outside the URL prefix.
7// ---------------------------------------------------------------------------
8
9export type OrgRouteValue = {
10 readonly orgId: string;
11 readonly orgName: string;
12 readonly orgHandle: string;
13};
14
15export const OrgRouteContext = createContext<OrgRouteValue | null>(null);
16
17export const OrgRouteProvider = (props: { value: OrgRouteValue; children: React.ReactNode }) => (
18 <OrgRouteContext.Provider value={props.value}>{props.children}</OrgRouteContext.Provider>
19);
20
21export const useOrgRoute = (): OrgRouteValue => {
22 const value = useContext(OrgRouteContext);
23 if (!value) {
24 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: React hook invariant
25 throw new Error("useOrgRoute must be used within an OrgRouteProvider");
26 }
27 return value;
28};
29
30/** Optional variant for code rendered both inside and outside the org layout. */
31export const useOrgRouteOptional = (): OrgRouteValue | null => useContext(OrgRouteContext);
apps/cloud/src/web/shell.tsx+181−90
@@ -1,8 +1,7 @@
1import { Link, Outlet, useLocation } from "@tanstack/react-router";1import { Link, Outlet, useLocation } from "@tanstack/react-router";
2import { useEffect, useRef, useState } from "react";2import { useEffect, useRef, useState } from "react";
3import { useAtomValue, useAtomSet } from "@effect/atom-react";3import { useAtomValue } from "@effect/atom-react";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import * as Exit from "effect/Exit";
6import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms";5import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms";
7import { useScope } from "@executor-js/react/api/scope-context";6import { useScope } from "@executor-js/react/api/scope-context";
8import { Button } from "@executor-js/react/components/button";7import { Button } from "@executor-js/react/components/button";
@@ -29,36 +28,95 @@ import { · 20 unmodified lines
29} from "@executor-js/react/components/dropdown-menu";28} from "@executor-js/react/components/dropdown-menu";
30import { SourceFavicon } from "@executor-js/react/components/source-favicon";29import { SourceFavicon } from "@executor-js/react/components/source-favicon";
31import { CommandPalette } from "@executor-js/react/components/command-palette";30import { CommandPalette } from "@executor-js/react/components/command-palette";
32import { authWriteKeys } from "@executor-js/react/api/reactivity-keys";
33import { AUTH_PATHS } from "../auth/api";31import { AUTH_PATHS } from "../auth/api";
34import { organizationsAtom, switchOrganization, useAuth } from "./auth";32import { useAuth } from "./auth";
33import { useOrgRoute } from "./org-route";
35import {34import {
36 CreateOrganizationFields,35 CreateOrganizationFields,
37 useCreateOrganizationForm,36 useCreateOrganizationForm,
38} from "./components/create-organization-form";37} from "./components/create-organization-form";
39 38
40// ── NavItem ──────────────────────────────────────────────────────────────39// ── ShellSkeleton ────────────────────────────────────────────────────────
41 40
42function NavItem(props: { to: string; label: string; active: boolean; onNavigate?: () => void }) {41export function ShellSkeleton() {
43 return (42 return (
44 <Link43 <div className="flex h-screen overflow-hidden">
45 to={props.to}44 {/* Desktop sidebar skeleton */}
46 onClick={props.onNavigate}45 <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56">
47 className={[46 <div className="flex h-12 shrink-0 items-center border-b border-sidebar-border px-4">
48 "flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors",47 <Skeleton className="h-4 w-20" />
49 props.active48 </div>
50 ? "bg-sidebar-active text-foreground font-medium"49 <nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-2">
51 : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",50 <Skeleton className="h-7 w-full rounded-md" />
52 ].join(" ")}51 <Skeleton className="h-7 w-full rounded-md" />
53 >52 <Skeleton className="h-7 w-full rounded-md" />
54 {props.label}53 <Skeleton className="h-7 w-full rounded-md" />
55 </Link>54 <div className="mt-5 mb-2 px-2.5">
55 <Skeleton className="h-3 w-14" />
56 </div>
57 <div className="flex flex-col gap-1">
58 <Skeleton className="h-7 w-11/12 rounded-md" />
59 <Skeleton className="h-7 w-10/12 rounded-md" />
60 <Skeleton className="h-7 w-9/12 rounded-md" />
61 </div>
62 </nav>
63 <div className="shrink-0 border-t border-sidebar-border px-3 py-2.5">
64 <div className="flex items-center gap-2.5">
65 <Skeleton className="size-7 rounded-full" />
66 <div className="flex min-w-0 flex-1 flex-col gap-1">
67 <Skeleton className="h-3 w-24" />
68 <Skeleton className="h-3 w-16" />
69 </div>
70 </div>
71 </div>
72 </aside>
73
74 {/* Main content skeleton */}
75 <main className="flex min-h-0 flex-1 flex-col overflow-hidden">
76 {/* Mobile top bar */}
77 <div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background px-4 md:hidden">
78 <Skeleton className="size-7 rounded-md" />
79 <Skeleton className="h-4 w-20" />
80 <div className="w-7 shrink-0" />
81 </div>
82
83 <div className="flex min-h-0 flex-1 flex-col gap-6 px-6 py-8">
84 <div className="flex items-center justify-between">
85 <div className="flex flex-col gap-2">
86 <Skeleton className="h-6 w-40" />
87 <Skeleton className="h-4 w-64" />
88 </div>
89 <Skeleton className="h-8 w-28 rounded-md" />
90 </div>
91 <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
92 {Array.from({ length: 6 }).map((_, i) => (
93 <Skeleton key={i} className="h-24 w-full rounded-lg" />
94 ))}
95 </div>
96 </div>
97 </main>
98 </div>
56 );99 );
57}100}
58 101
102// ── NavItem styling ──────────────────────────────────────────────────────
103//
104// Sidebar links share active/inactive styling but each call site uses a
105// statically-known route template — keep the className helper here and let
106// the call site own the typed `<Link>`.
107
108const navItemClassName = (active: boolean) =>
109 [
110 "flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors",
111 active
112 ? "bg-sidebar-active text-foreground font-medium"
113 : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",
114 ].join(" ");
115
59// ── SourceList ───────────────────────────────────────────────────────────116// ── SourceList ───────────────────────────────────────────────────────────
60 117
61function SourceList(props: { pathname: string; onNavigate?: () => void }) {118function SourceList(props: { pathname: string; onNavigate?: () => void }) {
119 const { orgHandle } = useOrgRoute();
62 const scopeId = useScope();120 const scopeId = useScope();
63 const sources = useAtomValue(sourcesOptimisticAtom(scopeId));121 const sources = useAtomValue(sourcesOptimisticAtom(scopeId));
64 122
@@ -84,14 +142,14 @@ function SourceList(props: { pathname: string; onNavigate?: () => void }) { · 19 unmodified lines
84 ) : (142 ) : (
85 <div className="flex flex-col gap-px">143 <div className="flex flex-col gap-px">
86 {value.map((s) => {144 {value.map((s) => {
87 const detailPath = `/sources/${s.id}`;145 const detailPath = `/${orgHandle}/sources/${s.id}`;
88 const active =146 const active =
89 props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`);147 props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`);
90 return (148 return (
91 <Link149 <Link
92 key={s.id}150 key={s.id}
93 to="/sources/$namespace"151 to="/$org/sources/$namespace"
94 params={{ namespace: s.id }}152 params={{ org: orgHandle, namespace: s.id }}
95 onClick={props.onNavigate}153 onClick={props.onNavigate}
96 className={[154 className={[
97 "group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-xs transition-colors",155 "group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-xs transition-colors",
@@ -148,43 +206,33 @@ function Avatar(props: { · 50 unmodified lines
148}206}
149 207
150function OrganizationSwitcherItems(props: { activeOrganizationId: string | null }) {208function OrganizationSwitcherItems(props: { activeOrganizationId: string | null }) {
151 const organizations = useAtomValue(organizationsAtom);209 const auth = useAuth();
152 const doSwitchOrganization = useAtomSet(switchOrganization, { mode: "promiseExit" });
153
154 const handleSwitch = async (organizationId: string) => {
155 if (organizationId === props.activeOrganizationId) return;
156 const exit = await doSwitchOrganization({
157 payload: { organizationId },
158 reactivityKeys: authWriteKeys,
159 });
160 if (Exit.isSuccess(exit)) window.location.reload();
161 };
162 210
163 return AsyncResult.match(organizations, {211 if (auth.status !== "authenticated") {
164 onInitial: () => <DropdownMenuItem disabled>Loading…</DropdownMenuItem>,212 return <DropdownMenuItem disabled>Loading…</DropdownMenuItem>;
165 onFailure: () => <DropdownMenuItem disabled>Failed to load organizations</DropdownMenuItem>,213 }
166 onSuccess: ({ value }) =>214 if (auth.organizations.length === 0) {
167 value.organizations.length === 0 ? (215 return <DropdownMenuItem disabled>No organizations</DropdownMenuItem>;
168 <DropdownMenuItem disabled>No organizations</DropdownMenuItem>216 }
169 ) : (217 return (
170 <>218 <>
171 {value.organizations.map((organization: { id: string; name: string }) => {219 {auth.organizations.map((organization) => {
172 const isActive = organization.id === props.activeOrganizationId;220 const isActive = organization.id === props.activeOrganizationId;
173 return (221 return (
174 <DropdownMenuItem222 <DropdownMenuItem key={organization.id} disabled={isActive} className="text-xs" asChild>
175 key={organization.id}223 <Link
176 disabled={isActive}224 to="/$org"
177 onClick={() => handleSwitch(organization.id)}225 params={{ org: organization.handle }}
178 className="text-xs"226 className="flex w-full items-center gap-2"
179 >227 >
180 <span className="min-w-0 flex-1 truncate">{organization.name}</span>228 <span className="min-w-0 flex-1 truncate">{organization.name}</span>
181 {isActive && <CheckIcon />}229 {isActive && <CheckIcon />}
182 </DropdownMenuItem>230 </Link>
183 );231 </DropdownMenuItem>
184 })}232 );
185 </>233 })}
186 ),234 </>
187 });235 );
188}236}
189 237
190function CheckIcon() {238function CheckIcon() {
@@ -203,6 +251,7 @@ function CheckIcon() { · 12 unmodified lines
203 251
204function UserFooter() {252function UserFooter() {
205 const auth = useAuth();253 const auth = useAuth();
254 const orgRoute = useOrgRoute();
206 const [createOrganizationOpen, setCreateOrganizationOpen] = useState(false);255 const [createOrganizationOpen, setCreateOrganizationOpen] = useState(false);
207 256
208 const suggestedOrganizationName =257 const suggestedOrganizationName =
@@ -212,7 +261,15 @@ function UserFooter() { · 3 unmodified lines
212 261
213 const form = useCreateOrganizationForm({262 const form = useCreateOrganizationForm({
214 defaultName: suggestedOrganizationName,263 defaultName: suggestedOrganizationName,
215 onSuccess: () => window.location.reload(),264 // The form returns the new org's handle on success — navigate via the URL
265 // by reloading at the new handle. Once we wire useNavigate in here we can
266 // do a soft navigation instead.
267 onSuccess: (org) => {
268 // Navigate to the new org's URL — the URL is the source of truth for
269 // active org now, so a hard reload at the new handle re-renders the
270 // shell with the right context.
271 window.location.href = `/${org.handle}`;
272 },
216 });273 });
217 274
218 if (auth.status !== "authenticated") return null;275 if (auth.status !== "authenticated") return null;
@@ -243,9 +300,7 @@ function UserFooter() { · 24 unmodified lines
243 <p className="truncate text-xs font-medium text-foreground">300 <p className="truncate text-xs font-medium text-foreground">
244 {auth.user.name ?? auth.user.email}301 {auth.user.name ?? auth.user.email}
245 </p>302 </p>
246 {auth.organization && (303 <p className="truncate text-xs text-muted-foreground">{orgRoute.orgName}</p>
247 <p className="truncate text-xs text-muted-foreground">{auth.organization.name}</p>
248 )}
249 </div>304 </div>
250 <svg305 <svg
251 viewBox="0 0 16 16"306 viewBox="0 0 16 16"
@@ -268,12 +323,10 @@ function UserFooter() { · 16 unmodified lines
268 </DropdownMenuLabel>323 </DropdownMenuLabel>
269 <DropdownMenuSub>324 <DropdownMenuSub>
270 <DropdownMenuSubTrigger className="text-xs">325 <DropdownMenuSubTrigger className="text-xs">
271 <span className="min-w-0 flex-1 truncate">326 <span className="min-w-0 flex-1 truncate">{orgRoute.orgName}</span>
272 {auth.organization?.name ?? "No organization"}
273 </span>
274 </DropdownMenuSubTrigger>327 </DropdownMenuSubTrigger>
275 <DropdownMenuSubContent className="w-56">328 <DropdownMenuSubContent className="w-56">
276 <OrganizationSwitcherItems activeOrganizationId={auth.organization?.id ?? null} />329 <OrganizationSwitcherItems activeOrganizationId={orgRoute.orgId} />
277 <DropdownMenuSeparator />330 <DropdownMenuSeparator />
278 <DropdownMenuItem331 <DropdownMenuItem
279 className="text-xs"332 className="text-xs"
@@ -354,40 +407,77 @@ function UserFooter() { · 74 unmodified lines
354// ── SidebarContent ───────────────────────────────────────────────────────407// ── SidebarContent ───────────────────────────────────────────────────────
355 408
356function SidebarContent(props: { pathname: string; onNavigate?: () => void; showBrand?: boolean }) {409function SidebarContent(props: { pathname: string; onNavigate?: () => void; showBrand?: boolean }) {
357 const isHome = props.pathname === "/";410 const { orgHandle } = useOrgRoute();
358 const isSecrets = props.pathname === "/secrets";411 const orgPrefix = `/${orgHandle}`;
359 const isConnections = props.pathname === "/connections";412 const params = { org: orgHandle };
360 const isPolicies = props.pathname === "/policies";413 const isHome = props.pathname === orgPrefix || props.pathname === `${orgPrefix}/`;
361 const isBilling = props.pathname === "/billing" || props.pathname.startsWith("/billing/");414 const isSecrets = props.pathname === `${orgPrefix}/secrets`;
362 const isOrg = props.pathname === "/org";415 const isConnections = props.pathname === `${orgPrefix}/connections`;
416 const isPolicies = props.pathname === `${orgPrefix}/policies`;
417 const isBilling =
418 props.pathname === `${orgPrefix}/-/billing` ||
419 props.pathname.startsWith(`${orgPrefix}/-/billing/`);
420 const isOrg = props.pathname === `${orgPrefix}/-/settings`;
363 421
364 return (422 return (
365 <>423 <>
366 {props.showBrand !== false && (424 {props.showBrand !== false && (
367 <div className="flex h-12 shrink-0 items-center border-b border-sidebar-border px-4">425 <div className="flex h-12 shrink-0 items-center border-b border-sidebar-border px-4">
368 <Link to="/" className="flex items-center gap-1.5">426 <Link to="/$org" params={params} className="flex items-center gap-1.5">
369 <span className="font-display text-base tracking-tight text-foreground">executor</span>427 <span className="font-display text-base tracking-tight text-foreground">executor</span>
370 </Link>428 </Link>
371 </div>429 </div>
372 )}430 )}
373 431
374 <nav className="flex flex-1 flex-col overflow-y-auto p-2">432 <nav className="flex flex-1 flex-col overflow-y-auto p-2">
375 <NavItem to="/" label="Sources" active={isHome} onNavigate={props.onNavigate} />433 <Link
376 <NavItem434 to="/$org"
377 to="/connections"435 params={params}
378 label="Connections"436 onClick={props.onNavigate}
379 active={isConnections}437 className={navItemClassName(isHome)}
380 onNavigate={props.onNavigate}438 >
381 />439 Sources
382 <NavItem to="/secrets" label="Secrets" active={isSecrets} onNavigate={props.onNavigate} />440 </Link>
383 <NavItem441 <Link
384 to="/policies"442 to="/$org/connections"
385 label="Policies"443 params={params}
386 active={isPolicies}444 onClick={props.onNavigate}
387 onNavigate={props.onNavigate}445 className={navItemClassName(isConnections)}
388 />446 >
389 <NavItem to="/org" label="Organization" active={isOrg} onNavigate={props.onNavigate} />447 Connections
390 <NavItem to="/billing" label="Billing" active={isBilling} onNavigate={props.onNavigate} />448 </Link>
449 <Link
450 to="/$org/secrets"
451 params={params}
452 onClick={props.onNavigate}
453 className={navItemClassName(isSecrets)}
454 >
455 Secrets
456 </Link>
457 <Link
458 to="/$org/policies"
459 params={params}
460 onClick={props.onNavigate}
461 className={navItemClassName(isPolicies)}
462 >
463 Policies
464 </Link>
465 <Link
466 to="/$org/-/settings"
467 params={params}
468 onClick={props.onNavigate}
469 className={navItemClassName(isOrg)}
470 >
471 Organization
472 </Link>
473 <Link
474 to="/$org/-/billing"
475 params={params}
476 onClick={props.onNavigate}
477 className={navItemClassName(isBilling)}
478 >
479 Billing
480 </Link>
391 481
392 <div className="mt-5 mb-1 px-2.5 text-xs font-medium uppercase tracking-widest text-muted-foreground">482 <div className="mt-5 mb-1 px-2.5 text-xs font-medium uppercase tracking-widest text-muted-foreground">
393 <span>Sources</span>483 <span>Sources</span>
@@ -404,6 +494,7 @@ function SidebarContent(props: { pathname: string; onNavigate?: () => void; show · 10 unmodified lines
404// ── Shell ─────────────────────────────────────────────────────────────────494// ── Shell ─────────────────────────────────────────────────────────────────
405 495
406export function Shell() {496export function Shell() {
497 const { orgHandle } = useOrgRoute();
407 const location = useLocation();498 const location = useLocation();
408 const pathname = location.pathname;499 const pathname = location.pathname;
409 const lastPathname = useRef(pathname);500 const lastPathname = useRef(pathname);
@@ -443,7 +534,7 @@ export function Shell() { · 33 unmodified lines
443 />534 />
444 <div className="relative flex h-full w-[84vw] max-w-xs flex-col border-r border-sidebar-border bg-sidebar shadow-2xl">535 <div className="relative flex h-full w-[84vw] max-w-xs flex-col border-r border-sidebar-border bg-sidebar shadow-2xl">
445 <div className="flex h-12 shrink-0 items-center justify-between border-b border-sidebar-border px-4">536 <div className="flex h-12 shrink-0 items-center justify-between border-b border-sidebar-border px-4">
446 <Link to="/" className="flex items-center gap-1.5">537 <Link to="/$org" params={{ org: orgHandle }} className="flex items-center gap-1.5">
447 <span className="font-display text-base tracking-tight text-foreground">538 <span className="font-display text-base tracking-tight text-foreground">
448 executor539 executor
449 </span>540 </span>
@@ -496,7 +587,7 @@ export function Shell() { · 46 unmodified lines
496 />587 />
497 </svg>588 </svg>
498 </Button>589 </Button>
499 <Link to="/" className="flex items-center gap-1.5">590 <Link to="/$org" params={{ org: orgHandle }} className="flex items-center gap-1.5">
500 <span className="font-display text-base tracking-tight text-foreground">executor</span>591 <span className="font-display text-base tracking-tight text-foreground">executor</span>
501 </Link>592 </Link>
502 <div className="w-8 shrink-0" />593 <div className="w-8 shrink-0" />