Skip to content

Commit cc8c951

Browse files
committed
refactor: migrate PAT org/project management routes to createActionPATApiRoute
1 parent e6a6845 commit cc8c951

10 files changed

Lines changed: 331 additions & 508 deletions

apps/webapp/app/models/member.server.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { logger } from "~/services/logger.server";
66
import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server";
77
import { rbac } from "~/services/rbac.server";
88
import { ssoController } from "~/services/sso.server";
9+
import { ServiceValidationError } from "~/v3/services/common.server";
910

1011
export const INVITE_NOT_FOUND = "Invite not found";
1112
export const INVITE_BLOCKED_DIRECTORY_MANAGED =
@@ -88,7 +89,7 @@ export async function removeTeamMember({
8889
});
8990

9091
if (!org) {
91-
throw new Error("User does not have access to this organization");
92+
throw new ServiceValidationError("User does not have access to this organization", 403);
9293
}
9394

9495
// Scope the target to this org. A member id is a globally unique key, so
@@ -103,7 +104,7 @@ export async function removeTeamMember({
103104
});
104105

105106
if (!member) {
106-
throw new Error("Member not found in this organization");
107+
throw new ServiceValidationError("Member not found in this organization", 404);
107108
}
108109

109110
await prisma.orgMember.delete({ where: { id: member.id } });
Lines changed: 36 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,54 @@
1-
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
21
import { json } from "@remix-run/server-runtime";
32
import { z } from "zod";
3+
import { prisma } from "~/db.server";
44
import { revokeInvite } from "~/models/member.server";
5-
import { logger } from "~/services/logger.server";
6-
import {
7-
authorizePatOrganizationAccess,
8-
resolveOrganizationForApiUser,
9-
} from "~/services/organizationApiAccess.server";
10-
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
5+
import { resolveOrganizationForApiUser } from "~/services/organizationApiAccess.server";
6+
import { createActionPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
117

128
const ParamsSchema = z.object({
139
orgParam: z.string(),
1410
inviteId: z.string(),
1511
});
1612

17-
export async function action({ request, params }: ActionFunctionArgs) {
18-
if (request.method.toUpperCase() !== "DELETE") {
19-
return json({ error: "Method Not Allowed" }, { status: 405 });
20-
}
21-
22-
const parsedParams = ParamsSchema.safeParse(params);
23-
24-
if (!parsedParams.success) {
25-
return json({ error: "Invalid Params" }, { status: 400 });
26-
}
27-
28-
const { orgParam, inviteId } = parsedParams.data;
29-
30-
try {
31-
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
32-
33-
if (!authenticationResult) {
34-
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
35-
}
36-
13+
export const action = createActionPATApiRoute(
14+
{
15+
method: "DELETE",
16+
params: ParamsSchema,
17+
// Resolve the org (id only, no membership) so the plugin can compute the
18+
// caller's role floor for the manage:members gate below.
19+
context: async ({ orgParam }) => {
20+
const org = await prisma.organization.findFirst({
21+
where: { OR: [{ id: orgParam }, { slug: orgParam }], deletedAt: null },
22+
select: { id: true },
23+
});
24+
return org ? { organizationId: org.id } : {};
25+
},
26+
authorization: { action: "manage", resource: () => ({ type: "members" }) },
27+
},
28+
async ({ params, authentication }) => {
29+
// Membership floor: a non-member gets a 404.
3730
const organization = await resolveOrganizationForApiUser({
38-
orgParam,
39-
userId: authenticationResult.userId,
31+
orgParam: params.orgParam,
32+
userId: authentication.userId,
4033
});
4134

4235
if (!organization) {
4336
return json({ error: "Organization not found" }, { status: 404 });
4437
}
4538

46-
const denied = await authorizePatOrganizationAccess({
47-
request,
48-
organizationId: organization.id,
49-
resource: "members",
50-
action: "manage",
51-
});
52-
if (denied) return denied;
53-
54-
const revoked = await revokeInvite({
55-
userId: authenticationResult.userId,
56-
orgSlug: organization.slug,
57-
inviteId,
58-
});
59-
60-
return json({ id: inviteId, email: revoked.email });
61-
} catch (error) {
62-
if (error instanceof Response) throw error;
63-
if (error instanceof Error && error.message === "Invite not found") {
64-
return json({ error: error.message }, { status: 404 });
39+
try {
40+
const revoked = await revokeInvite({
41+
userId: authentication.userId,
42+
orgSlug: organization.slug,
43+
inviteId: params.inviteId,
44+
});
45+
46+
return json({ id: params.inviteId, email: revoked.email });
47+
} catch (error) {
48+
if (error instanceof Error && error.message === "Invite not found") {
49+
return json({ error: error.message }, { status: 404 });
50+
}
51+
throw error;
6552
}
66-
logger.error("Failed to revoke invite", { error });
67-
return json({ error: "Internal Server Error" }, { status: 500 });
6853
}
69-
}
54+
);
Lines changed: 26 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
1-
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
21
import { json } from "@remix-run/server-runtime";
32
import { z } from "zod";
3+
import { prisma } from "~/db.server";
44
import { env } from "~/env.server";
55
import { inviteMembers } from "~/models/member.server";
66
import { logger } from "~/services/logger.server";
7-
import {
8-
authorizePatOrganizationAccess,
9-
resolveOrganizationForApiUser,
10-
} from "~/services/organizationApiAccess.server";
11-
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
7+
import { resolveOrganizationForApiUser } from "~/services/organizationApiAccess.server";
8+
import { createActionPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
129
import { scheduleEmail } from "~/services/scheduleEmail.server";
1310
import { acceptInvitePath } from "~/utils/pathBuilder";
1411

@@ -20,58 +17,37 @@ const InviteRequestBody = z.object({
2017
emails: z.string().email().array().nonempty("At least one email is required"),
2118
});
2219

23-
export async function action({ request, params }: ActionFunctionArgs) {
24-
if (request.method.toUpperCase() !== "POST") {
25-
return json({ error: "Method Not Allowed" }, { status: 405 });
26-
}
27-
28-
const parsedParams = ParamsSchema.safeParse(params);
29-
30-
if (!parsedParams.success) {
31-
return json({ error: "Invalid Params" }, { status: 400 });
32-
}
33-
34-
try {
35-
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
36-
37-
if (!authenticationResult) {
38-
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
39-
}
40-
20+
export const action = createActionPATApiRoute(
21+
{
22+
method: "POST",
23+
params: ParamsSchema,
24+
body: InviteRequestBody,
25+
// Resolve the org (id only, no membership) so the plugin can compute the
26+
// caller's role floor for the manage:members gate below.
27+
context: async ({ orgParam }) => {
28+
const org = await prisma.organization.findFirst({
29+
where: { OR: [{ id: orgParam }, { slug: orgParam }], deletedAt: null },
30+
select: { id: true },
31+
});
32+
return org ? { organizationId: org.id } : {};
33+
},
34+
authorization: { action: "manage", resource: () => ({ type: "members" }) },
35+
},
36+
async ({ params, body, authentication }) => {
37+
// Membership floor: a non-member gets a 404.
4138
const organization = await resolveOrganizationForApiUser({
42-
orgParam: parsedParams.data.orgParam,
43-
userId: authenticationResult.userId,
39+
orgParam: params.orgParam,
40+
userId: authentication.userId,
4441
});
4542

4643
if (!organization) {
4744
return json({ error: "Organization not found" }, { status: 404 });
4845
}
4946

50-
const denied = await authorizePatOrganizationAccess({
51-
request,
52-
organizationId: organization.id,
53-
resource: "members",
54-
action: "manage",
55-
});
56-
if (denied) return denied;
57-
58-
let rawBody: unknown;
59-
try {
60-
rawBody = await request.json();
61-
} catch {
62-
return json({ error: "Invalid request body" }, { status: 400 });
63-
}
64-
65-
const body = InviteRequestBody.safeParse(rawBody);
66-
67-
if (!body.success) {
68-
return json({ error: "Invalid request body" }, { status: 400 });
69-
}
70-
7147
const invites = await inviteMembers({
7248
slug: organization.slug,
73-
emails: body.data.emails,
74-
userId: authenticationResult.userId,
49+
emails: body.emails,
50+
userId: authentication.userId,
7551
});
7652

7753
// Send invite emails the same way the dashboard invite action does. A
@@ -98,9 +74,5 @@ export async function action({ request, params }: ActionFunctionArgs) {
9874
},
9975
{ status: 201 }
10076
);
101-
} catch (error) {
102-
if (error instanceof Response) throw error;
103-
logger.error("Failed to invite org members", { error });
104-
return json({ error: "Internal Server Error" }, { status: 500 });
10577
}
106-
}
78+
);
Lines changed: 26 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,41 @@
1-
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
21
import { json } from "@remix-run/server-runtime";
32
import { z } from "zod";
43
import { prisma } from "~/db.server";
54
import { removeTeamMember } from "~/models/member.server";
6-
import { logger } from "~/services/logger.server";
7-
import {
8-
authorizePatOrganizationAccess,
9-
resolveOrganizationForApiUser,
10-
} from "~/services/organizationApiAccess.server";
11-
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
5+
import { resolveOrganizationForApiUser } from "~/services/organizationApiAccess.server";
6+
import { createActionPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
127

138
const ParamsSchema = z.object({
149
orgParam: z.string(),
1510
memberId: z.string(),
1611
});
1712

18-
export async function action({ request, params }: ActionFunctionArgs) {
19-
if (request.method.toUpperCase() !== "DELETE") {
20-
return json({ error: "Method Not Allowed" }, { status: 405 });
21-
}
22-
23-
const parsedParams = ParamsSchema.safeParse(params);
24-
25-
if (!parsedParams.success) {
26-
return json({ error: "Invalid Params" }, { status: 400 });
27-
}
28-
29-
const { orgParam, memberId } = parsedParams.data;
30-
31-
try {
32-
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
33-
34-
if (!authenticationResult) {
35-
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
36-
}
37-
13+
export const action = createActionPATApiRoute(
14+
{
15+
method: "DELETE",
16+
params: ParamsSchema,
17+
// Resolve the org (id only, no membership) so the plugin can compute the
18+
// caller's role floor for the manage:members gate below.
19+
context: async ({ orgParam }) => {
20+
const org = await prisma.organization.findFirst({
21+
where: { OR: [{ id: orgParam }, { slug: orgParam }], deletedAt: null },
22+
select: { id: true },
23+
});
24+
return org ? { organizationId: org.id } : {};
25+
},
26+
authorization: { action: "manage", resource: () => ({ type: "members" }) },
27+
},
28+
async ({ params, authentication }) => {
29+
// Membership floor: a non-member gets a 404.
3830
const organization = await resolveOrganizationForApiUser({
39-
orgParam,
40-
userId: authenticationResult.userId,
31+
orgParam: params.orgParam,
32+
userId: authentication.userId,
4133
});
4234

4335
if (!organization) {
4436
return json({ error: "Organization not found" }, { status: 404 });
4537
}
4638

47-
const denied = await authorizePatOrganizationAccess({
48-
request,
49-
organizationId: organization.id,
50-
resource: "members",
51-
action: "manage",
52-
});
53-
if (denied) return denied;
54-
5539
// An org must keep at least one member. The dashboard guards this in the
5640
// UI only; enforce it here since removeTeamMember doesn't.
5741
const memberCount = await prisma.orgMember.count({
@@ -61,10 +45,12 @@ export async function action({ request, params }: ActionFunctionArgs) {
6145
return json({ error: "Cannot remove the last member of an organization" }, { status: 400 });
6246
}
6347

48+
// removeTeamMember throws ServiceValidationError; the builder maps it to
49+
// its status (e.g. 404 for a member not in this org).
6450
const removed = await removeTeamMember({
65-
userId: authenticationResult.userId,
51+
userId: authentication.userId,
6652
slug: organization.slug,
67-
memberId,
53+
memberId: params.memberId,
6854
});
6955

7056
return json({
@@ -75,12 +61,5 @@ export async function action({ request, params }: ActionFunctionArgs) {
7561
email: removed.user.email,
7662
},
7763
});
78-
} catch (error) {
79-
if (error instanceof Response) throw error;
80-
if (error instanceof Error && error.message === "Member not found in this organization") {
81-
return json({ error: error.message }, { status: 404 });
82-
}
83-
logger.error("Failed to remove org member", { error });
84-
return json({ error: "Internal Server Error" }, { status: 500 });
8564
}
86-
}
65+
);

0 commit comments

Comments
 (0)