-
-
-
-
-
-
-
-
-
-
-
-
- Remove domain
-
-
-
+
+ {access.status === "loading" ? (
+
+ ) : access.canManageOrganization ? (
+
+
+
+
+
+
+
+
+
+
+
+
+ Remove domain
+
+
+
+ ) : null}
diff --git a/apps/cloud/src/web/components/org-menu-slot.tsx b/apps/cloud/src/web/components/org-menu-slot.tsx
index 085656616..a73813b98 100644
--- a/apps/cloud/src/web/components/org-menu-slot.tsx
+++ b/apps/cloud/src/web/components/org-menu-slot.tsx
@@ -23,6 +23,7 @@ import {
import { useAuth } from "../auth";
import { organizationsAtom } from "../auth";
import { CreateOrganizationFields, useCreateOrganizationForm } from "./create-organization-form";
+import { organizationNavigationHref } from "./org-navigation";
// ---------------------------------------------------------------------------
// Cloud-only org-switcher slot for the shared shell's account dropdown.
@@ -54,11 +55,11 @@ function OrganizationSwitcherItems(props: { activeOrganizationId: string | null
// Switching orgs is now a pure URL navigation: the session authenticates the
// user to ALL their orgs, and the slug in the path scopes every request (the
// `x-executor-organization` header). No cookie to rewrite, no server switch
- // call — just land on the other org's URL root and the whole app re-scopes.
+ // call, just land on the other org's URL and let the whole app re-scope.
const handleSwitch = (organization: { id: string; slug: string }) => {
if (organization.id === props.activeOrganizationId) return;
trackEvent("org_switched", { success: true });
- window.location.href = `/${organization.slug}`;
+ window.location.href = organizationNavigationHref(organization.slug, window.location);
};
return AsyncResult.match(organizations, {
@@ -101,10 +102,9 @@ export function OrgMenuSlot() {
const form = useCreateOrganizationForm({
defaultName: suggestedOrganizationName,
- // Land on the new org's URL root — a reload would keep the old slug and
- // the slug gate would switch the session right back.
+ // Keep the current route intent while replacing its organization scope.
onSuccess: (org) => {
- window.location.href = `/${org.slug}`;
+ window.location.href = organizationNavigationHref(org.slug, window.location);
},
});
diff --git a/apps/cloud/src/web/components/org-navigation.test.ts b/apps/cloud/src/web/components/org-navigation.test.ts
new file mode 100644
index 000000000..4e5f81b56
--- /dev/null
+++ b/apps/cloud/src/web/components/org-navigation.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "@effect/vitest";
+
+import { organizationNavigationHref } from "./org-navigation";
+
+describe("organizationNavigationHref", () => {
+ it("replaces the organization while preserving route, query, and hash", () => {
+ expect(
+ organizationNavigationHref("org-b", {
+ pathname: "/org-a/policies",
+ search: "?owner=user",
+ hash: "#rules",
+ }),
+ ).toBe("/org-b/policies?owner=user#rules");
+ });
+
+ it("adds an organization to a bare deep link", () => {
+ expect(
+ organizationNavigationHref("org-b", {
+ pathname: "/policies",
+ search: "?owner=org",
+ hash: "",
+ }),
+ ).toBe("/org-b/policies?owner=org");
+ });
+
+ it("lands a root route at the target organization root", () => {
+ expect(organizationNavigationHref("org-b", { pathname: "/", search: "", hash: "#top" })).toBe(
+ "/org-b#top",
+ );
+ });
+});
diff --git a/apps/cloud/src/web/components/org-navigation.ts b/apps/cloud/src/web/components/org-navigation.ts
new file mode 100644
index 000000000..3e8534a72
--- /dev/null
+++ b/apps/cloud/src/web/components/org-navigation.ts
@@ -0,0 +1,27 @@
+import { isValidOrgSlug } from "@executor-js/api";
+
+export type OrganizationNavigationLocation = {
+ readonly pathname: string;
+ readonly search: string;
+ readonly hash: string;
+};
+
+export const organizationNavigationHref = (
+ targetSlug: string,
+ location: OrganizationNavigationLocation,
+) => {
+ const segments = location.pathname.split("/");
+ const currentSlug = segments[1];
+ let pathname: string;
+
+ if (currentSlug && isValidOrgSlug(currentSlug)) {
+ segments[1] = targetSlug;
+ pathname = segments.join("/");
+ } else if (location.pathname === "/") {
+ pathname = `/${targetSlug}`;
+ } else {
+ pathname = `/${targetSlug}${location.pathname.startsWith("/") ? "" : "/"}${location.pathname}`;
+ }
+
+ return `${pathname}${location.search}${location.hash}`;
+};
diff --git a/apps/host-cloudflare/src/auth/cloudflare-access.ts b/apps/host-cloudflare/src/auth/cloudflare-access.ts
index 178f8818f..fc7d585db 100644
--- a/apps/host-cloudflare/src/auth/cloudflare-access.ts
+++ b/apps/host-cloudflare/src/auth/cloudflare-access.ts
@@ -58,7 +58,7 @@ export const principalFromAccessClaims = (
* `jose` caches + rotates the team JWKS, so build the verifier once per config.
*/
export const makeAccessVerifier = (config: CloudflareConfig) => {
- const issuer = `https://${config.accessTeamDomain}`;
+ const issuer = config.accessIssuerUrl ?? `https://${config.accessTeamDomain}`;
// Cached, lazily-fetched team signing keys; jose handles rotation + caching.
const jwks = createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`));
@@ -83,12 +83,18 @@ export const makeAccessVerifier = (config: CloudflareConfig) => {
if (!token) return null;
const verified = yield* Effect.tryPromise({
- try: () => jwtVerify(token, jwks, { issuer, audience: config.accessAud }),
+ try: () =>
+ jwtVerify(token, jwks, {
+ algorithms: ["RS256"],
+ issuer,
+ audience: config.accessAud,
+ }),
catch: () => "invalid access assertion",
}).pipe(Effect.orElseSucceed(() => null));
if (!verified) return null;
- return principalFromAccessClaims(verified.payload as Record