Skip to content

Commit df34722

Browse files
committed
feat: require owner role for org and project management dashboard actions
1 parent bab7e55 commit df34722

3 files changed

Lines changed: 256 additions & 209 deletions

File tree

  • apps/webapp/app/routes
    • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions
    • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general
    • _app.orgs.$organizationSlug.settings._index

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx

Lines changed: 45 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
MapPinIcon,
88
} from "@heroicons/react/20/solid";
99
import { Form } from "@remix-run/react";
10-
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
10+
import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
1111
import { tryCatch } from "@trigger.dev/core";
1212
import { useState } from "react";
1313
import { typedjson, useTypedLoaderData } from "remix-typedjson";
@@ -51,9 +51,11 @@ import { useFeatures } from "~/hooks/useFeatures";
5151
import { useOrganization } from "~/hooks/useOrganizations";
5252
import { useHasAdminAccess } from "~/hooks/useUser";
5353
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
54+
import { resolveOrgIdFromSlug } from "~/models/organization.server";
5455
import { findProjectBySlug } from "~/models/project.server";
5556
import { type Region, RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
5657
import { requireUser } from "~/services/session.server";
58+
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
5759
import {
5860
docsPath,
5961
EnvironmentParamSchema,
@@ -90,44 +92,56 @@ const FormSchema = z.object({
9092
regionId: z.string(),
9193
});
9294

93-
export const action = async ({ request, params }: ActionFunctionArgs) => {
94-
const user = await requireUser(request);
95-
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
95+
export const action = dashboardAction(
96+
{
97+
params: EnvironmentParamSchema,
98+
context: async (params) => {
99+
const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
100+
return orgId ? { organizationId: orgId } : {};
101+
},
102+
},
103+
async ({ user, ability, request, params }) => {
104+
const { organizationSlug, projectParam, envParam } = params;
96105

97-
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
106+
if (!ability.can("manage", { type: "project" })) {
107+
return json({ ok: false, error: "Unauthorized" } as const, { status: 403 });
108+
}
98109

99-
const redirectPath = regionsPath(
100-
{ slug: organizationSlug },
101-
{ slug: projectParam },
102-
{ slug: envParam }
103-
);
110+
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
104111

105-
if (!project) {
106-
throw redirectWithErrorMessage(redirectPath, request, "Project not found");
107-
}
112+
const redirectPath = regionsPath(
113+
{ slug: organizationSlug },
114+
{ slug: projectParam },
115+
{ slug: envParam }
116+
);
108117

109-
const formData = await request.formData();
110-
const parsedFormData = FormSchema.safeParse(Object.fromEntries(formData));
118+
if (!project) {
119+
throw redirectWithErrorMessage(redirectPath, request, "Project not found");
120+
}
111121

112-
if (!parsedFormData.success) {
113-
throw redirectWithErrorMessage(redirectPath, request, "No region specified");
114-
}
122+
const formData = await request.formData();
123+
const parsedFormData = FormSchema.safeParse(Object.fromEntries(formData));
115124

116-
const service = new SetDefaultRegionService();
117-
const [error, result] = await tryCatch(
118-
service.call({
119-
projectId: project.id,
120-
regionId: parsedFormData.data.regionId,
121-
isAdmin: user.admin || user.isImpersonating,
122-
})
123-
);
125+
if (!parsedFormData.success) {
126+
throw redirectWithErrorMessage(redirectPath, request, "No region specified");
127+
}
124128

125-
if (error) {
126-
return redirectWithErrorMessage(redirectPath, request, error.message);
127-
}
129+
const service = new SetDefaultRegionService();
130+
const [error, result] = await tryCatch(
131+
service.call({
132+
projectId: project.id,
133+
regionId: parsedFormData.data.regionId,
134+
isAdmin: user.admin || user.isImpersonating,
135+
})
136+
);
128137

129-
return redirectWithSuccessMessage(redirectPath, request, `Set ${result.name} as default`);
130-
};
138+
if (error) {
139+
return redirectWithErrorMessage(redirectPath, request, error.message);
140+
}
141+
142+
return redirectWithSuccessMessage(redirectPath, request, `Set ${result.name} as default`);
143+
}
144+
);
131145

132146
export default function Page() {
133147
const { regions, isPaying: _isPaying } = useTypedLoaderData<typeof loader>();

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx

Lines changed: 90 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { getFormProps, getInputProps, useForm } from "@conform-to/react";
22
import { conformZodMessage, parseWithZod } from "@conform-to/zod";
33
import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid";
44
import { Form, useActionData, useNavigation } from "@remix-run/react";
5-
import { type ActionFunction, json } from "@remix-run/server-runtime";
5+
import { json } from "@remix-run/server-runtime";
66
import { z } from "zod";
77
import { InlineCode } from "~/components/code/InlineCode";
88
import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout";
@@ -19,9 +19,10 @@ import { Label } from "~/components/primitives/Label";
1919
import { SpinnerWhite } from "~/components/primitives/Spinner";
2020
import { useProject } from "~/hooks/useProject";
2121
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
22+
import { resolveOrgIdFromSlug } from "~/models/organization.server";
2223
import { ProjectSettingsService } from "~/services/projectSettings.server";
2324
import { logger } from "~/services/logger.server";
24-
import { requireUserId } from "~/services/session.server";
25+
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
2526
import { organizationPath, v3ProjectPath } from "~/utils/pathBuilder";
2627
import { useState } from "react";
2728

@@ -59,98 +60,112 @@ function createSchema(
5960
]);
6061
}
6162

62-
export const action: ActionFunction = async ({ request, params }) => {
63-
const userId = await requireUserId(request);
64-
const { organizationSlug, projectParam } = params;
65-
if (!organizationSlug || !projectParam) {
66-
return json(
67-
{ errors: { body: "organizationSlug and projectParam are required" } },
68-
{ status: 400 }
69-
);
70-
}
71-
72-
const formData = await request.formData();
63+
const Params = z.object({
64+
organizationSlug: z.string(),
65+
projectParam: z.string(),
66+
});
7367

74-
const schema = createSchema({
75-
getSlugMatch: (slug) => {
76-
return { isMatch: slug === projectParam, projectSlug: projectParam };
68+
export const action = dashboardAction(
69+
{
70+
params: Params,
71+
context: async (params) => {
72+
const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
73+
return orgId ? { organizationId: orgId } : {};
7774
},
78-
});
79-
const submission = parseWithZod(formData, { schema });
75+
},
76+
async ({ user, ability, request, params }) => {
77+
const userId = user.id;
78+
const { organizationSlug, projectParam } = params;
8079

81-
if (submission.status !== "success") {
82-
return json(submission.reply());
83-
}
80+
const formData = await request.formData();
8481

85-
const projectSettingsService = new ProjectSettingsService();
86-
const membershipResultOrFail = await projectSettingsService.verifyProjectMembership(
87-
organizationSlug,
88-
projectParam,
89-
userId
90-
);
82+
const schema = createSchema({
83+
getSlugMatch: (slug) => {
84+
return { isMatch: slug === projectParam, projectSlug: projectParam };
85+
},
86+
});
87+
const submission = parseWithZod(formData, { schema });
9188

92-
if (membershipResultOrFail.isErr()) {
93-
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
94-
}
89+
if (submission.status !== "success") {
90+
return json(submission.reply());
91+
}
9592

96-
const { projectId } = membershipResultOrFail.value;
93+
const projectSettingsService = new ProjectSettingsService();
94+
const membershipResultOrFail = await projectSettingsService.verifyProjectMembership(
95+
organizationSlug,
96+
projectParam,
97+
userId
98+
);
9799

98-
switch (submission.value.action) {
99-
case "rename": {
100-
const resultOrFail = await projectSettingsService.renameProject(
101-
projectId,
102-
submission.value.projectName
103-
);
100+
if (membershipResultOrFail.isErr()) {
101+
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
102+
}
104103

105-
if (resultOrFail.isErr()) {
106-
switch (resultOrFail.error.type) {
107-
case "other":
108-
default: {
109-
resultOrFail.error.type satisfies "other";
104+
const { projectId } = membershipResultOrFail.value;
110105

111-
logger.error("Failed to rename project", {
112-
error: resultOrFail.error,
113-
});
114-
return json({ errors: { body: "Failed to rename project" } }, { status: 400 });
106+
switch (submission.value.action) {
107+
case "rename": {
108+
if (!ability.can("manage", { type: "project" })) {
109+
return json({ ok: false, error: "Unauthorized" } as const, { status: 403 });
110+
}
111+
const resultOrFail = await projectSettingsService.renameProject(
112+
projectId,
113+
submission.value.projectName
114+
);
115+
116+
if (resultOrFail.isErr()) {
117+
switch (resultOrFail.error.type) {
118+
case "other":
119+
default: {
120+
resultOrFail.error.type satisfies "other";
121+
122+
logger.error("Failed to rename project", {
123+
error: resultOrFail.error,
124+
});
125+
return json({ errors: { body: "Failed to rename project" } }, { status: 400 });
126+
}
115127
}
116128
}
117-
}
118129

119-
return redirectWithSuccessMessage(
120-
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
121-
request,
122-
`Project renamed to ${submission.value.projectName}`
123-
);
124-
}
125-
case "delete": {
126-
const resultOrFail = await projectSettingsService.deleteProject(projectId, userId);
130+
return redirectWithSuccessMessage(
131+
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
132+
request,
133+
`Project renamed to ${submission.value.projectName}`
134+
);
135+
}
136+
case "delete": {
137+
if (!ability.can("manage", { type: "project" })) {
138+
return json({ ok: false, error: "Unauthorized" } as const, { status: 403 });
139+
}
140+
const resultOrFail = await projectSettingsService.deleteProject(projectId, userId);
127141

128-
if (resultOrFail.isErr()) {
129-
switch (resultOrFail.error.type) {
130-
case "other":
131-
default: {
132-
resultOrFail.error.type satisfies "other";
142+
if (resultOrFail.isErr()) {
143+
switch (resultOrFail.error.type) {
144+
case "other":
145+
default: {
146+
resultOrFail.error.type satisfies "other";
133147

134-
logger.error("Failed to delete project", {
135-
error: resultOrFail.error,
136-
});
137-
return redirectWithErrorMessage(
138-
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
139-
request,
140-
`Project ${projectParam} could not be deleted`
141-
);
148+
logger.error("Failed to delete project", {
149+
error: resultOrFail.error,
150+
});
151+
return redirectWithErrorMessage(
152+
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
153+
request,
154+
`Project ${projectParam} could not be deleted`
155+
);
156+
}
142157
}
143158
}
144-
}
145159

146-
return redirectWithSuccessMessage(
147-
organizationPath({ slug: organizationSlug }),
148-
request,
149-
"Project deleted"
150-
);
160+
return redirectWithSuccessMessage(
161+
organizationPath({ slug: organizationSlug }),
162+
request,
163+
"Project deleted"
164+
);
165+
}
151166
}
152167
}
153-
};
168+
);
154169

155170
export default function GeneralSettingsPage() {
156171
const project = useProject();

0 commit comments

Comments
 (0)