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;
+};