Skip to content

Commit dc43eba

Browse files
hariombalharadevin-ai-integration[bot]bot_apk
authored
feat: add Move Team to Organization admin migration page (calcom#25067)
* feat: restore moveTeamToOrg admin endpoint for organization migration - Add moveTeamToOrg and removeTeamFromOrg functions to orgMigration.ts - Restore API endpoint at /api/orgMigration/moveTeamToOrg - Restore admin UI page at /settings/admin/orgMigrations/moveTeamToOrg - Add helper functions for team redirect management - Support moving team members along with the team This endpoint allows admins to migrate teams to organizations after org creation, which is needed as a temporary solution until proper org admin permissions are implemented. Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: move moveTeamToOrg to lib/orgMigration and fix redirect URL - Move moveTeamToOrg and removeTeamFromOrg functions from playwright/lib to lib/orgMigration.ts - Update API endpoint to import from lib/orgMigration instead of playwright/lib - Fix redirect URL format: use / instead of /team/ - Fix import path: use ../playwright/lib/orgMigration instead of ./playwright/lib/orgMigration - Rename unused _dbRemoveTeamFromOrg in playwright file to satisfy linter - Remove duplicate functions from playwright/lib/orgMigration.ts This fixes the Vercel deployment failure caused by importing from test-only directories in production API routes, and corrects the redirect URL format to match the original implementation. Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * refactor: reuse existing createTeamsHandler for moveTeamToOrg endpoint - Remove custom orgMigration.ts implementation - Update API endpoint to call existing createTeamsHandler with org owner impersonation - Remove moveMembers option from UI (always moves members by design) - Fix Vercel deployment by removing playwright import from production code - Use OrganizationRepository.adminFindById to fetch org owner Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * refactor: migrate moveTeamToOrg admin page and API to App Router - Move admin page from pages/settings/admin/orgMigrations to app/(use-page-wrapper)/settings/(admin-layout)/admin/orgMigrations - Convert API route from pages/api/orgMigration/moveTeamToOrg.ts to app/api/orgMigration/moveTeamToOrg/route.ts - Create client view component in modules/settings/admin/org-migrations/ - Remove old pages directory files and getServerSideProps Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: correct import paths for App Router compatibility - Fix @calcom/lib/server to @calcom/lib/server/i18n for getTranslation - Fix @calcom/ui barrel import to specific component paths Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: use TFunction type for getFormSchema parameter Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: use buildLegacyRequest for App Router session compatibility Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * Remove unused fn * cleanup * cleanup * fix: ui * fix: handle slug conflict error when moving team to organization - Intercept Prisma P2002 unique constraint error when moving a team - Convert to user-friendly CONFLICT error with clear message - Add test case for slug conflict scenario Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: use isPending instead of isLoading for tRPC mutation Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fixes * fix: remove PII (emails) from admin log statement Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * refactor: use instanceof pattern for Prisma error detection Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fixes * fix: use i18n key for slug conflict error message instead of hardcoded English string Co-Authored-By: bot_apk <apk@cognition.ai> * fix: narrow P2002 catch scope to only prisma.team.update call Separates the try-catch for prisma.team.update (slug conflict) from creditService.moveCreditsFromTeamToOrg to avoid misattributing credit service P2002 errors as slug conflicts. Co-Authored-By: bot_apk <apk@cognition.ai> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: bot_apk <apk@cognition.ai>
1 parent 4b24764 commit dc43eba

10 files changed

Lines changed: 449 additions & 129 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
2+
import { _generateMetadata, getTranslate } from "app/_utils";
3+
import MoveTeamToOrgView from "~/settings/admin/org-migrations/move-team-to-org-view";
4+
5+
export const generateMetadata = async () =>
6+
await _generateMetadata(
7+
(t) => t("organization_migration_move_team"),
8+
(t) => t("organization_migration_move_team_description"),
9+
undefined,
10+
undefined,
11+
"/settings/admin/migrations/move-team-to-org"
12+
);
13+
14+
const Page = async (): Promise<React.ReactNode> => {
15+
const t = await getTranslate();
16+
return (
17+
<SettingsHeader
18+
title={t("organization_migration_move_team")}
19+
description={t("organization_migration_move_team_description")}>
20+
<MoveTeamToOrgView />
21+
</SettingsHeader>
22+
);
23+
};
24+
25+
export default Page;

apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx

Lines changed: 67 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,12 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
8080
},
8181
...(HAS_USER_OPT_IN_FEATURES
8282
? [
83-
{
84-
name: "features",
85-
href: "/settings/my-account/features",
86-
trackingMetadata: { section: "my_account", page: "features" },
87-
},
88-
]
83+
{
84+
name: "features",
85+
href: "/settings/my-account/features",
86+
trackingMetadata: { section: "my_account", page: "features" },
87+
},
88+
]
8989
: []),
9090
// TODO
9191
// { name: "referrals", href: "/settings/my-account/referrals" },
@@ -181,13 +181,13 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
181181
},
182182
...(orgBranding
183183
? [
184-
{
185-
name: "members",
186-
href: `${WEBAPP_URL}/settings/organizations/${orgBranding.slug}/members`,
187-
isExternalLink: true,
188-
trackingMetadata: { section: "organization", page: "members" },
189-
},
190-
]
184+
{
185+
name: "members",
186+
href: `${WEBAPP_URL}/settings/organizations/${orgBranding.slug}/members`,
187+
isExternalLink: true,
188+
trackingMetadata: { section: "organization", page: "members" },
189+
},
190+
]
191191
: []),
192192
{
193193
name: "privacy_and_security",
@@ -212,12 +212,12 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
212212
},
213213
...(HAS_ORG_OPT_IN_FEATURES
214214
? [
215-
{
216-
name: "features",
217-
href: "/settings/organizations/features",
218-
trackingMetadata: { section: "organization", page: "features" },
219-
},
220-
]
215+
{
216+
name: "features",
217+
href: "/settings/organizations/features",
218+
trackingMetadata: { section: "organization", page: "features" },
219+
},
220+
]
221221
: []),
222222
],
223223
},
@@ -274,6 +274,11 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
274274
href: "/settings/admin/organizations",
275275
trackingMetadata: { section: "admin", page: "organizations" },
276276
},
277+
{
278+
name: "migrations",
279+
href: "/settings/admin/migrations/move-team-to-org",
280+
trackingMetadata: { section: "admin", page: "migrations" },
281+
},
277282
{
278283
name: "lockedSMS",
279284
href: "/settings/admin/lockedSMS",
@@ -610,17 +615,16 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
610615
setTeamMenuState(newTeamMenuState);
611616
}
612617
}}
613-
aria-label={`${team.name} ${
614-
teamMenuState[index].teamMenuOpen ? t("collapse_menu") : t("expand_menu")
615-
}`}>
618+
aria-label={`${team.name} ${teamMenuState[index].teamMenuOpen ? t("collapse_menu") : t("expand_menu")
619+
}`}>
616620
<div className="me-3">
617621
{teamMenuState[index].teamMenuOpen ? (
618622
<ChevronDownIcon className="h-4 w-4" />
619623
) : (
620624
<ChevronRightIcon className="h-4 w-4" />
621625
)}
622626
</div>
623-
{}
627+
{ }
624628
{!team.parentId && (
625629
<Avatar
626630
size="xs"
@@ -662,55 +666,55 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
662666
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
663667
// @ts-expect-error this exists wtf?
664668
(team.isOrgAdmin && team.isOrgAdmin)) && (
665-
<>
666-
{/* TODO */}
667-
{/* <VerticalTabItem
669+
<>
670+
{/* TODO */}
671+
{/* <VerticalTabItem
668672
name={t("general")}
669673
href={`${WEBAPP_URL}/settings/my-account/appearance`}
670674
textClassNames="px-3 text-emphasis font-medium text-sm"
671675
disableChevron
672676
/> */}
673-
<VerticalTabItem
674-
name={t("appearance")}
675-
href={`/settings/teams/${team.id}/appearance`}
676-
textClassNames="px-3 text-emphasis font-medium text-sm"
677-
trackingMetadata={{ section: "team", page: "appearance", teamId: team.id }}
678-
className="px-2! me-5 h-7 w-auto"
679-
disableChevron
680-
/>
681-
{HAS_TEAM_OPT_IN_FEATURES && (
682677
<VerticalTabItem
683-
name={t("features")}
684-
href={`/settings/teams/${team.id}/features`}
678+
name={t("appearance")}
679+
href={`/settings/teams/${team.id}/appearance`}
685680
textClassNames="px-3 text-emphasis font-medium text-sm"
686-
trackingMetadata={{ section: "team", page: "features", teamId: team.id }}
681+
trackingMetadata={{ section: "team", page: "appearance", teamId: team.id }}
687682
className="px-2! me-5 h-7 w-auto"
688683
disableChevron
689684
/>
690-
)}
691-
{/* Hide if there is a parent ID */}
692-
{!team.parentId ? (
693-
<>
685+
{HAS_TEAM_OPT_IN_FEATURES && (
694686
<VerticalTabItem
695-
name={t("billing")}
696-
href={`/settings/teams/${team.id}/billing`}
687+
name={t("features")}
688+
href={`/settings/teams/${team.id}/features`}
697689
textClassNames="px-3 text-emphasis font-medium text-sm"
698-
trackingMetadata={{ section: "team", page: "billing", teamId: team.id }}
690+
trackingMetadata={{ section: "team", page: "features", teamId: team.id }}
699691
className="px-2! me-5 h-7 w-auto"
700692
disableChevron
701693
/>
702-
</>
703-
) : null}
704-
<VerticalTabItem
705-
name={t("settings")}
706-
href={`/settings/teams/${team.id}/settings`}
707-
textClassNames="px-3 text-emphasis font-medium text-sm"
708-
trackingMetadata={{ section: "team", page: "settings", teamId: team.id }}
709-
className="px-2! me-5 h-7 w-auto"
710-
disableChevron
711-
/>
712-
</>
713-
)}
694+
)}
695+
{/* Hide if there is a parent ID */}
696+
{!team.parentId ? (
697+
<>
698+
<VerticalTabItem
699+
name={t("billing")}
700+
href={`/settings/teams/${team.id}/billing`}
701+
textClassNames="px-3 text-emphasis font-medium text-sm"
702+
trackingMetadata={{ section: "team", page: "billing", teamId: team.id }}
703+
className="px-2! me-5 h-7 w-auto"
704+
disableChevron
705+
/>
706+
</>
707+
) : null}
708+
<VerticalTabItem
709+
name={t("settings")}
710+
href={`/settings/teams/${team.id}/settings`}
711+
textClassNames="px-3 text-emphasis font-medium text-sm"
712+
trackingMetadata={{ section: "team", page: "settings", teamId: team.id }}
713+
className="px-2! me-5 h-7 w-auto"
714+
disableChevron
715+
/>
716+
</>
717+
)}
714718
</CollapsibleContent>
715719
</Collapsible>
716720
);
@@ -810,7 +814,7 @@ const SettingsSidebarContainer = ({
810814
className="text-subtle h-[16px] w-[16px] stroke-[2px] ltr:mr-3 rtl:ml-3 md:mt-0"
811815
/>
812816
)}
813-
{}
817+
{ }
814818
{!tab.icon && tab?.avatar && (
815819
<Avatar
816820
size="xs"
@@ -953,19 +957,18 @@ const SettingsSidebarContainer = ({
953957
setOtherTeamMenuState(newOtherTeamMenuState);
954958
}
955959
}}
956-
aria-label={`${otherTeam.name} ${
957-
otherTeamMenuState[index].teamMenuOpen
958-
? t("collapse_menu")
959-
: t("expand_menu")
960-
}`}>
960+
aria-label={`${otherTeam.name} ${otherTeamMenuState[index].teamMenuOpen
961+
? t("collapse_menu")
962+
: t("expand_menu")
963+
}`}>
961964
<div className="me-3">
962965
{otherTeamMenuState[index].teamMenuOpen ? (
963966
<ChevronDownIcon className="h-4 w-4" />
964967
) : (
965968
<ChevronRightIcon className="h-4 w-4" />
966969
)}
967970
</div>
968-
{}
971+
{ }
969972
{!otherTeam.parentId && (
970973
<Avatar
971974
size="xs"
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"use client";
2+
3+
import { useLocale } from "@calcom/lib/hooks/useLocale";
4+
import { trpc } from "@calcom/trpc/react";
5+
import { Alert } from "@calcom/ui/components/alert";
6+
import { Button } from "@calcom/ui/components/button";
7+
import { Form, TextField } from "@calcom/ui/components/form";
8+
import { showToast } from "@calcom/ui/components/toast";
9+
import { zodResolver } from "@hookform/resolvers/zod";
10+
import type { TFunction } from "next-i18next";
11+
import { useForm } from "react-hook-form";
12+
import { z } from "zod";
13+
14+
export const getFormSchema = (t: TFunction) => {
15+
return z.object({
16+
teamId: z.number(),
17+
targetOrgId: z.number(),
18+
teamSlugInOrganization: z.string().min(1),
19+
});
20+
};
21+
22+
export default function MoveTeamToOrgView() {
23+
const { t } = useLocale();
24+
const formSchema = getFormSchema(t);
25+
const formMethods = useForm({
26+
mode: "onSubmit",
27+
resolver: zodResolver(formSchema),
28+
});
29+
30+
const moveTeamMutation = trpc.viewer.admin.moveTeamToOrg.useMutation({
31+
onSuccess: (data) => {
32+
showToast(t(data.message), "success", 10000);
33+
},
34+
onError: (error) => {
35+
showToast(t(error.message), "error", 10000);
36+
},
37+
});
38+
39+
const { register } = formMethods;
40+
return (
41+
<div className="space-y-3">
42+
<Form
43+
className="space-y-3"
44+
noValidate={true}
45+
form={formMethods}
46+
handleSubmit={async (values) => {
47+
const parsedValues = formSchema.parse(values);
48+
moveTeamMutation.mutate(parsedValues);
49+
}}>
50+
<div className="space-y-3">
51+
<TextField
52+
{...register("teamId", { valueAsNumber: true })}
53+
type="number"
54+
label={t("team_id")}
55+
required
56+
placeholder={t("move_team_to_org_team_id_placeholder")}
57+
/>
58+
<TextField
59+
{...register("teamSlugInOrganization")}
60+
label={t("move_team_to_org_new_slug")}
61+
required
62+
placeholder={t("move_team_to_org_new_slug_placeholder")}
63+
/>
64+
<TextField
65+
{...register("targetOrgId", { valueAsNumber: true })}
66+
type="number"
67+
label={t("move_team_to_org_target_org_id")}
68+
required
69+
placeholder={t("move_team_to_org_target_org_id_placeholder")}
70+
/>
71+
<div className="mt-2 text-gray-600 text-sm">
72+
{t("organization_migration_move_team_footnote")}
73+
</div>
74+
</div>
75+
<Button type="submit" loading={moveTeamMutation.isPending}>
76+
{t("organization_migration_move_team")}
77+
</Button>
78+
</Form>
79+
80+
{moveTeamMutation.isSuccess && moveTeamMutation.data && (
81+
<Alert
82+
className="mt-6"
83+
severity="info"
84+
CustomIcon="check"
85+
title={t("move_team_to_org_migration_successful")}
86+
message={
87+
<div className="space-y-1">
88+
<p>
89+
<span className="font-medium">{t("team_id")}:</span> {moveTeamMutation.data.teamId}
90+
</p>
91+
{moveTeamMutation.data.oldTeamSlug && (
92+
<p>
93+
<span className="font-medium">{t("move_team_to_org_old_slug")}:</span>{" "}
94+
{moveTeamMutation.data.oldTeamSlug}
95+
</p>
96+
)}
97+
{moveTeamMutation.data.newTeamSlug && (
98+
<p>
99+
<span className="font-medium">{t("move_team_to_org_new_slug")}:</span>{" "}
100+
{moveTeamMutation.data.newTeamSlug}
101+
</p>
102+
)}
103+
<p>
104+
<span className="font-medium">{t("organization_id")}:</span>{" "}
105+
{moveTeamMutation.data.organizationId}
106+
</p>
107+
{moveTeamMutation.data.organizationSlug && (
108+
<p>
109+
<span className="font-medium">{t("move_team_to_org_organization_slug")}:</span>{" "}
110+
{moveTeamMutation.data.organizationSlug}
111+
</p>
112+
)}
113+
</div>
114+
}
115+
/>
116+
)}
117+
</div>
118+
);
119+
}

0 commit comments

Comments
 (0)