Files
triggerdotdev--trigger.dev/apps/webapp/app/routes/invites.tsx
T
2026-07-13 13:32:57 +08:00

183 lines
6.5 KiB
TypeScript

import { getFormProps, useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { EnvelopeIcon } from "@heroicons/react/20/solid";
import { type ActionFunction, type LoaderFunctionArgs, json, redirect } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import simplur from "simplur";
import { z } from "zod";
import { BackgroundWrapper } from "~/components/BackgroundWrapper";
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
import { Button } from "~/components/primitives/Buttons";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormError } from "~/components/primitives/FormError";
import { FormTitle } from "~/components/primitives/FormTitle";
import { Header2 } from "~/components/primitives/Headers";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
acceptInvite,
declineInvite,
ENV_SETUP_INCOMPLETE,
getUsersInvites,
isAcceptInviteFormError,
} from "~/models/member.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { requireUser } from "~/services/session.server";
import { invitesPath, rootPath } from "~/utils/pathBuilder";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireUser(request);
//if there are no invites left we should redirect to the orgs page
const invites = await getUsersInvites({ email: user.email });
if (invites.length === 0) {
throw redirect(rootPath());
}
return typedjson({ invites });
};
const schema = z.object({
inviteId: z.string(),
organizationId: z.string().optional(),
});
export const action: ActionFunction = async ({ request }) => {
const user = await requireUser(request);
const formData = await request.formData();
const submission = parseWithZod(formData, { schema });
if (submission.status !== "success") {
return json(submission.reply());
}
const intent = formData.get("intent");
try {
if (intent === "accept") {
const { remainingInvites, organization } = await acceptInvite({
inviteId: submission.value.inviteId,
organizationId: submission.value.organizationId,
user: { id: user.id, email: user.email },
});
if (remainingInvites.length === 0) {
return redirectWithSuccessMessage(rootPath(), request, `You joined ${organization.title}`);
} else {
return redirectWithSuccessMessage(
invitesPath(),
request,
`You joined ${organization.title}`
);
}
} else if (intent === "decline") {
const { remainingInvites, organization } = await declineInvite({
inviteId: submission.value.inviteId,
user: { id: user.id, email: user.email },
});
if (remainingInvites.length === 0) {
return redirectWithSuccessMessage(
rootPath(),
request,
`You declined the invite for ${organization.title}`
);
} else {
return redirectWithSuccessMessage(
invitesPath(),
request,
`You declined the invite for ${organization.title}`
);
}
}
} catch (error) {
if (isAcceptInviteFormError(error)) {
// Membership may already exist while the invite is still present if env
// provisioning failed. With no invites left, the loader would redirect
// and discard a 400 FormError — send the user to orgs with a toast instead.
if (error.message === ENV_SETUP_INCOMPLETE) {
const remainingInvites = await getUsersInvites({ email: user.email });
if (remainingInvites.length === 0) {
return redirectWithErrorMessage(rootPath(), request, error.message, {
ephemeral: false,
});
}
}
return json(
{
intent: intent,
payload: submission.payload,
error: { "": [error.message] },
},
{ status: 400 }
);
}
throw error;
}
};
export default function Page() {
const { invites } = useTypedLoaderData<typeof loader>();
const lastSubmission = useActionData();
const [form, _fields] = useForm({
id: "accept-invite",
// TODO: type this
lastResult: lastSubmission as any,
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
});
return (
<AppContainer className="bg-background-deep">
<BackgroundWrapper>
<MainCenteredContainer
variant="onboarding"
className="max-w-104 rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg"
>
<div>
<FormTitle
LeadingIcon={<EnvelopeIcon className="size-6 text-cyan-500" />}
className="mb-0 text-sky-500"
title={simplur`You have ${invites.length} new invitation[|s]`}
/>
<FormError>{form.errors}</FormError>
{invites.map((invite) => (
<Form key={invite.id} method="post" {...getFormProps(form)}>
<Fieldset>
<InputGroup className="flex items-center justify-between border-b border-background-bright py-4">
<div className="flex flex-col gap-y-0.5 overflow-hidden">
<Header2 className="truncate">{invite.organization.title}</Header2>
<Paragraph variant="small" className="truncate">
Invited by {invite.inviter.displayName ?? invite.inviter.email}
</Paragraph>
<input name="inviteId" type="hidden" value={invite.id} />
<input name="organizationId" type="hidden" value={invite.organizationId} />
</div>
<div className="flex flex-col gap-y-1">
<Button type="submit" name="intent" value="accept" variant={"primary/small"}>
Accept
</Button>
<Button
type="submit"
name="intent"
value="decline"
variant={"secondary/small"}
>
Decline
</Button>
</div>
</InputGroup>
</Fieldset>
</Form>
))}
</div>
</MainCenteredContainer>
</BackgroundWrapper>
</AppContainer>
);
}