Team member limiting
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user