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