feat: orgMigration - Support moving users as an option when moving a team (#12917)

* Move orgMigration routes to app to allow them to be tested as they are here to stay for longer tim

* move to Form everywhere and fix session reading

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
This commit is contained in:
Hariom Balhara 2024-01-04 08:33:51 +05:30 committed by GitHub
parent 6a1325867e
commit de1c9d01cd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 3281 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,817 @@
import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
import type { Team, User } from "@calcom/prisma/client";
import { RedirectType } from "@calcom/prisma/client";
import { Prisma } from "@calcom/prisma/client";
import type { MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
const log = logger.getSubLogger({ prefix: ["orgMigration"] });
type UserMetadata = {
migratedToOrgFrom?: {
username: string;
reverted: boolean;
revertTime: string;
lastMigrationTime: string;
};
};
/**
* Make sure that the migration is idempotent
*/
export async function moveUserToOrg({
user: { id: userId, userName: userName },
targetOrg: {
id: targetOrgId,
username: targetOrgUsername,
membership: { role: targetOrgRole, accepted: targetOrgMembershipAccepted = true },
},
shouldMoveTeams,
}: {
user: { id?: number; userName?: string };
targetOrg: {
id: number;
username?: string;
membership: { role: MembershipRole; accepted?: boolean };
};
shouldMoveTeams: boolean;
}) {
assertUserIdOrUserName(userId, userName);
const team = await getTeamOrThrowError(targetOrgId);
const teamMetadata = teamMetadataSchema.parse(team?.metadata);
if (!teamMetadata?.isOrganization) {
throw new Error(`Team with ID:${targetOrgId} is not an Org`);
}
const targetOrganization = {
...team,
metadata: teamMetadata,
};
const userToMoveToOrg = await getUniqueUserThatDoesntBelongToOrg(userName, userId, targetOrgId);
assertUserPartOfOtherOrg(userToMoveToOrg, userName, userId, targetOrgId);
if (!targetOrgUsername) {
targetOrgUsername = getOrgUsernameFromEmail(
userToMoveToOrg.email,
targetOrganization.metadata.orgAutoAcceptEmail || ""
);
}
const userWithSameUsernameInOrg = await prisma.user.findFirst({
where: {
username: targetOrgUsername,
organizationId: targetOrgId,
},
});
log.debug({
userWithSameUsernameInOrg,
targetOrgUsername,
targetOrgId,
userId,
});
if (userWithSameUsernameInOrg && userWithSameUsernameInOrg.id !== userId) {
throw new HttpError({
statusCode: 400,
message: `Username ${targetOrgUsername} already exists for orgId: ${targetOrgId} for some other user`,
});
}
assertUserPartOfOrgAndRemigrationAllowed(userToMoveToOrg, targetOrgId, targetOrgUsername, userId);
const orgMetadata = teamMetadata;
const userToMoveToOrgMetadata = (userToMoveToOrg.metadata || {}) as UserMetadata;
const nonOrgUserName =
(userToMoveToOrgMetadata.migratedToOrgFrom?.username as string) || userToMoveToOrg.username;
if (!nonOrgUserName) {
throw new HttpError({
statusCode: 400,
message: `User with id: ${userId} doesn't have a non-org username`,
});
}
await dbMoveUserToOrg({ userToMoveToOrg, targetOrgId, targetOrgUsername, nonOrgUserName });
let teamsToBeMovedToOrg;
if (shouldMoveTeams) {
teamsToBeMovedToOrg = await moveTeamsWithoutMembersToOrg({ targetOrgId, userToMoveToOrg });
}
await updateMembership({ targetOrgId, userToMoveToOrg, targetOrgRole, targetOrgMembershipAccepted });
await addRedirect({
nonOrgUserName,
teamsToBeMovedToOrg: teamsToBeMovedToOrg || [],
organization: targetOrganization,
targetOrgUsername,
});
await setOrgSlugIfNotSet(targetOrganization, orgMetadata, targetOrgId);
log.debug(`orgId:${targetOrgId} attached to userId:${userId}`);
}
/**
* Make sure that the migration is idempotent
*/
export async function removeUserFromOrg({ targetOrgId, userId }: { targetOrgId: number; userId: number }) {
const userToRemoveFromOrg = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (!userToRemoveFromOrg) {
throw new HttpError({
statusCode: 400,
message: `User with id: ${userId} not found`,
});
}
if (userToRemoveFromOrg.organizationId !== targetOrgId) {
throw new HttpError({
statusCode: 400,
message: `User with id: ${userId} is not part of orgId: ${targetOrgId}`,
});
}
const userToRemoveFromOrgMetadata = (userToRemoveFromOrg.metadata || {}) as {
migratedToOrgFrom?: {
username: string;
reverted: boolean;
revertTime: string;
lastMigrationTime: string;
};
};
if (!userToRemoveFromOrgMetadata.migratedToOrgFrom) {
throw new HttpError({
statusCode: 400,
message: `User with id: ${userId} wasn't migrated. So, there is nothing to revert`,
});
}
const nonOrgUserName = userToRemoveFromOrgMetadata.migratedToOrgFrom.username as string;
if (!nonOrgUserName) {
throw new HttpError({
statusCode: 500,
message: `User with id: ${userId} doesn't have a non-org username`,
});
}
const teamsToBeRemovedFromOrg = await removeTeamsWithoutItsMemberFromOrg({ userToRemoveFromOrg });
await dbRemoveUserFromOrg({ userToRemoveFromOrg, nonOrgUserName });
await removeUserAlongWithItsTeamsRedirects({ nonOrgUserName, teamsToBeRemovedFromOrg });
await removeMembership({ targetOrgId, userToRemoveFromOrg });
log.debug(`orgId:${targetOrgId} attached to userId:${userId}`);
}
/**
* Make sure that the migration is idempotent
*/
export async function moveTeamToOrg({
targetOrgId,
teamId,
moveMembers,
}: {
targetOrgId: number;
teamId: number;
moveMembers?: boolean;
}) {
const possibleOrg = await getTeamOrThrowError(targetOrgId);
const movedTeam = await dbMoveTeamToOrg({ teamId, targetOrgId });
const teamMetadata = teamMetadataSchema.parse(possibleOrg?.metadata);
if (!teamMetadata?.isOrganization) {
throw new Error(`${targetOrgId} is not an Org`);
}
const targetOrganization = possibleOrg;
const orgMetadata = teamMetadata;
await addTeamRedirect(movedTeam.slug, targetOrganization.slug || orgMetadata.requestedSlug || null);
await setOrgSlugIfNotSet({ slug: targetOrganization.slug }, orgMetadata, targetOrgId);
if (moveMembers) {
for (const membership of movedTeam.members) {
await moveUserToOrg({
user: {
id: membership.userId,
},
targetOrg: {
id: targetOrgId,
membership: {
role: membership.role,
accepted: membership.accepted,
},
},
shouldMoveTeams: false,
});
}
}
log.debug(`Successfully moved team ${teamId} to org ${targetOrgId}`);
}
/**
* Make sure that the migration is idempotent
*/
export async function removeTeamFromOrg({ targetOrgId, teamId }: { targetOrgId: number; teamId: number }) {
const removedTeam = await dbRemoveTeamFromOrg({ teamId, targetOrgId });
await removeTeamRedirect(removedTeam.slug);
log.debug(`Successfully removed team ${teamId} from org ${targetOrgId}`);
}
async function dbMoveTeamToOrg({ teamId, targetOrgId }: { teamId: number; targetOrgId: number }) {
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
include: {
members: true,
},
});
if (!team) {
throw new HttpError({
statusCode: 400,
message: `Team with id: ${teamId} not found`,
});
}
if (team.parentId === targetOrgId) {
log.warn(`Team ${teamId} is already in org ${targetOrgId}`);
return team;
}
await prisma.team.update({
where: {
id: teamId,
},
data: {
parentId: targetOrgId,
},
});
return team;
}
async function getUniqueUserThatDoesntBelongToOrg(
userName: string | undefined,
userId: number | undefined,
excludeOrgId: number
) {
log.debug("getUniqueUserThatDoesntBelongToOrg", { userName, userId, excludeOrgId });
if (userName) {
const matchingUsers = await prisma.user.findMany({
where: {
username: userName,
},
});
const foundUsers = matchingUsers.filter(
(user) => user.organizationId === excludeOrgId || user.organizationId === null
);
if (foundUsers.length > 1) {
throw new Error(`More than one user found with username: ${userName}`);
}
return foundUsers[0];
} else {
return await prisma.user.findUnique({
where: {
id: userId,
},
});
}
}
async function setOrgSlugIfNotSet(
targetOrganization: {
slug: string | null;
},
orgMetadata: {
requestedSlug?: string | undefined;
},
targetOrgId: number
) {
if (targetOrganization.slug) {
return;
}
if (!orgMetadata.requestedSlug) {
throw new HttpError({
statusCode: 400,
message: `Org with id: ${targetOrgId} doesn't have a slug. Tried using requestedSlug but that's also not present. So, all migration done but failed to set the Organization slug. Please set it manually`,
});
}
await setOrgSlug({
targetOrgId,
targetSlug: orgMetadata.requestedSlug,
});
}
function assertUserPartOfOrgAndRemigrationAllowed(
userToMoveToOrg: {
organizationId: User["organizationId"];
},
targetOrgId: number,
targetOrgUsername: string,
userId: number | undefined
) {
if (userToMoveToOrg.organizationId) {
if (userToMoveToOrg.organizationId !== targetOrgId) {
throw new HttpError({
statusCode: 400,
message: `User ${targetOrgUsername} already exists for different Org with orgId: ${targetOrgId}`,
});
} else {
log.debug(`Redoing migration for userId: ${userId} to orgId:${targetOrgId}`);
}
}
}
async function getTeamOrThrowError(targetOrgId: number) {
const team = await prisma.team.findUnique({
where: {
id: targetOrgId,
},
});
if (!team) {
throw new HttpError({
statusCode: 400,
message: `Org with id: ${targetOrgId} not found`,
});
}
return team;
}
function assertUserPartOfOtherOrg(
userToMoveToOrg: {
organizationId: User["organizationId"];
} | null,
userName: string | undefined,
userId: number | undefined,
targetOrgId: number
): asserts userToMoveToOrg {
if (!userToMoveToOrg) {
throw new HttpError({
message: `User ${userName ? userName : `ID:${userId}`} is part of an org already`,
statusCode: 400,
});
}
if (userToMoveToOrg.organizationId && userToMoveToOrg.organizationId !== targetOrgId) {
throw new HttpError({
message: `User is already a part of different organization ID: ${userToMoveToOrg.organizationId}`,
statusCode: 400,
});
}
}
function assertUserIdOrUserName(userId: number | undefined, userName: string | undefined) {
if (!userId && !userName) {
throw new HttpError({ statusCode: 400, message: "userId or userName is required" });
}
if (userId && userName) {
throw new HttpError({ statusCode: 400, message: "Provide either userId or userName" });
}
}
async function addRedirect({
nonOrgUserName,
organization,
targetOrgUsername,
teamsToBeMovedToOrg,
}: {
nonOrgUserName: string | null;
organization: Team;
targetOrgUsername: string;
teamsToBeMovedToOrg: { slug: string | null }[];
}) {
if (!nonOrgUserName) {
return;
}
const orgSlug = organization.slug || (organization.metadata as { requestedSlug?: string })?.requestedSlug;
if (!orgSlug) {
log.debug("No slug for org. Not adding the redirect", safeStringify({ organization, nonOrgUserName }));
return;
}
// If the user had a username earlier, we need to redirect it to the new org username
const orgUrlPrefix = getOrgFullOrigin(orgSlug);
log.debug({
orgUrlPrefix,
nonOrgUserName,
targetOrgUsername,
});
await prisma.tempOrgRedirect.upsert({
where: {
from_type_fromOrgId: {
type: RedirectType.User,
from: nonOrgUserName,
fromOrgId: 0,
},
},
create: {
type: RedirectType.User,
from: nonOrgUserName,
fromOrgId: 0,
toUrl: `${orgUrlPrefix}/${targetOrgUsername}`,
},
update: {
toUrl: `${orgUrlPrefix}/${targetOrgUsername}`,
},
});
for (const [, team] of Object.entries(teamsToBeMovedToOrg)) {
if (!team.slug) {
log.debug("No slug for team. Not adding the redirect", safeStringify({ team }));
continue;
}
await prisma.tempOrgRedirect.upsert({
where: {
from_type_fromOrgId: {
type: RedirectType.Team,
from: team.slug,
fromOrgId: 0,
},
},
create: {
type: RedirectType.Team,
from: team.slug,
fromOrgId: 0,
toUrl: `${orgUrlPrefix}/team/${team.slug}`,
},
update: {
toUrl: `${orgUrlPrefix}/team/${team.slug}`,
},
});
}
}
async function addTeamRedirect(teamSlug: string | null, orgSlug: string | null) {
if (!teamSlug) {
throw new HttpError({
statusCode: 400,
message: "No slug for team. Not removing the redirect",
});
}
if (!orgSlug) {
log.warn(`No slug for org. Not adding the redirect`);
return;
}
const orgUrlPrefix = getOrgFullOrigin(orgSlug);
await prisma.tempOrgRedirect.upsert({
where: {
from_type_fromOrgId: {
type: RedirectType.Team,
from: teamSlug,
fromOrgId: 0,
},
},
create: {
type: RedirectType.Team,
from: teamSlug,
fromOrgId: 0,
toUrl: `${orgUrlPrefix}/${teamSlug}`,
},
update: {
toUrl: `${orgUrlPrefix}/${teamSlug}`,
},
});
}
async function updateMembership({
targetOrgId,
userToMoveToOrg,
targetOrgRole,
targetOrgMembershipAccepted,
}: {
targetOrgId: number;
userToMoveToOrg: User;
targetOrgRole: MembershipRole;
targetOrgMembershipAccepted: boolean;
}) {
log.debug("updateMembership", { targetOrgId, userToMoveToOrg, targetOrgRole, targetOrgMembershipAccepted });
await prisma.membership.upsert({
where: {
userId_teamId: {
teamId: targetOrgId,
userId: userToMoveToOrg.id,
},
},
create: {
teamId: targetOrgId,
userId: userToMoveToOrg.id,
role: targetOrgRole,
accepted: targetOrgMembershipAccepted,
},
update: {
role: targetOrgRole,
accepted: targetOrgMembershipAccepted,
},
});
}
async function dbMoveUserToOrg({
userToMoveToOrg,
targetOrgId,
targetOrgUsername,
nonOrgUserName,
}: {
userToMoveToOrg: User;
targetOrgId: number;
targetOrgUsername: string;
nonOrgUserName: string | null;
}) {
await prisma.user.update({
where: {
id: userToMoveToOrg.id,
},
data: {
organizationId: targetOrgId,
username: targetOrgUsername,
metadata: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
...(userToMoveToOrg.metadata || {}),
migratedToOrgFrom: {
username: nonOrgUserName,
lastMigrationTime: new Date().toISOString(),
},
},
},
});
}
async function moveTeamsWithoutMembersToOrg({
targetOrgId,
userToMoveToOrg,
}: {
targetOrgId: number;
userToMoveToOrg: User;
}) {
const memberships = await prisma.membership.findMany({
where: {
userId: userToMoveToOrg.id,
},
});
const membershipTeamIds = memberships.map((m) => m.teamId);
const teams = await prisma.team.findMany({
where: {
id: {
in: membershipTeamIds,
},
},
select: {
id: true,
slug: true,
metadata: true,
},
});
const teamsToBeMovedToOrg = teams
.map((team) => {
return {
...team,
metadata: teamMetadataSchema.parse(team.metadata),
};
})
// Remove Orgs from the list
.filter((team) => !team.metadata?.isOrganization);
const teamIdsToBeMovedToOrg = teamsToBeMovedToOrg.map((t) => t.id);
if (memberships.length) {
// Add the user's teams to the org
await prisma.team.updateMany({
where: {
id: {
in: teamIdsToBeMovedToOrg,
},
},
data: {
parentId: targetOrgId,
},
});
}
return teamsToBeMovedToOrg;
}
/**
* Make sure you pass it an organization ID only and not a team ID.
*/
async function setOrgSlug({ targetOrgId, targetSlug }: { targetOrgId: number; targetSlug: string }) {
await prisma.team.update({
where: {
id: targetOrgId,
},
data: {
slug: targetSlug,
},
});
}
async function removeTeamRedirect(teamSlug: string | null) {
if (!teamSlug) {
throw new HttpError({
statusCode: 400,
message: "No slug for team. Not removing the redirect",
});
return;
}
await prisma.tempOrgRedirect.deleteMany({
where: {
type: RedirectType.Team,
from: teamSlug,
fromOrgId: 0,
},
});
}
async function removeUserAlongWithItsTeamsRedirects({
nonOrgUserName,
teamsToBeRemovedFromOrg,
}: {
nonOrgUserName: string | null;
teamsToBeRemovedFromOrg: { slug: string | null }[];
}) {
if (!nonOrgUserName) {
return;
}
await prisma.tempOrgRedirect.deleteMany({
// This where clause is unique, so we will get only one result but using deleteMany because it doesn't throw an error if there are no rows to delete
where: {
type: RedirectType.User,
from: nonOrgUserName,
fromOrgId: 0,
},
});
for (const [, team] of Object.entries(teamsToBeRemovedFromOrg)) {
if (!team.slug) {
log.debug("No slug for team. Not removing the redirect", safeStringify({ team }));
continue;
}
await prisma.tempOrgRedirect.deleteMany({
where: {
type: RedirectType.Team,
from: team.slug,
fromOrgId: 0,
},
});
}
}
async function dbRemoveTeamFromOrg({ teamId, targetOrgId }: { teamId: number; targetOrgId: number }) {
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
});
if (!team) {
throw new HttpError({
statusCode: 400,
message: `Team with id: ${teamId} not found`,
});
}
if (team.parentId !== targetOrgId) {
log.warn(`Team ${teamId} is not part of org ${targetOrgId}. Not updating`);
return {
slug: team.slug,
};
}
try {
return await prisma.team.update({
where: {
id: teamId,
},
data: {
parentId: null,
},
select: {
slug: true,
},
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === "P2002") {
throw new HttpError({
message: `Looks like the team's name is already taken by some other team outside the org or an org itself. Please change this team's name or the other team/org's name. If you rename the team that you are trying to remove from the org, you will have to manually remove the redirect from the database for that team as the slug would have changed.`,
statusCode: 400,
});
}
}
throw e;
}
}
async function removeTeamsWithoutItsMemberFromOrg({ userToRemoveFromOrg }: { userToRemoveFromOrg: User }) {
const memberships = await prisma.membership.findMany({
where: {
userId: userToRemoveFromOrg.id,
},
});
const membershipTeamIds = memberships.map((m) => m.teamId);
const teams = await prisma.team.findMany({
where: {
id: {
in: membershipTeamIds,
},
},
select: {
id: true,
slug: true,
metadata: true,
},
});
const teamsToBeRemovedFromOrg = teams
.map((team) => {
return {
...team,
metadata: teamMetadataSchema.parse(team.metadata),
};
})
// Remove Orgs from the list
.filter((team) => !team.metadata?.isOrganization);
const teamIdsToBeRemovedFromOrg = teamsToBeRemovedFromOrg.map((t) => t.id);
if (memberships.length) {
// Remove the user's teams from the org
await prisma.team.updateMany({
where: {
id: {
in: teamIdsToBeRemovedFromOrg,
},
},
data: {
parentId: null,
},
});
}
return teamsToBeRemovedFromOrg;
}
async function dbRemoveUserFromOrg({
userToRemoveFromOrg,
nonOrgUserName,
}: {
userToRemoveFromOrg: User;
nonOrgUserName: string;
}) {
await prisma.user.update({
where: {
id: userToRemoveFromOrg.id,
},
data: {
organizationId: null,
username: nonOrgUserName,
metadata: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
...(userToRemoveFromOrg.metadata || {}),
migratedToOrgFrom: {
username: null,
reverted: true,
revertTime: new Date().toISOString(),
},
},
},
});
}
async function removeMembership({
targetOrgId,
userToRemoveFromOrg,
}: {
targetOrgId: number;
userToRemoveFromOrg: User;
}) {
await prisma.membership.deleteMany({
where: {
teamId: targetOrgId,
userId: userToRemoveFromOrg.id,
},
});
}

View File

@ -0,0 +1,73 @@
import { getFormSchema } from "@pages/settings/admin/orgMigrations/moveTeamToOrg";
import type { NextApiRequest, NextApiResponse } from "next/types";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { moveTeamToOrg } from "../../../lib/orgMigration";
const log = logger.getSubLogger({ prefix: ["moveTeamToOrg"] });
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const rawBody = req.body;
log.debug(
"Moving team to org:",
safeStringify({
body: rawBody,
})
);
const translate = await getTranslation("en", "common");
const moveTeamToOrgSchema = getFormSchema(translate);
const parsedBody = moveTeamToOrgSchema.safeParse(rawBody);
const session = await getServerSession({ req, res });
if (!session) {
return res.status(403).json({ message: "No session found" });
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!parsedBody.success) {
log.error("moveTeamToOrg failed:", safeStringify(parsedBody.error));
return res.status(400).json({ message: JSON.stringify(parsedBody.error) });
}
const { teamId, targetOrgId, moveMembers } = parsedBody.data;
const isAllowed = isAdmin;
if (!isAllowed) {
return res.status(403).json({ message: "Not Authorized" });
}
try {
await moveTeamToOrg({
targetOrgId,
teamId,
moveMembers,
});
} catch (error) {
if (error instanceof HttpError) {
if (error.statusCode > 300) {
log.error("moveTeamToOrg failed:", safeStringify(error.message));
}
return res.status(error.statusCode).json({ message: error.message });
}
log.error("moveTeamToOrg failed:", safeStringify(error));
const errorMessage = error instanceof Error ? error.message : "Something went wrong";
return res.status(500).json({ message: errorMessage });
}
return res.status(200).json({
message: `Added team ${teamId} to Org: ${targetOrgId} ${
moveMembers ? " along with the members" : " without the members"
}`,
});
}

View File

@ -0,0 +1,75 @@
import { getFormSchema } from "@pages/settings/admin/orgMigrations/moveUserToOrg";
import type { NextApiRequest, NextApiResponse } from "next/types";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { moveUserToOrg } from "../../../lib/orgMigration";
const log = logger.getSubLogger({ prefix: ["moveUserToOrg"] });
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const rawBody = req.body;
const translate = await getTranslation("en", "common");
const migrateBodySchema = getFormSchema(translate);
log.debug(
"Starting migration:",
safeStringify({
body: rawBody,
})
);
const parsedBody = migrateBodySchema.safeParse(rawBody);
const session = await getServerSession({ req });
if (!session) {
res.status(403).json({ message: "No session found" });
return;
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (parsedBody.success) {
const { userId, userName, shouldMoveTeams, targetOrgId, targetOrgUsername, targetOrgRole } =
parsedBody.data;
const isAllowed = isAdmin;
if (isAllowed) {
try {
await moveUserToOrg({
targetOrg: {
id: targetOrgId,
username: targetOrgUsername,
membership: {
role: targetOrgRole,
},
},
user: {
id: userId,
userName,
},
shouldMoveTeams,
});
} catch (error) {
if (error instanceof HttpError) {
if (error.statusCode > 300) {
log.error("Migration failed:", safeStringify(error));
}
return res.status(error.statusCode).json({ message: error.message });
}
log.error("Migration failed:", safeStringify(error));
const errorMessage = error instanceof Error ? error.message : "Something went wrong";
return res.status(400).json({ message: errorMessage });
}
} else {
return res.status(403).json({ message: "Not Authorized" });
}
return res.status(200).json({ message: "Migrated" });
}
log.error("Migration failed:", safeStringify(parsedBody.error));
return res.status(400).json({ message: JSON.stringify(parsedBody.error) });
}

View File

@ -0,0 +1,63 @@
import { getFormSchema } from "@pages/settings/admin/orgMigrations/removeTeamFromOrg";
import type { NextApiRequest, NextApiResponse } from "next/types";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { removeTeamFromOrg } from "../../../lib/orgMigration";
const log = logger.getSubLogger({ prefix: ["removeTeamFromOrg"] });
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const rawBody = req.body;
const translate = await getTranslation("en", "common");
const removeTeamFromOrgSchema = getFormSchema(translate);
log.debug(
"Removing team from org:",
safeStringify({
body: rawBody,
})
);
const parsedBody = removeTeamFromOrgSchema.safeParse(rawBody);
const session = await getServerSession({ req });
if (!session) {
return res.status(403).json({ message: "No session found" });
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!parsedBody.success) {
log.error("RemoveTeamFromOrg failed:", safeStringify(parsedBody.error));
return res.status(400).json({ message: JSON.stringify(parsedBody.error) });
}
const { teamId, targetOrgId } = parsedBody.data;
const isAllowed = isAdmin;
if (!isAllowed) {
return res.status(403).json({ message: "Not Authorized" });
}
try {
await removeTeamFromOrg({
targetOrgId,
teamId,
});
} catch (error) {
if (error instanceof HttpError) {
if (error.statusCode > 300) {
log.error("RemoveTeamFromOrg failed:", safeStringify(error));
}
return res.status(error.statusCode).json({ message: error.message });
}
log.error("RemoveTeamFromOrg failed:", safeStringify(error));
const errorMessage = error instanceof Error ? error.message : "Something went wrong";
return res.status(500).json({ message: errorMessage });
}
return res.status(200).json({ message: `Removed team ${teamId} from ${targetOrgId}` });
}

View File

@ -0,0 +1,59 @@
import { getFormSchema } from "@pages/settings/admin/orgMigrations/removeUserFromOrg";
import type { NextApiRequest, NextApiResponse } from "next/types";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { removeUserFromOrg } from "../../../lib/orgMigration";
const log = logger.getSubLogger({ prefix: ["removeUserFromOrg"] });
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const body = req.body;
log.debug(
"Starting reverse migration:",
safeStringify({
body,
})
);
const translate = await getTranslation("en", "common");
const migrateRevertBodySchema = getFormSchema(translate);
const parsedBody = migrateRevertBodySchema.safeParse(body);
const session = await getServerSession({ req });
if (!session) {
return res.status(403).json({ message: "No session found" });
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!isAdmin) {
return res.status(403).json({ message: "Only admin can take this action" });
}
if (parsedBody.success) {
const { userId, targetOrgId } = parsedBody.data;
try {
await removeUserFromOrg({ targetOrgId, userId });
} catch (error) {
if (error instanceof HttpError) {
if (error.statusCode > 300) {
log.error("Reverse migration failed:", safeStringify(error));
}
return res.status(error.statusCode).json({ message: error.message });
}
log.error("Reverse migration failed:", safeStringify(error));
const errorMessage = error instanceof Error ? error.message : "Something went wrong";
return res.status(500).json({ message: errorMessage });
}
return res.status(200).json({ message: "Reverted" });
}
log.error("Reverse Migration failed:", safeStringify(parsedBody.error));
return res.status(400).json({ message: JSON.stringify(parsedBody.error) });
}

View File

@ -0,0 +1,33 @@
import { getLayout as getSettingsLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { HorizontalTabs } from "@calcom/ui";
export default function OrgMigrationLayout({ children }: { children: React.ReactElement }) {
return getSettingsLayout(
<div>
<HorizontalTabs
tabs={[
{
name: "Move Team to Org",
href: "/settings/admin/orgMigrations/moveTeamToOrg",
},
{
name: "Move User to Org",
href: "/settings/admin/orgMigrations/moveUserToOrg",
},
{
name: "Revert: Move Team to Org",
href: "/settings/admin/orgMigrations/removeTeamFromOrg",
},
{
name: "Revert: Move User to Org",
href: "/settings/admin/orgMigrations/removeUserFromOrg",
},
]}
/>
{children}
</div>
);
}
export const getLayout = (page: React.ReactElement) => {
return <OrgMigrationLayout>{page}</OrgMigrationLayout>;
};

View File

@ -0,0 +1,175 @@
import { zodResolver } from "@hookform/resolvers/zod";
import type { GetServerSidePropsContext } from "next";
import { getSession } from "next-auth/react";
import type { TFunction } from "next-i18next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { getStringAsNumberRequiredSchema } from "@calcom/prisma/zod-utils";
import { Button, Form, Meta, SelectField, TextField, showToast } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
import { getLayout } from "./_OrgMigrationLayout";
export const getFormSchema = (t: TFunction) => {
return z.object({
teamId: z.number().or(getStringAsNumberRequiredSchema(t)),
targetOrgId: z.number().or(getStringAsNumberRequiredSchema(t)),
moveMembers: z.boolean(),
});
};
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<div>
<Meta title="Organization Migration: Move a team" description="Migrates a team to an organization" />
{children}
</div>
);
}
const enum State {
IDLE,
LOADING,
SUCCESS,
ERROR,
}
export default function MoveTeamToOrg() {
const [state, setState] = useState(State.IDLE);
const moveUsersOptions = [
{
label: "No",
value: "false",
},
{
label: "Yes",
value: "true",
},
];
const { t } = useLocale();
const formSchema = getFormSchema(t);
const formMethods = useForm({
mode: "onSubmit",
resolver: zodResolver(formSchema),
});
const { register, watch } = formMethods;
const moveMembers = watch("moveMembers");
return (
<Wrapper>
<Form
className="space-y-6"
noValidate={true}
form={formMethods}
handleSubmit={async (values) => {
setState(State.LOADING);
const res = await fetch(`/api/orgMigration/moveTeamToOrg`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(values),
});
let response = null;
try {
response = await res.json();
} catch (e) {
if (e instanceof Error) {
showToast(e.message, "error", 10000);
} else {
showToast(t("something_went_wrong"), "error", 10000);
}
setState(State.ERROR);
return;
}
if (res.status === 200) {
setState(State.SUCCESS);
showToast(response.message, "success", 10000);
} else {
setState(State.ERROR);
showToast(response.message, "error", 10000);
}
}}>
<div className="space-y-6">
<TextField
{...register("teamId")}
label="Team ID"
required
placeholder="Enter teamId to move to org"
/>
<TextField
{...register("targetOrgId")}
label="Target Organization ID"
required
placeholder="Enter Target organization ID"
/>
<div>
<Controller
name="moveMembers"
render={({ field: { value, onChange } }) => (
<SelectField
containerClassName="mb-0"
label="Move users"
onChange={(option) => {
onChange(option?.value === "true");
}}
value={moveUsersOptions.find((opt) => opt.value === value)}
options={moveUsersOptions}
/>
)}
/>
{moveMembers === true ? (
<div className="mt-2">Members of the team will also be moved to the organization</div>
) : moveMembers === false ? (
<div className="mt-2">Members of the team will not be moved to the organization</div>
) : null}
</div>
</div>
<Button type="submit" loading={state === State.LOADING}>
Move Team to Org
</Button>
</Form>
</Wrapper>
);
}
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const session = await getSession(ctx);
if (!session || !session.user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!isAdmin) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: {
error: null,
migrated: null,
userId: session.user.id,
...(await serverSideTranslations(ctx.locale || "en", ["common"])),
username: session.user.username,
},
};
}
MoveTeamToOrg.PageWrapper = PageWrapper;
MoveTeamToOrg.getLayout = getLayout;

View File

@ -0,0 +1,213 @@
/**
* It could be an admin feature to move a user to an organization but because it's a temporary thing before mono-user orgs are implemented, it's not right to spend time on it.
* Plus, we need to do it only for cal.com and not provide as a feature to our self hosters.
*/
import { zodResolver } from "@hookform/resolvers/zod";
import type { GetServerSidePropsContext } from "next";
import { getSession } from "next-auth/react";
import type { TFunction } from "next-i18next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import z from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/client";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { getStringAsNumberRequiredSchema } from "@calcom/prisma/zod-utils";
import { Button, Form, Meta, SelectField, TextField, showToast } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
import { getLayout } from "./_OrgMigrationLayout";
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<div>
<Meta
title="Organization Migration: Move a user"
description="Migrates a user to an organization along with the user's teams. But the teams' users are not migrated"
/>
{children}
</div>
);
}
export const getFormSchema = (t: TFunction) =>
z.object({
userId: z.union([z.string().pipe(z.coerce.number()), z.number()]).optional(),
userName: z.string().optional(),
targetOrgId: z.union([getStringAsNumberRequiredSchema(t), z.number()]),
targetOrgUsername: z.string().min(1, t("error_required_field")),
shouldMoveTeams: z.boolean(),
targetOrgRole: z.union([
z.literal(MembershipRole.ADMIN),
z.literal(MembershipRole.MEMBER),
z.literal(MembershipRole.OWNER),
]),
});
const enum State {
IDLE,
LOADING,
SUCCESS,
ERROR,
}
export default function MoveUserToOrg() {
const [state, setState] = useState(State.IDLE);
const roles = Object.values(MembershipRole).map((role) => ({
label: role,
value: role,
}));
const moveTeamsOptions = [
{
label: "Yes",
value: "true",
},
{
label: "No",
value: "false",
},
];
const { t } = useLocale();
const formSchema = getFormSchema(t);
const form = useForm({
mode: "onSubmit",
resolver: zodResolver(formSchema),
});
const shouldMoveTeams = form.watch("shouldMoveTeams");
const register = form.register;
return (
<Wrapper>
{/* Due to some reason auth from website doesn't work if /api endpoint is used. Spent a lot of time and in the end went with submitting data to the same page, because you can't do POST request to a page in Next.js, doing a GET request */}
<Form
form={form}
className="space-y-6"
handleSubmit={async (values) => {
setState(State.LOADING);
const res = await fetch(`/api/orgMigration/moveUserToOrg`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(values),
});
let response = null;
try {
response = await res.json();
} catch (e) {
if (e instanceof Error) {
showToast(e.message, "error", 10000);
} else {
showToast(t("something_went_wrong"), "error", 10000);
}
setState(State.ERROR);
return;
}
if (res.status === 200) {
setState(State.SUCCESS);
showToast(response.message, "success", 10000);
} else {
setState(State.ERROR);
showToast(response.message, "error", 10000);
}
}}>
<div className="space-y-6">
<TextField
type="text"
{...register("userName")}
label="User Name"
required
defaultValue=""
placeholder="Enter username to move to Org"
/>
<Controller
name="targetOrgRole"
render={({ field: { value, onChange } }) => (
<SelectField
label="Role"
options={roles}
onChange={(option) => {
if (!option) return;
onChange(option.value);
}}
value={roles.find((role) => role.value === value)}
required
placeholder="Enter userId"
/>
)}
/>
<TextField
label="Username in Target Org"
type="text"
required
{...register("targetOrgUsername")}
placeholder="Enter New username for the Org"
/>
<TextField
label="Target Organization ID"
type="number"
required
{...register("targetOrgId")}
placeholder="Enter Target organization ID"
/>
<Controller
name="shouldMoveTeams"
render={({ field: { value, onChange } }) => (
<SelectField
label="Move Teams"
className="mb-0"
onChange={(option) => {
if (!option) return;
onChange(option.value === "true");
}}
value={moveTeamsOptions.find((opt) => opt.value === value)}
required
options={moveTeamsOptions}
/>
)}
/>
</div>
<Button type="submit" loading={state === State.LOADING}>
{shouldMoveTeams
? "Move User to Org along with its teams(except the teams' users)"
: "Move User to Org"}
</Button>
</Form>
</Wrapper>
);
}
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const session = await getSession(ctx);
if (!session || !session.user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!isAdmin) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: {
...(await serverSideTranslations(ctx.locale || "en", ["common"])),
},
};
}
MoveUserToOrg.PageWrapper = PageWrapper;
MoveUserToOrg.getLayout = getLayout;

View File

@ -0,0 +1,142 @@
import { zodResolver } from "@hookform/resolvers/zod";
import type { GetServerSidePropsContext } from "next";
import { getSession } from "next-auth/react";
import type { TFunction } from "next-i18next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { useState } from "react";
import { useForm } from "react-hook-form";
import z from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { getStringAsNumberRequiredSchema } from "@calcom/prisma/zod-utils";
import { Button, Form, Meta, TextField, showToast } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
import { getLayout } from "./_OrgMigrationLayout";
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<div>
<Meta
title="Organization Migration: Revert a team"
description="Reverts a migration of a team to an organization"
/>
{children}
</div>
);
}
const enum State {
IDLE,
LOADING,
SUCCESS,
ERROR,
}
export const getFormSchema = (t: TFunction) =>
z.object({
targetOrgId: z.union([getStringAsNumberRequiredSchema(t), z.number()]),
teamId: z.union([getStringAsNumberRequiredSchema(t), z.number()]),
});
export default function RemoveTeamFromOrg() {
const [state, setState] = useState(State.IDLE);
const { t } = useLocale();
const formSchema = getFormSchema(t);
const form = useForm({
mode: "onSubmit",
resolver: zodResolver(formSchema),
});
const register = form.register;
return (
<Wrapper>
{/* Due to some reason auth from website doesn't work if /api endpoint is used. Spent a lot of time and in the end went with submitting data to the same page, because you can't do POST request to a page in Next.js, doing a GET request */}
<Form
form={form}
className="space-y-6"
handleSubmit={async (values) => {
setState(State.LOADING);
const res = await fetch(`/api/orgMigration/removeTeamFromOrg`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(values),
});
let response = null;
try {
response = await res.json();
} catch (e) {
if (e instanceof Error) {
showToast(e.message, "error", 10000);
} else {
showToast(t("something_went_wrong"), "error", 10000);
}
setState(State.ERROR);
return;
}
if (res.status === 200) {
setState(State.SUCCESS);
showToast(response.message, "success", 10000);
} else {
setState(State.ERROR);
showToast(response.message, "error", 10000);
}
}}>
<div className="space-y-6">
<TextField
label="Team ID"
{...register("teamId")}
required
placeholder="Enter teamId to remove from org"
/>
<TextField
className="mb-0"
{...register("targetOrgId")}
label="Target Organization ID"
type="number"
required
placeholder="Enter Target organization ID"
/>
</div>
<Button type="submit" loading={state === State.LOADING}>
Remove Team from Org
</Button>
</Form>
</Wrapper>
);
}
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const session = await getSession(ctx);
if (!session || !session.user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!isAdmin) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: {
...(await serverSideTranslations(ctx.locale || "en", ["common"])),
},
};
}
RemoveTeamFromOrg.PageWrapper = PageWrapper;
RemoveTeamFromOrg.getLayout = getLayout;

View File

@ -0,0 +1,137 @@
import { zodResolver } from "@hookform/resolvers/zod";
import type { GetServerSidePropsContext } from "next";
import { getSession } from "next-auth/react";
import type { TFunction } from "next-i18next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { useState } from "react";
import { useForm } from "react-hook-form";
import z from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { getStringAsNumberRequiredSchema } from "@calcom/prisma/zod-utils";
import { Button, TextField, Meta, showToast, Form } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
import { getLayout } from "./_OrgMigrationLayout";
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<div>
<Meta title="Organization Migration: Move a team" description="Migrates a team to an organization" />
{children}
</div>
);
}
const enum State {
IDLE,
LOADING,
SUCCESS,
ERROR,
}
export const getFormSchema = (t: TFunction) =>
z.object({
userId: z.union([getStringAsNumberRequiredSchema(t), z.number()]),
targetOrgId: z.union([getStringAsNumberRequiredSchema(t), z.number()]),
});
export default function RemoveUserFromOrg() {
const [state, setState] = useState(State.IDLE);
const { t } = useLocale();
const formSchema = getFormSchema(t);
const form = useForm({
mode: "onSubmit",
resolver: zodResolver(formSchema),
});
const register = form.register;
return (
<Wrapper>
{/* Due to some reason auth from website doesn't work if /api endpoint is used. Spent a lot of time and in the end went with submitting data to the same page, because you can't do POST request to a page in Next.js, doing a GET request */}
<Form
form={form}
className="space-y-6"
handleSubmit={async (values) => {
setState(State.LOADING);
const res = await fetch(`/api/orgMigration/removeUserFromOrg`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(values),
});
let response = null;
try {
response = await res.json();
} catch (e) {
if (e instanceof Error) {
showToast(e.message, "error", 10000);
} else {
showToast(t("something_went_wrong"), "error", 10000);
}
setState(State.ERROR);
return;
}
if (res.status === 200) {
setState(State.SUCCESS);
showToast(response.message, "success", 10000);
} else {
setState(State.ERROR);
showToast(response.message, "error", 10000);
}
}}>
<div className="space-y-6">
<TextField
label="User ID"
{...register("userId")}
required
placeholder="Enter userId to remove from org"
/>
<TextField
className="mb-0"
label="Target Organization ID"
type="number"
required
{...register("targetOrgId")}
placeholder="Enter Target organization ID"
/>
</div>
<Button type="submit" loading={state === State.LOADING}>
Remove User from Org along with its teams
</Button>
</Form>
</Wrapper>
);
}
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const session = await getSession(ctx);
if (!session || !session.user) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!isAdmin) {
return {
redirect: {
destination: "/",
permanent: false,
},
};
}
return {
props: {
...(await serverSideTranslations(ctx.locale || "en", ["common"])),
},
};
}
RemoveUserFromOrg.PageWrapper = PageWrapper;
RemoveUserFromOrg.getLayout = getLayout;

View File

@ -1,5 +1,6 @@
import type { Prisma } from "@prisma/client";
import type { UnitTypeLongPlural } from "dayjs";
import type { TFunction } from "next-i18next";
import z, { ZodNullable, ZodObject, ZodOptional } from "zod";
/* eslint-disable no-underscore-dangle */
@ -640,3 +641,5 @@ export const ZVerifyCodeInputSchema = z.object({
export type ZVerifyCodeInputSchema = z.infer<typeof ZVerifyCodeInputSchema>;
export const coerceToDate = z.coerce.date();
export const getStringAsNumberRequiredSchema = (t: TFunction) =>
z.string().min(1, t("error_required_field")).pipe(z.coerce.number());