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