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`); 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( setError(
error.message || error.message ||
t`Failed to accept invitation. Please try again later, or contact customer support.`, 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({ export function InviteMemberForm({
subscriptions, subscriptions,
unlimitedSeats, unlimitedSeats,
memberCount,
seatLimit,
}: { }: {
subscriptions: Subscription[] | undefined; subscriptions: Subscription[] | undefined;
unlimitedSeats: boolean; unlimitedSeats: boolean;
memberCount: number;
seatLimit: number | null;
}) { }) {
const utils = api.useUtils(); const utils = api.useUtils();
const [isShareInviteLinkEnabled, setIsShareInviteLinkEnabled] = const [isShareInviteLinkEnabled, setIsShareInviteLinkEnabled] =
@@ -77,6 +81,8 @@ export function InviteMemberForm({
} }
}, [activeInviteLink]); }, [activeInviteLink]);
const isAtSeatLimit = seatLimit !== null && memberCount >= seatLimit;
const inviteMember = api.member.invite.useMutation({ const inviteMember = api.member.invite.useMutation({
onSuccess: async () => { onSuccess: async () => {
closeModal(); closeModal();
@@ -93,6 +99,15 @@ export function InviteMemberForm({
message: t`User is already a member of this workspace`, message: t`User is already a member of this workspace`,
icon: "error", 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 { } else {
showPopup({ showPopup({
header: t`Error inviting member`, header: t`Error inviting member`,
@@ -284,22 +299,30 @@ export function InviteMemberForm({
</div> </div>
)} )}
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && {env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
!isPartnerTier && <div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
!unlimitedSeats && ( {isPartnerTier && seatLimit !== null ? (
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900"> <div>
{hasTeamSubscription || hasProSubscription ? ( <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> <div>
<span className="font-medium text-emerald-500 dark:text-emerald-400"> <span className="font-medium text-emerald-500 dark:text-emerald-400">
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`} {hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`}
</span> </span>
{!isPartnerTier && ( <p className="mt-1">
<p className="mt-1"> {unlimitedSeats
{unlimitedSeats ? t`You have unlimited seats with your Pro Plan. There is no additional charge for new members!`
? 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.`}
: t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`} </p>
</p>
)}
</div> </div>
) : ( ) : (
<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.`} {t`Inviting members requires a Team or Pro plan. You'll be redirected to upgrade your workspace.`}
</p> </p>
</div> </div>
)} )
</div> ) : null}
)} </div>
)}
</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"> <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 type { Subscription } from "@kan/shared/utils";
import { authClient } from "@kan/auth/client"; 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 Avatar from "~/components/Avatar";
import Button from "~/components/Button"; import Button from "~/components/Button";
@@ -84,6 +88,7 @@ export default function MembersPage() {
const isPaidPlan = isProPlan || isTeamPlan; const isPaidPlan = isProPlan || isTeamPlan;
const activeMembers = data?.members.length ?? 0; const activeMembers = data?.members.length ?? 0;
const seatLimit = getSeatLimit(subscriptions);
const totalSeats = const totalSeats =
teamSubscription?.seats ?? teamSubscription?.seats ??
proSubscription?.seats ?? proSubscription?.seats ??
@@ -402,6 +407,8 @@ export default function MembersPage() {
<InviteMemberForm <InviteMemberForm
subscriptions={subscriptions} subscriptions={subscriptions}
unlimitedSeats={unlimitedSeats} unlimitedSeats={unlimitedSeats}
memberCount={activeMembers}
seatLimit={seatLimit}
/> />
</Modal> </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 * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { import {
generateUID, generateUID,
getSeatLimit,
getSubscriptionByPlan, getSubscriptionByPlan,
hasUnlimitedSeats, hasUnlimitedSeats,
} from "@kan/shared"; } from "@kan/shared";
import { updateSubscriptionSeats } from "@kan/stripe"; import { updateSubscriptionSeats } from "@kan/stripe";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { memberInviteResponseSchema } from "../schemas"; import { memberInviteResponseSchema } from "../schemas";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { import {
assertCanManageMember, assertCanManageMember,
assertCanManageRole, 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); 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 // 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 { dbClient } from "@kan/db/client";
import type { MemberRole, MemberStatus } from "@kan/db/schema"; import type { MemberRole, MemberStatus } from "@kan/db/schema";
@@ -19,6 +19,27 @@ export const getActiveCount = async (db: dbClient) => {
return result[0]?.count ?? 0; 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 ( export const create = async (
db: dbClient, db: dbClient,
memberInput: { memberInput: {

View File

@@ -54,3 +54,14 @@ export const hasUnlimitedSeats = (
const activeSubscriptions = getActiveSubscriptions(subscriptions); const activeSubscriptions = getActiveSubscriptions(subscriptions);
return activeSubscriptions.some((sub) => sub.unlimitedSeats); 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;
};