Team member limiting

This commit is contained in:
Matt Aitken
2024-07-04 21:34:27 +01:00
parent f6037595e1
commit 675be98dce
4 changed files with 146 additions and 31 deletions
+8 -2
View File
@@ -1,9 +1,15 @@
import { prisma } from "~/db.server";
import { createEnvironment } from "./organization.server";
export async function getTeamMembersAndInvites({ userId, slug }: { userId: string; slug: string }) {
export async function getTeamMembersAndInvites({
userId,
organizationId,
}: {
userId: string;
organizationId: string;
}) {
const org = await prisma.organization.findFirst({
where: { slug, members: { some: { userId } } },
where: { id: organizationId, members: { some: { userId } } },
select: {
members: {
select: {
@@ -0,0 +1,26 @@
import { getTeamMembersAndInvites } from "~/models/member.server";
import { BasePresenter } from "./v3/basePresenter.server";
import { getLimit } from "~/services/platform.v3.server";
export class TeamPresenter extends BasePresenter {
public async call({ userId, organizationId }: { userId: string; organizationId: string }) {
const result = await getTeamMembersAndInvites({
userId,
organizationId,
});
if (!result) {
return;
}
const limit = await getLimit(organizationId, "teamMembers", 25);
return {
...result,
limits: {
used: result.members.length + result.invites.length,
limit,
},
};
}
}
@@ -1,9 +1,11 @@
import { conform, list, requestIntent, useFieldList, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import type { ActionFunction } from "@remix-run/node";
import { LockOpenIcon } from "@heroicons/react/20/solid";
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import { Fragment, useRef } from "react";
import { Fragment, useRef, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import simplur from "simplur";
import invariant from "tiny-invariant";
import { z } from "zod";
@@ -13,16 +15,50 @@ import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { FormTitle } from "~/components/primitives/FormTitle";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import { $replica } from "~/db.server";
import { env } from "~/env.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { inviteMembers } from "~/models/member.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
import { scheduleEmail } from "~/services/email.server";
import { requireUserId } from "~/services/session.server";
import { acceptInvitePath, organizationTeamPath } from "~/utils/pathBuilder";
import { acceptInvitePath, organizationTeamPath, v3BillingPath } from "~/utils/pathBuilder";
const Params = z.object({
organizationSlug: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug } = Params.parse(params);
const organization = await $replica.organization.findFirst({
where: { slug: organizationSlug },
select: { id: true },
});
if (!organization) {
throw new Response("Not Found", { status: 404 });
}
const presenter = new TeamPresenter();
const result = await presenter.call({
userId,
organizationId: organization.id,
});
if (!result) {
throw new Response("Not Found", { status: 404 });
}
return typedjson(result);
};
const schema = z.object({
emails: z.preprocess((i) => {
@@ -86,6 +122,8 @@ export const action: ActionFunction = async ({ request, params }) => {
};
export default function Page() {
const { limits } = useTypedLoaderData<typeof loader>();
const [total, setTotal] = useState(limits.used);
const organization = useOrganization();
const lastSubmission = useActionData();
@@ -109,6 +147,22 @@ export default function Page() {
title="Invite team members"
description={`Invite new team members to ${organization.title}.`}
/>
{total > limits.limit && (
<InfoPanel
variant="upgrade"
icon={LockOpenIcon}
iconClassName="text-indigo-500"
title="Unlock more team members"
to={v3BillingPath(organization)}
buttonLabel="Upgrade"
panelClassName="mb-4"
>
<Paragraph variant="small">
You've used all {limits.limit} of your available team members. Upgrade your plan to
add more.
</Paragraph>
</InfoPanel>
)}
<Form method="post" {...form.props}>
<Fieldset>
<InputGroup>
@@ -122,6 +176,8 @@ export default function Page() {
autoFocus={index === 0}
onChange={(e) => {
fieldValues.current[index] = e.target.value;
const filledFields = fieldValues.current.filter((v) => v !== "");
setTotal(limits.used + filledFields.length);
if (
emailFields.length === fieldValues.current.length &&
fieldValues.current.every((v) => v !== "")
@@ -136,7 +192,7 @@ export default function Page() {
</InputGroup>
<FormButtons
confirmButton={
<Button type="submit" variant={"primary/small"}>
<Button type="submit" variant={"primary/small"} disabled={total > limits.limit}>
Send invitations
</Button>
}
@@ -1,6 +1,6 @@
import { useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { UserPlusIcon } from "@heroicons/react/20/solid";
import { LockOpenIcon, UserPlusIcon } from "@heroicons/react/20/solid";
import { Form, useActionData } from "@remix-run/react";
import { ActionFunction, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { useState } from "react";
@@ -23,41 +23,55 @@ import {
import { Button, ButtonContent, LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { NamedIcon } from "~/components/primitives/NamedIcon";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { $replica } from "~/db.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { useUser } from "~/hooks/useUser";
import { getTeamMembersAndInvites, removeTeamMember } from "~/models/member.server";
import { removeTeamMember } from "~/models/member.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
import { requireUserId } from "~/services/session.server";
import {
inviteTeamMemberPath,
organizationTeamPath,
resendInvitePath,
revokeInvitePath,
v3BillingPath,
} from "~/utils/pathBuilder";
const Params = z.object({
organizationSlug: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug } = params;
invariant(organizationSlug, "organizationSlug not found");
const { organizationSlug } = Params.parse(params);
const result = await getTeamMembersAndInvites({
userId,
slug: organizationSlug,
const organization = await $replica.organization.findFirst({
where: { slug: organizationSlug },
select: { id: true },
});
if (result === null) {
if (!organization) {
throw new Response("Not Found", { status: 404 });
}
return typedjson({
members: result.members,
invites: result.invites,
const presenter = new TeamPresenter();
const result = await presenter.call({
userId,
organizationId: organization.id,
});
if (!result) {
throw new Response("Not Found", { status: 404 });
}
return typedjson(result);
};
const schema = z.object({
@@ -101,10 +115,12 @@ type Member = UseDataFunctionReturn<typeof loader>["members"][number];
type Invite = UseDataFunctionReturn<typeof loader>["invites"][number];
export default function Page() {
const { members, invites, limits } = useTypedLoaderData<typeof loader>();
const user = useUser();
const { members, invites } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const requiresUpgrade = limits.used >= limits.limit;
return (
<PageContainer>
<NavBar>
@@ -150,11 +166,6 @@ export default function Page() {
<Paragraph variant="small">{member.user.email}</Paragraph>
</div>
<div className="flex grow items-center justify-end gap-4">
{/*
// This displays Member or Admin but we'll implement this when we implement roles properly
<Paragraph variant="extra-small">
{titleCase(member.role.toLocaleLowerCase())}
</Paragraph> */}
<LeaveRemoveButton userId={user.id} member={member} memberCount={members.length} />
</div>
</li>
@@ -186,15 +197,31 @@ export default function Page() {
</>
)}
<div className="mt-4 flex max-w-md justify-end">
<LinkButton
to={inviteTeamMemberPath(organization)}
variant={"primary/small"}
LeadingIcon={UserPlusIcon}
{requiresUpgrade ? (
<InfoPanel
variant="upgrade"
icon={LockOpenIcon}
iconClassName="text-indigo-500"
title="Unlock more team members"
to={v3BillingPath(organization)}
buttonLabel="Upgrade"
>
Invite a team member
</LinkButton>
</div>
<Paragraph variant="small">
You've used all {limits.limit} of your available team members. Upgrade your plan to
enable more.
</Paragraph>
</InfoPanel>
) : (
<div className="mt-4 flex max-w-md justify-end">
<LinkButton
to={inviteTeamMemberPath(organization)}
variant={"primary/small"}
LeadingIcon={UserPlusIcon}
>
Invite a team member
</LinkButton>
</div>
)}
</PageBody>
</PageContainer>
);