fix: onboarding - team invites (#27113)

This commit is contained in:
sean-brydon
2026-01-23 16:31:35 +00:00
committed by GitHub
parent 02824b17c3
commit 09194c58e1
7 changed files with 61 additions and 13 deletions
@@ -25,10 +25,10 @@ const ServerPage = async () => {
return redirect("/auth/login");
}
// If user has pending team invites, redirect them directly to personal onboarding
// This handles the case where users sign up with an invite token and are redirected here
const hasPendingInvite = await MembershipRepository.hasPendingInviteByUserId({ userId: session.user.id });
if (hasPendingInvite) {
// If user has any team membership (pending or accepted), redirect them directly to personal onboarding
// This handles the case where users sign up with an invite token (membership is auto-accepted)
const hasTeamMembership = await MembershipRepository.hasAnyTeamMembershipByUserId({ userId: session.user.id });
if (hasTeamMembership) {
return redirect("/onboarding/personal/settings");
}
@@ -25,6 +25,12 @@ export function useTeamInvites() {
return { isPending: listInvites.isPending, listInvites: listInvites.data };
}
export function useHasTeamMembership() {
const { data, isPending } = trpc.viewer.teams.hasTeamMembership.useQuery();
return { isPending, hasTeamMembership: data?.hasTeamMembership ?? false };
}
export function useHasTeamPlan() {
const { data: hasTeamPlan, isPending } = trpc.viewer.teams.hasTeamPlan.useQuery();
@@ -12,7 +12,7 @@ import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { type IconName } from "@calcom/ui/components/icon";
import { RadioAreaGroup } from "@calcom/ui/components/radio";
import { useTeamInvites } from "@calcom/web/modules/billing/hooks/useHasPaidPlan";
import { useHasTeamMembership } from "@calcom/web/modules/billing/hooks/useHasPaidPlan";
import { OnboardingCard } from "../components/OnboardingCard";
import { OnboardingLayout } from "../components/OnboardingLayout";
@@ -30,7 +30,7 @@ export const OnboardingView = ({ userEmail }: OnboardingViewProps) => {
const { selectedPlan, setSelectedPlan, resetOnboardingPreservingPlan } = useOnboardingStore();
const previousPlanRef = useRef<PlanType | null>(null);
const [isPending, startTransition] = useTransition();
const { listInvites, isPending: isPendingInvites } = useTeamInvites();
const { hasTeamMembership, isPending: isPendingMembership } = useHasTeamMembership();
// Reset onboarding data when visiting this page, but preserve the selected plan
useEffect(() => {
@@ -38,15 +38,16 @@ export const OnboardingView = ({ userEmail }: OnboardingViewProps) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// If user has pending team invites, redirect them directly to personal onboarding
// If user has any team membership (pending or accepted), redirect them directly to personal onboarding
// This handles the case where users sign up with an invite token (membership is auto-accepted)
useEffect(() => {
if (!isPendingInvites && listInvites && listInvites.length > 0) {
if (!isPendingMembership && hasTeamMembership) {
setSelectedPlan("personal");
startTransition(() => {
router.push("/onboarding/personal/settings");
});
}
}, [isPendingInvites, listInvites, router, setSelectedPlan]);
}, [isPendingMembership, hasTeamMembership, router, setSelectedPlan]);
// Plan order mapping for determining direction
const planOrder: Record<PlanType, number> = {
@@ -130,8 +131,8 @@ export const OnboardingView = ({ userEmail }: OnboardingViewProps) => {
const selectedPlanData = plans.find((plan) => plan.id === selectedPlan);
// Show loading state while checking for invites or if redirecting
if (isPendingInvites || (listInvites && listInvites.length > 0)) {
// Show loading state while checking for team membership or if redirecting
if (isPendingMembership || hasTeamMembership) {
return (
<OnboardingLayout userEmail={userEmail}>
<OnboardingCard title={t("loading")} subtitle="" />
@@ -64,9 +64,11 @@ export async function checkOnboardingRedirect(
// Determine which onboarding path to use
const onboardingV3Enabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("onboarding-v3");
const hasPendingInvite = await MembershipRepository.hasPendingInviteByUserId({ userId });
// Check for any team membership (pending or accepted) to handle users who signed up via invite token
// When users sign up with an invite token, the membership is auto-accepted
const hasTeamMembership = await MembershipRepository.hasAnyTeamMembershipByUserId({ userId });
if (hasPendingInvite && onboardingV3Enabled) {
if (hasTeamMembership && onboardingV3Enabled) {
return "/onboarding/personal/settings";
}
@@ -593,4 +593,24 @@ export class MembershipRepository {
});
return !!pendingInvite;
}
/**
* Checks if a user has any team membership (pending or accepted).
* Used during onboarding to detect users who signed up via invite token,
* where the membership is auto-accepted.
*/
static async hasAnyTeamMembershipByUserId({ userId }: { userId: number }): Promise<boolean> {
const membership = await prisma.membership.findFirst({
where: {
userId,
team: {
isOrganization: false,
},
},
select: {
id: true,
},
});
return !!membership;
}
}
@@ -124,6 +124,10 @@ export const viewerTeamsRouter = router({
return handler(opts);
}),
hasTeamPlan,
hasTeamMembership: authedProcedure.query(async (opts) => {
const { default: handler } = await import("./hasTeamMembership.handler");
return handler(opts);
}),
listInvites: authedProcedure.query(async (opts) => {
const { default: handler } = await import("./listInvites.handler");
return handler(opts);
@@ -0,0 +1,15 @@
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
type HasTeamMembershipOptions = {
ctx: {
user: { id: number };
};
};
export const hasTeamMembershipHandler = async ({ ctx }: HasTeamMembershipOptions) => {
const userId = ctx.user.id;
const hasTeamMembership = await MembershipRepository.hasAnyTeamMembershipByUserId({ userId });
return { hasTeamMembership };
};
export default hasTeamMembershipHandler;