chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user