diff --git a/apps/web/src/views/invite/index.tsx b/apps/web/src/views/invite/index.tsx index 29bd73e8..7a5748ff 100644 --- a/apps/web/src/views/invite/index.tsx +++ b/apps/web/src/views/invite/index.tsx @@ -36,6 +36,17 @@ export default function InvitePage() { return router.push(`/boards`); } + if ( + error.data?.code === "FORBIDDEN" && + error.message === "SEAT_LIMIT_REACHED" + ) { + setError( + t`This workspace has reached its member limit. The workspace owner will need to upgrade their plan.`, + ); + setIsProcessing(false); + return; + } + setError( error.message || t`Failed to accept invitation. Please try again later, or contact customer support.`, diff --git a/apps/web/src/views/members/components/InviteMemberForm.tsx b/apps/web/src/views/members/components/InviteMemberForm.tsx index 69809630..6919c6af 100644 --- a/apps/web/src/views/members/components/InviteMemberForm.tsx +++ b/apps/web/src/views/members/components/InviteMemberForm.tsx @@ -26,9 +26,13 @@ import { api } from "~/utils/api"; export function InviteMemberForm({ subscriptions, unlimitedSeats, + memberCount, + seatLimit, }: { subscriptions: Subscription[] | undefined; unlimitedSeats: boolean; + memberCount: number; + seatLimit: number | null; }) { const utils = api.useUtils(); const [isShareInviteLinkEnabled, setIsShareInviteLinkEnabled] = @@ -77,6 +81,8 @@ export function InviteMemberForm({ } }, [activeInviteLink]); + const isAtSeatLimit = seatLimit !== null && memberCount >= seatLimit; + const inviteMember = api.member.invite.useMutation({ onSuccess: async () => { closeModal(); @@ -93,6 +99,15 @@ export function InviteMemberForm({ message: t`User is already a member of this workspace`, icon: "error", }); + } else if ( + error.data?.code === "FORBIDDEN" && + error.message === "SEAT_LIMIT_REACHED" + ) { + showPopup({ + header: t`Seat limit reached`, + message: t`You've reached your ${seatLimit ?? 0}-seat limit. Please upgrade your plan to add more members.`, + icon: "error", + }); } else { showPopup({ header: t`Error inviting member`, @@ -284,22 +299,30 @@ export function InviteMemberForm({ )} - {env("NEXT_PUBLIC_KAN_ENV") === "cloud" && - !isPartnerTier && - !unlimitedSeats && ( -
- {hasTeamSubscription || hasProSubscription ? ( + {env("NEXT_PUBLIC_KAN_ENV") === "cloud" && ( +
+ {isPartnerTier && seatLimit !== null ? ( +
+
+ + {hasTeamSubscription ? t`Team Plan` : t`Pro Plan`} + + + {memberCount} / {seatLimit} {t`seats`} + +
+
+ ) : !unlimitedSeats ? ( + hasTeamSubscription || hasProSubscription ? (
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`} - {!isPartnerTier && ( -

- {unlimitedSeats - ? t`You have unlimited seats with your Pro Plan. There is no additional charge for new members!` - : t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`} -

- )} +

+ {unlimitedSeats + ? t`You have unlimited seats with your Pro Plan. There is no additional charge for new members!` + : t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`} +

) : (
@@ -310,9 +333,10 @@ export function InviteMemberForm({ {t`Inviting members requires a Team or Pro plan. You'll be redirected to upgrade your workspace.`}

- )} -
- )} + ) + ) : null} +
+ )}
diff --git a/apps/web/src/views/members/index.tsx b/apps/web/src/views/members/index.tsx index 6ea90cbc..a73527b1 100644 --- a/apps/web/src/views/members/index.tsx +++ b/apps/web/src/views/members/index.tsx @@ -11,7 +11,11 @@ import { twMerge } from "tailwind-merge"; import type { Subscription } from "@kan/shared/utils"; import { authClient } from "@kan/auth/client"; -import { getSubscriptionByPlan, hasUnlimitedSeats } from "@kan/shared/utils"; +import { + getSeatLimit, + getSubscriptionByPlan, + hasUnlimitedSeats, +} from "@kan/shared/utils"; import Avatar from "~/components/Avatar"; import Button from "~/components/Button"; @@ -84,6 +88,7 @@ export default function MembersPage() { const isPaidPlan = isProPlan || isTeamPlan; const activeMembers = data?.members.length ?? 0; + const seatLimit = getSeatLimit(subscriptions); const totalSeats = teamSubscription?.seats ?? proSubscription?.seats ?? @@ -402,6 +407,8 @@ export default function MembersPage() { diff --git a/packages/api/src/routers/member.ts b/packages/api/src/routers/member.ts index 6e518b94..e74ebbac 100644 --- a/packages/api/src/routers/member.ts +++ b/packages/api/src/routers/member.ts @@ -10,13 +10,14 @@ import * as userRepo from "@kan/db/repository/user.repo"; import * as workspaceRepo from "@kan/db/repository/workspace.repo"; import { generateUID, + getSeatLimit, getSubscriptionByPlan, hasUnlimitedSeats, } from "@kan/shared"; import { updateSubscriptionSeats } from "@kan/stripe"; -import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import { memberInviteResponseSchema } from "../schemas"; +import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import { assertCanManageMember, assertCanManageRole, @@ -114,6 +115,20 @@ export const memberRouter = createTRPCRouter({ }); } } + + const seatLimit = getSeatLimit(subscriptions); + if (seatLimit !== null) { + const memberCount = await memberRepo.getCountByWorkspaceId( + ctx.db, + workspace.id, + ); + if (memberCount >= seatLimit) { + throw new TRPCError({ + message: `SEAT_LIMIT_REACHED`, + code: "FORBIDDEN", + }); + } + } } const existingUser = await userRepo.getByEmail(ctx.db, input.email); @@ -644,6 +659,20 @@ export const memberRouter = createTRPCRouter({ }); } } + + const seatLimit = getSeatLimit(subscriptions); + if (seatLimit !== null) { + const memberCount = await memberRepo.getCountByWorkspaceId( + ctx.db, + workspace.id, + ); + if (memberCount >= seatLimit) { + throw new TRPCError({ + message: `SEAT_LIMIT_REACHED`, + code: "FORBIDDEN", + }); + } + } } // Get the workspace role to set roleId diff --git a/packages/db/src/repository/member.repo.ts b/packages/db/src/repository/member.repo.ts index 4035784e..5436ef74 100644 --- a/packages/db/src/repository/member.repo.ts +++ b/packages/db/src/repository/member.repo.ts @@ -1,4 +1,4 @@ -import { and, count, eq, isNull } from "drizzle-orm"; +import { and, count, eq, isNull, or } from "drizzle-orm"; import type { dbClient } from "@kan/db/client"; import type { MemberRole, MemberStatus } from "@kan/db/schema"; @@ -19,6 +19,27 @@ export const getActiveCount = async (db: dbClient) => { return result[0]?.count ?? 0; }; +export const getCountByWorkspaceId = async ( + db: dbClient, + workspaceId: number, +) => { + const result = await db + .select({ count: count() }) + .from(workspaceMembers) + .where( + and( + eq(workspaceMembers.workspaceId, workspaceId), + isNull(workspaceMembers.deletedAt), + or( + eq(workspaceMembers.status, "active"), + eq(workspaceMembers.status, "invited"), + ), + ), + ); + + return result[0]?.count ?? 0; +}; + export const create = async ( db: dbClient, memberInput: { diff --git a/packages/shared/src/utils/subscriptions.ts b/packages/shared/src/utils/subscriptions.ts index 0aa5c68c..186db358 100644 --- a/packages/shared/src/utils/subscriptions.ts +++ b/packages/shared/src/utils/subscriptions.ts @@ -54,3 +54,14 @@ export const hasUnlimitedSeats = ( const activeSubscriptions = getActiveSubscriptions(subscriptions); return activeSubscriptions.some((sub) => sub.unlimitedSeats); }; + +export const getSeatLimit = ( + subscriptions: Subscription[] | undefined, +): number | null => { + const activeSubscriptions = getActiveSubscriptions(subscriptions); + const partnerSub = activeSubscriptions.find( + (sub) => + sub.partnerTier !== null && !sub.unlimitedSeats && sub.seats !== null, + ); + return partnerSub?.seats ?? null; +};