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.
Unified · Split
| @@ -183,11 +183,12 @@ describe("makeApiLive (prod handler factory) request scoping", () => { · 182 unmodified lines | ||
| 183 | 183 | }).handler; |
| 184 | 184 | |
| 185 | 185 | // Hit a protected route. ExecutionStackMiddleware short-circuits with |
| 186 | − // 403 (no session cookie) but not before `requestScopedMiddleware` | |
| 187 | − // has built the per-request layer. We don't care about the response — | |
| 188 | − // only that the layer was built once per request. | |
| 189 | − await handler(new Request("http://test.local/scope")); | |
| 190 | − await handler(new Request("http://test.local/scope")); | |
| 186 | + // 401/404 (no session cookie / unknown org) but not before | |
| 187 | + // `requestScopedMiddleware` has built the per-request layer. We don't | |
| 188 | + // care about the response — only that the layer was built once per | |
| 189 | + // request. The protected API mounts under `/api/:org/...`. | |
| 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 | 193 | expect(counts.acquires).toBe(2); |
| 193 | 194 | expect(counts.releases).toBe(2); |
| @@ -21,11 +21,22 @@ import { WorkOSAuth } from "../auth/workos"; · 20 unmodified lines | ||
| 21 | 21 | import { AutumnService } from "../services/autumn"; |
| 22 | 22 | import { DbService } from "../services/db"; |
| 23 | 23 | import { makeExecutionStack } from "../services/execution-stack"; |
| 24 | +import { makeUserStore } from "../services/user-store"; | |
| 24 | 25 | import { HttpResponseError } from "./error-response"; |
| 25 | 26 | import { RequestScopedServicesLive } from "./layers"; |
| 26 | 27 | import { ProtectedCloudApi, ProtectedCloudApiLive, RouterConfig } from "./protected-layers"; |
| 27 | 28 | import { 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. | |
| 34 | +const 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 | 40 | // Pre-compute the per-plugin `Effect.provideService(extensionService, |
| 30 | 41 | // executor[id])` chain. The plugin spec carries the Service tag so |
| 31 | 42 | // this file doesn't import each plugin's `*/api` directly. |
| @@ -72,19 +83,42 @@ const ExecutionStackMiddleware = HttpRouter.middleware<{ · 40 unmodified lines | ||
| 72 | 83 | const webRequest = yield* HttpServerRequest.toWeb(request); |
| 73 | 84 | const workos = yield* WorkOSAuth; |
| 74 | 85 | const session = yield* workos.authenticateRequest(webRequest); |
| 75 | − if (!session || !session.organizationId) { | |
| 86 | + if (!session) { | |
| 76 | 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 | 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 | 117 | if (!org) { |
| 84 | 118 | return yield* new HttpResponseError({ |
| 85 | 119 | status: 403, |
| 86 | 120 | code: "no_organization", |
| 87 | − message: "No organization in session", | |
| 121 | + message: "Not a member of this organization", | |
| 88 | 122 | }); |
| 89 | 123 | } |
| 90 | 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. | |
| 148 | +const PrefixedRouterLayer = Layer.effect(HttpRouter.HttpRouter)( | |
| 149 | + Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed("/api/:org")), | |
| 150 | +); | |
| 151 | + | |
| 108 | 152 | // `rsLive` is the per-request DB layer. Combining it into the auth |
| 109 | 153 | // middleware collapses `requires: DbService | UserStoreService` to |
| 110 | 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 | 162 | ).layer; |
| 119 | 163 | return ProtectedCloudApiLive.pipe( |
| 120 | 164 | Layer.provide(protectedMiddleware), |
| 165 | + Layer.provide(PrefixedRouterLayer), | |
| 121 | 166 | Layer.provideMerge(HttpApiSwagger.layer(ProtectedCloudApi, { path: "/docs" })), |
| 122 | 167 | Layer.provideMerge(RouterConfig), |
| 123 | 168 | ); |
| @@ -12,26 +12,18 @@ const AuthUser = Schema.Struct({ · 11 unmodified lines | ||
| 12 | 12 | |
| 13 | 13 | const AuthOrganization = Schema.Struct({ |
| 14 | 14 | id: Schema.String, |
| 15 | + handle: Schema.String, | |
| 15 | 16 | name: Schema.String, |
| 16 | 17 | }); |
| 17 | 18 | |
| 18 | 19 | const AuthMeResponse = Schema.Struct({ |
| 19 | 20 | user: AuthUser, |
| 20 | − organization: Schema.NullOr(AuthOrganization), | |
| 21 | −}); | |
| 22 | − | |
| 23 | −const AuthOrganizationSummary = Schema.Struct({ | |
| 24 | − id: Schema.String, | |
| 25 | − name: Schema.String, | |
| 21 | + /** Memberships, with the org `handle` URL routes use. Sorted alphabetically. */ | |
| 22 | + organizations: Schema.Array(AuthOrganization), | |
| 26 | 23 | }); |
| 27 | 24 | |
| 28 | 25 | const AuthOrganizationsResponse = Schema.Struct({ |
| 29 | − organizations: Schema.Array(AuthOrganizationSummary), | |
| 30 | − activeOrganizationId: Schema.NullOr(Schema.String), | |
| 31 | −}); | |
| 32 | − | |
| 33 | −const SwitchOrganizationBody = Schema.Struct({ | |
| 34 | − organizationId: Schema.String, | |
| 26 | + organizations: Schema.Array(AuthOrganization), | |
| 35 | 27 | }); |
| 36 | 28 | |
| 37 | 29 | const CreateOrganizationBody = Schema.Struct({ |
| @@ -40,6 +32,7 @@ const CreateOrganizationBody = Schema.Struct({ · 2 unmodified lines | ||
| 40 | 32 | |
| 41 | 33 | const CreateOrganizationResponse = Schema.Struct({ |
| 42 | 34 | id: Schema.String, |
| 35 | + handle: Schema.String, | |
| 43 | 36 | name: Schema.String, |
| 44 | 37 | }); |
| 45 | 38 | |
| @@ -81,7 +74,6 @@ export const AUTH_PATHS = { · 35 unmodified lines | ||
| 81 | 74 | login: "/api/auth/login", |
| 82 | 75 | logout: "/api/auth/logout", |
| 83 | 76 | callback: "/api/auth/callback", |
| 84 | − switchOrganization: "/api/auth/switch-organization", | |
| 85 | 77 | } as const; |
| 86 | 78 | |
| 87 | 79 | const AuthErrors = [UserStoreError, WorkOSError] as const; |
| @@ -111,12 +103,6 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") · 23 unmodified lines | ||
| 111 | 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 | 106 | .add( |
| 121 | 107 | HttpApiEndpoint.post("createOrganization", "/auth/create-organization", { |
| 122 | 108 | payload: CreateOrganizationBody, |
| @@ -1,12 +1,11 @@ | ||
| 1 | 1 | import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"; |
| 2 | 2 | import { HttpServerResponse } from "effect/unstable/http"; |
| 3 | 3 | import { Duration, Effect } from "effect"; |
| 4 | −import { setCookie, deleteCookie } from "@tanstack/react-start/server"; | |
| 4 | +import { deleteCookie } from "@tanstack/react-start/server"; | |
| 5 | 5 | |
| 6 | 6 | import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api"; |
| 7 | 7 | import { SessionContext } from "./middleware"; |
| 8 | 8 | import { UserStoreService } from "./context"; |
| 9 | −import { authorizeOrganization } from "./authorize-organization"; | |
| 10 | 9 | import { env } from "cloudflare:workers"; |
| 11 | 10 | import { WorkOSError } from "./errors"; |
| 12 | 11 | import { WorkOSAuth } from "./workos"; |
| @@ -181,9 +180,37 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 168 unmodified lines | ||
| 181 | 180 | .handle("me", () => |
| 182 | 181 | Effect.gen(function* () { |
| 183 | 182 | const session = yield* SessionContext; |
| 184 | − const org = session.organizationId | |
| 185 | − ? yield* authorizeOrganization(session.accountId, session.organizationId) | |
| 186 | − : null; | |
| 183 | + const users = yield* UserStoreService; | |
| 184 | + const workos = yield* WorkOSAuth; | |
| 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 | 215 | return { |
| 189 | 216 | user: { |
| @@ -192,7 +219,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 2 unmodified lines | ||
| 192 | 219 | name: session.name, |
| 193 | 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 | 230 | .handle("organizations", () => |
| 204 | 231 | Effect.gen(function* () { |
| 205 | 232 | const workos = yield* WorkOSAuth; |
| 233 | + const users = yield* UserStoreService; | |
| 206 | 234 | const session = yield* SessionContext; |
| 207 | 235 | |
| 208 | 236 | const memberships = yield* workos.listUserMemberships(session.accountId); |
| 209 | − const organizations = yield* Effect.all( | |
| 237 | + const orgs = yield* Effect.all( | |
| 210 | 238 | memberships.data.map((m) => |
| 211 | 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 | 251 | Effect.orElseSucceed(() => null), |
| 214 | 252 | ), |
| 215 | 253 | ), |
| 216 | 254 | { concurrency: "unbounded" }, |
| 217 | 255 | ); |
| 218 | 256 | |
| 219 | − return { | |
| 220 | − organizations: organizations.filter( | |
| 221 | − (org): org is NonNullable<typeof org> => org !== null, | |
| 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; | |
| 257 | + const organizations = orgs | |
| 258 | + .filter(<T>(v: T | null): v is T => v !== null) | |
| 259 | + .sort((a, b) => a.name.localeCompare(b.name)); | |
| 231 | 260 | |
| 232 | − const refreshed = yield* workos.refreshSession( | |
| 233 | − session.sealedSession, | |
| 234 | − payload.organizationId, | |
| 235 | − ); | |
| 236 | − if (refreshed) { | |
| 237 | − setCookie("wos-session", refreshed, COOKIE_OPTIONS); | |
| 238 | − } | |
| 261 | + return { organizations }; | |
| 239 | 262 | }), |
| 240 | 263 | ) |
| 241 | 264 | .handle("createOrganization", ({ payload }) => |
| @@ -247,34 +270,15 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 5 unmodified lines | ||
| 247 | 270 | const name = payload.name.trim(); |
| 248 | 271 | const org = yield* workos.createOrganization(name); |
| 249 | 272 | yield* workos.createMembership(org.id, session.accountId, "admin"); |
| 250 | − yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name })); | |
| 251 | − | |
| 252 | − // Try to attach the new org to the current session. This can fail | |
| 253 | − // (or silently return a session still scoped to the old org) when | |
| 254 | − // the caller's current session is stale — most commonly after the | |
| 255 | − // user was removed from the org their cookie is pinned to. In that | |
| 256 | − // case we can't repair the session in-place, so we clear the | |
| 257 | − // cookie and fail loudly; the frontend will bounce to login and | |
| 258 | − // the callback's rehydrate path will pick up the new membership. | |
| 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 }; | |
| 273 | + const mirrored = yield* users.use((s) => | |
| 274 | + s.upsertOrganization({ id: org.id, name: org.name }), | |
| 275 | + ); | |
| 276 | + // No session refresh — the URL is the source of truth for active | |
| 277 | + // org now, so the client just navigates to /:handle/... after | |
| 278 | + // create. The WorkOS session's `organizationId` only proves login | |
| 279 | + // identity; it doesn't gate per-org access (that goes through the | |
| 280 | + // membership check on the URL-resolved org). | |
| 281 | + return { id: org.id, handle: mirrored.handle, name: org.name }; | |
| 278 | 282 | }), |
| 279 | 283 | ) |
| 280 | 284 | .handle("pendingInvitations", () => |
| @@ -330,7 +334,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 49 unmodified lines | ||
| 330 | 334 | Effect.gen(function* () { |
| 331 | 335 | const workos = yield* WorkOSAuth; |
| 332 | 336 | const users = yield* UserStoreService; |
| 333 | − const session = yield* SessionContext; | |
| 334 | 337 | |
| 335 | 338 | const invitation = yield* workos.acceptInvitation(payload.invitationId); |
| 336 | 339 | |
| @@ -343,30 +346,14 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 6 unmodified lines | ||
| 343 | 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 | 351 | const org = yield* workos.getOrganization(invitation.organizationId); |
| 348 | 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 shape | |
| 351 | − // as createOrganization: refresh + verify; if we can't pin the | |
| 352 | − // session in-place, clear the cookie and let the user bounce | |
| 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); | |
| 354 | + // No session refresh — the URL is the source of truth for active | |
| 355 | + // org. The client refreshes the auth atom and navigates to the | |
| 356 | + // new org's `/$handle` after this resolves. | |
| 370 | 357 | return { id: org.id, name: org.name }; |
| 371 | 358 | }), |
| 372 | 359 | ), |
| @@ -9,201 +9,181 @@ · 8 unmodified lines | ||
| 9 | 9 | // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. |
| 10 | 10 | |
| 11 | 11 | import { Route as rootRouteImport } from './routes/__root' |
| 12 | −import { Route as ToolsRouteImport } from './routes/tools' | |
| 13 | −import { Route as SecretsRouteImport } from './routes/secrets' | |
| 14 | −import { Route as PoliciesRouteImport } from './routes/policies' | |
| 15 | −import { Route as OrgRouteImport } from './routes/org' | |
| 16 | −import { Route as ConnectionsRouteImport } from './routes/connections' | |
| 17 | −import { Route as BillingRouteImport } from './routes/billing' | |
| 12 | +import { Route as OrgRouteImport } from './routes/$org' | |
| 18 | 13 | import { Route as IndexRouteImport } from './routes/index' |
| 19 | −import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace' | |
| 20 | −import { Route as BillingPlansRouteImport } from './routes/billing_.plans' | |
| 21 | −import { Route as SourcesAddPluginKeyRouteImport } from './routes/sources.add.$pluginKey' | |
| 14 | +import { Route as OrgIndexRouteImport } from './routes/$org/index' | |
| 15 | +import { Route as OrgToolsRouteImport } from './routes/$org/tools' | |
| 16 | +import { Route as OrgSecretsRouteImport } from './routes/$org/secrets' | |
| 17 | +import { Route as OrgPoliciesRouteImport } from './routes/$org/policies' | |
| 18 | +import { Route as OrgConnectionsRouteImport } from './routes/$org/connections' | |
| 19 | +import { Route as OrgSourcesNamespaceRouteImport } from './routes/$org/sources.$namespace' | |
| 20 | +import { Route as OrgChar91Char93SettingsRouteImport } from './routes/$org/[-].settings' | |
| 21 | +import { Route as OrgChar91Char93BillingRouteImport } from './routes/$org/[-].billing' | |
| 22 | +import { Route as OrgSourcesAddPluginKeyRouteImport } from './routes/$org/sources.add.$pluginKey' | |
| 23 | +import { Route as OrgChar91Char93BillingPlansRouteImport } from './routes/$org/[-].billing_.plans' | |
| 22 | 24 | |
| 23 | −const ToolsRoute = ToolsRouteImport.update({ | |
| 25 | +const OrgRoute = OrgRouteImport.update({ | |
| 26 | + id: '/$org', | |
| 27 | + path: '/$org', | |
| 28 | + getParentRoute: () => rootRouteImport, | |
| 29 | +} as any) | |
| 30 | +const IndexRoute = IndexRouteImport.update({ | |
| 31 | + id: '/', | |
| 32 | + path: '/', | |
| 33 | + getParentRoute: () => rootRouteImport, | |
| 34 | +} as any) | |
| 35 | +const OrgIndexRoute = OrgIndexRouteImport.update({ | |
| 36 | + id: '/', | |
| 37 | + path: '/', | |
| 38 | + getParentRoute: () => OrgRoute, | |
| 39 | +} as any) | |
| 40 | +const OrgToolsRoute = OrgToolsRouteImport.update({ | |
| 24 | 41 | id: '/tools', |
| 25 | 42 | path: '/tools', |
| 26 | − getParentRoute: () => rootRouteImport, | |
| 43 | + getParentRoute: () => OrgRoute, | |
| 27 | 44 | } as any) |
| 28 | −const SecretsRoute = SecretsRouteImport.update({ | |
| 45 | +const OrgSecretsRoute = OrgSecretsRouteImport.update({ | |
| 29 | 46 | id: '/secrets', |
| 30 | 47 | path: '/secrets', |
| 31 | − getParentRoute: () => rootRouteImport, | |
| 48 | + getParentRoute: () => OrgRoute, | |
| 32 | 49 | } as any) |
| 33 | −const PoliciesRoute = PoliciesRouteImport.update({ | |
| 50 | +const OrgPoliciesRoute = OrgPoliciesRouteImport.update({ | |
| 34 | 51 | id: '/policies', |
| 35 | 52 | path: '/policies', |
| 36 | − getParentRoute: () => rootRouteImport, | |
| 37 | −} as any) | |
| 38 | −const OrgRoute = OrgRouteImport.update({ | |
| 39 | − id: '/org', | |
| 40 | − path: '/org', | |
| 41 | − getParentRoute: () => rootRouteImport, | |
| 53 | + getParentRoute: () => OrgRoute, | |
| 42 | 54 | } as any) |
| 43 | −const ConnectionsRoute = ConnectionsRouteImport.update({ | |
| 55 | +const OrgConnectionsRoute = OrgConnectionsRouteImport.update({ | |
| 44 | 56 | id: '/connections', |
| 45 | 57 | path: '/connections', |
| 46 | − getParentRoute: () => rootRouteImport, | |
| 47 | −} as any) | |
| 48 | −const BillingRoute = BillingRouteImport.update({ | |
| 49 | − id: '/billing', | |
| 50 | − path: '/billing', | |
| 51 | − getParentRoute: () => rootRouteImport, | |
| 52 | −} as any) | |
| 53 | −const IndexRoute = IndexRouteImport.update({ | |
| 54 | − id: '/', | |
| 55 | − path: '/', | |
| 56 | − getParentRoute: () => rootRouteImport, | |
| 58 | + getParentRoute: () => OrgRoute, | |
| 57 | 59 | } as any) |
| 58 | −const SourcesNamespaceRoute = SourcesNamespaceRouteImport.update({ | |
| 60 | +const OrgSourcesNamespaceRoute = OrgSourcesNamespaceRouteImport.update({ | |
| 59 | 61 | id: '/sources/$namespace', |
| 60 | 62 | path: '/sources/$namespace', |
| 61 | − getParentRoute: () => rootRouteImport, | |
| 63 | + getParentRoute: () => OrgRoute, | |
| 62 | 64 | } as any) |
| 63 | −const BillingPlansRoute = BillingPlansRouteImport.update({ | |
| 64 | − id: '/billing_/plans', | |
| 65 | − path: '/billing/plans', | |
| 66 | − getParentRoute: () => rootRouteImport, | |
| 65 | +const OrgChar91Char93SettingsRoute = OrgChar91Char93SettingsRouteImport.update({ | |
| 66 | + id: '/-/settings', | |
| 67 | + path: '/-/settings', | |
| 68 | + getParentRoute: () => OrgRoute, | |
| 69 | +} as any) | |
| 70 | +const OrgChar91Char93BillingRoute = OrgChar91Char93BillingRouteImport.update({ | |
| 71 | + id: '/-/billing', | |
| 72 | + path: '/-/billing', | |
| 73 | + getParentRoute: () => OrgRoute, | |
| 67 | 74 | } as any) |
| 68 | −const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ | |
| 75 | +const OrgSourcesAddPluginKeyRoute = OrgSourcesAddPluginKeyRouteImport.update({ | |
| 69 | 76 | id: '/sources/add/$pluginKey', |
| 70 | 77 | path: '/sources/add/$pluginKey', |
| 71 | − getParentRoute: () => rootRouteImport, | |
| 78 | + getParentRoute: () => OrgRoute, | |
| 72 | 79 | } as any) |
| 80 | +const OrgChar91Char93BillingPlansRoute = | |
| 81 | + OrgChar91Char93BillingPlansRouteImport.update({ | |
| 82 | + id: '/-/billing_/plans', | |
| 83 | + path: '/-/billing/plans', | |
| 84 | + getParentRoute: () => OrgRoute, | |
| 85 | + } as any) | |
| 73 | 86 | |
| 74 | 87 | export interface FileRoutesByFullPath { |
| 75 | 88 | '/': typeof IndexRoute |
| 76 | − '/billing': typeof BillingRoute | |
| 77 | − '/connections': typeof ConnectionsRoute | |
| 78 | − '/org': typeof OrgRoute | |
| 79 | − '/policies': typeof PoliciesRoute | |
| 80 | − '/secrets': typeof SecretsRoute | |
| 81 | − '/tools': typeof ToolsRoute | |
| 82 | − '/billing/plans': typeof BillingPlansRoute | |
| 83 | − '/sources/$namespace': typeof SourcesNamespaceRoute | |
| 84 | − '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute | |
| 89 | + '/$org': typeof OrgRouteWithChildren | |
| 90 | + '/$org/connections': typeof OrgConnectionsRoute | |
| 91 | + '/$org/policies': typeof OrgPoliciesRoute | |
| 92 | + '/$org/secrets': typeof OrgSecretsRoute | |
| 93 | + '/$org/tools': typeof OrgToolsRoute | |
| 94 | + '/$org/': typeof OrgIndexRoute | |
| 95 | + '/$org/-/billing': typeof OrgChar91Char93BillingRoute | |
| 96 | + '/$org/-/settings': typeof OrgChar91Char93SettingsRoute | |
| 97 | + '/$org/sources/$namespace': typeof OrgSourcesNamespaceRoute | |
| 98 | + '/$org/-/billing/plans': typeof OrgChar91Char93BillingPlansRoute | |
| 99 | + '/$org/sources/add/$pluginKey': typeof OrgSourcesAddPluginKeyRoute | |
| 85 | 100 | } |
| 86 | 101 | export interface FileRoutesByTo { |
| 87 | 102 | '/': typeof IndexRoute |
| 88 | − '/billing': typeof BillingRoute | |
| 89 | − '/connections': typeof ConnectionsRoute | |
| 90 | − '/org': typeof OrgRoute | |
| 91 | − '/policies': typeof PoliciesRoute | |
| 92 | − '/secrets': typeof SecretsRoute | |
| 93 | − '/tools': typeof ToolsRoute | |
| 94 | − '/billing/plans': typeof BillingPlansRoute | |
| 95 | − '/sources/$namespace': typeof SourcesNamespaceRoute | |
| 96 | − '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute | |
| 103 | + '/$org/connections': typeof OrgConnectionsRoute | |
| 104 | + '/$org/policies': typeof OrgPoliciesRoute | |
| 105 | + '/$org/secrets': typeof OrgSecretsRoute | |
| 106 | + '/$org/tools': typeof OrgToolsRoute | |
| 107 | + '/$org': typeof OrgIndexRoute | |
| 108 | + '/$org/-/billing': typeof OrgChar91Char93BillingRoute | |
| 109 | + '/$org/-/settings': typeof OrgChar91Char93SettingsRoute | |
| 110 | + '/$org/sources/$namespace': typeof OrgSourcesNamespaceRoute | |
| 111 | + '/$org/-/billing/plans': typeof OrgChar91Char93BillingPlansRoute | |
| 112 | + '/$org/sources/add/$pluginKey': typeof OrgSourcesAddPluginKeyRoute | |
| 97 | 113 | } |
| 98 | 114 | export interface FileRoutesById { |
| 99 | 115 | __root__: typeof rootRouteImport |
| 100 | 116 | '/': typeof IndexRoute |
| 101 | − '/billing': typeof BillingRoute | |
| 102 | − '/connections': typeof ConnectionsRoute | |
| 103 | − '/org': typeof OrgRoute | |
| 104 | − '/policies': typeof PoliciesRoute | |
| 105 | − '/secrets': typeof SecretsRoute | |
| 106 | − '/tools': typeof ToolsRoute | |
| 107 | − '/billing_/plans': typeof BillingPlansRoute | |
| 108 | − '/sources/$namespace': typeof SourcesNamespaceRoute | |
| 109 | − '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute | |
| 117 | + '/$org': typeof OrgRouteWithChildren | |
| 118 | + '/$org/connections': typeof OrgConnectionsRoute | |
| 119 | + '/$org/policies': typeof OrgPoliciesRoute | |
| 120 | + '/$org/secrets': typeof OrgSecretsRoute | |
| 121 | + '/$org/tools': typeof OrgToolsRoute | |
| 122 | + '/$org/': typeof OrgIndexRoute | |
| 123 | + '/$org/-/billing': typeof OrgChar91Char93BillingRoute | |
| 124 | + '/$org/-/settings': typeof OrgChar91Char93SettingsRoute | |
| 125 | + '/$org/sources/$namespace': typeof OrgSourcesNamespaceRoute | |
| 126 | + '/$org/-/billing_/plans': typeof OrgChar91Char93BillingPlansRoute | |
| 127 | + '/$org/sources/add/$pluginKey': typeof OrgSourcesAddPluginKeyRoute | |
| 110 | 128 | } |
| 111 | 129 | export interface FileRouteTypes { |
| 112 | 130 | fileRoutesByFullPath: FileRoutesByFullPath |
| 113 | 131 | fullPaths: |
| 114 | 132 | | '/' |
| 115 | − | '/billing' | |
| 116 | − | '/connections' | |
| 117 | − | '/org' | |
| 118 | − | '/policies' | |
| 119 | − | '/secrets' | |
| 120 | − | '/tools' | |
| 121 | − | '/billing/plans' | |
| 122 | − | '/sources/$namespace' | |
| 123 | − | '/sources/add/$pluginKey' | |
| 133 | + | '/$org' | |
| 134 | + | '/$org/connections' | |
| 135 | + | '/$org/policies' | |
| 136 | + | '/$org/secrets' | |
| 137 | + | '/$org/tools' | |
| 138 | + | '/$org/' | |
| 139 | + | '/$org/-/billing' | |
| 140 | + | '/$org/-/settings' | |
| 141 | + | '/$org/sources/$namespace' | |
| 142 | + | '/$org/-/billing/plans' | |
| 143 | + | '/$org/sources/add/$pluginKey' | |
| 124 | 144 | fileRoutesByTo: FileRoutesByTo |
| 125 | 145 | to: |
| 126 | 146 | | '/' |
| 127 | − | '/billing' | |
| 128 | − | '/connections' | |
| 129 | − | '/org' | |
| 130 | − | '/policies' | |
| 131 | − | '/secrets' | |
| 132 | − | '/tools' | |
| 133 | − | '/billing/plans' | |
| 134 | − | '/sources/$namespace' | |
| 135 | − | '/sources/add/$pluginKey' | |
| 147 | + | '/$org/connections' | |
| 148 | + | '/$org/policies' | |
| 149 | + | '/$org/secrets' | |
| 150 | + | '/$org/tools' | |
| 151 | + | '/$org' | |
| 152 | + | '/$org/-/billing' | |
| 153 | + | '/$org/-/settings' | |
| 154 | + | '/$org/sources/$namespace' | |
| 155 | + | '/$org/-/billing/plans' | |
| 156 | + | '/$org/sources/add/$pluginKey' | |
| 136 | 157 | id: |
| 137 | 158 | | '__root__' |
| 138 | 159 | | '/' |
| 139 | − | '/billing' | |
| 140 | − | '/connections' | |
| 141 | − | '/org' | |
| 142 | − | '/policies' | |
| 143 | − | '/secrets' | |
| 144 | − | '/tools' | |
| 145 | − | '/billing_/plans' | |
| 146 | − | '/sources/$namespace' | |
| 147 | − | '/sources/add/$pluginKey' | |
| 160 | + | '/$org' | |
| 161 | + | '/$org/connections' | |
| 162 | + | '/$org/policies' | |
| 163 | + | '/$org/secrets' | |
| 164 | + | '/$org/tools' | |
| 165 | + | '/$org/' | |
| 166 | + | '/$org/-/billing' | |
| 167 | + | '/$org/-/settings' | |
| 168 | + | '/$org/sources/$namespace' | |
| 169 | + | '/$org/-/billing_/plans' | |
| 170 | + | '/$org/sources/add/$pluginKey' | |
| 148 | 171 | fileRoutesById: FileRoutesById |
| 149 | 172 | } |
| 150 | 173 | export interface RootRouteChildren { |
| 151 | 174 | IndexRoute: typeof IndexRoute |
| 152 | − BillingRoute: typeof BillingRoute | |
| 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 | |
| 175 | + OrgRoute: typeof OrgRouteWithChildren | |
| 161 | 176 | } |
| 162 | 177 | |
| 163 | 178 | declare module '@tanstack/react-router' { |
| 164 | 179 | interface FileRoutesByPath { |
| 165 | − '/tools': { | |
| 166 | − id: '/tools' | |
| 167 | − path: '/tools' | |
| 168 | − fullPath: '/tools' | |
| 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' | |
| 180 | + '/$org': { | |
| 181 | + id: '/$org' | |
| 182 | + path: '/$org' | |
| 183 | + fullPath: '/$org' | |
| 190 | 184 | preLoaderRoute: typeof OrgRouteImport |
| 191 | 185 | 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 | 188 | id: '/' |
| 209 | 189 | path: '/' |
| @@ -211,41 +191,110 @@ declare module '@tanstack/react-router' { · 1 unmodified line | ||
| 211 | 191 | preLoaderRoute: typeof IndexRouteImport |
| 212 | 192 | parentRoute: typeof rootRouteImport |
| 213 | 193 | } |
| 214 | − '/sources/$namespace': { | |
| 215 | − id: '/sources/$namespace' | |
| 194 | + '/$org/': { | |
| 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 | 231 | path: '/sources/$namespace' |
| 217 | − fullPath: '/sources/$namespace' | |
| 218 | − preLoaderRoute: typeof SourcesNamespaceRouteImport | |
| 219 | − parentRoute: typeof rootRouteImport | |
| 232 | + fullPath: '/$org/sources/$namespace' | |
| 233 | + preLoaderRoute: typeof OrgSourcesNamespaceRouteImport | |
| 234 | + parentRoute: typeof OrgRoute | |
| 220 | 235 | } |
| 221 | − '/billing_/plans': { | |
| 222 | − id: '/billing_/plans' | |
| 223 | − path: '/billing/plans' | |
| 224 | − fullPath: '/billing/plans' | |
| 225 | − preLoaderRoute: typeof BillingPlansRouteImport | |
| 226 | − parentRoute: typeof rootRouteImport | |
| 236 | + '/$org/-/settings': { | |
| 237 | + id: '/$org/-/settings' | |
| 238 | + path: '/-/settings' | |
| 239 | + fullPath: '/$org/-/settings' | |
| 240 | + preLoaderRoute: typeof OrgChar91Char93SettingsRouteImport | |
| 241 | + 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': { | |
| 229 | − id: '/sources/add/$pluginKey' | |
| 250 | + '/$org/sources/add/$pluginKey': { | |
| 251 | + id: '/$org/sources/add/$pluginKey' | |
| 230 | 252 | path: '/sources/add/$pluginKey' |
| 231 | − fullPath: '/sources/add/$pluginKey' | |
| 232 | − preLoaderRoute: typeof SourcesAddPluginKeyRouteImport | |
| 233 | − parentRoute: typeof rootRouteImport | |
| 253 | + fullPath: '/$org/sources/add/$pluginKey' | |
| 254 | + preLoaderRoute: typeof OrgSourcesAddPluginKeyRouteImport | |
| 255 | + 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 | |
| 267 | +interface 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 | + | |
| 280 | +const 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 | + | |
| 293 | +const OrgRouteWithChildren = OrgRoute._addFileChildren(OrgRouteChildren) | |
| 294 | + | |
| 238 | 295 | const rootRouteChildren: RootRouteChildren = { |
| 239 | 296 | IndexRoute: IndexRoute, |
| 240 | − BillingRoute: BillingRoute, | |
| 241 | − ConnectionsRoute: ConnectionsRoute, | |
| 242 | − OrgRoute: OrgRoute, | |
| 243 | − PoliciesRoute: PoliciesRoute, | |
| 244 | − SecretsRoute: SecretsRoute, | |
| 245 | − ToolsRoute: ToolsRoute, | |
| 246 | − BillingPlansRoute: BillingPlansRoute, | |
| 247 | − SourcesNamespaceRoute: SourcesNamespaceRoute, | |
| 248 | − SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute, | |
| 297 | + OrgRoute: OrgRouteWithChildren, | |
| 249 | 298 | } |
| 250 | 299 | export const routeTree = rootRouteImport |
| 251 | 300 | ._addFileChildren(rootRouteChildren) |
| @@ -0,0 +1,61 @@ | ||
| 1 | +import { createFileRoute, useNavigate, useParams } from "@tanstack/react-router"; | |
| 2 | +import { useEffect } from "react"; | |
| 3 | +import { AutumnProvider } from "autumn-js/react"; | |
| 4 | +import { ExecutorProvider } from "@executor-js/react/api/provider"; | |
| 5 | +import { setBaseUrl } from "@executor-js/react/api/base-url"; | |
| 6 | +import { Toaster } from "@executor-js/react/components/sonner"; | |
| 7 | +import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; | |
| 8 | +import { plugins as clientPlugins } from "virtual:executor/plugins-client"; | |
| 9 | + | |
| 10 | +import { findOrgByHandle, useAuth } from "../web/auth"; | |
| 11 | +import { OrgRouteProvider } from "../web/org-route"; | |
| 12 | +import { Shell, ShellSkeleton } from "../web/shell"; | |
| 13 | + | |
| 14 | +export const Route = createFileRoute("/$org")({ | |
| 15 | + component: OrgLayout, | |
| 16 | +}); | |
| 17 | + | |
| 18 | +function 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 | +} | |
| @@ -2,10 +2,11 @@ import { createFileRoute, Link } from "@tanstack/react-router"; · 1 unmodified line | ||
| 2 | 2 | import { useCustomer, useListPlans } from "autumn-js/react"; |
| 3 | 3 | import { Button } from "@executor-js/react/components/button"; |
| 4 | 4 | import { Badge } from "@executor-js/react/components/badge"; |
| 5 | +import { useOrgRoute } from "../../web/org-route"; | |
| 5 | 6 | |
| 6 | 7 | type Plan = NonNullable<ReturnType<typeof useListPlans>["data"]>[number]; |
| 7 | 8 | |
| 8 | −export const Route = createFileRoute("/billing")({ | |
| 9 | +export const Route = createFileRoute("/$org/-/billing")({ | |
| 9 | 10 | component: BillingPage, |
| 10 | 11 | }); |
| 11 | 12 | |
| @@ -15,6 +16,7 @@ const PLAN_TAGLINES: Record<string, string> = { · 3 unmodified lines | ||
| 15 | 16 | }; |
| 16 | 17 | |
| 17 | 18 | function BillingPage() { |
| 19 | + const { orgHandle } = useOrgRoute(); | |
| 18 | 20 | const { data: customer, openCustomerPortal, isLoading: customerLoading } = useCustomer(); |
| 19 | 21 | const { data: plans, isLoading: plansLoading } = useListPlans(); |
| 20 | 22 | |
| @@ -99,7 +101,8 @@ function BillingPage() { · 78 unmodified lines | ||
| 99 | 101 | </Button> |
| 100 | 102 | )} |
| 101 | 103 | <Link |
| 102 | − to="/billing/plans" | |
| 104 | + to="/$org/-/billing/plans" | |
| 105 | + params={{ org: orgHandle }} | |
| 103 | 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 | 108 | Manage |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { useEffect, useState } from "react"; |
| 2 | 2 | import { createFileRoute, Link } from "@tanstack/react-router"; |
| 3 | −import { useAuth } from "../web/auth"; | |
| 3 | +import { useAuth } from "../../web/auth"; | |
| 4 | 4 | import { useCustomer, useListPlans } from "autumn-js/react"; |
| 5 | 5 | import { Button } from "@executor-js/react/components/button"; |
| 6 | 6 | import { Badge } from "@executor-js/react/components/badge"; |
| @@ -16,10 +16,11 @@ import { · 9 unmodified lines | ||
| 16 | 16 | } from "@executor-js/react/components/dialog"; |
| 17 | 17 | import { Input } from "@executor-js/react/components/input"; |
| 18 | 18 | import { Label } from "@executor-js/react/components/label"; |
| 19 | +import { useOrgRoute } from "../../web/org-route"; | |
| 19 | 20 | |
| 20 | 21 | type Plan = NonNullable<ReturnType<typeof useListPlans>["data"]>[number]; |
| 21 | 22 | |
| 22 | −export const Route = createFileRoute("/billing_/plans")({ | |
| 23 | +export const Route = createFileRoute("/$org/-/billing_/plans")({ | |
| 23 | 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 | |
| 79 | 80 | function PlansPage() { |
| 81 | + const { orgHandle } = useOrgRoute(); | |
| 80 | 82 | const { attach, openCustomerPortal, isLoading: customerLoading } = useCustomer(); |
| 81 | 83 | const { data: plans, isLoading: plansLoading, isFetching } = useListPlans(); |
| 82 | 84 | const [loadingPlan, setLoadingPlan] = useState<string | null>(null); |
| @@ -92,7 +94,8 @@ function PlansPage() { · 9 unmodified lines | ||
| 92 | 94 | <div className="mx-auto max-w-5xl px-6 py-10 lg:px-10 lg:py-14"> |
| 93 | 95 | <div className="mb-8"> |
| 94 | 96 | <Link |
| 95 | − to="/billing" | |
| 97 | + to="/$org/-/billing" | |
| 98 | + params={{ org: orgHandle }} | |
| 96 | 99 | className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors mb-4" |
| 97 | 100 | > |
| 98 | 101 | <svg viewBox="0 0 16 16" fill="none" className="size-3.5"> |
| @@ -307,9 +310,9 @@ function PlansPage() { · 208 unmodified lines | ||
| 307 | 310 | |
| 308 | 311 | function SlackContactCta() { |
| 309 | 312 | const auth = useAuth(); |
| 313 | + const { orgName } = useOrgRoute(); | |
| 310 | 314 | const signedIn = auth.status === "authenticated" ? auth : null; |
| 311 | 315 | const prefillEmail = signedIn?.user.email ?? ""; |
| 312 | − const orgName = signedIn?.organization?.name ?? ""; | |
| 313 | 316 | |
| 314 | 317 | const [open, setOpen] = useState(false); |
| 315 | 318 | const [email, setEmail] = useState(prefillEmail); |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { useReducer, useState } from "react"; |
| 2 | 2 | import { Cause, Exit, Result } from "effect"; |
| 3 | −import { Forbidden } from "../org/api"; | |
| 3 | +import { Forbidden } from "../../org/api"; | |
| 4 | 4 | import { createFileRoute, Link } from "@tanstack/react-router"; |
| 5 | 5 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; |
| 6 | 6 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| @@ -52,10 +52,10 @@ import { · 45 unmodified lines | ||
| 52 | 52 | getDomainVerificationLink, |
| 53 | 53 | deleteDomain, |
| 54 | 54 | updateOrgName, |
| 55 | −} from "../web/org-atoms"; | |
| 56 | −import { useAuth } from "../web/auth"; | |
| 55 | +} from "../../web/org-atoms"; | |
| 56 | +import { useOrgRoute } from "../../web/org-route"; | |
| 57 | 57 | |
| 58 | −export const Route = createFileRoute("/org")({ | |
| 58 | +export const Route = createFileRoute("/$org/-/settings")({ | |
| 59 | 59 | component: OrgPage, |
| 60 | 60 | }); |
| 61 | 61 | |
| @@ -110,9 +110,8 @@ function formatLastActive(lastActiveAt: string | null): string { · 48 unmodified lines | ||
| 110 | 110 | } |
| 111 | 111 | |
| 112 | 112 | function OrgPage() { |
| 113 | − const auth = useAuth(); | |
| 114 | − const orgName = | |
| 115 | − auth.status === "authenticated" ? (auth.organization?.name ?? "Organization") : "Organization"; | |
| 113 | + const { orgName: routeOrgName, orgHandle } = useOrgRoute(); | |
| 114 | + const orgName = routeOrgName ?? "Organization"; | |
| 116 | 115 | const membersResult = useAtomValue(orgMembersAtom); |
| 117 | 116 | const rolesResult = useAtomValue(orgRolesAtom); |
| 118 | 117 | const domainsResult = useAtomValue(orgDomainsAtom); |
| @@ -262,7 +261,7 @@ function OrgPage() { · 143 unmodified lines | ||
| 262 | 261 | <p className="text-sm text-muted-foreground"> |
| 263 | 262 | Join by domain is available on the Team plan. |
| 264 | 263 | </p> |
| 265 | − <Link to="/billing/plans"> | |
| 264 | + <Link to="/$org/-/billing/plans" params={{ org: orgHandle }}> | |
| 266 | 265 | <Button size="sm" variant="outline"> |
| 267 | 266 | Upgrade |
| 268 | 267 | </Button> |
| @@ -322,7 +321,7 @@ function OrgPage() { · 53 unmodified lines | ||
| 322 | 321 | Invite member |
| 323 | 322 | </Button> |
| 324 | 323 | ) : ( |
| 325 | − <Link to="/billing/plans"> | |
| 324 | + <Link to="/$org/-/billing/plans" params={{ org: orgHandle }}> | |
| 326 | 325 | <Button size="sm" className="min-w-32"> |
| 327 | 326 | Upgrade |
| 328 | 327 | </Button> |
| @@ -608,6 +607,7 @@ function DomainCard({ domain: d, onDelete }: { domain: DomainData; onDelete: () · 279 unmodified lines | ||
| 608 | 607 | } |
| 609 | 608 | |
| 610 | 609 | function InviteErrorAlert({ cause }: { cause: Cause.Cause<unknown> }) { |
| 610 | + const { orgHandle } = useOrgRoute(); | |
| 611 | 611 | const failure = Cause.findError(cause); |
| 612 | 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 | 617 | <p className="text-sm text-destructive"> |
| 618 | 618 | You've reached your member limit. Upgrade to Team to invite more. |
| 619 | 619 | </p> |
| 620 | − <Link to="/billing/plans"> | |
| 620 | + <Link to="/$org/-/billing/plans" params={{ org: orgHandle }}> | |
| 621 | 621 | <Button size="sm" variant="outline"> |
| 622 | 622 | Upgrade |
| 623 | 623 | </Button> |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { createFileRoute } from "@tanstack/react-router"; |
| 2 | 2 | import { ConnectionsPage } from "@executor-js/react/pages/connections"; |
| 3 | 3 | |
| 4 | −export const Route = createFileRoute("/connections")({ | |
| 4 | +export const Route = createFileRoute("/$org/connections")({ | |
| 5 | 5 | component: () => <ConnectionsPage />, |
| 6 | 6 | }); |
| @@ -0,0 +1,6 @@ | ||
| 1 | +import { createFileRoute } from "@tanstack/react-router"; | |
| 2 | +import { SourcesPage } from "@executor-js/react/pages/sources"; | |
| 3 | + | |
| 4 | +export const Route = createFileRoute("/$org/")({ | |
| 5 | + component: SourcesPage, | |
| 6 | +}); | |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { createFileRoute } from "@tanstack/react-router"; |
| 2 | 2 | import { PoliciesPage } from "@executor-js/react/pages/policies"; |
| 3 | 3 | |
| 4 | −export const Route = createFileRoute("/policies")({ | |
| 4 | +export const Route = createFileRoute("/$org/policies")({ | |
| 5 | 5 | component: () => <PoliciesPage />, |
| 6 | 6 | }); |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { createFileRoute } from "@tanstack/react-router"; |
| 2 | 2 | import { SecretsPage } from "@executor-js/react/pages/secrets"; |
| 3 | 3 | |
| 4 | −export const Route = createFileRoute("/secrets")({ | |
| 4 | +export const Route = createFileRoute("/$org/secrets")({ | |
| 5 | 5 | component: () => ( |
| 6 | 6 | <SecretsPage |
| 7 | 7 | addSecretDescription="Store a credential or API key for this organization." |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { createFileRoute } from "@tanstack/react-router"; |
| 2 | 2 | import { SourceDetailPage } from "@executor-js/react/pages/source-detail"; |
| 3 | 3 | |
| 4 | −export const Route = createFileRoute("/sources/$namespace")({ | |
| 4 | +export const Route = createFileRoute("/$org/sources/$namespace")({ | |
| 5 | 5 | component: () => { |
| 6 | 6 | const { namespace } = Route.useParams(); |
| 7 | 7 | return <SourceDetailPage namespace={namespace} />; |
| @@ -10,7 +10,7 @@ const SearchParams = Schema.toStandardSchemaV1( · 9 unmodified lines | ||
| 10 | 10 | }), |
| 11 | 11 | ); |
| 12 | 12 | |
| 13 | −export const Route = createFileRoute("/sources/add/$pluginKey")({ | |
| 13 | +export const Route = createFileRoute("/$org/sources/add/$pluginKey")({ | |
| 14 | 14 | validateSearch: SearchParams, |
| 15 | 15 | component: () => { |
| 16 | 16 | const { pluginKey } = Route.useParams(); |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { createFileRoute } from "@tanstack/react-router"; |
| 2 | 2 | import { ToolsPage } from "@executor-js/react/pages/tools"; |
| 3 | 3 | |
| 4 | −export const Route = createFileRoute("/tools")({ | |
| 4 | +export const Route = createFileRoute("/$org/tools")({ | |
| 5 | 5 | component: ToolsPage, |
| 6 | 6 | }); |
| @@ -1,18 +1,12 @@ | ||
| 1 | 1 | import React from "react"; |
| 2 | 2 | import * as Sentry from "@sentry/react"; |
| 3 | −import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router"; | |
| 4 | −import { AutumnProvider } from "autumn-js/react"; | |
| 3 | +import { HeadContent, Outlet, Scripts, createRootRoute } from "@tanstack/react-router"; | |
| 5 | 4 | import posthog from "posthog-js"; |
| 6 | 5 | import { PostHogProvider } from "posthog-js/react"; |
| 7 | −import { ExecutorProvider } from "@executor-js/react/api/provider"; | |
| 8 | −import { Skeleton } from "@executor-js/react/components/skeleton"; | |
| 9 | −import { Toaster } from "@executor-js/react/components/sonner"; | |
| 10 | −import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; | |
| 11 | −import { plugins as clientPlugins } from "virtual:executor/plugins-client"; | |
| 12 | 6 | import { AuthProvider, useAuth } from "../web/auth"; |
| 13 | 7 | import { LoginPage } from "../web/pages/login"; |
| 14 | 8 | import { OnboardingPage } from "../web/pages/onboarding"; |
| 15 | −import { Shell } from "../web/shell"; | |
| 9 | +import { ShellSkeleton } from "../web/shell"; | |
| 16 | 10 | import appCss from "@executor-js/react/globals.css?url"; |
| 17 | 11 | |
| 18 | 12 | if (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 | |
| 93 | −function 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 | − | |
| 154 | 87 | function AuthGate() { |
| 155 | 88 | const auth = useAuth(); |
| 156 | 89 | |
| @@ -162,18 +95,9 @@ function AuthGate() { · 5 unmodified lines | ||
| 162 | 95 | return <LoginPage />; |
| 163 | 96 | } |
| 164 | 97 | |
| 165 | − if (auth.organization == null) { | |
| 98 | + if (auth.organizations.length === 0) { | |
| 166 | 99 | return <OnboardingPage />; |
| 167 | 100 | } |
| 168 | 101 | |
| 169 | − return ( | |
| 170 | − <AutumnProvider pathPrefix="/api/autumn"> | |
| 171 | − <ExecutorProvider fallback={<ShellSkeleton />}> | |
| 172 | − <ExecutorPluginsProvider plugins={clientPlugins}> | |
| 173 | − <Shell /> | |
| 174 | − <Toaster /> | |
| 175 | − </ExecutorPluginsProvider> | |
| 176 | − </ExecutorProvider> | |
| 177 | − </AutumnProvider> | |
| 178 | − ); | |
| 102 | + return <Outlet />; | |
| 179 | 103 | } |
| @@ -1,6 +1,26 @@ | ||
| 1 | −import { createFileRoute } from "@tanstack/react-router"; | |
| 2 | −import { SourcesPage } from "@executor-js/react/pages/sources"; | |
| 1 | +import { createFileRoute, useNavigate } from "@tanstack/react-router"; | |
| 2 | +import { useEffect } from "react"; | |
| 3 | + | |
| 4 | +import { useAuth } from "../web/auth"; | |
| 3 | 5 | |
| 4 | 6 | export const Route = createFileRoute("/")({ |
| 5 | − component: SourcesPage, | |
| 7 | + component: IndexRedirect, | |
| 6 | 8 | }); |
| 9 | + | |
| 10 | +function 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 | +} | |
| @@ -5,14 +5,16 @@ · 4 unmodified lines | ||
| 5 | 5 | // two test-only swaps: |
| 6 | 6 | // |
| 7 | 7 | // - `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 | 10 | // - `workos-vault` is configured with an in-memory `WorkOSVaultClient` |
| 10 | 11 | // so secret writes never reach WorkOS's real API. |
| 11 | 12 | // |
| 12 | 13 | // Tests get a `fetchForOrg(orgId)` they can hand to `FetchHttpClient` |
| 13 | 14 | // and then call `HttpApiClient.make(ProtectedCloudApi)` against it. |
| 14 | 15 | // 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 | |
| 17 | 19 | import { Effect, Layer } from "effect"; |
| 18 | 20 | import { HttpApiBuilder, HttpApiClient, HttpApiSwagger } from "effect/unstable/httpapi"; |
| @@ -38,9 +40,13 @@ import { · 19 unmodified lines | ||
| 38 | 40 | RouterConfig, |
| 39 | 41 | } from "../../api/protected-layers"; |
| 40 | 42 | import { DbService } from "../db"; |
| 43 | +import { organizations } from "../schema"; | |
| 41 | 44 | |
| 42 | 45 | export const TEST_BASE_URL = "http://test.local"; |
| 43 | −export 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 | + */ | |
| 44 | 50 | export const TEST_USER_HEADER = "x-test-user-id"; |
| 45 | 51 | |
| 46 | 52 | // 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. | |
| 105 | +const 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 | 117 | // 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. | |
| 123 | +const 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 | 129 | // Test version of the production `ExecutionStackMiddleware` — reads the |
| 97 | −// `x-test-org-id` (and optional `x-test-user-id`) header, builds a | |
| 98 | −// test-scoped executor against the live postgres test db with a fake | |
| 99 | −// WorkOS vault, and provides `AuthContext` + the executor services to the | |
| 100 | −// handler. Mirrors prod's HttpRouter middleware but with test-mode | |
| 101 | −// constructors. | |
| 130 | +// org handle from the URL `/api/:org/...` prefix (matching production), | |
| 131 | +// builds a test-scoped executor against the live postgres test db with a | |
| 132 | +// fake WorkOS vault, and provides `AuthContext` + the executor services | |
| 133 | +// to the handler. The optional `x-test-user-id` header overrides the | |
| 134 | +// default per-org user. | |
| 102 | 135 | const TestExecutionStackMiddleware = HttpRouter.middleware<{ |
| 103 | 136 | provides: |
| 104 | 137 | | AuthContext |
| @@ -115,11 +148,20 @@ const TestExecutionStackMiddleware = HttpRouter.middleware<{ · 10 unmodified lines | ||
| 115 | 148 | return (httpEffect) => |
| 116 | 149 | Effect.gen(function* () { |
| 117 | 150 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 118 | − const orgId = request.headers[TEST_ORG_HEADER]; | |
| 119 | − if (!orgId || typeof orgId !== "string") { | |
| 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-id | |
| 121 | − return yield* Effect.die(new Error("missing x-test-org-id")); | |
| 151 | + const webRequest = yield* HttpServerRequest.toWeb(request); | |
| 152 | + const url = new URL(webRequest.url); | |
| 153 | + const orgId = orgHandleFromPath(url.pathname); | |
| 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 | 165 | const userHeader = request.headers[TEST_USER_HEADER]; |
| 124 | 166 | const userId = |
| 125 | 167 | typeof userHeader === "string" && userHeader.length > 0 |
| @@ -150,9 +192,18 @@ const TestExecutionStackMiddleware = HttpRouter.middleware<{ · 24 unmodified lines | ||
| 150 | 192 | }), |
| 151 | 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. | |
| 199 | +const PrefixedRouterLayer = Layer.effect(HttpRouter.HttpRouter)( | |
| 200 | + Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed("/api/:org")), | |
| 201 | +); | |
| 202 | + | |
| 153 | 203 | const TestApiLive = HttpApiBuilder.layer(ProtectedCloudApi).pipe( |
| 154 | 204 | Layer.provide(ProtectedCloudApiHandlers), |
| 155 | 205 | Layer.provide(TestExecutionStackMiddleware), |
| 206 | + Layer.provide(PrefixedRouterLayer), | |
| 156 | 207 | Layer.provideMerge(HttpApiSwagger.layer(ProtectedCloudApi, { path: "/docs" })), |
| 157 | 208 | Layer.provideMerge(RouterConfig), |
| 158 | 209 | Layer.provideMerge(DbService.Live), |
| @@ -161,25 +212,43 @@ const TestApiLive = HttpApiBuilder.layer(ProtectedCloudApi).pipe( · 2 unmodified lines | ||
| 161 | 212 | |
| 162 | 213 | const 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. | |
| 220 | +const 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 | + | |
| 164 | 241 | export const fetchForOrg = (orgId: string): typeof globalThis.fetch => |
| 165 | − ((input: RequestInfo | URL, init?: RequestInit) => { | |
| 242 | + (async (input: RequestInfo | URL, init?: RequestInit) => { | |
| 166 | 243 | const base = input instanceof Request ? input : new Request(input, init); |
| 167 | − const req = new Request(base, { | |
| 168 | − headers: { ...Object.fromEntries(base.headers), [TEST_ORG_HEADER]: orgId }, | |
| 169 | − }); | |
| 244 | + const req = await rewriteRequestForOrg(base, orgId); | |
| 170 | 245 | return handler(req); |
| 171 | 246 | }) as typeof globalThis.fetch; |
| 172 | 247 | |
| 173 | 248 | export const fetchForUser = (userId: string, orgId: string): typeof globalThis.fetch => |
| 174 | − ((input: RequestInfo | URL, init?: RequestInit) => { | |
| 249 | + (async (input: RequestInfo | URL, init?: RequestInit) => { | |
| 175 | 250 | const base = input instanceof Request ? input : new Request(input, init); |
| 176 | − const req = new Request(base, { | |
| 177 | − headers: { | |
| 178 | − ...Object.fromEntries(base.headers), | |
| 179 | − [TEST_ORG_HEADER]: orgId, | |
| 180 | − [TEST_USER_HEADER]: userId, | |
| 181 | − }, | |
| 182 | − }); | |
| 251 | + const req = await rewriteRequestForOrg(base, orgId, { [TEST_USER_HEADER]: userId }); | |
| 183 | 252 | return handler(req); |
| 184 | 253 | }) as typeof globalThis.fetch; |
| 185 | 254 | |
| @@ -1,5 +1,4 @@ | ||
| 1 | 1 | import React, { createContext, useContext, useEffect } from "react"; |
| 2 | −import * as Atom from "effect/unstable/reactivity/Atom"; | |
| 3 | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 4 | 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | 4 | import { usePostHog } from "posthog-js/react"; |
| @@ -18,8 +17,9 @@ type AuthUser = { · 12 unmodified lines | ||
| 18 | 17 | avatarUrl: string | null; |
| 19 | 18 | }; |
| 20 | 19 | |
| 21 | −type AuthOrganization = { | |
| 20 | +export type AuthOrganization = { | |
| 22 | 21 | id: string; |
| 22 | + handle: string; | |
| 23 | 23 | name: string; |
| 24 | 24 | }; |
| 25 | 25 | |
| @@ -32,14 +32,6 @@ export const authAtom = CloudApiClient.query("cloudAuth", "me", { · 6 unmodified lines | ||
| 32 | 32 | reactivityKeys: [ReactivityKey.auth], |
| 33 | 33 | }); |
| 34 | 34 | |
| 35 | −export const organizationsAtom = Atom.refreshOnWindowFocus( | |
| 36 | − CloudApiClient.query("cloudAuth", "organizations", { | |
| 37 | − timeToLive: "1 minute", | |
| 38 | − reactivityKeys: [ReactivityKey.auth], | |
| 39 | − }), | |
| 40 | −); | |
| 41 | − | |
| 42 | −export const switchOrganization = CloudApiClient.mutation("cloudAuth", "switchOrganization"); | |
| 43 | 35 | export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization"); |
| 44 | 36 | |
| 45 | 37 | export const pendingInvitationsAtom = CloudApiClient.query("cloudAuth", "pendingInvitations", { |
| @@ -56,7 +48,11 @@ export const acceptInvitation = CloudApiClient.mutation("cloudAuth", "acceptInvi · 10 unmodified lines | ||
| 56 | 48 | type AuthState = |
| 57 | 49 | | { status: "loading" } |
| 58 | 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 | |
| 61 | 57 | const AuthContext = createContext<AuthState>({ status: "loading" }); |
| 62 | 58 | |
| @@ -71,7 +67,7 @@ const AuthProviderClient = ({ children }: { children: React.ReactNode }) => { · 8 unmodified lines | ||
| 71 | 67 | onSuccess: ({ value }) => ({ |
| 72 | 68 | status: "authenticated" as const, |
| 73 | 69 | user: value.user, |
| 74 | − organization: value.organization, | |
| 70 | + organizations: value.organizations, | |
| 75 | 71 | }), |
| 76 | 72 | onFailure: () => ({ status: "unauthenticated" as const }), |
| 77 | 73 | }); |
| @@ -79,21 +75,25 @@ const AuthProviderClient = ({ children }: { children: React.ReactNode }) => { · 1 unmodified line | ||
| 79 | 75 | const userId = state.status === "authenticated" ? state.user.id : null; |
| 80 | 76 | const email = state.status === "authenticated" ? state.user.email : null; |
| 81 | 77 | const name = state.status === "authenticated" ? state.user.name : null; |
| 82 | − const orgId = state.status === "authenticated" ? (state.organization?.id ?? null) : null; | |
| 83 | − const orgName = state.status === "authenticated" ? (state.organization?.name ?? null) : null; | |
| 78 | + // PostHog org grouping uses the first membership; the user can navigate | |
| 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 | 84 | const isUnauthenticated = state.status === "unauthenticated"; |
| 85 | 85 | |
| 86 | 86 | useEffect(() => { |
| 87 | 87 | if (!posthog) return; |
| 88 | 88 | if (userId) { |
| 89 | 89 | posthog.identify(userId, { email, name }); |
| 90 | − if (orgId) { | |
| 91 | − posthog.group("organization", orgId, { name: orgName }); | |
| 90 | + if (firstOrgId) { | |
| 91 | + posthog.group("organization", firstOrgId, { name: firstOrgName }); | |
| 92 | 92 | } |
| 93 | 93 | } else if (isUnauthenticated) { |
| 94 | 94 | posthog.reset(); |
| 95 | 95 | } |
| 96 | − }, [posthog, userId, email, name, orgId, orgName, isUnauthenticated]); | |
| 96 | + }, [posthog, userId, email, name, firstOrgId, firstOrgName, isUnauthenticated]); | |
| 97 | 97 | |
| 98 | 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 | 105 | return <AuthProviderClient>{children}</AuthProviderClient>; |
| 106 | 106 | }; |
| 107 | + | |
| 108 | +/** Find the organization in the auth state matching a given URL handle. */ | |
| 109 | +export 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 | +}; | |
| @@ -7,7 +7,7 @@ import { Label } from "@executor-js/react/components/label"; · 6 unmodified lines | ||
| 7 | 7 | |
| 8 | 8 | import { createOrganization } from "../auth"; |
| 9 | 9 | |
| 10 | −type CreatedOrganization = { id: string; name: string }; | |
| 10 | +type CreatedOrganization = { id: string; handle: string; name: string }; | |
| 11 | 11 | |
| 12 | 12 | export function useCreateOrganizationForm(options: { |
| 13 | 13 | defaultName?: string; |
| @@ -0,0 +1,31 @@ | ||
| 1 | +import 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 | + | |
| 9 | +export type OrgRouteValue = { | |
| 10 | + readonly orgId: string; | |
| 11 | + readonly orgName: string; | |
| 12 | + readonly orgHandle: string; | |
| 13 | +}; | |
| 14 | + | |
| 15 | +export const OrgRouteContext = createContext<OrgRouteValue | null>(null); | |
| 16 | + | |
| 17 | +export const OrgRouteProvider = (props: { value: OrgRouteValue; children: React.ReactNode }) => ( | |
| 18 | + <OrgRouteContext.Provider value={props.value}>{props.children}</OrgRouteContext.Provider> | |
| 19 | +); | |
| 20 | + | |
| 21 | +export 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. */ | |
| 31 | +export const useOrgRouteOptional = (): OrgRouteValue | null => useContext(OrgRouteContext); | |
| @@ -1,8 +1,7 @@ | ||
| 1 | 1 | import { Link, Outlet, useLocation } from "@tanstack/react-router"; |
| 2 | 2 | import { useEffect, useRef, useState } from "react"; |
| 3 | −import { useAtomValue, useAtomSet } from "@effect/atom-react"; | |
| 3 | +import { useAtomValue } from "@effect/atom-react"; | |
| 4 | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | −import * as Exit from "effect/Exit"; | |
| 6 | 5 | import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; |
| 7 | 6 | import { useScope } from "@executor-js/react/api/scope-context"; |
| 8 | 7 | import { Button } from "@executor-js/react/components/button"; |
| @@ -29,36 +28,95 @@ import { · 20 unmodified lines | ||
| 29 | 28 | } from "@executor-js/react/components/dropdown-menu"; |
| 30 | 29 | import { SourceFavicon } from "@executor-js/react/components/source-favicon"; |
| 31 | 30 | import { CommandPalette } from "@executor-js/react/components/command-palette"; |
| 32 | −import { authWriteKeys } from "@executor-js/react/api/reactivity-keys"; | |
| 33 | 31 | import { AUTH_PATHS } from "../auth/api"; |
| 34 | −import { organizationsAtom, switchOrganization, useAuth } from "./auth"; | |
| 32 | +import { useAuth } from "./auth"; | |
| 33 | +import { useOrgRoute } from "./org-route"; | |
| 35 | 34 | import { |
| 36 | 35 | CreateOrganizationFields, |
| 37 | 36 | useCreateOrganizationForm, |
| 38 | 37 | } from "./components/create-organization-form"; |
| 39 | 38 | |
| 40 | −// ── NavItem ────────────────────────────────────────────────────────────── | |
| 39 | +// ── ShellSkeleton ──────────────────────────────────────────────────────── | |
| 41 | 40 | |
| 42 | −function NavItem(props: { to: string; label: string; active: boolean; onNavigate?: () => void }) { | |
| 41 | +export function ShellSkeleton() { | |
| 43 | 42 | return ( |
| 44 | − <Link | |
| 45 | − to={props.to} | |
| 46 | − onClick={props.onNavigate} | |
| 47 | − className={[ | |
| 48 | − "flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors", | |
| 49 | − props.active | |
| 50 | − ? "bg-sidebar-active text-foreground font-medium" | |
| 51 | − : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground", | |
| 52 | − ].join(" ")} | |
| 53 | − > | |
| 54 | − {props.label} | |
| 55 | − </Link> | |
| 43 | + <div className="flex h-screen overflow-hidden"> | |
| 44 | + {/* Desktop sidebar skeleton */} | |
| 45 | + <aside className="hidden w-52 shrink-0 border-r border-sidebar-border bg-sidebar md:flex md:flex-col lg:w-56"> | |
| 46 | + <div className="flex h-12 shrink-0 items-center border-b border-sidebar-border px-4"> | |
| 47 | + <Skeleton className="h-4 w-20" /> | |
| 48 | + </div> | |
| 49 | + <nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-2"> | |
| 50 | + <Skeleton className="h-7 w-full rounded-md" /> | |
| 51 | + <Skeleton className="h-7 w-full rounded-md" /> | |
| 52 | + <Skeleton className="h-7 w-full rounded-md" /> | |
| 53 | + <Skeleton className="h-7 w-full rounded-md" /> | |
| 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 | + | |
| 108 | +const 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 | 116 | // ── SourceList ─────────────────────────────────────────────────────────── |
| 60 | 117 | |
| 61 | 118 | function SourceList(props: { pathname: string; onNavigate?: () => void }) { |
| 119 | + const { orgHandle } = useOrgRoute(); | |
| 62 | 120 | const scopeId = useScope(); |
| 63 | 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 | 143 | <div className="flex flex-col gap-px"> |
| 86 | 144 | {value.map((s) => { |
| 87 | − const detailPath = `/sources/${s.id}`; | |
| 145 | + const detailPath = `/${orgHandle}/sources/${s.id}`; | |
| 88 | 146 | const active = |
| 89 | 147 | props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`); |
| 90 | 148 | return ( |
| 91 | 149 | <Link |
| 92 | 150 | key={s.id} |
| 93 | − to="/sources/$namespace" | |
| 94 | − params={{ namespace: s.id }} | |
| 151 | + to="/$org/sources/$namespace" | |
| 152 | + params={{ org: orgHandle, namespace: s.id }} | |
| 95 | 153 | onClick={props.onNavigate} |
| 96 | 154 | className={[ |
| 97 | 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 | |
| 150 | 208 | function OrganizationSwitcherItems(props: { activeOrganizationId: string | null }) { |
| 151 | − const organizations = useAtomValue(organizationsAtom); | |
| 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 | − }; | |
| 209 | + const auth = useAuth(); | |
| 162 | 210 | |
| 163 | − return AsyncResult.match(organizations, { | |
| 164 | − onInitial: () => <DropdownMenuItem disabled>Loading…</DropdownMenuItem>, | |
| 165 | − onFailure: () => <DropdownMenuItem disabled>Failed to load organizations</DropdownMenuItem>, | |
| 166 | − onSuccess: ({ value }) => | |
| 167 | − value.organizations.length === 0 ? ( | |
| 168 | − <DropdownMenuItem disabled>No organizations</DropdownMenuItem> | |
| 169 | − ) : ( | |
| 170 | − <> | |
| 171 | − {value.organizations.map((organization: { id: string; name: string }) => { | |
| 172 | − const isActive = organization.id === props.activeOrganizationId; | |
| 173 | − return ( | |
| 174 | − <DropdownMenuItem | |
| 175 | − key={organization.id} | |
| 176 | − disabled={isActive} | |
| 177 | − onClick={() => handleSwitch(organization.id)} | |
| 178 | − className="text-xs" | |
| 179 | − > | |
| 180 | − <span className="min-w-0 flex-1 truncate">{organization.name}</span> | |
| 181 | − {isActive && <CheckIcon />} | |
| 182 | − </DropdownMenuItem> | |
| 183 | − ); | |
| 184 | − })} | |
| 185 | − </> | |
| 186 | − ), | |
| 187 | − }); | |
| 211 | + if (auth.status !== "authenticated") { | |
| 212 | + return <DropdownMenuItem disabled>Loading…</DropdownMenuItem>; | |
| 213 | + } | |
| 214 | + if (auth.organizations.length === 0) { | |
| 215 | + return <DropdownMenuItem disabled>No organizations</DropdownMenuItem>; | |
| 216 | + } | |
| 217 | + return ( | |
| 218 | + <> | |
| 219 | + {auth.organizations.map((organization) => { | |
| 220 | + const isActive = organization.id === props.activeOrganizationId; | |
| 221 | + return ( | |
| 222 | + <DropdownMenuItem key={organization.id} disabled={isActive} className="text-xs" asChild> | |
| 223 | + <Link | |
| 224 | + to="/$org" | |
| 225 | + params={{ org: organization.handle }} | |
| 226 | + className="flex w-full items-center gap-2" | |
| 227 | + > | |
| 228 | + <span className="min-w-0 flex-1 truncate">{organization.name}</span> | |
| 229 | + {isActive && <CheckIcon />} | |
| 230 | + </Link> | |
| 231 | + </DropdownMenuItem> | |
| 232 | + ); | |
| 233 | + })} | |
| 234 | + </> | |
| 235 | + ); | |
| 188 | 236 | } |
| 189 | 237 | |
| 190 | 238 | function CheckIcon() { |
| @@ -203,6 +251,7 @@ function CheckIcon() { · 12 unmodified lines | ||
| 203 | 251 | |
| 204 | 252 | function UserFooter() { |
| 205 | 253 | const auth = useAuth(); |
| 254 | + const orgRoute = useOrgRoute(); | |
| 206 | 255 | const [createOrganizationOpen, setCreateOrganizationOpen] = useState(false); |
| 207 | 256 | |
| 208 | 257 | const suggestedOrganizationName = |
| @@ -212,7 +261,15 @@ function UserFooter() { · 3 unmodified lines | ||
| 212 | 261 | |
| 213 | 262 | const form = useCreateOrganizationForm({ |
| 214 | 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 | 275 | if (auth.status !== "authenticated") return null; |
| @@ -243,9 +300,7 @@ function UserFooter() { · 24 unmodified lines | ||
| 243 | 300 | <p className="truncate text-xs font-medium text-foreground"> |
| 244 | 301 | {auth.user.name ?? auth.user.email} |
| 245 | 302 | </p> |
| 246 | − {auth.organization && ( | |
| 247 | − <p className="truncate text-xs text-muted-foreground">{auth.organization.name}</p> | |
| 248 | − )} | |
| 303 | + <p className="truncate text-xs text-muted-foreground">{orgRoute.orgName}</p> | |
| 249 | 304 | </div> |
| 250 | 305 | <svg |
| 251 | 306 | viewBox="0 0 16 16" |
| @@ -268,12 +323,10 @@ function UserFooter() { · 16 unmodified lines | ||
| 268 | 323 | </DropdownMenuLabel> |
| 269 | 324 | <DropdownMenuSub> |
| 270 | 325 | <DropdownMenuSubTrigger className="text-xs"> |
| 271 | − <span className="min-w-0 flex-1 truncate"> | |
| 272 | − {auth.organization?.name ?? "No organization"} | |
| 273 | − </span> | |
| 326 | + <span className="min-w-0 flex-1 truncate">{orgRoute.orgName}</span> | |
| 274 | 327 | </DropdownMenuSubTrigger> |
| 275 | 328 | <DropdownMenuSubContent className="w-56"> |
| 276 | − <OrganizationSwitcherItems activeOrganizationId={auth.organization?.id ?? null} /> | |
| 329 | + <OrganizationSwitcherItems activeOrganizationId={orgRoute.orgId} /> | |
| 277 | 330 | <DropdownMenuSeparator /> |
| 278 | 331 | <DropdownMenuItem |
| 279 | 332 | className="text-xs" |
| @@ -354,40 +407,77 @@ function UserFooter() { · 74 unmodified lines | ||
| 354 | 407 | // ── SidebarContent ─────────────────────────────────────────────────────── |
| 355 | 408 | |
| 356 | 409 | function SidebarContent(props: { pathname: string; onNavigate?: () => void; showBrand?: boolean }) { |
| 357 | − const isHome = props.pathname === "/"; | |
| 358 | − const isSecrets = props.pathname === "/secrets"; | |
| 359 | − const isConnections = props.pathname === "/connections"; | |
| 360 | − const isPolicies = props.pathname === "/policies"; | |
| 361 | − const isBilling = props.pathname === "/billing" || props.pathname.startsWith("/billing/"); | |
| 362 | − const isOrg = props.pathname === "/org"; | |
| 410 | + const { orgHandle } = useOrgRoute(); | |
| 411 | + const orgPrefix = `/${orgHandle}`; | |
| 412 | + const params = { org: orgHandle }; | |
| 413 | + const isHome = props.pathname === orgPrefix || props.pathname === `${orgPrefix}/`; | |
| 414 | + const isSecrets = props.pathname === `${orgPrefix}/secrets`; | |
| 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 | 422 | return ( |
| 365 | 423 | <> |
| 366 | 424 | {props.showBrand !== false && ( |
| 367 | 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 | 427 | <span className="font-display text-base tracking-tight text-foreground">executor</span> |
| 370 | 428 | </Link> |
| 371 | 429 | </div> |
| 372 | 430 | )} |
| 373 | 431 | |
| 374 | 432 | <nav className="flex flex-1 flex-col overflow-y-auto p-2"> |
| 375 | − <NavItem to="/" label="Sources" active={isHome} onNavigate={props.onNavigate} /> | |
| 376 | − <NavItem | |
| 377 | − to="/connections" | |
| 378 | − label="Connections" | |
| 379 | − active={isConnections} | |
| 380 | − onNavigate={props.onNavigate} | |
| 381 | − /> | |
| 382 | − <NavItem to="/secrets" label="Secrets" active={isSecrets} onNavigate={props.onNavigate} /> | |
| 383 | − <NavItem | |
| 384 | − to="/policies" | |
| 385 | − label="Policies" | |
| 386 | − active={isPolicies} | |
| 387 | − onNavigate={props.onNavigate} | |
| 388 | − /> | |
| 389 | − <NavItem to="/org" label="Organization" active={isOrg} onNavigate={props.onNavigate} /> | |
| 390 | − <NavItem to="/billing" label="Billing" active={isBilling} onNavigate={props.onNavigate} /> | |
| 433 | + <Link | |
| 434 | + to="/$org" | |
| 435 | + params={params} | |
| 436 | + onClick={props.onNavigate} | |
| 437 | + className={navItemClassName(isHome)} | |
| 438 | + > | |
| 439 | + Sources | |
| 440 | + </Link> | |
| 441 | + <Link | |
| 442 | + to="/$org/connections" | |
| 443 | + params={params} | |
| 444 | + onClick={props.onNavigate} | |
| 445 | + className={navItemClassName(isConnections)} | |
| 446 | + > | |
| 447 | + Connections | |
| 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 | 482 | <div className="mt-5 mb-1 px-2.5 text-xs font-medium uppercase tracking-widest text-muted-foreground"> |
| 393 | 483 | <span>Sources</span> |
| @@ -404,6 +494,7 @@ function SidebarContent(props: { pathname: string; onNavigate?: () => void; show · 10 unmodified lines | ||
| 404 | 494 | // ── Shell ───────────────────────────────────────────────────────────────── |
| 405 | 495 | |
| 406 | 496 | export function Shell() { |
| 497 | + const { orgHandle } = useOrgRoute(); | |
| 407 | 498 | const location = useLocation(); |
| 408 | 499 | const pathname = location.pathname; |
| 409 | 500 | const lastPathname = useRef(pathname); |
| @@ -443,7 +534,7 @@ export function Shell() { · 33 unmodified lines | ||
| 443 | 534 | /> |
| 444 | 535 | <div className="relative flex h-full w-[84vw] max-w-xs flex-col border-r border-sidebar-border bg-sidebar shadow-2xl"> |
| 445 | 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 | 538 | <span className="font-display text-base tracking-tight text-foreground"> |
| 448 | 539 | executor |
| 449 | 540 | </span> |
| @@ -496,7 +587,7 @@ export function Shell() { · 46 unmodified lines | ||
| 496 | 587 | /> |
| 497 | 588 | </svg> |
| 498 | 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 | 591 | <span className="font-display text-base tracking-tight text-foreground">executor</span> |
| 501 | 592 | </Link> |
| 502 | 593 | <div className="w-8 shrink-0" /> |