diff --git a/apps/web/app/(use-page-wrapper)/onboarding/organization/migrate-members/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/organization/migrate-members/page.tsx new file mode 100644 index 0000000000..5b93b7feb8 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/onboarding/organization/migrate-members/page.tsx @@ -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 ; +} diff --git a/apps/web/app/(use-page-wrapper)/onboarding/organization/migrate-teams/page.tsx b/apps/web/app/(use-page-wrapper)/onboarding/organization/migrate-teams/page.tsx new file mode 100644 index 0000000000..df801856b7 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/onboarding/organization/migrate-teams/page.tsx @@ -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 ; +} diff --git a/apps/web/app/api/organizations/payment-redirect/route.ts b/apps/web/app/api/organizations/payment-redirect/route.ts index 884cee42ce..d49bd32f65 100644 --- a/apps/web/app/api/organizations/payment-redirect/route.ts +++ b/apps/web/app/api/organizations/payment-redirect/route.ts @@ -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()}`, diff --git a/apps/web/modules/onboarding/components/onboarding-invite-browser-view.tsx b/apps/web/modules/onboarding/components/onboarding-invite-browser-view.tsx index 79e4dc5d84..d90ade065f 100644 --- a/apps/web/modules/onboarding/components/onboarding-invite-browser-view.tsx +++ b/apps/web/modules/onboarding/components/onboarding-invite-browser-view.tsx @@ -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", }}>
-
- {/* Banner Image */} - {organizationBrand.banner && ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {displayName} -
- )} + {useOrganizationInvites || organizationBrand.banner || avatar ? ( +
+ {/* Banner Image */} + {useOrganizationInvites && ( +
+ {organizationBrand.banner ? ( + /* eslint-disable-next-line @next/next/no-img-element */ + {displayName} + ) : ( +
+ )} +
+ )} + {!useOrganizationInvites && organizationBrand.banner && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {displayName} +
+ )} - {/* Organization Avatar - Overlaying the banner */} - {organizationBrand.banner && avatar && ( -
- -
- )} -
+ {/* Organization Avatar - Overlaying the banner */} + {useOrganizationInvites && avatar && ( +
+ +
+ )} + {!useOrganizationInvites && organizationBrand.banner && avatar && ( +
+ +
+ )} +
+ ) : null} {/* Organization Info */} -
- {!organizationBrand.banner && avatar && ( +
+ {!useOrganizationInvites && !organizationBrand.banner && avatar && (
- {item.team && ( -
- {item.team} -
- )} +
+ {item.team && !item.isMigrated && ( +
+ {item.team} +
+ )} + {item.isMigrated && ( + + {t("migrating")} + + )} +
))} diff --git a/apps/web/modules/onboarding/components/onboarding-migrate-members-browser-view.tsx b/apps/web/modules/onboarding/components/onboarding-migrate-members-browser-view.tsx new file mode 100644 index 0000000000..c1327ca419 --- /dev/null +++ b/apps/web/modules/onboarding/components/onboarding-migrate-members-browser-view.tsx @@ -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 ( +
+ {/* Browser header */} +
+ {/* Navigation buttons */} +
+ + + +
+
+ +

{displayUrl}/members

+
+ +
+ + {/* Content */} +
+ + + {/* Members Header with Banner */} +
+
+ {/* Banner Image */} +
+ {organizationBanner ? ( + // eslint-disable-next-line @next/next/no-img-element + {organizationName + ) : ( +
+ )} +
+ + {/* Organization Logo - Overlaying the banner */} + {organizationLogo && ( +
+ +
+ )} +
+ + {/* Members Info */} +
+

{t("members")}

+ {hasMembers && ( +

+ {members.length === 1 + ? t("migrating_members_count", { count: members.length }) + : t("migrating_members_count_plural", { count: members.length })} +

+ )} +
+
+ + {/* Members List */} +
+ {hasMembers ? ( + <> + {members.map((member, index) => ( +
+ {index > 0 &&
} +
+ + + {(member.name || member.email).charAt(0).toUpperCase()} + +
+ } + /> +
+
+

+ {member.name || member.email} +

+ + {t("migrating")} + +
+

{member.email}

+
+ +
+
+ ))} + + ) : ( +
+
+ +
+
+

+ {t("no_members_to_migrate")} +

+

+ {t("members_will_be_added_to_organization")} +

+
+
+ )} +
+ + +
+
+ ); +}; + diff --git a/apps/web/modules/onboarding/components/onboarding-migrate-teams-browser-view.tsx b/apps/web/modules/onboarding/components/onboarding-migrate-teams-browser-view.tsx new file mode 100644 index 0000000000..5fbe4f84dd --- /dev/null +++ b/apps/web/modules/onboarding/components/onboarding-migrate-teams-browser-view.tsx @@ -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 ( +
+ {/* Browser header */} +
+ {/* Navigation buttons */} +
+ + + +
+
+ +

{displayUrl}/teams

+
+ +
+ + {/* Content */} +
+ + + {/* Teams Header with Banner */} +
+
+ {/* Banner Image */} +
+ {organizationBanner ? ( + // eslint-disable-next-line @next/next/no-img-element + {organizationName + ) : ( +
+ )} +
+ + {/* Organization Logo - Overlaying the banner */} + {organizationLogo && ( +
+ +
+ )} +
+ + {/* Teams Info */} +
+

{t("teams")}

+ {selectedTeams.length > 0 && ( +

+ {selectedTeams.length === 1 + ? t("migrating_teams_count", { count: selectedTeams.length }) + : t("migrating_teams_count_plural", { count: selectedTeams.length })} +

+ )} +
+
+ + {/* Teams List */} +
+ {hasValidTeams ? ( + <> + {teams.map((team, index) => ( +
+ {index > 0 &&
} +
+ + {team.name.charAt(0)} +
+ } + /> +
+
+

+ {team.name} +

+ {team.isSelected && ( + + {t("migrating")} + + )} +
+

+ {team.slug && slug + ? `${slug}.${subdomainSuffix()}/${team.slug}` + : team.slug + ? `${team.slug}.${subdomainSuffix()}` + : t("team_slug_pending")} +

+
+ {team.isSelected ? ( + + ) : ( + + )} +
+
+ ))} + + ) : ( +
+
+ +
+
+

+ {t("no_teams_to_migrate")} +

+

+ {t("create_teams_in_next_step")} +

+
+
+ )} +
+ + +
+
+ ); +}; + diff --git a/apps/web/modules/onboarding/components/onboarding-teams-browser-view.tsx b/apps/web/modules/onboarding/components/onboarding-teams-browser-view.tsx index f18307873a..4a2c19743a 100644 --- a/apps/web/modules/onboarding/components/onboarding-teams-browser-view.tsx +++ b/apps/web/modules/onboarding/components/onboarding-teams-browser-view.tsx @@ -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 = ({ } />
-

{team.name}

+
+

+ {team.name} +

+ {team.isMigrated && ( + + {t("migrating")} + + )} +

- {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")}

- + {team.isMigrated ? ( + + ) : ( + + )}
)) diff --git a/apps/web/modules/onboarding/hooks/__tests__/useSubmitOnboarding.test.ts b/apps/web/modules/onboarding/hooks/__tests__/useSubmitOnboarding.test.ts new file mode 100644 index 0000000000..eb04c92610 --- /dev/null +++ b/apps/web/modules/onboarding/hooks/__tests__/useSubmitOnboarding.test.ts @@ -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("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; + let mockResetOnboarding: ReturnType; + let mockUseMutation: ReturnType; + + 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: [], + }) + ); + }); +}); diff --git a/apps/web/modules/onboarding/hooks/useMigrationFlow.ts b/apps/web/modules/onboarding/hooks/useMigrationFlow.ts new file mode 100644 index 0000000000..92a79f4053 --- /dev/null +++ b/apps/web/modules/onboarding/hooks/useMigrationFlow.ts @@ -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, + }; +}; + diff --git a/apps/web/modules/onboarding/hooks/useSubmitOnboarding.ts b/apps/web/modules/onboarding/hooks/useSubmitOnboarding.ts index 68f1c30efc..0c172b18d8 100644 --- a/apps/web/modules/onboarding/hooks/useSubmitOnboarding.ts +++ b/apps/web/modules/onboarding/hooks/useSubmitOnboarding.ts @@ -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); diff --git a/apps/web/modules/onboarding/organization/brand/organization-brand-view.tsx b/apps/web/modules/onboarding/organization/brand/organization-brand-view.tsx index 3b32381750..c3352258d7 100644 --- a/apps/web/modules/onboarding/organization/brand/organization-brand-view.tsx +++ b/apps/web/modules/onboarding/organization/brand/organization-brand-view.tsx @@ -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(null); const [_bannerFile, setBannerFile] = useState(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 ( - + {/* Left column - Main content */} { 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 ( - + {/* Left column - Main content */} { 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 ( - +
diff --git a/apps/web/modules/onboarding/organization/migrate-members/organization-migrate-members-view.tsx b/apps/web/modules/onboarding/organization/migrate-members/organization-migrate-members-view.tsx new file mode 100644 index 0000000000..21fbb2791d --- /dev/null +++ b/apps/web/modules/onboarding/organization/migrate-members/organization-migrate-members-view.tsx @@ -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((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 ( + + + + + + + + ); + } + + if (teamsToMigrate.length === 0) { + return null; + } + + return ( + + + +
+ +
+
+ }> +
+ {uniqueMembers.length > 0 ? ( +
    + {uniqueMembers.map((member) => ( +
  • +
    + +
    + + + {member.name || member.email} + + + {t("migrating_from_team")} +
    +
    +
  • + ))} +
+ ) : ( +

{t("no_members_to_migrate")}

+ )} +
+
+ + {/* Right column - Browser view */} + ({ + 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} + /> +
+ ); +}; diff --git a/apps/web/modules/onboarding/organization/migrate-teams/organization-migrate-teams-view.tsx b/apps/web/modules/onboarding/organization/migrate-teams/organization-migrate-teams-view.tsx new file mode 100644 index 0000000000..ed1ff646a5 --- /dev/null +++ b/apps/web/modules/onboarding/organization/migrate-teams/organization-migrate-teams-view.tsx @@ -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; + +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({ + 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 ( + + + + ); + } + + 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 ( + + + +
+ + +
+
+ }> +
+ {moveTeams.length > 0 ? ( +
+ +
    + {moveTeams.map((team, index) => { + const currentTeam = teams.find((t) => t.id === team.id); + const slugConflictsWithOrg = currentTeam?.slug === orgSlug; + return ( +
  • + ( + + )} + /> + {moveTeams[index].shouldMove ? ( + 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} +
  • + ); + })} +
+
+ ) : null} +
+ + + {/* Right column - Browser view */} + + + ); +}; diff --git a/apps/web/modules/onboarding/organization/migrate-teams/utils.ts b/apps/web/modules/onboarding/organization/migrate-teams/utils.ts new file mode 100644 index 0000000000..a3f1e1c6a1 --- /dev/null +++ b/apps/web/modules/onboarding/organization/migrate-teams/utils.ts @@ -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}`, ""); +}; + diff --git a/apps/web/modules/onboarding/organization/teams/organization-teams-view.tsx b/apps/web/modules/onboarding/organization/teams/organization-teams-view.tsx index 267ad36aa6..445a4ecab0 100644 --- a/apps/web/modules/onboarding/organization/teams/organization-teams-view.tsx +++ b/apps/web/modules/onboarding/organization/teams/organization-teams-view.tsx @@ -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({ 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 ( - + {/* Left column - Main content */} { posthog.capture("onboarding_organization_teams_back_clicked"); - router.push("/onboarding/organization/brand"); + router.back(); }}> {t("back")} @@ -105,7 +140,36 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps) }>
-
+
+ {/* Migrated teams section */} + {migratedTeams.length > 0 && ( +
+
+

{t("migrated_teams")}

+

{t("migrated_teams_description")}

+
+
+ {migratedTeams.map((team) => ( +
+
+ +
+ + {t("migrating")} + +
+ ))} +
+
+ )} + + {/* New teams section */}

{t("team")}

@@ -165,7 +229,17 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps) {/* Right column - Browser view */} ({ + 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} diff --git a/apps/web/modules/onboarding/store/onboarding-store.ts b/apps/web/modules/onboarding/store/onboarding-store.ts index b7a6500f68..c6e045cca4 100644 --- a/apps/web/modules/onboarding/store/onboarding-store.ts +++ b/apps/web/modules/onboarding/store/onboarding-store.ts @@ -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) => void; setTeamBrand: (brand: Partial) => 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()( 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()( teams: state.teams, invites: state.invites, inviteRole: state.inviteRole, + migratedMembers: state.migratedMembers, teamDetails: state.teamDetails, teamBrand: state.teamBrand, teamInvites: state.teamInvites, diff --git a/apps/web/modules/settings/my-account/components/CompanyEmailOrganizationBanner.tsx b/apps/web/modules/settings/my-account/components/CompanyEmailOrganizationBanner.tsx index 634dd9cbf7..5d796a6bb9 100644 --- a/apps/web/modules/settings/my-account/components/CompanyEmailOrganizationBanner.tsx +++ b/apps/web/modules/settings/my-account/components/CompanyEmailOrganizationBanner.tsx @@ -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"); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 4fc1e75ac8..497561437c 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/features/ee/organizations/lib/service/onboarding/BaseOnboardingService.ts b/packages/features/ee/organizations/lib/service/onboarding/BaseOnboardingService.ts index 5d677155d6..f8f3b86ed5 100644 --- a/packages/features/ee/organizations/lib/service/onboarding/BaseOnboardingService.ts +++ b/packages/features/ee/organizations/lib/service/onboarding/BaseOnboardingService.ts @@ -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 ${ diff --git a/packages/features/ee/organizations/lib/service/onboarding/__tests__/BillingEnabledOrgOnboardingService.test.ts b/packages/features/ee/organizations/lib/service/onboarding/__tests__/BillingEnabledOrgOnboardingService.test.ts index b9ddaba3af..db271dc081 100644 --- a/packages/features/ee/organizations/lib/service/onboarding/__tests__/BillingEnabledOrgOnboardingService.test.ts +++ b/packages/features/ee/organizations/lib/service/onboarding/__tests__/BillingEnabledOrgOnboardingService.test.ts @@ -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", () => {