Skip to content

Commit 5a292da

Browse files
committed
fix(webapp): drop the organization-gated pages on an organization switch
Logs and Query are gated by an organization feature flag, and their loaders redirect home when it is off, so carrying either across an organization switch sent the user through `/` instead of straight into the organization they picked. They now travel with an environment or project switch — both stay inside the organization whose flag let the user open the page — and an organization switch falls back to Tasks, the same shape the branch lists already use. A test scans the routes below an environment for the ones that redirect home and asserts they are exactly the pages the organization switch drops, so a third gated page cannot be added without updating the list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nj3iCRvSP9sP7y7hxXVJbx
1 parent 5746dd8 commit 5a292da

5 files changed

Lines changed: 106 additions & 20 deletions

File tree

apps/webapp/app/hooks/useEnvironmentSwitcher.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useMatches } from "@remix-run/react";
22
import { type RuntimeEnvironment } from "@trigger.dev/database";
33
import {
44
ENVIRONMENT_MATCH_ID,
5+
organizationPortablePage,
56
pageBelowEnvironment,
67
pathForEnvironmentSwitch,
78
portablePageSearch,
@@ -43,13 +44,15 @@ export function useEnvironmentSwitcher() {
4344
export function usePageSwitcher() {
4445
const location = useOptimisticLocation();
4546
const environmentPathname = useEnvironmentPathname();
46-
const page = projectPortablePage(pageBelowEnvironment(location.pathname, environmentPathname));
47-
const search = portablePageSearch(page);
47+
const page = pageBelowEnvironment(location.pathname, environmentPathname);
48+
const projectSearch = portablePageSearch(projectPortablePage(page));
49+
const organizationSearch = portablePageSearch(organizationPortablePage(page));
4850

4951
return {
5052
urlForProject: (organization: OrgForPath, project: ProjectForPath) =>
51-
`${v3ProjectPath(organization, project)}${search}`,
52-
urlForOrganization: (organization: OrgForPath) => `${organizationPath(organization)}${search}`,
53+
`${v3ProjectPath(organization, project)}${projectSearch}`,
54+
urlForOrganization: (organization: OrgForPath) =>
55+
`${organizationPath(organization)}${organizationSearch}`,
5356
};
5457
}
5558

apps/webapp/app/routes/_app.orgs.$organizationSlug._index/route.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { prisma } from "~/db.server";
33
import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server";
44
import { logger } from "~/services/logger.server";
55
import { requireUser } from "~/services/session.server";
6-
import { portablePageSearch, requestedPortablePage } from "~/utils/pageSwitching";
6+
import { portablePageSearch, requestedOrganizationPortablePage } from "~/utils/pageSwitching";
77
import {
88
newOrganizationPath,
99
newProjectPath,
@@ -52,5 +52,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
5252

5353
const projectPath = v3ProjectPath({ slug: organizationSlug }, bestProject);
5454

55-
return redirect(`${projectPath}${portablePageSearch(requestedPortablePage(request))}`);
55+
return redirect(
56+
`${projectPath}${portablePageSearch(requestedOrganizationPortablePage(request))}`
57+
);
5658
};

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam._index/route.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
22
import { prisma } from "~/db.server";
33
import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server";
44
import { requireUser } from "~/services/session.server";
5-
import { pagePath, requestedPortablePage } from "~/utils/pageSwitching";
5+
import { pagePath, requestedProjectPortablePage } from "~/utils/pageSwitching";
66
import { ProjectParamSchema, v3EnvironmentPath } from "~/utils/pathBuilder";
77

88
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
@@ -43,5 +43,5 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
4343

4444
const environmentPath = v3EnvironmentPath({ slug: organizationSlug }, project, environment);
4545

46-
return redirect(pagePath(environmentPath, requestedPortablePage(request)));
46+
return redirect(pagePath(environmentPath, requestedProjectPortablePage(request)));
4747
};

apps/webapp/app/utils/pageSwitching.test.ts

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,18 @@ import {
77
ENVIRONMENT_MATCH_ID,
88
ENVIRONMENT_PORTABLE_PAGES,
99
environmentPortablePage,
10+
ORGANIZATION_PORTABLE_PAGES,
11+
ORGANIZATION_SPECIFIC_PAGES,
12+
organizationPortablePage,
1013
pageBelowEnvironment,
1114
pagePath,
1215
pathForEnvironmentSwitch,
1316
portablePageSearch,
1417
PROJECT_PORTABLE_PAGES,
1518
PROJECT_SPECIFIC_PAGES,
1619
projectPortablePage,
17-
requestedPortablePage,
20+
requestedOrganizationPortablePage,
21+
requestedProjectPortablePage,
1822
} from "./pageSwitching";
1923

2024
const APP_DIR = join(__dirname, "..");
@@ -40,11 +44,16 @@ function rendersAPage(file: string): boolean {
4044
return /^export default/m.test(readFileSync(join(APP_DIR, file), "utf8"));
4145
}
4246

47+
function sendsYouHome(file: string): boolean {
48+
return /redirect\("\/"\)/.test(readFileSync(join(APP_DIR, file), "utf8"));
49+
}
50+
4351
const belowEnvironment = Object.values(compiledRoutes)
4452
.filter((route) => compiledUrl(route.id).startsWith(ENVIRONMENT_URL))
4553
.map((route) => ({
4654
suffix: compiledUrl(route.id).slice(ENVIRONMENT_URL.length).replace(/^\//, ""),
4755
rendersAPage: rendersAPage(route.file),
56+
sendsYouHome: sendsYouHome(route.file),
4857
}));
4958

5059
const environmentRoutes = [...new Set(belowEnvironment.map((route) => route.suffix))];
@@ -122,8 +131,12 @@ describe("portable pages", () => {
122131
for (const page of PROJECT_PORTABLE_PAGES) {
123132
expect(projectPortablePage(page)).toBe(page);
124133
}
134+
for (const page of ORGANIZATION_PORTABLE_PAGES) {
135+
expect(organizationPortablePage(page)).toBe(page);
136+
}
125137
expect(environmentPortablePage("")).toBe("");
126138
expect(projectPortablePage("")).toBe("");
139+
expect(organizationPortablePage("")).toBe("");
127140
});
128141

129142
it("include the pages named in the request", () => {
@@ -133,7 +146,7 @@ describe("portable pages", () => {
133146
});
134147
});
135148

136-
describe("pages a project or organization switch cannot carry", () => {
149+
describe("pages a project switch cannot carry", () => {
137150
it("are the branch lists, which not every project has", () => {
138151
expect([...PROJECT_SPECIFIC_PAGES].sort()).toEqual(["branches", "dev-branches"]);
139152

@@ -174,10 +187,53 @@ describe("pages a project or organization switch cannot carry", () => {
174187
it("fall back to the tasks page when the project changes", () => {
175188
expect(portablePageSearch(projectPortablePage("branches"))).toBe("");
176189
expect(portablePageSearch(projectPortablePage("dev-branches"))).toBe("");
177-
expect(requestedPortablePage(new Request("http://localhost/orgs/acme?page=branches"))).toBe("");
178-
expect(requestedPortablePage(new Request("http://localhost/orgs/acme?page=dev-branches"))).toBe(
179-
""
180-
);
190+
expect(
191+
requestedProjectPortablePage(new Request("http://localhost/orgs/acme?page=branches"))
192+
).toBe("");
193+
expect(
194+
requestedProjectPortablePage(new Request("http://localhost/orgs/acme?page=dev-branches"))
195+
).toBe("");
196+
});
197+
});
198+
199+
describe("pages an organization switch cannot carry", () => {
200+
it("are the ones whose loaders send you home when the organization is not allowed in", () => {
201+
const sendHome = [
202+
...new Set(
203+
belowEnvironment.filter((route) => route.sendsYouHome).map((route) => route.suffix)
204+
),
205+
].sort();
206+
207+
expect(sendHome).toEqual([...ORGANIZATION_SPECIFIC_PAGES].sort());
208+
});
209+
210+
it("still travel with an environment or project switch, which stay in the same organization", () => {
211+
for (const page of ORGANIZATION_SPECIFIC_PAGES) {
212+
expect(ENVIRONMENT_PORTABLE_PAGES.has(page)).toBe(true);
213+
expect(PROJECT_PORTABLE_PAGES.has(page)).toBe(true);
214+
expect(ORGANIZATION_PORTABLE_PAGES.has(page)).toBe(false);
215+
expect(environmentPortablePage(page)).toBe(page);
216+
expect(projectPortablePage(page)).toBe(page);
217+
expect(organizationPortablePage(page)).toBe("");
218+
}
219+
});
220+
221+
it("are otherwise the same list, so nothing else is quietly dropped", () => {
222+
const dropped = [...PROJECT_PORTABLE_PAGES]
223+
.filter((page) => !ORGANIZATION_PORTABLE_PAGES.has(page))
224+
.sort();
225+
226+
expect(dropped).toEqual([...ORGANIZATION_SPECIFIC_PAGES].sort());
227+
});
228+
229+
it("fall back to the tasks page when the organization changes", () => {
230+
const read = (search: string) =>
231+
requestedOrganizationPortablePage(new Request(`http://localhost/orgs/acme${search}`));
232+
233+
expect(portablePageSearch(organizationPortablePage("logs"))).toBe("");
234+
expect(read("?page=logs")).toBe("");
235+
expect(read("?page=query")).toBe("");
236+
expect(read("?page=apikeys")).toBe("apikeys");
181237
});
182238
});
183239

@@ -194,6 +250,7 @@ describe("pages named after a resource", () => {
194250

195251
expect(leaks(environmentPortablePage, ENVIRONMENT_PORTABLE_PAGES)).toEqual([]);
196252
expect(leaks(projectPortablePage, PROJECT_PORTABLE_PAGES)).toEqual([]);
253+
expect(leaks(organizationPortablePage, ORGANIZATION_PORTABLE_PAGES)).toEqual([]);
197254
});
198255

199256
it("truncate to the list they were reached from", () => {
@@ -272,6 +329,10 @@ describe("a page suffix that is not a plain relative page", () => {
272329
environmentPortablePage(attempt) === "" ||
273330
ENVIRONMENT_PORTABLE_PAGES.has(environmentPortablePage(attempt))
274331
).toBe(true);
332+
expect(
333+
organizationPortablePage(attempt) === "" ||
334+
ORGANIZATION_PORTABLE_PAGES.has(organizationPortablePage(attempt))
335+
).toBe(true);
275336
}
276337
});
277338

@@ -392,7 +453,7 @@ describe("carrying a page across a project or organization switch", () => {
392453

393454
it("reads the page back off the request, validating it again", () => {
394455
const read = (search: string) =>
395-
requestedPortablePage(new Request(`http://localhost/orgs/acme${search}`));
456+
requestedProjectPortablePage(new Request(`http://localhost/orgs/acme${search}`));
396457

397458
expect(read("?page=apikeys")).toBe("apikeys");
398459
expect(read("?page=waitpoints/tokens")).toBe("waitpoints/tokens");

apps/webapp/app/utils/pageSwitching.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ const NESTED_PORTABLE_PAGES = [
2222
/** The branch lists render under any environment of their project, but not in every project. */
2323
export const PROJECT_SPECIFIC_PAGES = ["branches", "dev-branches"];
2424

25+
/** Gated by an organization feature flag, so their loaders send you home from an organization without it. */
26+
export const ORGANIZATION_SPECIFIC_PAGES = ["logs", "query"];
27+
2528
/** Every page below an environment that names no resource, so any environment can render it. */
2629
export const ENVIRONMENT_PORTABLE_PAGES: ReadonlySet<string> = new Set(
2730
[
@@ -30,11 +33,16 @@ export const ENVIRONMENT_PORTABLE_PAGES: ReadonlySet<string> = new Set(
3033
].filter((page) => page !== "")
3134
);
3235

33-
/** The ones every project has, which is what a project or organization switch can carry. */
36+
/** The ones every project has, which is what a project switch can carry. */
3437
export const PROJECT_PORTABLE_PAGES: ReadonlySet<string> = new Set(
3538
[...ENVIRONMENT_PORTABLE_PAGES].filter((page) => !PROJECT_SPECIFIC_PAGES.includes(page))
3639
);
3740

41+
/** The ones every organization has, which is what an organization switch can carry. */
42+
export const ORGANIZATION_PORTABLE_PAGES: ReadonlySet<string> = new Set(
43+
[...PROJECT_PORTABLE_PAGES].filter((page) => !ORGANIZATION_SPECIFIC_PAGES.includes(page))
44+
);
45+
3846
/**
3947
* The nearest page above `suffix` in `pages`, as a path relative to the environment. A page named
4048
* after a resource truncates to its list page, and anything else — an unknown page, or a suffix
@@ -56,14 +64,26 @@ export function environmentPortablePage(suffix: string): string {
5664
return nearestPage(suffix, ENVIRONMENT_PORTABLE_PAGES);
5765
}
5866

59-
/** The page to keep when the project or organization changes. */
67+
/** The page to keep when the project changes. */
6068
export function projectPortablePage(suffix: string): string {
6169
return nearestPage(suffix, PROJECT_PORTABLE_PAGES);
6270
}
6371

64-
export function requestedPortablePage(request: Request): string {
65-
const requested = new URL(request.url).searchParams.get(PORTABLE_PAGE_PARAM);
66-
return projectPortablePage(requested ?? "");
72+
/** The page to keep when the organization changes. */
73+
export function organizationPortablePage(suffix: string): string {
74+
return nearestPage(suffix, ORGANIZATION_PORTABLE_PAGES);
75+
}
76+
77+
function requestedPage(request: Request): string {
78+
return new URL(request.url).searchParams.get(PORTABLE_PAGE_PARAM) ?? "";
79+
}
80+
81+
export function requestedProjectPortablePage(request: Request): string {
82+
return projectPortablePage(requestedPage(request));
83+
}
84+
85+
export function requestedOrganizationPortablePage(request: Request): string {
86+
return organizationPortablePage(requestedPage(request));
6787
}
6888

6989
export function portablePageSearch(page: string): string {

0 commit comments

Comments
 (0)