Skip to content

Commit a452614

Browse files
committed
fix(webapp): enforce manage:members on invite/resend/revoke routes + UI
Migrate the invite, invite-resend, and invite-revoke routes to dashboardLoader/dashboardAction with a manage:members authorization block. The resend/revoke routes have no URL params, so the org for the auth scope is resolved from the form body (read via a cloned request) — from the invite's organization (resend) or the slug field (revoke). Gate the Resend/Revoke buttons on the team page with the existing canManageMembers flag. Existing tenancy/inviter checks in the model layer are unchanged.
1 parent bc13af5 commit a452614

4 files changed

Lines changed: 248 additions & 205 deletions

File tree

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

Lines changed: 139 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,11 @@ import {
66
LockOpenIcon,
77
UserPlusIcon,
88
} from "@heroicons/react/20/solid";
9-
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
109
import { json } from "@remix-run/node";
1110
import { Form, useActionData } from "@remix-run/react";
1211
import { Fragment, useRef, useState } from "react";
1312
import { typedjson, useTypedLoaderData } from "remix-typedjson";
1413
import simplur from "simplur";
15-
import invariant from "tiny-invariant";
1614
import { z } from "zod";
1715
import { MainCenteredContainer } from "~/components/layout/AppLayout";
1816
import { Button, LinkButton } from "~/components/primitives/Buttons";
@@ -34,63 +32,71 @@ import { redirectWithSuccessMessage } from "~/models/message.server";
3432
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
3533
import { scheduleEmail } from "~/services/scheduleEmail.server";
3634
import { rbac } from "~/services/rbac.server";
37-
import { requireUserId } from "~/services/session.server";
35+
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
3836
import { acceptInvitePath, organizationTeamPath, v3BillingPath } from "~/utils/pathBuilder";
3937
import { PurchaseSeatsModal } from "../_app.orgs.$organizationSlug.settings.team/route";
4038

4139
const Params = z.object({
4240
organizationSlug: z.string(),
4341
});
4442

45-
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
46-
const userId = await requireUserId(request);
47-
const { organizationSlug } = Params.parse(params);
48-
49-
const organization = await $replica.organization.findFirst({
50-
where: { slug: organizationSlug },
51-
select: { id: true },
52-
});
43+
async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
44+
const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } });
45+
return org?.id ?? null;
46+
}
5347

54-
if (!organization) {
55-
throw new Response("Not Found", { status: 404 });
56-
}
48+
export const loader = dashboardLoader(
49+
{
50+
params: Params,
51+
context: async (params) => {
52+
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
53+
return organizationId ? { organizationId } : {};
54+
},
55+
authorization: { action: "manage", resource: { type: "members" } },
56+
},
57+
async ({ user, context }) => {
58+
const organizationId = context.organizationId;
59+
if (!organizationId) {
60+
throw new Response("Not Found", { status: 404 });
61+
}
62+
const userId = user.id;
5763

58-
const presenter = new TeamPresenter();
59-
const result = await presenter.call({
60-
userId,
61-
organizationId: organization.id,
62-
});
64+
const presenter = new TeamPresenter();
65+
const result = await presenter.call({
66+
userId,
67+
organizationId,
68+
});
6369

64-
if (!result) {
65-
throw new Response("Not Found", { status: 404 });
66-
}
70+
if (!result) {
71+
throw new Response("Not Found", { status: 404 });
72+
}
6773

68-
// Inviter's own role drives the "below their level" filter on the
69-
// dropdown. Plus assignable role IDs already encode the org's plan
70-
// tier — the intersection is what we offer.
71-
const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([
72-
rbac.getUserRole({ userId, organizationId: organization.id }),
73-
rbac.getAssignableRoleIds(organization.id),
74-
rbac.systemRoles(organization.id),
75-
]);
74+
// Inviter's own role drives the "below their level" filter on the
75+
// dropdown. Plus assignable role IDs already encode the org's plan
76+
// tier — the intersection is what we offer.
77+
const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([
78+
rbac.getUserRole({ userId, organizationId }),
79+
rbac.getAssignableRoleIds(organizationId),
80+
rbac.systemRoles(organizationId),
81+
]);
7682

77-
// Build the dropdown's offerable set server-side: roles that are
78-
// (a) assignable on the current plan AND (b) at or below the
79-
// inviter's own level. The client just renders these — it doesn't
80-
// need to know about the system-role catalogue or the ladder.
81-
const assignableSet = new Set(assignableRoleIds);
82-
const offerableRoleIds = systemRoles
83-
? result.roles
84-
.filter(
85-
(r) =>
86-
assignableSet.has(r.id) &&
87-
isAtOrBelow(systemRoles, inviterRole?.id ?? null, r.id)
88-
)
89-
.map((r) => r.id)
90-
: [];
83+
// Build the dropdown's offerable set server-side: roles that are
84+
// (a) assignable on the current plan AND (b) at or below the
85+
// inviter's own level. The client just renders these — it doesn't
86+
// need to know about the system-role catalogue or the ladder.
87+
const assignableSet = new Set(assignableRoleIds);
88+
const offerableRoleIds = systemRoles
89+
? result.roles
90+
.filter(
91+
(r) =>
92+
assignableSet.has(r.id) && isAtOrBelow(systemRoles, inviterRole?.id ?? null, r.id)
93+
)
94+
.map((r) => r.id)
95+
: [];
9196

92-
return typedjson({ ...result, offerableRoleIds });
93-
};
97+
return typedjson({ ...result, offerableRoleIds });
98+
}
99+
);
94100

95101
// Sentinel for "no RBAC role attached to invite" — the runtime
96102
// fallback will derive a role from the legacy OrgMember.role write at
@@ -153,101 +159,101 @@ const schema = z.object({
153159
rbacRoleId: z.string().optional(),
154160
});
155161

156-
export const action: ActionFunction = async ({ request, params }) => {
157-
const userId = await requireUserId(request);
158-
const { organizationSlug } = params;
159-
invariant(organizationSlug, "organizationSlug is required");
160-
161-
const formData = await request.formData();
162-
const submission = parse(formData, { schema });
162+
export const action = dashboardAction(
163+
{
164+
params: Params,
165+
context: async (params) => {
166+
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
167+
return organizationId ? { organizationId } : {};
168+
},
169+
authorization: { action: "manage", resource: { type: "members" } },
170+
},
171+
async ({ request, params, user }) => {
172+
const userId = user.id;
173+
const { organizationSlug } = params;
163174

164-
if (!submission.value || submission.intent !== "submit") {
165-
return json(submission);
166-
}
175+
const formData = await request.formData();
176+
const submission = parse(formData, { schema });
167177

168-
// Resolve the RBAC role choice. NO_RBAC_ROLE / undefined / unknown
169-
// role → don't pass one through; the runtime fallback handles it.
170-
// Validation: the chosen role must be in the org's assignable set
171-
// (plan-tier) and at or below the inviter's own level.
172-
let resolvedRbacRoleId: string | null = null;
173-
const submittedRbacRoleId = submission.value.rbacRoleId;
174-
if (
175-
submittedRbacRoleId &&
176-
submittedRbacRoleId !== NO_RBAC_ROLE
177-
) {
178-
const org = await $replica.organization.findFirst({
179-
where: { slug: organizationSlug },
180-
select: { id: true },
181-
});
182-
if (!org) {
183-
return json({ errors: { body: "Organization not found" } }, { status: 404 });
178+
if (!submission.value || submission.intent !== "submit") {
179+
return json(submission);
184180
}
185-
const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([
186-
rbac.getUserRole({ userId, organizationId: org.id }),
187-
rbac.getAssignableRoleIds(org.id),
188-
rbac.systemRoles(org.id),
189-
]);
190-
if (!systemRoles) {
191-
// No plugin installed but the form somehow submitted a role id —
192-
// ignore it (fall through to legacy behaviour rather than 400).
193-
resolvedRbacRoleId = null;
194-
} else {
195-
const assignable = new Set(assignableRoleIds);
196-
if (!assignable.has(submittedRbacRoleId)) {
197-
return json(
198-
{ errors: { body: "You can't invite someone with this role on your current plan" } },
199-
{ status: 400 }
200-
);
181+
182+
// Resolve the RBAC role choice. NO_RBAC_ROLE / undefined / unknown
183+
// role → don't pass one through; the runtime fallback handles it.
184+
// Validation: the chosen role must be in the org's assignable set
185+
// (plan-tier) and at or below the inviter's own level.
186+
let resolvedRbacRoleId: string | null = null;
187+
const submittedRbacRoleId = submission.value.rbacRoleId;
188+
if (submittedRbacRoleId && submittedRbacRoleId !== NO_RBAC_ROLE) {
189+
const org = await $replica.organization.findFirst({
190+
where: { slug: organizationSlug },
191+
select: { id: true },
192+
});
193+
if (!org) {
194+
return json({ errors: { body: "Organization not found" } }, { status: 404 });
201195
}
202-
if (
203-
!isAtOrBelow(
204-
systemRoles,
205-
inviterRole?.id ?? null,
206-
submittedRbacRoleId
207-
)
208-
) {
209-
return json(
210-
{ errors: { body: "You can only invite members at or below your own role" } },
211-
{ status: 403 }
212-
);
196+
const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([
197+
rbac.getUserRole({ userId, organizationId: org.id }),
198+
rbac.getAssignableRoleIds(org.id),
199+
rbac.systemRoles(org.id),
200+
]);
201+
if (!systemRoles) {
202+
// No plugin installed but the form somehow submitted a role id —
203+
// ignore it (fall through to legacy behaviour rather than 400).
204+
resolvedRbacRoleId = null;
205+
} else {
206+
const assignable = new Set(assignableRoleIds);
207+
if (!assignable.has(submittedRbacRoleId)) {
208+
return json(
209+
{ errors: { body: "You can't invite someone with this role on your current plan" } },
210+
{ status: 400 }
211+
);
212+
}
213+
if (!isAtOrBelow(systemRoles, inviterRole?.id ?? null, submittedRbacRoleId)) {
214+
return json(
215+
{ errors: { body: "You can only invite members at or below your own role" } },
216+
{ status: 403 }
217+
);
218+
}
219+
resolvedRbacRoleId = submittedRbacRoleId;
213220
}
214-
resolvedRbacRoleId = submittedRbacRoleId;
215221
}
216-
}
217222

218-
try {
219-
const invites = await inviteMembers({
220-
slug: organizationSlug,
221-
emails: submission.value.emails,
222-
userId,
223-
rbacRoleId: resolvedRbacRoleId,
224-
});
223+
try {
224+
const invites = await inviteMembers({
225+
slug: organizationSlug,
226+
emails: submission.value.emails,
227+
userId,
228+
rbacRoleId: resolvedRbacRoleId,
229+
});
225230

226-
for (const invite of invites) {
227-
try {
228-
await scheduleEmail({
229-
email: "invite",
230-
to: invite.email,
231-
orgName: invite.organization.title,
232-
inviterName: invite.inviter.name ?? undefined,
233-
inviterEmail: invite.inviter.email,
234-
inviteLink: `${env.LOGIN_ORIGIN}${acceptInvitePath(invite.token)}`,
235-
});
236-
} catch (error) {
237-
console.error("Failed to send invite email");
238-
console.error(error);
231+
for (const invite of invites) {
232+
try {
233+
await scheduleEmail({
234+
email: "invite",
235+
to: invite.email,
236+
orgName: invite.organization.title,
237+
inviterName: invite.inviter.name ?? undefined,
238+
inviterEmail: invite.inviter.email,
239+
inviteLink: `${env.LOGIN_ORIGIN}${acceptInvitePath(invite.token)}`,
240+
});
241+
} catch (error) {
242+
console.error("Failed to send invite email");
243+
console.error(error);
244+
}
239245
}
240-
}
241246

242-
return redirectWithSuccessMessage(
243-
organizationTeamPath(invites[0].organization),
244-
request,
245-
simplur`${submission.value.emails.length} member[|s] invited`
246-
);
247-
} catch (error: any) {
248-
return json({ errors: { body: error.message } }, { status: 400 });
247+
return redirectWithSuccessMessage(
248+
organizationTeamPath(invites[0].organization),
249+
request,
250+
simplur`${submission.value.emails.length} member[|s] invited`
251+
);
252+
} catch (error: any) {
253+
return json({ errors: { body: error.message } }, { status: 400 });
254+
}
249255
}
250-
};
256+
);
251257

252258
export default function Page() {
253259
const {
@@ -274,9 +280,7 @@ export default function Page() {
274280
// Default to the lowest-tier offered role (the loader returns roles
275281
// in its allRoles order, which the plugin emits Owner→Member; the
276282
// last entry is the most restrictive).
277-
const defaultRoleId = showRolePicker
278-
? offerable[offerable.length - 1].id
279-
: NO_RBAC_ROLE;
283+
const defaultRoleId = showRolePicker ? offerable[offerable.length - 1].id : NO_RBAC_ROLE;
280284
const [selectedRoleId, setSelectedRoleId] = useState(defaultRoleId);
281285

282286
const [form, { emails }] = useForm({
@@ -386,9 +390,7 @@ export default function Page() {
386390
items={offerable}
387391
variant="tertiary/medium"
388392
dropdownIcon
389-
text={(v) =>
390-
offerable.find((r) => r.id === v)?.name ?? "Pick a role"
391-
}
393+
text={(v) => offerable.find((r) => r.id === v)?.name ?? "Pick a role"}
392394
setValue={(next) => {
393395
if (typeof next === "string") setSelectedRoleId(next);
394396
}}
@@ -402,8 +404,7 @@ export default function Page() {
402404
}
403405
</Select>
404406
<Paragraph variant="extra-small" className="text-text-dimmed">
405-
Invitees join with this role. They can be promoted later
406-
from the Team page.
407+
Invitees join with this role. They can be promoted later from the Team page.
407408
</Paragraph>
408409
</InputGroup>
409410
) : null}

0 commit comments

Comments
 (0)