Compare commits

...

1 Commits

Author SHA1 Message Date
Henry
f84e3d1d2e feat(cloud): set seat limit checks for partner member invitations 2026-05-21 21:35:19 +01:00
6 changed files with 121 additions and 18 deletions

View File

@@ -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.`,

View File

@@ -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({
</div>
)}
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!isPartnerTier &&
!unlimitedSeats && (
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
{hasTeamSubscription || hasProSubscription ? (
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
{isPartnerTier && seatLimit !== null ? (
<div>
<div className="flex items-center justify-between">
<span className="font-medium text-emerald-500 dark:text-emerald-400">
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan`}
</span>
<span className="text-light-900 dark:text-dark-900">
{memberCount} / {seatLimit} {t`seats`}
</span>
</div>
</div>
) : !unlimitedSeats ? (
hasTeamSubscription || hasProSubscription ? (
<div>
<span className="font-medium text-emerald-500 dark:text-emerald-400">
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`}
</span>
{!isPartnerTier && (
<p className="mt-1">
{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.`}
</p>
)}
<p className="mt-1">
{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.`}
</p>
</div>
) : (
<div>
@@ -310,9 +333,10 @@ export function InviteMemberForm({
{t`Inviting members requires a Team or Pro plan. You'll be redirected to upgrade your workspace.`}
</p>
</div>
)}
</div>
)}
)
) : null}
</div>
)}
</div>
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">

View File

@@ -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() {
<InviteMemberForm
subscriptions={subscriptions}
unlimitedSeats={unlimitedSeats}
memberCount={activeMembers}
seatLimit={seatLimit}
/>
</Modal>

View File

@@ -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

View File

@@ -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: {

View File

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