Compare commits
1 Commits
main
...
feat/partn
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f84e3d1d2e |
@@ -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.`,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user