diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index ccbdb2367..c2e05248b 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -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: { diff --git a/apps/webapp/app/presenters/TeamPresenter.server.ts b/apps/webapp/app/presenters/TeamPresenter.server.ts new file mode 100644 index 000000000..4124dc5fc --- /dev/null +++ b/apps/webapp/app/presenters/TeamPresenter.server.ts @@ -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, + }, + }; + } +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx index 9d9279c31..cf60e8186 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx @@ -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(); + 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 && ( + + + You've used all {limits.limit} of your available team members. Upgrade your plan to + add more. + + + )}
@@ -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() { + } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.team/route.tsx index 0885e3714..85ed4af0a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.team/route.tsx @@ -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["members"][number]; type Invite = UseDataFunctionReturn["invites"][number]; export default function Page() { + const { members, invites, limits } = useTypedLoaderData(); const user = useUser(); - const { members, invites } = useTypedLoaderData(); const organization = useOrganization(); + const requiresUpgrade = limits.used >= limits.limit; + return ( @@ -150,11 +166,6 @@ export default function Page() { {member.user.email}
- {/* - // This displays Member or Admin but we'll implement this when we implement roles properly - - {titleCase(member.role.toLocaleLowerCase())} - */}
@@ -186,15 +197,31 @@ export default function Page() { )} -
- - Invite a team member - -
+ + You've used all {limits.limit} of your available team members. Upgrade your plan to + enable more. + + + ) : ( +
+ + Invite a team member + +
+ )}
);