feat: organization upgrade flow v3 (#25972)

* wip flow

* add tests

* WIP migrateing view

* push back step

* fix tests and logic for adding new members to existing teams

* few UI fixes

* type fixes

* fix nits

* few UI + re-route fixes

* fix teamId when migrating
This commit is contained in:
sean-brydon authored and GitHub committed 2026-01-26 17:03:12 +01:00
1 parent 8848efef4f
commit a359f0ba01
22 files changed
+1747 -79

No files matched your search

@@ -0,0 +1,38 @@
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils";
import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { prisma } from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { OrganizationMigrateMembersView } from "~/onboarding/organization/migrate-members/organization-migrate-members-view";
export default async function MigrateMembersPage() {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
return redirect("/auth/login");
}
const userEmail = session.user.email || "";
const userId = session.user.id;
const gettingStartedPath = await OnboardingPathService.getGettingStartedPath(prisma);
if (!isCompanyEmail(userEmail)) {
return redirect(gettingStartedPath);
}
const userRepository = new UserRepository(prisma);
const isMemberOfOrganization = await userRepository.findIfAMemberOfSomeOrganization({ user: { id: userId } });
if (isMemberOfOrganization) {
return redirect(gettingStartedPath);
}
return <OrganizationMigrateMembersView userEmail={userEmail} />;
}
@@ -0,0 +1,61 @@
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { prisma } from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { OrganizationMigrateTeamsView } from "~/onboarding/organization/migrate-teams/organization-migrate-teams-view";
type PageProps = {
searchParams: Promise<{ migrate?: string }>;
};
export default async function MigrateTeamsPage({ searchParams }: PageProps) {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
return redirect("/auth/login");
}
const userEmail = session.user.email || "";
const userId = session.user.id;
const gettingStartedPath = await OnboardingPathService.getGettingStartedPath(prisma);
if (!isCompanyEmail(userEmail)) {
return redirect(gettingStartedPath);
}
const userRepository = new UserRepository(prisma);
const { organizations } = await userRepository.findOrganizations({ userId });
if (organizations.length > 0) {
return redirect(gettingStartedPath);
}
// Check for migrate query param
const params = await searchParams;
const migrateParam = params?.migrate;
// If no migrate param, redirect to teams step
if (migrateParam !== "true") {
return redirect("/onboarding/organization/teams");
}
// Check if user has teams to migrate
const teamRepository = new TeamRepository(prisma);
const ownedTeams = await teamRepository.findOwnedTeamsByUserId({ userId });
// If no teams, redirect to teams step
if (ownedTeams.length === 0) {
return redirect("/onboarding/organization/teams");
}
return <OrganizationMigrateTeamsView userEmail={userEmail} />;
}
@@ -5,9 +5,11 @@ import { z } from "zod";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { OrganizationOnboardingRepository } from "@calcom/features/organizations/repositories/OrganizationOnboardingRepository";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import { prisma } from "@calcom/prisma";
import { orgOnboardingTeamsSchema } from "@calcom/prisma/zod-utils";
const querySchema = z.object({
session_id: z.string().min(1),
@@ -43,6 +45,19 @@ async function getHandler(req: NextRequest) {
// If onboarding-v3 is enabled AND organizationOnboardingId exists, redirect to onboarding flow
if (isOnboardingV3Enabled && organizationOnboardingId) {
// Check if this is a migration flow (user has already completed onboarding)
const onboarding = await OrganizationOnboardingRepository.findById(organizationOnboardingId);
const hasMigratedTeams =
onboarding?.teams &&
orgOnboardingTeamsSchema.parse(onboarding.teams).some((team) => team.isBeingMigrated);
if (hasMigratedTeams) {
// Migration flow - user already completed onboarding, redirect to event-types
const redirectUrl = new URL("/event-types?newOrganizationModal=true", WEBAPP_URL).toString();
return NextResponse.redirect(redirectUrl);
}
// Regular flow - redirect to personal onboarding
params.append("fromTeamOnboarding", "true");
const redirectUrl = new URL(
`/onboarding/personal/settings?${params.toString()}`,
@@ -6,6 +6,8 @@ import { usePathname } from "next/navigation";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Avatar } from "@calcom/ui/components/avatar";
import { Badge } from "@calcom/ui/components/badge";
import classNames from "@calcom/ui/classNames";
import { useOnboardingStore, type Invite } from "../store/onboarding-store";
@@ -26,6 +28,7 @@ type DisplayItem = {
email: string;
team: string;
isReal?: boolean;
isMigrated?: boolean;
};
export const OnboardingInviteBrowserView = ({
@@ -35,8 +38,16 @@ export const OnboardingInviteBrowserView = ({
}: OnboardingInviteBrowserViewProps) => {
const pathname = usePathname();
const { data: user } = trpc.viewer.me.get.useQuery();
const { teamBrand, teamInvites, invites, teamDetails, organizationBrand, organizationDetails } =
useOnboardingStore();
const {
teamBrand,
teamInvites,
invites,
teamDetails,
organizationBrand,
organizationDetails,
migratedMembers,
teams,
} = useOnboardingStore();
const { t } = useLocale();
// Animation variants for entry and exit
@@ -83,6 +94,32 @@ export const OnboardingInviteBrowserView = ({
// Filter out empty invites (where email is empty or just whitespace)
const validInvites = actualInvites.filter((invite) => invite.email && invite.email.trim().length > 0);
// Add migrated members if using organization invites
const migratedInvites: Invite[] = [];
if (useOrganizationInvites && migratedMembers.length > 0) {
migratedInvites.push(
...migratedMembers.map((member) => {
// Find team name from teamId
const team = teams.find((t) => t.id === member.teamId);
return {
email: member.email,
team: team?.name || "",
role: "MEMBER" as const,
};
})
);
}
// Combine form invites with migrated members, avoiding duplicates
const allInvites = [...validInvites];
const existingEmails = new Set(validInvites.map((inv) => inv.email.toLowerCase()));
migratedInvites.forEach((migratedInvite) => {
if (!existingEmails.has(migratedInvite.email.toLowerCase())) {
allInvites.push(migratedInvite);
existingEmails.add(migratedInvite.email.toLowerCase());
}
});
// Create empty state items
const emptyStateItem = {
name: t("team_member"),
@@ -95,14 +132,19 @@ export const OnboardingInviteBrowserView = ({
const displayItems: DisplayItem[] = [];
const maxItems = 9;
// Add actual invites first
for (let i = 0; i < validInvites.length && i < maxItems; i++) {
const invite = validInvites[i];
// Create a set of migrated member emails for quick lookup
const migratedEmails = new Set(migratedMembers.map((member) => member.email.toLowerCase()));
// Add all invites (form invites + migrated members)
for (let i = 0; i < allInvites.length && i < maxItems; i++) {
const invite = allInvites[i];
const isMigrated = migratedEmails.has(invite.email.toLowerCase());
displayItems.push({
name: invite.email.split("@")[0] || t("team_member"),
email: invite.email,
team: invite.team || t("team"),
isReal: true,
isMigrated,
});
}
@@ -134,30 +176,65 @@ export const OnboardingInviteBrowserView = ({
ease: "backOut",
}}>
<div className="bg-default border-subtle flex flex-col rounded-2xl border">
<div className="relative p-1">
{/* Banner Image */}
{organizationBrand.banner && (
<div className="border-subtle relative h-36 w-full overflow-hidden rounded-xl border">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={organizationBrand.banner}
alt={displayName}
className="h-full w-full object-cover"
/>
</div>
)}
{useOrganizationInvites || organizationBrand.banner || avatar ? (
<div className="relative p-1">
{/* Banner Image */}
{useOrganizationInvites && (
<div className="border-subtle relative h-36 w-full overflow-hidden rounded-xl border">
{organizationBrand.banner ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={organizationBrand.banner}
alt={displayName}
className="h-full w-full object-cover"
/>
) : (
<div className="bg-emphasis h-full w-full" />
)}
</div>
)}
{!useOrganizationInvites && organizationBrand.banner && (
<div className="border-subtle relative h-36 w-full overflow-hidden rounded-xl border">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={organizationBrand.banner}
alt={displayName}
className="h-full w-full object-cover"
/>
</div>
)}
{/* Organization Avatar - Overlaying the banner */}
{organizationBrand.banner && avatar && (
<div className="absolute -bottom-6 left-4">
<Avatar size="lg" imageSrc={avatar} alt={displayName} className="h-12 w-12 border" />
</div>
)}
</div>
{/* Organization Avatar - Overlaying the banner */}
{useOrganizationInvites && avatar && (
<div className="absolute -bottom-6 left-4">
<Avatar
size="lg"
imageSrc={avatar}
alt={displayName}
className="h-12 w-12 border"
/>
</div>
)}
{!useOrganizationInvites && organizationBrand.banner && avatar && (
<div className="absolute -bottom-6 left-4">
<Avatar
size="lg"
imageSrc={avatar}
alt={displayName}
className="h-12 w-12 border"
/>
</div>
)}
</div>
) : null}
{/* Organization Info */}
<div className={`flex flex-col items-start gap-1 px-4 pb-4 pt-8`}>
{!organizationBrand.banner && avatar && (
<div
className={classNames(
`flex flex-col items-start gap-1 px-4 pb-4`,
useOrganizationInvites || organizationBrand.banner || avatar ? "pt-8" : "pt-4"
)}>
{!useOrganizationInvites && !organizationBrand.banner && avatar && (
<Avatar
size="lg"
imageSrc={avatar}
@@ -192,11 +269,18 @@ export const OnboardingInviteBrowserView = ({
{item.email}
</p>
</div>
{item.team && (
<div className="bg-emphasis text-emphasis rounded-md px-2 py-0.5 text-xs">
{item.team}
</div>
)}
<div className="flex flex-col items-center gap-1">
{item.team && !item.isMigrated && (
<div className="bg-emphasis text-emphasis line-clamp-1 max-w-[86px] truncate rounded-md px-2 py-0.5 text-xs">
{item.team}
</div>
)}
{item.isMigrated && (
<Badge variant="green" className="text-xs">
{t("migrating")}
</Badge>
)}
</div>
</div>
</div>
))}
@@ -0,0 +1,189 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { usePathname } from "next/navigation";
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Avatar } from "@calcom/ui/components/avatar";
import { Badge } from "@calcom/ui/components/badge";
import { Icon } from "@calcom/ui/components/icon";
type MigratedMember = {
email: string;
name?: string | null;
avatarUrl?: string | null;
teamId: number;
teamName?: string;
};
type OnboardingMigrateMembersBrowserViewProps = {
members: MigratedMember[];
organizationLogo?: string | null;
organizationName?: string;
organizationBanner?: string | null;
slug?: string;
};
export const OnboardingMigrateMembersBrowserView = ({
members,
organizationLogo,
organizationName,
organizationBanner,
slug,
}: OnboardingMigrateMembersBrowserViewProps) => {
const pathname = usePathname();
const { t } = useLocale();
const displayUrl = slug ? `${slug}.${subdomainSuffix()}` : subdomainSuffix();
const containerVariants = {
initial: {
opacity: 0,
y: -20,
},
animate: {
opacity: 1,
y: 0,
},
exit: {
opacity: 0,
y: 20,
},
};
const hasMembers = members.length > 0;
return (
<div className="bg-default border-subtle hidden h-full w-full flex-col overflow-hidden rounded-l-2xl border-y border-s xl:flex">
{/* Browser header */}
<div className="border-subtle bg-default flex min-w-0 shrink-0 items-center gap-3 rounded-t-2xl border-b p-3">
{/* Navigation buttons */}
<div className="flex shrink-0 items-center gap-4 opacity-50">
<Icon name="arrow-left" className="text-subtle h-4 w-4" />
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
<Icon name="rotate-cw" className="text-subtle h-4 w-4" />
</div>
<div className="bg-cal-muted flex w-full min-w-0 items-center gap-2 rounded-[32px] px-3 py-2">
<Icon name="lock" className="text-subtle h-4 w-4" />
<p className="text-default truncate text-sm font-medium leading-tight">{displayUrl}/members</p>
</div>
<Icon name="ellipsis-vertical" className="text-subtle h-4 w-4" />
</div>
{/* Content */}
<div className="bg-cal-muted h-full pl-11 pt-11">
<AnimatePresence mode="wait">
<motion.div
key={pathname}
className="bg-default border-muted flex h-full w-full flex-col overflow-hidden rounded-tl-xl border"
variants={containerVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{
duration: 0.5,
ease: "backOut",
}}>
{/* Members Header with Banner */}
<div className="border-subtle flex flex-col border-b">
<div className="relative">
{/* Banner Image */}
<div className="border-subtle relative h-40 w-full overflow-hidden border-b">
{organizationBanner ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={organizationBanner}
alt={organizationName || ""}
className="h-full w-full object-cover"
/>
) : (
<div className="bg-emphasis h-full w-full" />
)}
</div>
{/* Organization Logo - Overlaying the banner */}
{organizationLogo && (
<div className="absolute -bottom-8 left-4">
<Avatar
size="lg"
imageSrc={organizationLogo}
alt={organizationName || ""}
className="border-4 border-white"
/>
</div>
)}
</div>
{/* Members Info */}
<div className="flex flex-col gap-2 px-4 pb-4 pt-12">
<h2 className="text-emphasis text-xl font-semibold leading-tight">{t("members")}</h2>
{hasMembers && (
<p className="text-subtle text-sm font-medium leading-tight">
{members.length === 1
? t("migrating_members_count", { count: members.length })
: t("migrating_members_count_plural", { count: members.length })}
</p>
)}
</div>
</div>
{/* Members List */}
<div className="flex flex-col overflow-y-auto">
{hasMembers ? (
<>
{members.map((member, index) => (
<div key={member.email}>
{index > 0 && <div className="border-subtle h-px border-t" />}
<div className="flex items-center gap-3 px-5 py-4">
<Avatar
size="md"
alt={member.name || member.email}
imageSrc={member.avatarUrl || undefined}
className="border-2 border-white"
fallback={
<div className="bg-emphasis flex h-full w-full items-center justify-center text-white">
<span className="text-sm font-semibold">
{(member.name || member.email).charAt(0).toUpperCase()}
</span>
</div>
}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<h3 className="text-subtle text-sm font-semibold leading-none">
{member.name || member.email}
</h3>
<Badge variant="green" className="text-xs">
{t("migrating")}
</Badge>
</div>
<p className="text-muted text-xs font-medium leading-tight">{member.email}</p>
</div>
<Icon name="check" className="text-emphasis h-5 w-5" />
</div>
</div>
))}
</>
) : (
<div className="flex flex-col items-center justify-center gap-3 px-5 py-12">
<div className="bg-cal-muted flex h-16 w-16 items-center justify-center rounded-full">
<Icon name="user-plus" className="text-subtle h-8 w-8" />
</div>
<div className="flex flex-col gap-1 text-center">
<p className="text-default truncate text-sm font-semibold leading-tight">
{t("no_members_to_migrate")}
</p>
<p className="text-subtle truncate text-xs font-medium leading-tight">
{t("members_will_be_added_to_organization")}
</p>
</div>
</div>
)}
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
);
};
@@ -0,0 +1,191 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { usePathname } from "next/navigation";
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Avatar } from "@calcom/ui/components/avatar";
import { Badge } from "@calcom/ui/components/badge";
import { Icon } from "@calcom/ui/components/icon";
type OnboardingMigrateTeamsBrowserViewProps = {
teams: Array<{ id: number; name: string; slug: string | null; isSelected?: boolean }>;
organizationLogo?: string | null;
organizationName?: string;
organizationBanner?: string | null;
slug?: string;
};
export const OnboardingMigrateTeamsBrowserView = ({
teams,
organizationLogo,
organizationName,
organizationBanner,
slug,
}: OnboardingMigrateTeamsBrowserViewProps) => {
const pathname = usePathname();
const { t } = useLocale();
const displayUrl = slug ? `${slug}.${subdomainSuffix()}` : subdomainSuffix();
const selectedTeams = teams.filter((team) => team.isSelected);
const hasValidTeams = teams.length > 0;
const containerVariants = {
initial: {
opacity: 0,
y: -20,
},
animate: {
opacity: 1,
y: 0,
},
exit: {
opacity: 0,
y: 20,
},
};
return (
<div className="bg-default border-subtle hidden h-full w-full flex-col overflow-hidden rounded-l-2xl border-y border-s xl:flex">
{/* Browser header */}
<div className="border-subtle bg-default flex min-w-0 shrink-0 items-center gap-3 rounded-t-2xl border-b p-3">
{/* Navigation buttons */}
<div className="flex shrink-0 items-center gap-4 opacity-50">
<Icon name="arrow-left" className="text-subtle h-4 w-4" />
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
<Icon name="rotate-cw" className="text-subtle h-4 w-4" />
</div>
<div className="bg-cal-muted flex w-full min-w-0 items-center gap-2 rounded-[32px] px-3 py-2">
<Icon name="lock" className="text-subtle h-4 w-4" />
<p className="text-default truncate text-sm font-medium leading-tight">{displayUrl}/teams</p>
</div>
<Icon name="ellipsis-vertical" className="text-subtle h-4 w-4" />
</div>
{/* Content */}
<div className="bg-cal-muted h-full pl-11 pt-11">
<AnimatePresence mode="wait">
<motion.div
key={pathname}
className="bg-default border-muted flex h-full w-full flex-col overflow-hidden rounded-tl-xl border"
variants={containerVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{
duration: 0.5,
ease: "backOut",
}}>
{/* Teams Header with Banner */}
<div className="border-subtle flex flex-col border-b">
<div className="relative">
{/* Banner Image */}
<div className="border-subtle relative h-40 w-full overflow-hidden border-b">
{organizationBanner ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={organizationBanner}
alt={organizationName || ""}
className="h-full w-full object-cover"
/>
) : (
<div className="bg-emphasis h-full w-full" />
)}
</div>
{/* Organization Logo - Overlaying the banner */}
{organizationLogo && (
<div className="absolute -bottom-8 left-4">
<Avatar
size="lg"
imageSrc={organizationLogo}
alt={organizationName || ""}
className="border-default border-4"
/>
</div>
)}
</div>
{/* Teams Info */}
<div className="flex flex-col gap-2 px-4 pb-4 pt-12">
<h2 className="text-emphasis text-xl font-semibold leading-tight">{t("teams")}</h2>
{selectedTeams.length > 0 && (
<p className="text-subtle text-sm font-medium leading-tight">
{selectedTeams.length === 1
? t("migrating_teams_count", { count: selectedTeams.length })
: t("migrating_teams_count_plural", { count: selectedTeams.length })}
</p>
)}
</div>
</div>
{/* Teams List */}
<div className="flex flex-col overflow-y-auto">
{hasValidTeams ? (
<>
{teams.map((team, index) => (
<div key={team.id}>
{index > 0 && <div className="border-subtle h-px border-t" />}
<div className="flex items-center gap-3 px-5 py-4">
<Avatar
size="md"
alt={team.name}
className="border-default border-2"
fallback={
<div className="bg-emphasis flex h-full w-full items-center justify-center text-white">
<span className="text-sm font-semibold uppercase">{team.name.charAt(0)}</span>
</div>
}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex min-w-0 items-center gap-2">
<h3 className="text-subtle line-clamp-1 min-w-0 truncate text-sm font-semibold leading-none">
{team.name}
</h3>
{team.isSelected && (
<Badge variant="green" className="shrink-0 text-xs">
{t("migrating")}
</Badge>
)}
</div>
<p className="text-muted text-xs font-medium leading-tight">
{team.slug && slug
? `${slug}.${subdomainSuffix()}/${team.slug}`
: team.slug
? `${team.slug}.${subdomainSuffix()}`
: t("team_slug_pending")}
</p>
</div>
{team.isSelected ? (
<Icon name="check" className="text-emphasis h-5 w-5" />
) : (
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
)}
</div>
</div>
))}
</>
) : (
<div className="flex flex-col items-center justify-center gap-3 px-5 py-12">
<div className="bg-cal-muted flex h-16 w-16 items-center justify-center rounded-full">
<Icon name="users" className="text-subtle h-8 w-8" />
</div>
<div className="flex flex-col gap-1 text-center">
<p className="text-default truncate text-sm font-semibold leading-tight">
{t("no_teams_to_migrate")}
</p>
<p className="text-subtle truncate text-xs font-medium leading-tight">
{t("create_teams_in_next_step")}
</p>
</div>
</div>
)}
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
);
};
@@ -6,10 +6,11 @@ import { usePathname } from "next/navigation";
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Avatar } from "@calcom/ui/components/avatar";
import { Badge } from "@calcom/ui/components/badge";
import { Icon } from "@calcom/ui/components/icon";
type OnboardingTeamsBrowserViewProps = {
teams: Array<{ name: string }>;
teams: Array<{ name: string; slug?: string | null; isMigrated?: boolean }>;
organizationLogo?: string | null;
organizationName?: string;
organizationBanner?: string | null;
@@ -135,12 +136,29 @@ export const OnboardingTeamsBrowserView = ({
}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<h3 className="text-subtle text-sm font-semibold leading-none">{team.name}</h3>
<div className="flex min-w-0 items-center gap-2">
<h3 className="text-subtle line-clamp-1 min-w-0 truncate text-sm font-semibold leading-none">
{team.name}
</h3>
{team.isMigrated && (
<Badge variant="green" className="shrink-0 text-xs">
{t("migrating")}
</Badge>
)}
</div>
<p className="text-muted text-xs font-medium leading-tight">
{t("onboarding_teams_browser_view_team_description")}
{team.slug && slug
? `${slug}.${subdomainSuffix()}/${team.slug}`
: team.slug
? `${team.slug}.${subdomainSuffix()}`
: t("onboarding_teams_browser_view_team_description")}
</p>
</div>
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
{team.isMigrated ? (
<Icon name="check" className="text-emphasis h-5 w-5" />
) : (
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
)}
</div>
</div>
))
@@ -0,0 +1,276 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { CreationSource } from "@calcom/prisma/enums";
import type { OnboardingState } from "../../store/onboarding-store";
import { useSubmitOnboarding } from "../useSubmitOnboarding";
vi.mock("react", async () => {
const actual = await vi.importActual<typeof React>("react");
return {
...actual,
useState: vi.fn((initial: any) => [initial, vi.fn()]),
};
});
vi.mock("@calcom/features/flags/context/provider", () => ({
useFlagMap: vi.fn(() => ({})),
}));
vi.mock("@calcom/features/ee/organizations/hooks/useWelcomeModal", () => ({
setShowNewOrgModalFlag: vi.fn(),
}));
vi.mock("@calcom/ui/components/toast", () => ({
showToast: vi.fn(),
}));
vi.mock("@calcom/trpc/react", () => ({
trpc: {
viewer: {
organizations: {
intentToCreateOrg: {
useMutation: vi.fn(),
},
},
},
},
}));
describe("useSubmitOnboarding", () => {
let mockMutateAsync: ReturnType<typeof vi.fn>;
let mockResetOnboarding: ReturnType<typeof vi.fn>;
let mockUseMutation: ReturnType<typeof vi.fn>;
beforeEach(async () => {
mockMutateAsync = vi.fn();
mockResetOnboarding = vi.fn();
mockUseMutation = vi.fn(() => ({
mutateAsync: mockMutateAsync,
}));
// Mock window for skipToPersonal
global.window = {
location: {
href: "",
},
} as any;
const { trpc } = await import("@calcom/trpc/react");
vi.mocked(trpc.viewer.organizations.intentToCreateOrg.useMutation).mockImplementation(
mockUseMutation as any
);
});
it("should submit teams with migration data correctly", async () => {
const hook = useSubmitOnboarding();
const { submitOnboarding } = hook;
const store = {
selectedPlan: "organization",
organizationDetails: {
name: "Test Org",
link: "test-org",
bio: "Test bio",
},
organizationBrand: {
color: "#000000",
logo: "logo.png",
banner: "banner.png",
},
teams: [
{ id: 1, name: "Existing Team", slug: "existing-team", isBeingMigrated: true },
{ id: -1, name: "New Team", slug: null, isBeingMigrated: false },
],
invites: [{ email: "invite@example.com", team: "New Team", role: "MEMBER" }],
inviteRole: "MEMBER",
migratedMembers: [{ email: "migrated@example.com", teamId: 1, role: "MEMBER" as const }],
resetOnboarding: mockResetOnboarding,
} as unknown as OnboardingState;
mockMutateAsync.mockResolvedValue({
checkoutUrl: "https://stripe.com/checkout",
});
await submitOnboarding(store, "user@example.com", store.invites);
expect(mockMutateAsync).toHaveBeenCalledWith({
name: "Test Org",
slug: "test-org",
bio: "Test bio",
logo: "logo.png",
brandColor: "#000000",
bannerUrl: "banner.png",
orgOwnerEmail: "user@example.com",
seats: null,
pricePerSeat: null,
isPlatform: false,
creationSource: CreationSource.WEBAPP,
teams: [
{ id: 1, name: "Existing Team", isBeingMigrated: true, slug: "existing-team" },
{ id: -1, name: "New Team", isBeingMigrated: false, slug: null },
],
invitedMembers: [
{ email: "invite@example.com", teamName: "New Team", teamId: -1, role: "MEMBER" },
{ email: "migrated@example.com", teamId: 1, role: "MEMBER" },
],
});
});
it("should use teamId for invites to migrated teams", async () => {
const hook = useSubmitOnboarding();
const { submitOnboarding } = hook;
const store = {
selectedPlan: "organization",
organizationDetails: {
name: "Test Org",
link: "test-org",
bio: "Test bio",
},
organizationBrand: {
color: "#000000",
logo: "logo.png",
banner: "banner.png",
},
teams: [
{ id: 1, name: "Existing Team", slug: "existing-team", isBeingMigrated: true },
{ id: 2, name: "Another Existing Team", slug: "another-existing-team", isBeingMigrated: true },
{ id: -1, name: "New Team", slug: null, isBeingMigrated: false },
],
invites: [
{ email: "invite1@example.com", team: "Existing Team", role: "MEMBER" },
{ email: "invite2@example.com", team: "New Team", role: "ADMIN" },
{ email: "invite3@example.com", team: "another existing team", role: "MEMBER" },
],
inviteRole: "MEMBER",
migratedMembers: [],
resetOnboarding: mockResetOnboarding,
} as unknown as OnboardingState;
mockMutateAsync.mockResolvedValue({
checkoutUrl: "https://stripe.com/checkout",
});
await submitOnboarding(store, "user@example.com", store.invites);
expect(mockMutateAsync).toHaveBeenCalledWith({
name: "Test Org",
slug: "test-org",
bio: "Test bio",
logo: "logo.png",
brandColor: "#000000",
bannerUrl: "banner.png",
orgOwnerEmail: "user@example.com",
seats: null,
pricePerSeat: null,
isPlatform: false,
creationSource: CreationSource.WEBAPP,
teams: [
{ id: 1, name: "Existing Team", isBeingMigrated: true, slug: "existing-team" },
{ id: 2, name: "Another Existing Team", isBeingMigrated: true, slug: "another-existing-team" },
{ id: -1, name: "New Team", isBeingMigrated: false, slug: null },
],
invitedMembers: [
// Migrated team invite should use teamId
{ email: "invite1@example.com", teamId: 1, teamName: undefined, role: "MEMBER" },
// New team invite should use teamName (note: implementation uses inviteRole from store, not invite.role)
{ email: "invite2@example.com", teamName: "New Team", teamId: -1, role: "MEMBER" },
// Migrated team invite with different case should still use teamId
{ email: "invite3@example.com", teamId: 2, teamName: undefined, role: "MEMBER" },
],
});
});
it("should handle invites without teams correctly", async () => {
const hook = useSubmitOnboarding();
const { submitOnboarding } = hook;
const store = {
selectedPlan: "organization",
organizationDetails: {
name: "Test Org",
link: "test-org",
bio: "Test bio",
},
organizationBrand: {
color: "#000000",
logo: "logo.png",
banner: "banner.png",
},
teams: [{ id: 1, name: "Existing Team", slug: "existing-team", isBeingMigrated: true }],
invites: [
{ email: "invite1@example.com", team: "", role: "MEMBER" },
{ email: "invite2@example.com", team: " ", role: "ADMIN" },
],
inviteRole: "MEMBER",
migratedMembers: [],
resetOnboarding: mockResetOnboarding,
} as unknown as OnboardingState;
mockMutateAsync.mockResolvedValue({
checkoutUrl: "https://stripe.com/checkout",
});
await submitOnboarding(store, "user@example.com", store.invites);
expect(mockMutateAsync).toHaveBeenCalledWith({
name: "Test Org",
slug: "test-org",
bio: "Test bio",
logo: "logo.png",
brandColor: "#000000",
bannerUrl: "banner.png",
orgOwnerEmail: "user@example.com",
seats: null,
pricePerSeat: null,
isPlatform: false,
creationSource: CreationSource.WEBAPP,
teams: [{ id: 1, name: "Existing Team", isBeingMigrated: true, slug: "existing-team" }],
invitedMembers: [
// Invites without teams should have neither teamId nor teamName (note: implementation uses inviteRole from store)
{ email: "invite1@example.com", teamId: undefined, teamName: undefined, role: "MEMBER" },
{ email: "invite2@example.com", teamId: undefined, teamName: undefined, role: "MEMBER" },
],
});
});
it("should handle teams with null slugs correctly", async () => {
const hook = useSubmitOnboarding();
const { submitOnboarding } = hook;
const store = {
selectedPlan: "organization",
organizationDetails: {
name: "Test Org",
link: "test-org",
bio: "",
},
organizationBrand: {
color: "#000000",
logo: null,
banner: null,
},
teams: [{ id: -1, name: "New Team", slug: null, isBeingMigrated: false }],
invites: [],
inviteRole: "MEMBER",
migratedMembers: [],
resetOnboarding: mockResetOnboarding,
} as unknown as OnboardingState;
mockMutateAsync.mockResolvedValue({
checkoutUrl: null,
organizationId: 123,
});
await submitOnboarding(store, "user@example.com", []);
expect(mockMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
teams: [{ id: -1, name: "New Team", isBeingMigrated: false, slug: null }],
invitedMembers: [],
})
);
});
});
@@ -0,0 +1,24 @@
"use client";
import { useSearchParams } from "next/navigation";
import { trpc } from "@calcom/trpc/react";
export const useMigrationFlow = () => {
const searchParams = useSearchParams();
const migrateParam = searchParams?.get("migrate");
const isMigrationFlow = migrateParam === "true";
const { data: teams, isLoading } = trpc.viewer.teams.listOwnedTeams.useQuery(undefined, {
enabled: isMigrationFlow, // Only fetch if migration flow
});
const hasTeams = (teams?.length ?? 0) > 0;
return {
isMigrationFlow,
hasTeams,
teams: teams ?? [],
isLoading,
};
};
@@ -24,32 +24,65 @@ export const useSubmitOnboarding = () => {
setError(null);
try {
const { selectedPlan, organizationDetails, organizationBrand, teams, inviteRole, resetOnboarding } =
store;
const {
selectedPlan,
organizationDetails,
organizationBrand,
teams,
inviteRole,
resetOnboarding,
migratedMembers,
} = store;
if (selectedPlan !== "organization") {
throw new Error("Only organization plan is currently supported");
}
// Prepare teams data
const teamsData = teams
.filter((team) => team.name.trim().length > 0)
.map((team) => ({
id: -1, // New team
id: team.id,
name: team.name,
isBeingMigrated: false,
slug: null,
isBeingMigrated: team.isBeingMigrated,
slug: team.slug,
}));
// Prepare invites data
const invitedMembersData = invitesToSubmit
.filter((invite) => invite.email.trim().length > 0)
.map((invite) => ({
email: invite.email,
teamName: invite.team,
teamId: -1,
role: inviteRole,
}));
.map((invite) => {
// If invite has a team name, try to find the team ID (for migrated teams)
let teamId: number | undefined = undefined;
let teamName: string | undefined = undefined;
if (invite.team && invite.team.trim().length > 0) {
const matchingTeam = teams.find(
(team) => team.name.toLowerCase() === invite.team.toLowerCase()
);
if (matchingTeam?.isBeingMigrated && matchingTeam.id !== -1) {
// Use team ID for migrated teams
teamId = matchingTeam.id;
} else {
// Use team name for new teams (will be matched after creation)
teamName = invite.team;
teamId = -1;
}
}
return {
email: invite.email,
teamName,
teamId,
role: inviteRole,
};
});
const migratedMembersData = migratedMembers.map((member) => ({
email: member.email,
teamId: member.teamId,
role: member.role,
}));
const allInvitedMembers = [...invitedMembersData, ...migratedMembersData];
const result = await intentToCreateOrg.mutateAsync({
name: organizationDetails.name,
@@ -64,7 +97,7 @@ export const useSubmitOnboarding = () => {
isPlatform: false,
creationSource: CreationSource.WEBAPP,
teams: teamsData,
invitedMembers: invitedMembersData,
invitedMembers: allInvitedMembers,
});
// If there's a checkout URL, redirect to Stripe (billing enabled flow)
@@ -76,9 +109,19 @@ export const useSubmitOnboarding = () => {
// No checkout URL means billing is disabled (self-hosted flow)
// Organization has already been created by the backend
showToast("Organization created successfully!", "success");
// Set flag to show welcome modal after personal onboarding redirect
// Set flag to show welcome modal after redirect
setShowNewOrgModalFlag();
skipToPersonal(resetOnboarding);
// Check if this is a migration flow (user has already completed onboarding)
const hasMigratedTeams = teams.some((team) => team.isBeingMigrated);
if (hasMigratedTeams) {
// Migration flow - user already completed onboarding, redirect to event-types
resetOnboarding();
window.location.href = "/event-types?newOrganizationModal=true";
} else {
// Regular flow - redirect to personal onboarding
skipToPersonal(resetOnboarding);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to create organization";
setError(errorMessage);
@@ -1,6 +1,6 @@
"use client";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import posthog from "posthog-js";
import { useEffect, useRef, useState } from "react";
@@ -11,6 +11,7 @@ import { ColorPicker, Label } from "@calcom/ui/components/form";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingOrganizationBrowserView } from "../../components/onboarding-organization-browser-view";
import { useMigrationFlow } from "../../hooks/useMigrationFlow";
import { useOnboardingStore } from "../../store/onboarding-store";
type OrganizationBrandViewProps = {
@@ -19,8 +20,10 @@ type OrganizationBrandViewProps = {
export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useLocale();
const { organizationDetails, organizationBrand, setOrganizationBrand } = useOnboardingStore();
const { isMigrationFlow, hasTeams } = useMigrationFlow();
const [_logoFile, setLogoFile] = useState<File | null>(null);
const [_bannerFile, setBannerFile] = useState<File | null>(null);
@@ -72,6 +75,17 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
setOrganizationBrand({ color });
};
const getNextStep = () => {
const migrateParam = searchParams?.get("migrate");
const queryString = migrateParam ? `?migrate=${migrateParam}` : "";
// If migration flow and has teams, go to migrate-teams, otherwise go to teams
if (isMigrationFlow && hasTeams) {
return `/onboarding/organization/migrate-teams${queryString}`;
}
return `/onboarding/organization/teams${queryString}`;
};
const handleContinue = () => {
posthog.capture("onboarding_organization_brand_continue_clicked", {
has_logo: !!logoPreview,
@@ -84,7 +98,7 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
banner: bannerPreview,
color: brandColor,
});
router.push("/onboarding/organization/teams");
router.push(getNextStep());
};
const handleSkip = () => {
@@ -93,8 +107,10 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
router.push("/onboarding/organization/teams");
};
const totalSteps = isMigrationFlow && hasTeams ? 6 : 4;
return (
<OnboardingLayout userEmail={userEmail} currentStep={2} totalSteps={4}>
<OnboardingLayout userEmail={userEmail} currentStep={2} totalSteps={totalSteps}>
{/* Left column - Main content */}
<OnboardingCard
title={t("onboarding_org_brand_title")}
@@ -1,6 +1,6 @@
"use client";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import posthog from "posthog-js";
import { useEffect, useState } from "react";
@@ -11,6 +11,7 @@ import { Label, TextField, TextArea } from "@calcom/ui/components/form";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingOrganizationBrowserView } from "../../components/onboarding-organization-browser-view";
import { useMigrationFlow } from "../../hooks/useMigrationFlow";
import { useOnboardingStore } from "../../store/onboarding-store";
import { ValidatedOrganizationSlug } from "./validated-organization-slug";
@@ -30,8 +31,10 @@ const slugify = (text: string): string => {
export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useLocale();
const { organizationDetails, setOrganizationDetails } = useOnboardingStore();
const { organizationDetails, setOrganizationDetails, selectedPlan, setSelectedPlan } = useOnboardingStore();
const { isMigrationFlow, hasTeams } = useMigrationFlow();
const [organizationName, setOrganizationName] = useState("");
const [organizationLink, setOrganizationLink] = useState("");
@@ -39,6 +42,13 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr
const [isSlugValid, setIsSlugValid] = useState(false);
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
// Ensure selectedPlan is set to "organization" when entering organization onboarding
useEffect(() => {
if (selectedPlan !== "organization") {
setSelectedPlan("organization");
}
}, [selectedPlan, setSelectedPlan]);
// Load from store on mount
useEffect(() => {
setOrganizationName(organizationDetails.name);
@@ -78,11 +88,15 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr
link: organizationLink,
bio: organizationBio,
});
router.push("/onboarding/organization/brand");
const migrateParam = searchParams?.get("migrate");
const nextUrl = `/onboarding/organization/brand${migrateParam ? `?migrate=${migrateParam}` : ""}`;
router.push(nextUrl);
};
const totalSteps = isMigrationFlow && hasTeams ? 6 : 4;
return (
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={4}>
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={totalSteps}>
{/* Left column - Main content */}
<OnboardingCard
title={t("onboarding_org_details_title")}
@@ -1,7 +1,7 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import React from "react";
import { useForm, useFieldArray } from "react-hook-form";
import { z } from "zod";
@@ -35,8 +35,12 @@ type FormValues = {
export const OrganizationInviteEmailView = ({ userEmail }: OrganizationInviteEmailViewProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useLocale();
const flags = useFlags();
const { teams } = useOnboardingStore();
const migratedTeams = teams.filter((team) => team.isBeingMigrated);
const isMigrationFlow = searchParams?.get("migrate") === "true";
const store = useOnboardingStore();
const usersEmailDomain = userEmail.split("@")[1];
@@ -54,11 +58,11 @@ export const OrganizationInviteEmailView = ({ userEmail }: OrganizationInviteEma
const googleWorkspaceEnabled = flags["google-workspace-directory"];
const filteredTeams = store.teams.filter((team) => team.name && team.name.trim().length > 0);
const teams =
const teamOptions =
filteredTeams.length > 0
? filteredTeams.map((team) => ({ value: team.name.toLowerCase(), label: team.name }))
: [];
const hasTeams = teams.length > 0;
const hasTeams = teamOptions.length > 0;
// Conditional form schema - team is optional when there are no teams
const formSchema = z.object({
@@ -102,7 +106,7 @@ export const OrganizationInviteEmailView = ({ userEmail }: OrganizationInviteEma
};
const handleBack = () => {
router.push("/onboarding/organization/teams");
router.back();
};
const handleSkip = async () => {
@@ -136,8 +140,12 @@ export const OrganizationInviteEmailView = ({ userEmail }: OrganizationInviteEma
return email && email.trim().length > 0 && team && team.trim().length > 0;
});
// Calculate total steps dynamically
const totalSteps = isMigrationFlow && migratedTeams.length > 0 ? 6 : 4;
const currentStep = isMigrationFlow && migratedTeams.length > 0 ? 6 : 4;
return (
<OnboardingLayout userEmail={userEmail} currentStep={4} totalSteps={4}>
<OnboardingLayout userEmail={userEmail} currentStep={currentStep} totalSteps={totalSteps}>
<div className="flex h-full w-full flex-col gap-4">
<OnboardingCard
title={t("onboarding_org_invite_title")}
@@ -176,7 +184,7 @@ export const OrganizationInviteEmailView = ({ userEmail }: OrganizationInviteEma
remove={remove}
defaultRole={inviteRole}
showTeamSelect={hasTeams}
teams={teams}
teams={teamOptions}
emailPlaceholder={`dave@${usersEmailDomain}`}
/>
@@ -0,0 +1,141 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Avatar } from "@calcom/ui/components/avatar";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingMigrateMembersBrowserView } from "../../components/onboarding-migrate-members-browser-view";
import { useMigrationFlow } from "../../hooks/useMigrationFlow";
import { useOnboardingStore } from "../../store/onboarding-store";
type TeamMember = RouterOutputs["viewer"]["teams"]["listMembers"]["members"][number];
type OrganizationMigrateMembersViewProps = {
userEmail: string;
};
export const OrganizationMigrateMembersView = ({ userEmail }: OrganizationMigrateMembersViewProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useLocale();
const { isLoading: isMigrationFlowLoading } = useMigrationFlow();
const { teams, organizationBrand, organizationDetails, setMigratedMembers } = useOnboardingStore();
const teamsToMigrate = teams.filter((team) => team.isBeingMigrated && team.id > 0);
const teamIds = teamsToMigrate.map((team) => team.id);
const results = trpc.useQueries((t) => teamIds.map((teamId) => t.viewer.teams.listMembers({ teamId })));
const isLoading = results.some((result) => result.isLoading);
const uniqueMembers = results
.reduce<TeamMember[]>((acc, result) => {
if (result.data?.members) {
return [...acc, ...result.data.members];
}
return acc;
}, [])
.filter((member, index, self) => index === self.findIndex((m) => m.email === member.email));
const handleContinue = () => {
const migratedMembersData = uniqueMembers.map((member) => ({
email: member.email,
name: member.name || undefined,
teamId: member.teamId,
role: member.role === "OWNER" ? ("ADMIN" as const) : member.role,
}));
setMigratedMembers(migratedMembersData);
const migrateParam = searchParams?.get("migrate");
const nextUrl = `/onboarding/organization/invite/email${migrateParam ? `?migrate=${migrateParam}` : ""}`;
router.push(nextUrl);
};
if (isLoading) {
return (
<OnboardingLayout userEmail={userEmail} currentStep={5} totalSteps={6}>
<SkeletonContainer as="div" className="stack-y-6">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</SkeletonContainer>
</OnboardingLayout>
);
}
if (teamsToMigrate.length === 0) {
return null;
}
return (
<OnboardingLayout userEmail={userEmail} currentStep={5} totalSteps={6}>
<OnboardingCard
title={t("migrate_team_members")}
subtitle={t("members_from_migrated_teams_will_be_included")}
footer={
<div className="flex w-full items-center justify-between gap-4">
<Button color="minimal" className="rounded-[10px]" onClick={() => router.back()}>
{t("back")}
</Button>
<div className="flex items-center gap-2">
<Button
color="primary"
className="rounded-[10px]"
onClick={handleContinue}
disabled={isLoading || isMigrationFlowLoading}>
{t("continue")}
</Button>
</div>
</div>
}>
<div className="flex w-full flex-col gap-4">
{uniqueMembers.length > 0 ? (
<ul className="border-subtle divide-subtle max-h-[300px] divide-y overflow-y-auto rounded-md border">
{uniqueMembers.map((member) => (
<li key={member.email} className="flex items-center justify-between px-5 py-2">
<div className="flex items-center space-x-3">
<Avatar size="sm" alt={member.email} imageSrc={member.avatarUrl} />
<div className="flex gap-1">
<Tooltip content={member.email}>
<span className="text-emphasis text-sm font-medium">
{member.name || member.email}
</span>
</Tooltip>
<Badge variant="green">{t("migrating_from_team")}</Badge>
</div>
</div>
</li>
))}
</ul>
) : (
<p className="text-subtle text-sm">{t("no_members_to_migrate")}</p>
)}
</div>
</OnboardingCard>
{/* Right column - Browser view */}
<OnboardingMigrateMembersBrowserView
members={uniqueMembers.map((member) => ({
email: member.email,
name: member.name || null,
avatarUrl: member.avatarUrl || null,
teamId: member.teamId,
}))}
organizationLogo={organizationBrand.logo}
organizationName={organizationDetails.name}
organizationBanner={organizationBrand.banner}
slug={organizationDetails.link}
/>
</OnboardingLayout>
);
};
@@ -0,0 +1,234 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { CheckboxField, TextField } from "@calcom/ui/components/form";
import { Form } from "@calcom/ui/components/form";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingMigrateTeamsBrowserView } from "../../components/onboarding-migrate-teams-browser-view";
import { useMigrationFlow } from "../../hooks/useMigrationFlow";
import { useOnboardingStore } from "../../store/onboarding-store";
import { getSuggestedSlug } from "./utils";
type OrganizationMigrateTeamsViewProps = {
userEmail: string;
};
const schema = z.object({
moveTeams: z.array(
z.object({
id: z.number(),
shouldMove: z.boolean(),
newSlug: z.string().optional(),
})
),
});
type FormValues = z.infer<typeof schema>;
export const OrganizationMigrateTeamsView = ({ userEmail }: OrganizationMigrateTeamsViewProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useLocale();
const { isMigrationFlow, hasTeams, teams, isLoading } = useMigrationFlow();
const { organizationDetails, organizationBrand, teams: teamsFromStore, setTeams } = useOnboardingStore();
const orgSlug = organizationDetails.link;
const teamsToMigrateFromStore = teamsFromStore.filter((team) => team.isBeingMigrated);
const form = useForm<FormValues>({
defaultValues: {
moveTeams: [],
},
resolver: async (data) => {
try {
const validatedData = await schema.parseAsync(data);
return { values: validatedData, errors: {} };
} catch (error) {
if (error instanceof z.ZodError) {
return {
values: {
moveTeams: [],
},
errors: error.formErrors.fieldErrors,
};
}
return { values: {}, errors: { moveTeams: { message: "Error validating input" } } };
}
},
});
const { register, control, watch, getValues, setValue, reset } = form;
const moveTeams = watch("moveTeams");
useEffect(() => {
if (teams.length === 0) return;
const currentMoveTeams = getValues("moveTeams");
if (currentMoveTeams.length === 0 || currentMoveTeams.length !== teams.length) {
reset({
moveTeams: teams.map((team) => {
const teamToMigrateInStore = teamsToMigrateFromStore.find((t) => t.id === team.id);
const slugConflictsWithOrg = team.slug === orgSlug;
return {
id: team.id,
shouldMove: slugConflictsWithOrg || !!teamToMigrateInStore,
newSlug:
teamToMigrateInStore?.slug ?? getSuggestedSlug({ teamSlug: team.slug, orgSlug }) ?? undefined,
};
}),
});
}
}, [teams, reset, teamsToMigrateFromStore, orgSlug, getValues]);
const handleFormSubmit = () => {
const moveTeamsData = getValues("moveTeams");
const teamsBeingMoved = moveTeamsData
.filter((team) => team.shouldMove)
.map((team) => {
const originalTeam = teams.find((t) => t.id === team.id);
if (!originalTeam) {
throw new Error(`Team with id ${team.id} not found`);
}
// Use newSlug if provided and not empty, otherwise fall back to original team slug
// This matches the settings flow behavior - backend will use original slug if newSlug is null
const slug = team.newSlug?.trim() || originalTeam.slug || null;
return {
id: team.id,
isBeingMigrated: true,
slug,
name: originalTeam.name,
};
});
const existingNewTeams = teamsFromStore.filter((team) => !team.isBeingMigrated);
setTeams([...teamsBeingMoved, ...existingNewTeams]);
const migrateParam = searchParams?.get("migrate");
const nextUrl = `/onboarding/organization/teams${migrateParam ? `?migrate=${migrateParam}` : ""}`;
router.push(nextUrl);
};
const handleSkip = () => {
const migrateParam = searchParams?.get("migrate");
const nextUrl = `/onboarding/organization/teams${migrateParam ? `?migrate=${migrateParam}` : ""}`;
router.push(nextUrl);
};
if (isLoading) {
return (
<OnboardingLayout userEmail={userEmail} currentStep={3} totalSteps={6}>
<OnboardingCard title={t("loading")} subtitle={t("please_wait")} />
</OnboardingLayout>
);
}
if (!isMigrationFlow || !hasTeams) {
return null;
}
const hasSelectedTeams = moveTeams.some((team) => team.shouldMove);
const browserViewTeams = teams.map((team) => {
const moveTeam = moveTeams.find((mt) => mt.id === team.id);
return {
id: team.id,
name: team.name,
slug: moveTeam?.newSlug || team.slug,
isSelected: moveTeam?.shouldMove || false,
};
});
return (
<OnboardingLayout userEmail={userEmail} currentStep={3} totalSteps={6}>
<OnboardingCard
title={t("migrate_existing_teams")}
subtitle={t("select_teams_to_migrate_to_organization")}
footer={
<div className="flex w-full items-center justify-between gap-4">
<Button color="minimal" className="rounded-[10px]" onClick={() => router.back()}>
{t("back")}
</Button>
<div className="flex items-center gap-2">
<Button color="minimal" className="rounded-[10px]" onClick={handleSkip}>
{t("onboarding_skip_for_now")}
</Button>
<Button
type="submit"
form="migrate-teams-form"
color="primary"
className="rounded-[10px]"
disabled={!hasSelectedTeams}>
{t("continue")}
</Button>
</div>
</div>
}>
<Form id="migrate-teams-form" form={form} handleSubmit={handleFormSubmit} className="w-full">
{moveTeams.length > 0 ? (
<div className="flex w-full flex-col gap-4">
<label className="text-emphasis mb-2 block text-sm font-medium leading-none">
{t("move_existing_teams")}
</label>
<ul className="mb-8 flex flex-col gap-4">
{moveTeams.map((team, index) => {
const currentTeam = teams.find((t) => t.id === team.id);
const slugConflictsWithOrg = currentTeam?.slug === orgSlug;
return (
<li key={team.id}>
<Controller
name={`moveTeams.${index}.shouldMove`}
control={control}
render={({ field: { value, onChange } }) => (
<CheckboxField
checked={value || slugConflictsWithOrg}
onChange={onChange}
description={currentTeam?.name ?? ""}
disabled={slugConflictsWithOrg}
/>
)}
/>
{moveTeams[index].shouldMove ? (
<TextField
placeholder={t("new_slug")}
defaultValue={teams.find((t) => t.id === team.id)?.slug ?? ""}
{...register(`moveTeams.${index}.newSlug`)}
onChange={(e) => {
const slug = slugify(e.target.value, true);
setValue(`moveTeams.${index}.newSlug`, slug);
}}
className="mt-2"
label=""
/>
) : null}
</li>
);
})}
</ul>
</div>
) : null}
</Form>
</OnboardingCard>
{/* Right column - Browser view */}
<OnboardingMigrateTeamsBrowserView
teams={browserViewTeams}
organizationLogo={organizationBrand.logo}
organizationName={organizationDetails.name}
organizationBanner={organizationBrand.banner}
slug={organizationDetails.link}
/>
</OnboardingLayout>
);
};
@@ -0,0 +1,15 @@
export const getSuggestedSlug = ({
teamSlug,
orgSlug,
}: {
teamSlug: string | null;
orgSlug: string | null;
}) => {
// If there is no orgSlug, we can't suggest a slug
if (!teamSlug || !orgSlug) {
return teamSlug;
}
return teamSlug.replace(`${orgSlug}-`, "").replace(`-${orgSlug}`, "");
};
@@ -1,12 +1,14 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import posthog from "posthog-js";
import { useForm, useFieldArray } from "react-hook-form";
import { z } from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { Form, TextField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
@@ -14,6 +16,7 @@ import { Icon } from "@calcom/ui/components/icon";
import { OnboardingCard } from "../../components/OnboardingCard";
import { OnboardingLayout } from "../../components/OnboardingLayout";
import { OnboardingTeamsBrowserView } from "../../components/onboarding-teams-browser-view";
import { useMigrationFlow } from "../../hooks/useMigrationFlow";
import { useOnboardingStore } from "../../store/onboarding-store";
type OrganizationTeamsViewProps = {
@@ -26,8 +29,14 @@ type FormValues = {
export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useLocale();
const { teams: storedTeams, setTeams, organizationBrand, organizationDetails } = useOnboardingStore();
const { isMigrationFlow } = useMigrationFlow();
// Filter out migrated teams - only show new teams
const newTeams = storedTeams.filter((team) => !team.isBeingMigrated);
const migratedTeams = storedTeams.filter((team) => team.isBeingMigrated);
const formSchema = z.object({
teams: z.array(
@@ -40,7 +49,7 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
teams: storedTeams.length > 0 ? storedTeams : [{ name: "" }],
teams: newTeams.length > 0 ? newTeams.map((team) => ({ name: team.name })) : [{ name: "" }],
},
});
@@ -49,20 +58,42 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
name: "teams",
});
const getNextStep = () => {
const migrateParam = searchParams?.get("migrate");
const queryString = migrateParam ? `?migrate=${migrateParam}` : "";
// If migration flow and teams were migrated, go to migrate-members
if (isMigrationFlow && migratedTeams.length > 0) {
return `/onboarding/organization/migrate-members${queryString}`;
}
return `/onboarding/organization/invite/email${queryString}`;
};
const handleContinue = (data: FormValues) => {
const validTeams = data.teams.filter((team) => team.name.trim().length > 0);
// Convert form data to Team structure and combine with migrated teams
const newTeamsData = data.teams
.filter((team) => team.name && team.name.trim().length > 0)
.map((team) => ({
id: -1,
name: team.name,
slug: null,
isBeingMigrated: false,
}));
posthog.capture("onboarding_organization_teams_continue_clicked", {
team_count: validTeams.length,
team_count: newTeamsData.length,
});
// Save teams to store
setTeams(data.teams);
router.push("/onboarding/organization/invite/email");
// Combine migrated teams with new teams
setTeams([...migratedTeams, ...newTeamsData]);
router.push(getNextStep());
};
const handleSkip = () => {
posthog.capture("onboarding_organization_teams_skip_clicked");
// Skip teams and go to invite
router.push("/onboarding/organization/invite/email");
// Skip teams - keep migrated teams if any, otherwise empty
setTeams(migratedTeams);
router.push(getNextStep());
};
const hasValidTeams = fields.some((_, index) => {
@@ -70,8 +101,12 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
return teamName && teamName.trim().length > 0;
});
// Calculate total steps dynamically
const totalSteps = isMigrationFlow && migratedTeams.length > 0 ? 6 : 4;
const currentStep = isMigrationFlow && migratedTeams.length > 0 ? 4 : 3;
return (
<OnboardingLayout userEmail={userEmail} currentStep={3} totalSteps={4}>
<OnboardingLayout userEmail={userEmail} currentStep={currentStep} totalSteps={totalSteps}>
{/* Left column - Main content */}
<OnboardingCard
title={t("onboarding_org_teams_title")}
@@ -84,7 +119,7 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
className="rounded-[10px]"
onClick={() => {
posthog.capture("onboarding_organization_teams_back_clicked");
router.push("/onboarding/organization/brand");
router.back();
}}>
{t("back")}
</Button>
@@ -105,7 +140,36 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
}>
<Form id="teams-form" form={form} handleSubmit={handleContinue} className="w-full">
<div className="w-full ">
<div className="flex w-full flex-col gap-8 ">
<div className="flex w-full flex-col gap-6">
{/* Migrated teams section */}
{migratedTeams.length > 0 && (
<div className="flex w-full flex-col gap-2">
<div className="flex flex-col gap-1">
<p className="text-emphasis text-sm font-medium leading-4">{t("migrated_teams")}</p>
<p className="text-subtle text-xs leading-4">{t("migrated_teams_description")}</p>
</div>
<div className="flex flex-col gap-2">
{migratedTeams.map((team) => (
<div key={team.id} className="flex w-full items-center gap-1">
<div className="flex-1">
<TextField
labelSrOnly
value={team.name}
disabled
placeholder={t("team")}
className="h-7 w-full rounded-[10px] text-sm opacity-60"
/>
</div>
<Badge variant="green" className="text-xs">
{t("migrating")}
</Badge>
</div>
))}
</div>
</div>
)}
{/* New teams section */}
<div className="flex w-full flex-col gap-2">
<div className="flex flex-col gap-1">
<p className="text-emphasis text-sm font-medium leading-4">{t("team")}</p>
@@ -165,7 +229,17 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
{/* Right column - Browser view */}
<OnboardingTeamsBrowserView
teams={form.watch("teams")}
teams={[
...migratedTeams.map((team) => ({
name: team.name,
slug: team.slug,
isMigrated: true,
})),
...form
.watch("teams")
.filter((t) => t.name && t.name.trim().length > 0)
.map((t) => ({ name: t.name, isMigrated: false })),
]}
organizationLogo={organizationBrand.logo}
organizationName={organizationDetails.name}
organizationBanner={organizationBrand.banner}
@@ -19,7 +19,10 @@ export interface OrganizationBrand {
}
export interface Team {
id: number; // -1 for new teams, actual ID for existing teams
name: string;
slug: string | null; // null for new teams, slug for migrated teams
isBeingMigrated: boolean; // true if migrating existing team
}
export interface Invite {
@@ -59,6 +62,9 @@ export interface OnboardingState {
invites: Invite[];
inviteRole: InviteRole;
// Migration state
migratedMembers: { email: string; name?: string; teamId: number; role: "MEMBER" | "ADMIN" }[];
// Team-specific state
teamDetails: TeamDetails;
teamBrand: TeamBrand;
@@ -76,6 +82,9 @@ export interface OnboardingState {
setInvites: (invites: Invite[]) => void;
setInviteRole: (role: InviteRole) => void;
// Migration actions
setMigratedMembers: (members: { email: string; name?: string; teamId: number; role: "MEMBER" | "ADMIN" }[]) => void;
// Team actions
setTeamDetails: (details: Partial<TeamDetails>) => void;
setTeamBrand: (brand: Partial<TeamBrand>) => void;
@@ -105,6 +114,7 @@ const initialState = {
teams: [],
invites: [],
inviteRole: "MEMBER" as InviteRole,
migratedMembers: [],
teamDetails: {
name: "",
slug: "",
@@ -148,6 +158,8 @@ export const useOnboardingStore = create<OnboardingState>()(
setInviteRole: (role) => set({ inviteRole: role }),
setMigratedMembers: (members) => set({ migratedMembers: members }),
setTeamDetails: (details) =>
set((state) => ({
teamDetails: { ...state.teamDetails, ...details },
@@ -185,6 +197,7 @@ export const useOnboardingStore = create<OnboardingState>()(
teams: state.teams,
invites: state.invites,
inviteRole: state.inviteRole,
migratedMembers: state.migratedMembers,
teamDetails: state.teamDetails,
teamBrand: state.teamBrand,
teamInvites: state.teamInvites,
@@ -20,7 +20,7 @@ export const CompanyEmailOrganizationBanner = ({ onDismissAction }: CompanyEmail
const handleLearnMore = () => {
const redirectPath = flags["onboarding-v3"]
? "/onboarding/organization/details"
? "/onboarding/organization/details?migrate=true"
: "/settings/organizations/new";
posthog.capture("company_email_banner_upgrade_clicked");
@@ -3531,6 +3531,22 @@
"icon_showcase": "Icon showcase",
"icons_showcase": "Icons showcase",
"migrating_from_team": "Migrating from team",
"migrate_existing_teams": "Migrate existing teams",
"select_teams_to_migrate_to_organization": "Select which teams you'd like to migrate to your organization",
"move_existing_teams": "Move existing teams",
"migrating_teams_count": "Migrating {{count}} team",
"migrating_teams_count_plural": "Migrating {{count}} teams",
"team_slug_pending": "Slug pending",
"no_teams_to_migrate": "No teams to migrate",
"create_teams_in_next_step": "You can create teams in the next step",
"migrating": "Migrating",
"migrated_teams": "Migrated teams",
"migrated_teams_description": "These teams will be moved to your organization",
"migrate_team_members": "Migrate team members",
"members_from_migrated_teams_will_be_included": "Members from migrated teams will be automatically included in your organization",
"migrating_members_count": "Migrating {{count}} member",
"migrating_members_count_plural": "Migrating {{count}} members",
"members_will_be_added_to_organization": "Members will be automatically added to your organization",
"feature_currently_disabled": "Feature is currently disabled",
"choose_an_option": "Choose an option",
"enter_option_value": "Enter option value",
@@ -612,10 +612,12 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe
for (const member of invitedMembers) {
let targetTeamId: number | undefined;
if (member.teamId !== undefined) {
// For new teams (teamId === -1), multiple teams share the same -1 ID, so we must use teamName to match
if (member.teamId !== undefined && member.teamId !== -1) {
targetTeamId = teamIdMap.get(member.teamId) || member.teamId;
log.debug(`Member ${member.email}: teamId ${member.teamId} -> resolved to ${targetTeamId}`);
} else if (member.teamName) {
// New team or teamId is -1: use teamName to match
targetTeamId = teamNameToId.get(member.teamName.toLowerCase());
log.debug(
`Member ${member.email}: teamName "${member.teamName}" -> resolved to ${
@@ -462,6 +462,202 @@ describe("BillingEnabledOrgOnboardingService", () => {
expect(result.handoverUrl).toContain("/settings/organizations/new/resume");
expect(mockPaymentService.createPaymentIntent).not.toHaveBeenCalled();
});
it("should handle explicitly migrated teams with slugs", async () => {
const existingTeam1 = await prismock.team.create({
data: {
id: 101,
name: "Engineering",
slug: "engineering",
},
});
const existingTeam2 = await prismock.team.create({
data: {
id: 102,
name: "Marketing",
slug: "marketing",
},
});
await prismock.membership.create({
data: {
userId: mockUser.id,
teamId: existingTeam1.id,
role: MembershipRole.OWNER,
accepted: true,
},
});
await prismock.membership.create({
data: {
userId: mockUser.id,
teamId: existingTeam2.id,
role: MembershipRole.OWNER,
accepted: true,
},
});
const inputWithMigratedTeams = {
...mockInput,
teams: [
{ id: 101, name: "Engineering", isBeingMigrated: true, slug: "engineering" },
{ id: 102, name: "Marketing", isBeingMigrated: true, slug: "marketing" },
{ id: -1, name: "Sales", isBeingMigrated: false, slug: null },
],
};
await service.createOnboardingIntent(inputWithMigratedTeams);
expect(mockPaymentService.createOrganizationOnboarding).toHaveBeenCalledWith(
expect.objectContaining({
teams: expect.arrayContaining([
{ id: 101, name: "Engineering", isBeingMigrated: true, slug: "engineering" },
{ id: 102, name: "Marketing", isBeingMigrated: true, slug: "marketing" },
{ id: -1, name: "Sales", isBeingMigrated: false, slug: null },
]),
})
);
});
it("should handle migrated members with teamId", async () => {
const existingTeam = await prismock.team.create({
data: {
id: 103,
name: "Engineering",
slug: "engineering",
},
});
await prismock.membership.create({
data: {
userId: mockUser.id,
teamId: existingTeam.id,
role: MembershipRole.OWNER,
accepted: true,
},
});
const inputWithMigratedMembers = {
...mockInput,
teams: [{ id: 103, name: "Engineering", isBeingMigrated: true, slug: "engineering" }],
invitedMembers: [
{ email: "migrated1@example.com", teamId: 103, role: "MEMBER" },
{ email: "migrated2@example.com", teamId: 103, role: "MEMBER" },
{ email: "newinvite@example.com", teamName: "Engineering", teamId: -1, role: "ADMIN" },
],
};
await service.createOnboardingIntent(inputWithMigratedMembers);
expect(mockPaymentService.createOrganizationOnboarding).toHaveBeenCalledWith(
expect.objectContaining({
invitedMembers: expect.arrayContaining([
expect.objectContaining({
email: "migrated1@example.com",
teamId: 103,
role: "MEMBER",
}),
expect.objectContaining({
email: "migrated2@example.com",
teamId: 103,
role: "MEMBER",
}),
expect.objectContaining({
email: "newinvite@example.com",
teamName: "Engineering",
teamId: -1,
role: "ADMIN",
}),
]),
})
);
});
it("should handle mixed migrated teams and members with new teams and invites", async () => {
const existingTeam1 = await prismock.team.create({
data: {
id: 104,
name: "Engineering",
slug: "engineering",
},
});
const existingTeam2 = await prismock.team.create({
data: {
id: 105,
name: "Marketing",
slug: "marketing",
},
});
await prismock.membership.create({
data: {
userId: mockUser.id,
teamId: existingTeam1.id,
role: MembershipRole.OWNER,
accepted: true,
},
});
await prismock.membership.create({
data: {
userId: mockUser.id,
teamId: existingTeam2.id,
role: MembershipRole.OWNER,
accepted: true,
},
});
const inputWithMixedMigration = {
...mockInput,
teams: [
{ id: 104, name: "Engineering", isBeingMigrated: true, slug: "engineering" },
{ id: 105, name: "Marketing", isBeingMigrated: true, slug: "marketing" },
{ id: -1, name: "Sales", isBeingMigrated: false, slug: null },
],
invitedMembers: [
{ email: "eng-member@example.com", teamId: 104, role: "MEMBER" },
{ email: "marketing-member@example.com", teamId: 105, role: "MEMBER" },
{ email: "new-sales-member@example.com", teamName: "Sales", teamId: -1, role: "MEMBER" },
{ email: "org-member@example.com", role: "ADMIN" },
],
};
await service.createOnboardingIntent(inputWithMixedMigration);
expect(mockPaymentService.createOrganizationOnboarding).toHaveBeenCalledWith(
expect.objectContaining({
teams: expect.arrayContaining([
{ id: 104, name: "Engineering", isBeingMigrated: true, slug: "engineering" },
{ id: 105, name: "Marketing", isBeingMigrated: true, slug: "marketing" },
{ id: -1, name: "Sales", isBeingMigrated: false, slug: null },
]),
invitedMembers: expect.arrayContaining([
expect.objectContaining({
email: "eng-member@example.com",
teamId: 104,
role: "MEMBER",
}),
expect.objectContaining({
email: "marketing-member@example.com",
teamId: 105,
role: "MEMBER",
}),
expect.objectContaining({
email: "new-sales-member@example.com",
teamName: "Sales",
teamId: -1,
role: "MEMBER",
}),
expect.objectContaining({
email: "org-member@example.com",
role: "ADMIN",
}),
]),
})
);
});
});
describe("createOrganization", () => {