Skip to content

Commit a03061e

Browse files
committed
Switch PurchaseSeatsModal to useFetcher for scoped loading state, cross-route error handling, and fix open redirect vulnerability.
1 parent 56f0c74 commit a03061e

File tree

2 files changed

+17
-48
lines changed
  • apps/webapp/app/routes
    • _app.orgs.$organizationSlug.invite
    • _app.orgs.$organizationSlug.settings.team

2 files changed

+17
-48
lines changed

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

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,7 @@ import { redirectWithSuccessMessage } from "~/models/message.server";
3333
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
3434
import { scheduleEmail } from "~/services/email.server";
3535
import { requireUserId } from "~/services/session.server";
36-
import {
37-
acceptInvitePath,
38-
inviteTeamMemberPath,
39-
organizationTeamPath,
40-
v3BillingPath,
41-
} from "~/utils/pathBuilder";
36+
import { acceptInvitePath, organizationTeamPath, v3BillingPath } from "~/utils/pathBuilder";
4237
import { PurchaseSeatsModal } from "../_app.orgs.$organizationSlug.settings.team/route";
4338

4439
const Params = z.object({
@@ -177,7 +172,6 @@ export default function Page() {
177172
maxQuota={maxSeatQuota}
178173
planSeatLimit={planSeatLimit}
179174
triggerButton={<Button variant="primary/small">Purchase more seats…</Button>}
180-
redirectTo={inviteTeamMemberPath(organization)}
181175
/>
182176
}
183177
panelClassName="mb-4"

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

Lines changed: 16 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,8 @@ import { conform, useForm } from "@conform-to/react";
22
import { parse } from "@conform-to/zod";
33
import { EnvelopeIcon, NoSymbolIcon, UserPlusIcon } from "@heroicons/react/20/solid";
44
import { DialogClose } from "@radix-ui/react-dialog";
5-
import {
6-
Form,
7-
type MetaFunction,
8-
useActionData,
9-
useNavigation,
10-
useSearchParams,
11-
} from "@remix-run/react";
12-
import {
13-
type ActionFunctionArgs,
14-
type ActionFunction,
15-
type LoaderFunctionArgs,
16-
json,
17-
} from "@remix-run/server-runtime";
5+
import { Form, type MetaFunction, useActionData, useFetcher, useNavigation } from "@remix-run/react";
6+
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
187
import { tryCatch } from "@trigger.dev/core";
198
import { useEffect, useRef, useState } from "react";
209
import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson";
@@ -129,11 +118,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
129118
const formType = formData.get("_formType");
130119

131120
if (formType === "purchase-seats") {
132-
const redirectTo = formData.get("redirectTo");
133-
const redirectPath =
134-
typeof redirectTo === "string" && redirectTo
135-
? redirectTo
136-
: organizationTeamPath({ slug: organizationSlug });
121+
const redirectPath = organizationTeamPath({ slug: organizationSlug });
137122

138123
const org = await $replica.organization.findFirst({
139124
where: { slug: organizationSlug },
@@ -171,7 +156,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
171156
}
172157

173158
return redirectWithSuccessMessage(
174-
`${redirectPath}?purchaseSuccess=true`,
159+
redirectPath,
175160
request,
176161
submission.value.action === "purchase"
177162
? "Seats updated successfully"
@@ -512,11 +497,9 @@ function ResendButton({ invite }: { invite: Invite }) {
512497
prevSubmitting.current = isSubmitting;
513498
}, [isSubmitting]);
514499

500+
const cooldownActive = cooldown > 0;
515501
useEffect(() => {
516-
if (cooldown <= 0) {
517-
clearInterval(intervalRef.current);
518-
return;
519-
}
502+
if (!cooldownActive) return;
520503

521504
intervalRef.current = setInterval(() => {
522505
setCooldown((c) => {
@@ -529,7 +512,7 @@ function ResendButton({ invite }: { invite: Invite }) {
529512
}, 1000);
530513

531514
return () => clearInterval(intervalRef.current);
532-
}, [cooldown > 0]); // only re-run when transitioning between active/inactive
515+
}, [cooldownActive]);
533516

534517
const isDisabled = isSubmitting || cooldown > 0;
535518

@@ -580,7 +563,6 @@ export function PurchaseSeatsModal({
580563
maxQuota,
581564
planSeatLimit,
582565
triggerButton,
583-
redirectTo,
584566
}: {
585567
seatPricing: {
586568
stepSize: number;
@@ -591,35 +573,29 @@ export function PurchaseSeatsModal({
591573
maxQuota: number;
592574
planSeatLimit: number;
593575
triggerButton?: React.ReactNode;
594-
redirectTo?: string;
595576
}) {
596-
const lastSubmission = useActionData();
577+
const fetcher = useFetcher();
597578
const organization = useOrganization();
598579
const [form, { amount }] = useForm({
599580
id: "purchase-seats",
600-
lastSubmission: lastSubmission as any,
581+
lastSubmission: fetcher.data as any,
601582
onValidate({ formData }) {
602583
return parse(formData, { schema: PurchaseSchema });
603584
},
604585
shouldRevalidate: "onSubmit",
605586
});
606587

607588
const [amountValue, setAmountValue] = useState(extraSeats);
608-
const navigation = useNavigation();
609-
const isLoading = navigation.state !== "idle" && navigation.formMethod === "POST";
589+
const isLoading = fetcher.state !== "idle";
610590

611-
const [searchParams, setSearchParams] = useSearchParams();
612591
const [open, setOpen] = useState(false);
592+
const prevFetcherState = useRef(fetcher.state);
613593
useEffect(() => {
614-
const success = searchParams.get("purchaseSuccess");
615-
if (success) {
594+
if (prevFetcherState.current !== "idle" && fetcher.state === "idle" && !fetcher.data) {
616595
setOpen(false);
617-
setSearchParams((s) => {
618-
s.delete("purchaseSuccess");
619-
return s;
620-
});
621596
}
622-
}, [searchParams.get("purchaseSuccess")]);
597+
prevFetcherState.current = fetcher.state;
598+
}, [fetcher.state, fetcher.data]);
623599

624600
const state = updateSeatState({
625601
value: amountValue,
@@ -645,9 +621,8 @@ export function PurchaseSeatsModal({
645621
</DialogTrigger>
646622
<DialogContent>
647623
<DialogHeader>{title}</DialogHeader>
648-
<Form method="post" action={organizationTeamPath(organization)} {...form.props}>
624+
<fetcher.Form method="post" action={organizationTeamPath(organization)} {...form.props}>
649625
<input type="hidden" name="_formType" value="purchase-seats" />
650-
{redirectTo && <input type="hidden" name="redirectTo" value={redirectTo} />}
651626
<div className="flex flex-col gap-4 pt-2">
652627
<div className="flex flex-col gap-1">
653628
<Paragraph variant="small/bright">
@@ -803,7 +778,7 @@ export function PurchaseSeatsModal({
803778
</DialogClose>
804779
}
805780
/>
806-
</Form>
781+
</fetcher.Form>
807782
</DialogContent>
808783
</Dialog>
809784
);

0 commit comments

Comments
 (0)