From de1c9d01cd3c3a105f1d8a677aa24c3a4b64edf9 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Thu, 4 Jan 2024 08:33:51 +0530 Subject: [PATCH] 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> --- apps/web/lib/orgMigration.test.ts | 1491 +++++++++++++++++ apps/web/lib/orgMigration.ts | 817 +++++++++ .../pages/api/orgMigration/moveTeamToOrg.ts | 73 + .../pages/api/orgMigration/moveUserToOrg.ts | 75 + .../api/orgMigration/removeTeamFromOrg.ts | 63 + .../api/orgMigration/removeUserFromOrg.ts | 59 + .../orgMigrations/_OrgMigrationLayout.tsx | 33 + .../admin/orgMigrations/moveTeamToOrg.tsx | 175 ++ .../admin/orgMigrations/moveUserToOrg.tsx | 213 +++ .../admin/orgMigrations/removeTeamFromOrg.tsx | 142 ++ .../admin/orgMigrations/removeUserFromOrg.tsx | 137 ++ packages/prisma/zod-utils.ts | 3 + 12 files changed, 3281 insertions(+) create mode 100644 apps/web/lib/orgMigration.test.ts create mode 100644 apps/web/lib/orgMigration.ts create mode 100644 apps/web/pages/api/orgMigration/moveTeamToOrg.ts create mode 100644 apps/web/pages/api/orgMigration/moveUserToOrg.ts create mode 100644 apps/web/pages/api/orgMigration/removeTeamFromOrg.ts create mode 100644 apps/web/pages/api/orgMigration/removeUserFromOrg.ts create mode 100644 apps/web/pages/settings/admin/orgMigrations/_OrgMigrationLayout.tsx create mode 100644 apps/web/pages/settings/admin/orgMigrations/moveTeamToOrg.tsx create mode 100644 apps/web/pages/settings/admin/orgMigrations/moveUserToOrg.tsx create mode 100644 apps/web/pages/settings/admin/orgMigrations/removeTeamFromOrg.tsx create mode 100644 apps/web/pages/settings/admin/orgMigrations/removeUserFromOrg.tsx diff --git a/apps/web/lib/orgMigration.test.ts b/apps/web/lib/orgMigration.test.ts new file mode 100644 index 0000000000..cfe4c78b74 --- /dev/null +++ b/apps/web/lib/orgMigration.test.ts @@ -0,0 +1,1491 @@ +import prismock from "../../../tests/libs/__mocks__/prisma"; + +import { describe, expect, it } from "vitest"; +import type { z } from "zod"; + +import type { MembershipRole, Prisma } from "@calcom/prisma/client"; +import { RedirectType } from "@calcom/prisma/enums"; +import type { teamMetadataSchema } from "@calcom/prisma/zod-utils"; + +import { moveTeamToOrg, moveUserToOrg, removeTeamFromOrg, removeUserFromOrg } from "./orgMigration"; + +describe("orgMigration", () => { + describe("moveUserToOrg", () => { + describe("when user email does not match orgAutoAcceptEmail", () => { + it(`should migrate a user to become a part of an organization with ADMIN role + - username in the organization should be automatically derived from email when it isn't passed`, async () => { + const data = { + userToMigrate: { + username: "user-1", + email: "user-1@example.com", + // Because example.com isn't the orgAutoAcceptEmail + expectedUsernameInOrg: "user-1-example", + }, + targetOrg: { + name: "Org 1", + slug: "org1", + }, + membershipWeWant: { + role: "ADMIN", + } as const, + }; + + const dbUserToMigrate = await createUserOutsideOrg({ + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }); + + const dbOrg = await createOrg({ + name: data.targetOrg.name, + slug: data.targetOrg.slug, + }); + + const team1 = await createTeamOutsideOrg({ + name: "Team-1", + slug: "team-1", + }); + + // Make userToMigrate part of team-1 + await addMemberShipOfUserWithTeam({ + teamId: team1.id, + userId: dbUserToMigrate.id, + role: "MEMBER", + accepted: true, + }); + + await moveUserToOrg({ + user: { + id: dbUserToMigrate.id, + }, + targetOrg: { + id: dbOrg.id, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: false, + }); + + await expectUserToBeAPartOfOrg({ + userId: dbUserToMigrate.id, + orgId: dbOrg.id, + usernameInOrg: data.userToMigrate.expectedUsernameInOrg, + expectedMembership: { role: data.membershipWeWant.role, accepted: true }, + }); + + await expectTeamToBeNotPartOfAnyOrganization({ + teamId: team1.id, + }); + + expectUserRedirectToBeEnabled({ + from: { + username: data.userToMigrate.username, + }, + to: data.userToMigrate.expectedUsernameInOrg, + orgSlug: data.targetOrg.slug, + }); + }); + + it("should migrate a user to become a part of an organization(which has slug set) with MEMBER role", async () => { + const data = { + userToMigrate: { + username: "user-1", + email: "user-1@example.com", + // Because example.com isn't the orgAutoAcceptEmail + expectedUsernameInOrg: "user-1-example", + }, + targetOrg: { + id: 1, + name: "Org 1", + slug: "org1", + }, + membershipWeWant: { + role: "MEMBER", + } as const, + }; + + const dbUserToMigrate = await createUserOutsideOrg({ + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }); + + const dbOrg = await createOrg({ + slug: data.targetOrg.slug, + name: data.targetOrg.name, + }); + + await moveUserToOrg({ + user: { + id: dbOrg.id, + }, + targetOrg: { + id: data.targetOrg.id, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: false, + }); + + await expectUserToBeAPartOfOrg({ + userId: dbUserToMigrate.id, + orgId: data.targetOrg.id, + usernameInOrg: data.userToMigrate.expectedUsernameInOrg, + expectedMembership: { role: data.membershipWeWant.role, accepted: true }, + }); + }); + + it(`should migrate a user to become a part of an organization(which has no slug but requestedSlug) with MEMBER role + 1. Should set the slug as requestedSlug for the organization(so that the redirect doesnt take to an unpublished organization page)`, async () => { + const data = { + userToMigrate: { + username: "user-1", + email: "user-1@example.com", + // Because example.com isn't the orgAutoAcceptEmail + expectedUsernameInOrg: "user-1-example", + }, + targetOrg: { + name: "Org 1", + requestedSlug: "org1", + }, + membershipWeWant: { + role: "MEMBER", + } as const, + }; + + const dbUserToMigrate = await createUserOutsideOrg({ + email: "user-1@example.com", + username: "user-1", + }); + + const dbOrg = await createOrg({ + name: data.targetOrg.name, + metadata: { + requestedSlug: data.targetOrg.requestedSlug, + }, + }); + + await moveUserToOrg({ + user: { + id: dbUserToMigrate.id, + }, + targetOrg: { + id: dbOrg.id, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: false, + }); + + const organization = await prismock.team.findUnique({ + where: { + id: dbOrg.id, + }, + }); + + expect(organization?.slug).toBe(data.targetOrg.requestedSlug); + + await expectUserToBeAPartOfOrg({ + userId: dbUserToMigrate.id, + orgId: dbOrg.id, + usernameInOrg: data.userToMigrate.expectedUsernameInOrg, + expectedMembership: { role: data.membershipWeWant.role, accepted: true }, + }); + }); + + it("should migrate a user along with its teams(without other members)", async () => { + const data = { + userToMigrate: { + username: "user-1", + email: "user-1@example.com", + // Because example.com isn't the orgAutoAcceptEmail + expectedUsernameInOrg: "user-1-example", + teams: [ + { + name: "Team 100", + slug: "team-100", + }, + { + name: "Team 101", + slug: "team-101", + }, + ], + }, + targetOrg: { + name: "Org 1", + slug: "org1", + }, + membershipWeWant: { + role: "MEMBER", + } as const, + userPartOfTeam1: { + username: "user-2", + email: "user-2@example.com", + }, + userPartOfTeam2: { + username: "user-3", + email: "user-3@example.com", + }, + }; + + // Create user to migrate + const dbUserToMigrate = await createUserOutsideOrg({ + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }); + + // Create another user that would be part of team-1 along with userToMigrate + const userPartOfTeam1 = await createUserOutsideOrg({ + email: data.userPartOfTeam1.email, + username: data.userPartOfTeam1.username, + }); + + // Create another user that would be part of team-2 along with userToMigrate + const userPartOfTeam2 = await createUserOutsideOrg({ + email: data.userPartOfTeam2.email, + username: data.userPartOfTeam2.username, + }); + + const team1 = await createTeamOutsideOrg({ + name: data.userToMigrate.teams[0].name, + slug: data.userToMigrate.teams[0].slug, + }); + + const team2 = await createTeamOutsideOrg({ + name: data.userToMigrate.teams[1].name, + slug: data.userToMigrate.teams[1].slug, + }); + + // Make userToMigrate part of team-1 + await addMemberShipOfUserWithTeam({ + teamId: team1.id, + userId: dbUserToMigrate.id, + role: "MEMBER", + accepted: true, + }); + + // Make userToMigrate part of team-2 + await addMemberShipOfUserWithTeam({ + teamId: team2.id, + userId: dbUserToMigrate.id, + role: "MEMBER", + accepted: true, + }); + + // Make userPartofTeam1 part of team-1 + await addMemberShipOfUserWithTeam({ + teamId: team1.id, + userId: userPartOfTeam1.id, + role: "MEMBER", + accepted: true, + }); + + // Make userPartofTeam2 part of team2 + await addMemberShipOfUserWithTeam({ + teamId: team2.id, + userId: userPartOfTeam2.id, + role: "MEMBER", + accepted: true, + }); + + const dbOrg = await createOrg({ + name: data.targetOrg.name, + slug: data.targetOrg.slug, + }); + + await moveUserToOrg({ + user: { + id: dbUserToMigrate.id, + }, + targetOrg: { + id: dbOrg.id, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: true, + }); + + await expectUserToBeAPartOfOrg({ + userId: dbUserToMigrate.id, + orgId: dbOrg.id, + usernameInOrg: data.userToMigrate.expectedUsernameInOrg, + expectedMembership: { role: data.membershipWeWant.role, accepted: true }, + }); + + await expectTeamToBeAPartOfOrg({ + teamId: team1.id, + orgId: dbOrg.id, + }); + + await expectTeamToBeAPartOfOrg({ + teamId: team2.id, + orgId: dbOrg.id, + }); + + await expectUserToBeNotAPartOfTheOrg({ + userId: userPartOfTeam1.id, + orgId: dbOrg.id, + username: data.userPartOfTeam1.username, + }); + + await expectUserToBeNotAPartOfTheOrg({ + userId: userPartOfTeam2.id, + orgId: dbOrg.id, + username: data.userPartOfTeam2.username, + }); + }); + + it(`should migrate a user to become a part of an organization + - username in the organization should same as provided to moveUserToOrg`, async () => { + const data = { + userToMigrate: { + username: "user-1", + email: "user-1@example.com", + usernameInOrgThatWeWant: "user-1-in-org", + }, + targetOrg: { + name: "Org 1", + slug: "org1", + }, + membershipWeWant: { + role: "ADMIN", + } as const, + }; + + const dbUserToMigrate = await createUserOutsideOrg({ + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }); + + const dbOrg = await createOrg({ + name: data.targetOrg.name, + slug: data.targetOrg.slug, + }); + + await moveUserToOrg({ + user: { + id: dbUserToMigrate.id, + }, + targetOrg: { + id: dbOrg.id, + username: data.userToMigrate.usernameInOrgThatWeWant, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: false, + }); + + await expectUserToBeAPartOfOrg({ + userId: dbUserToMigrate.id, + orgId: dbOrg.id, + usernameInOrg: data.userToMigrate.usernameInOrgThatWeWant, + expectedMembership: { role: data.membershipWeWant.role, accepted: true }, + }); + + expectUserRedirectToBeEnabled({ + from: { + username: data.userToMigrate.username, + }, + to: data.userToMigrate.usernameInOrgThatWeWant, + orgSlug: data.targetOrg.slug, + }); + }); + + it(`should be able to re-migrate an already migrated user fixing things as we do it + - Redirect should correctly determine the nonOrgUsername so that on repeat migrations, the original user link doesn't break`, async () => { + const data = { + userToMigrate: { + username: "user-1", + email: "user-1@example.com", + // Because example.com isn't the orgAutoAcceptEmail + usernameWeWantInOrg: "user-1-in-org", + }, + targetOrg: { + name: "Org 1", + slug: "org1", + }, + membershipWeWant: { + role: "MEMBER", + } as const, + }; + + const dbOrg = await createOrg({ + name: data.targetOrg.name, + slug: data.targetOrg.slug, + }); + + const dbUserToMigrate = await createUserInsideTheOrg( + { + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }, + dbOrg.id + ); + + await moveUserToOrg({ + user: { + id: dbUserToMigrate.id, + }, + targetOrg: { + id: dbOrg.id, + username: data.userToMigrate.usernameWeWantInOrg, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: false, + }); + + await expectUserToBeAPartOfOrg({ + userId: dbUserToMigrate.id, + orgId: dbOrg.id, + usernameInOrg: data.userToMigrate.usernameWeWantInOrg, + // membership role should be updated + expectedMembership: { role: data.membershipWeWant.role, accepted: true }, + }); + + await moveUserToOrg({ + user: { + id: dbUserToMigrate.id, + }, + targetOrg: { + id: dbOrg.id, + username: data.userToMigrate.usernameWeWantInOrg, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: false, + }); + + await expectUserToBeAPartOfOrg({ + userId: dbUserToMigrate.id, + orgId: dbOrg.id, + usernameInOrg: data.userToMigrate.usernameWeWantInOrg, + // membership role should be updated + expectedMembership: { role: data.membershipWeWant.role, accepted: true }, + }); + + expectUserRedirectToBeEnabled({ + from: { + username: data.userToMigrate.username, + }, + to: data.userToMigrate.usernameWeWantInOrg, + orgSlug: data.targetOrg.slug, + }); + + console.log(await prismock.tempOrgRedirect.findMany({})); + }); + + describe("Failures handling", () => { + it("migration should fail if the username already exists in the organization", async () => { + const data = { + userToMigrate: { + username: "user-1", + email: "user-1@example.com", + // Because example.com isn't the orgAutoAcceptEmail + expectedUsernameInOrg: "user-1-example", + }, + targetOrg: { + name: "Org 1", + slug: "org1", + }, + membershipWeWant: { + role: "MEMBER", + } as const, + }; + + const dbOrg = await createOrg({ + name: data.targetOrg.name, + slug: data.targetOrg.slug, + }); + + await createUserInsideTheOrg( + { + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }, + dbOrg.id + ); + + // User with same username exists outside the org as well as inside the org + const dbUserToMigrate = await createUserOutsideOrg({ + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }); + + expect(() => { + return moveUserToOrg({ + user: { + id: dbUserToMigrate.id, + }, + targetOrg: { + id: dbOrg.id, + username: data.userToMigrate.username, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: false, + }); + }).rejects.toThrowError("already exists"); + }); + + it("should fail the migration if the target org doesn't exist", async () => { + const data = { + userToMigrate: { + username: "user-1", + email: "user-1@example.com", + // Because example.com isn't the orgAutoAcceptEmail + expectedUsernameInOrg: "user-1-example", + }, + targetOrg: { + name: "Org 1", + slug: "org1", + }, + membershipWeWant: { + role: "MEMBER", + } as const, + }; + + const dbOrg = await createOrg({ + name: data.targetOrg.name, + slug: data.targetOrg.slug, + }); + + await createUserInsideTheOrg( + { + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }, + dbOrg.id + ); + + // User with same username exists outside the org as well as inside the org + const dbUserToMigrate = await createUserOutsideOrg({ + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }); + + expect(() => { + return moveUserToOrg({ + user: { + id: dbUserToMigrate.id, + }, + targetOrg: { + // Non existent teamId + id: 1001, + username: data.userToMigrate.username, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: false, + }); + }).rejects.toThrowError(/Org .* not found/); + }); + + it("should fail the migration if the target teamId is not an organization", async () => { + const data = { + userToMigrate: { + username: "user-1", + email: "user-1@example.com", + // Because example.com isn't the orgAutoAcceptEmail + expectedUsernameInOrg: "user-1-example", + }, + targetOrg: { + name: "Org 1", + slug: "org1", + }, + membershipWeWant: { + role: "MEMBER", + } as const, + }; + + // Create a team instead of an organization + const dbOrgWhichIsActuallyATeam = await createTeamOutsideOrg({ + name: data.targetOrg.name, + slug: data.targetOrg.slug, + }); + + const dbUserToMigrate = await createUserOutsideOrg({ + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }); + + expect(() => { + return moveUserToOrg({ + user: { + id: dbUserToMigrate.id, + }, + targetOrg: { + // Non existent teamId + id: dbOrgWhichIsActuallyATeam.id, + username: data.userToMigrate.username, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: false, + }); + }).rejects.toThrowError(/is not an Org/); + }); + + it("should fail the migration if the user is part of any other organization", async () => { + const data = { + userToMigrate: { + username: "user-1", + email: "user-1@example.com", + // Because example.com isn't the orgAutoAcceptEmail + expectedUsernameInOrg: "user-1-example", + }, + targetOrg: { + name: "Org 1", + slug: "org1", + }, + membershipWeWant: { + role: "MEMBER", + } as const, + }; + + const dbOrg = await createOrg({ + name: data.targetOrg.name, + slug: data.targetOrg.slug, + }); + + const dbOrgOther = await createOrg({ + name: data.targetOrg.name, + slug: data.targetOrg.slug, + }); + + const dbUserToMigrate = await createUserInsideTheOrg( + { + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }, + dbOrgOther.id + ); + + expect(() => { + return moveUserToOrg({ + user: { + id: dbUserToMigrate.id, + }, + targetOrg: { + // Non existent teamId + id: dbOrg.id, + username: data.userToMigrate.username, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: false, + }); + }).rejects.toThrowError(/already a part of different organization/); + }); + }); + }); + + describe("when user email matches orgAutoAcceptEmail", () => { + const orgMetadata = { + orgAutoAcceptEmail: "org1.com", + } as const; + + it(`should migrate a user to become a part of an organization with ADMIN role`, async () => { + const data = { + userToMigrate: { + username: "user-1", + email: "user-1@org1.com", + // Because example.com isn't the orgAutoAcceptEmail + expectedUsernameInOrg: "user-1", + }, + targetOrg: { + name: "Org 1", + slug: "org1", + }, + membershipWeWant: { + role: "ADMIN", + } as const, + }; + + const dbUserToMigrate = await createUserOutsideOrg({ + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }); + + const dbOrg = await createOrg({ + slug: data.targetOrg.slug, + name: data.targetOrg.name, + metadata: { + ...orgMetadata, + }, + }); + + await moveUserToOrg({ + user: { + id: dbUserToMigrate.id, + }, + targetOrg: { + id: dbOrg.id, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: false, + }); + + await expectUserToBeAPartOfOrg({ + userId: dbUserToMigrate.id, + orgId: dbOrg.id, + usernameInOrg: data.userToMigrate.expectedUsernameInOrg, + expectedMembership: { role: data.membershipWeWant.role, accepted: true }, + }); + + expectUserRedirectToBeEnabled({ + from: { + username: data.userToMigrate.username, + }, + to: data.userToMigrate.expectedUsernameInOrg, + orgSlug: data.targetOrg.slug, + }); + }); + + it("should migrate a user to become a part of an organization(which has slug set) with MEMBER role", async () => { + const data = { + userToMigrate: { + username: "user-1", + email: "user-1@org1.com", + // Because example.com isn't the orgAutoAcceptEmail + expectedUsernameInOrg: "user-1", + }, + targetOrg: { + name: "Org 1", + slug: "org1", + }, + membershipWeWant: { + role: "MEMBER", + } as const, + }; + + const dbUserToMigrate = await createUserOutsideOrg({ + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }); + + const dbOrg = await createOrg({ + slug: data.targetOrg.slug, + name: data.targetOrg.name, + metadata: { + ...orgMetadata, + }, + }); + + await moveUserToOrg({ + user: { + id: dbUserToMigrate.id, + }, + targetOrg: { + id: dbOrg.id, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: false, + }); + + await expectUserToBeAPartOfOrg({ + userId: dbUserToMigrate.id, + orgId: dbOrg.id, + usernameInOrg: data.userToMigrate.expectedUsernameInOrg, + expectedMembership: { role: data.membershipWeWant.role, accepted: true }, + }); + }); + + it(`should migrate a user to become a part of an organization(which has no slug but requestedSlug) with MEMBER role + 1. Should set the slug as requestedSlug for the organization(so that the redirect doesnt take to an unpublished organization page)`, async () => { + const data = { + userToMigrate: { + username: "user-1", + email: "user-1@org1.com", + // Because example.com isn't the orgAutoAcceptEmail + expectedUsernameInOrg: "user-1", + }, + targetOrg: { + name: "Org 1", + requestedSlug: "org1", + }, + membershipWeWant: { + role: "MEMBER", + } as const, + }; + + const dbUserToMigrate = await createUserOutsideOrg({ + email: data.userToMigrate.email, + username: data.userToMigrate.username, + }); + + const dbOrg = await createOrg({ + name: data.targetOrg.name, + metadata: { + requestedSlug: data.targetOrg.requestedSlug, + ...orgMetadata, + }, + }); + + await moveUserToOrg({ + user: { + id: dbUserToMigrate.id, + }, + targetOrg: { + id: dbOrg.id, + membership: { + role: data.membershipWeWant.role, + }, + }, + shouldMoveTeams: false, + }); + + const organization = await prismock.team.findUnique({ + where: { + id: dbOrg.id, + }, + }); + + expect(organization?.slug).toBe(data.targetOrg.requestedSlug); + + await expectUserToBeAPartOfOrg({ + userId: dbUserToMigrate.id, + orgId: dbOrg.id, + usernameInOrg: data.userToMigrate.expectedUsernameInOrg, + expectedMembership: { role: data.membershipWeWant.role, accepted: true }, + }); + }); + }); + }); + + describe("moveTeamToOrg", () => { + it(`should migrate a team to become a part of an organization`, async () => { + const data = { + teamToMigrate: { + id: 1, + name: "Team 1", + slug: "team1", + }, + targetOrg: { + id: 2, + name: "Org 1", + slug: "org1", + }, + }; + + await prismock.team.create({ + data: { + id: data.teamToMigrate.id, + slug: data.teamToMigrate.slug, + name: data.teamToMigrate.name, + }, + }); + + await prismock.team.create({ + data: { + id: data.targetOrg.id, + slug: data.targetOrg.slug, + name: data.targetOrg.name, + metadata: { + isOrganization: true, + }, + }, + }); + + await moveTeamToOrg({ + teamId: data.teamToMigrate.id, + targetOrgId: data.targetOrg.id, + }); + + await expectTeamToBeAPartOfOrg({ + teamId: data.teamToMigrate.id, + orgId: data.targetOrg.id, + }); + + expectTeamRedirectToBeEnabled({ + from: { + teamSlug: data.teamToMigrate.slug, + }, + to: data.teamToMigrate.slug, + orgSlug: data.targetOrg.slug, + }); + }); + it.todo("should migrate a team with members"); + it.todo("Try migrating an already migrated team"); + }); + + describe("removeUserFromOrg", () => { + it(`should remove a user from an organization but he should remain in team`, async () => { + const data = { + userToUnmigrate: { + username: "user1-in-org1", + email: "user-1@example.com", + usernameBeforeMovingToOrg: "user1", + }, + targetOrg: { + name: "Org 1", + slug: "org1", + }, + membershipWeWant: { + role: "ADMIN", + } as const, + }; + + const dbOrg = await createOrg({ + slug: data.targetOrg.slug, + name: data.targetOrg.name, + }); + + const dbTeamOutsideOrg = await createTeamOutsideOrg({ + slug: "team-1", + name: "Team 1", + }); + + const dbUser = await createUserInsideTheOrg( + { + email: data.userToUnmigrate.email, + username: data.userToUnmigrate.username, + metadata: { + migratedToOrgFrom: { + username: data.userToUnmigrate.usernameBeforeMovingToOrg, + }, + }, + }, + dbOrg.id + ); + + await addMemberShipOfUserWithOrg({ + userId: dbUser.id, + teamId: dbTeamOutsideOrg.id, + role: "MEMBER", + accepted: true, + }); + + await addMemberShipOfUserWithTeam({ + userId: dbUser.id, + teamId: dbOrg.id, + role: data.membershipWeWant.role, + accepted: true, + }); + + const userToUnmigrate = await prismock.user.findUnique({ + where: { + id: dbUser.id, + }, + include: { + organization: true, + }, + }); + + if (!userToUnmigrate?.organizationId || !userToUnmigrate.organization) { + throw new Error( + `Couldn't setup user to unmigrate properly userToUnMigrate: ${{ + organizationId: userToUnmigrate?.organizationId, + organization: !!userToUnmigrate?.organization, + }}` + ); + } + + await removeUserFromOrg({ + userId: dbUser.id, + targetOrgId: dbOrg.id, + }); + + await expectUserToBeNotAPartOfTheOrg({ + userId: dbUser.id, + orgId: dbOrg.id, + username: data.userToUnmigrate.usernameBeforeMovingToOrg, + }); + + await expectUserToBeAPartOfTeam({ + userId: dbUser.id, + teamId: dbTeamOutsideOrg.id, + expectedMembership: { + role: "MEMBER", + accepted: true, + }, + }); + + expectUserRedirectToBeNotEnabled({ + from: { + username: data.userToUnmigrate.username, + }, + }); + }); + }); + + describe("removeTeamFromOrg", () => { + it(`should remove a team from an organization`, async () => { + const data = { + teamToUnmigrate: { + name: "Team 1", + slug: "team1", + }, + targetOrg: { + name: "Org 1", + slug: "org1", + }, + }; + + const targetOrg = await prismock.team.create({ + data: { + slug: data.targetOrg.slug, + name: data.targetOrg.name, + metadata: { + isOrganization: true, + }, + }, + }); + + const { id: teamToUnMigrateId } = await prismock.team.create({ + data: { + slug: data.teamToUnmigrate.slug, + name: data.teamToUnmigrate.name, + parent: { + connect: { + id: targetOrg.id, + }, + }, + }, + }); + + const teamToUnmigrate = await prismock.team.findUnique({ + where: { + id: teamToUnMigrateId, + }, + include: { + parent: true, + }, + }); + + if (!teamToUnmigrate?.parent || !teamToUnmigrate.parentId) { + throw new Error(`Couldn't setup team to unmigrate properly ID:${teamToUnMigrateId}`); + } + + await removeTeamFromOrg({ + teamId: teamToUnMigrateId, + targetOrgId: targetOrg.id, + }); + + await expectTeamToBeNotPartOfAnyOrganization({ + teamId: teamToUnMigrateId, + }); + + expectTeamRedirectToBeNotEnabled({ + from: { + teamSlug: data.teamToUnmigrate.slug, + }, + to: data.teamToUnmigrate.slug, + orgSlug: data.targetOrg.slug, + }); + }); + }); +}); + +async function expectUserToBeAPartOfOrg({ + userId, + orgId, + expectedMembership, + usernameInOrg, +}: { + userId: number; + orgId: number; + usernameInOrg: string; + expectedMembership: { + role: MembershipRole; + accepted: boolean; + }; +}) { + const migratedUser = await prismock.user.findUnique({ + where: { + id: userId, + }, + include: { + teams: true, + }, + }); + if (!migratedUser) { + throw new Error(`User with id ${userId} does not exist`); + } + + expect(migratedUser.username).toBe(usernameInOrg); + expect(migratedUser.organizationId).toBe(orgId); + + const membership = migratedUser.teams.find( + (membership) => membership.teamId === orgId && membership.userId === userId + ); + + expect(membership).not.toBeNull(); + if (!membership) { + throw new Error(`User with id ${userId} is not a part of org with id ${orgId}`); + } + expect(membership.role).toBe(expectedMembership.role); + expect(membership.accepted).toBe(expectedMembership.accepted); +} + +async function expectUserToBeAPartOfTeam({ + userId, + teamId, + expectedMembership, +}: { + userId: number; + teamId: number; + expectedMembership: { + role: MembershipRole; + accepted: boolean; + }; +}) { + const user = await prismock.user.findUnique({ + where: { + id: userId, + }, + include: { + teams: true, + }, + }); + if (!user) { + throw new Error(`User with id ${userId} does not exist`); + } + + const membership = user.teams.find( + (membership) => membership.teamId === teamId && membership.userId === userId + ); + + expect(membership).not.toBeNull(); + if (!membership) { + throw new Error(`User with id ${userId} is not a part of team with id ${teamId}`); + } + expect(membership.role).toBe(expectedMembership.role); + expect(membership.accepted).toBe(expectedMembership.accepted); +} + +async function expectUserToBeNotAPartOfTheOrg({ + userId, + orgId, + username, +}: { + userId: number; + orgId: number; + username: string; +}) { + const user = await prismock.user.findUnique({ + where: { + id: userId, + }, + include: { + teams: true, + }, + }); + if (!user) { + throw new Error(`User with id ${userId} does not exist`); + } + + expect(user.username).toBe(username); + expect(user.organizationId).toBe(null); + + const membership = user.teams.find( + (membership) => membership.teamId === orgId && membership.userId === userId + ); + + expect(membership).toBeUndefined(); +} + +async function expectTeamToBeAPartOfOrg({ teamId, orgId }: { teamId: number; orgId: number }) { + const migratedTeam = await prismock.team.findUnique({ + where: { + id: teamId, + }, + }); + if (!migratedTeam) { + throw new Error(`Team with id ${teamId} does not exist`); + } + + expect(migratedTeam.parentId).toBe(orgId); +} + +async function expectTeamToBeNotPartOfAnyOrganization({ teamId }: { teamId: number }) { + const team = await prismock.team.findUnique({ + where: { + id: teamId, + }, + }); + if (!team) { + throw new Error(`Team with id ${teamId} does not exist`); + } + + expect(team.parentId).toBe(null); +} + +async function expectUserRedirectToBeEnabled({ + from, + to, + orgSlug, +}: { + from: { username: string } | { teamSlug: string }; + to: string; + orgSlug: string; +}) { + expectRedirectToBeEnabled({ + from, + to, + orgSlug, + redirectType: RedirectType.User, + }); +} + +async function expectTeamRedirectToBeEnabled({ + from, + to, + orgSlug, +}: { + from: { username: string } | { teamSlug: string }; + to: string; + orgSlug: string; +}) { + expectRedirectToBeEnabled({ + from, + to, + orgSlug, + redirectType: RedirectType.Team, + }); +} + +async function expectUserRedirectToBeNotEnabled({ + from, +}: { + from: { username: string } | { teamSlug: string }; +}) { + expectRedirectToBeNotEnabled({ + from, + }); +} + +async function expectTeamRedirectToBeNotEnabled({ + from, +}: { + from: { username: string } | { teamSlug: string }; + to: string; + orgSlug: string; +}) { + expectRedirectToBeNotEnabled({ + from, + }); +} + +async function expectRedirectToBeEnabled({ + from, + to, + orgSlug, + redirectType, +}: { + from: { username: string } | { teamSlug: string }; + to: string; + orgSlug: string; + redirectType: RedirectType; +}) { + let tempOrgRedirectWhere = null; + let tempOrgRedirectThatShouldNotExistWhere = null; + if ("username" in from) { + tempOrgRedirectWhere = { + from_type_fromOrgId: { + from: from.username, + type: RedirectType.User, + fromOrgId: 0, + }, + }; + + // Normally with user migration `from.username != to` + if (from.username !== to) { + // There must not be a redirect setup from=To to something else as that would cause double redirection + tempOrgRedirectThatShouldNotExistWhere = { + from_type_fromOrgId: { + from: to, + type: RedirectType.User, + fromOrgId: 0, + }, + }; + } + } else if ("teamSlug" in from) { + tempOrgRedirectWhere = { + from_type_fromOrgId: { + from: from.teamSlug, + type: RedirectType.Team, + fromOrgId: 0, + }, + }; + + if (from.teamSlug !== to) { + // There must not be a redirect setup from=To to something else as that would cause double redirection + tempOrgRedirectThatShouldNotExistWhere = { + from_type_fromOrgId: { + from: to, + type: RedirectType.Team, + fromOrgId: 0, + }, + }; + } + } else { + throw new Error("Atleast one of userId or teamId should be present in from"); + } + const redirect = await prismock.tempOrgRedirect.findUnique({ + where: tempOrgRedirectWhere, + }); + + if (tempOrgRedirectThatShouldNotExistWhere) { + const redirectThatShouldntBeThere = await prismock.tempOrgRedirect.findUnique({ + where: tempOrgRedirectThatShouldNotExistWhere, + }); + expect(redirectThatShouldntBeThere).toBeNull(); + } + + expect(redirect).not.toBeNull(); + expect(redirect?.toUrl).toBe(`http://${orgSlug}.cal.local:3000/${to}`); + if (!redirect) { + throw new Error(`Redirect doesn't exist for ${JSON.stringify(tempOrgRedirectWhere)}`); + } + expect(redirect.type).toBe(redirectType); +} + +async function expectRedirectToBeNotEnabled({ from }: { from: { username: string } | { teamSlug: string } }) { + let tempOrgRedirectWhere = null; + if ("username" in from) { + tempOrgRedirectWhere = { + from_type_fromOrgId: { + from: from.username, + type: RedirectType.User, + fromOrgId: 0, + }, + }; + } else if ("teamSlug" in from) { + tempOrgRedirectWhere = { + from_type_fromOrgId: { + from: from.teamSlug, + type: RedirectType.Team, + fromOrgId: 0, + }, + }; + } else { + throw new Error("Atleast one of userId or teamId should be present in from"); + } + const redirect = await prismock.tempOrgRedirect.findUnique({ + where: tempOrgRedirectWhere, + }); + expect(redirect).toBeNull(); +} + +async function createOrg( + data: Omit & { + metadata?: z.infer; + } +) { + return await prismock.team.create({ + data: { + ...data, + metadata: { + ...(data.metadata || {}), + isOrganization: true, + }, + }, + }); +} + +async function createTeamOutsideOrg( + data: Omit & { + metadata?: z.infer; + } +) { + return await prismock.team.create({ + data: { + ...data, + parentId: null, + metadata: { + ...(data.metadata || {}), + isOrganization: false, + }, + }, + }); +} + +async function createUserOutsideOrg(data: Omit) { + return await prismock.user.create({ + data: { + ...data, + organizationId: null, + }, + }); +} + +async function createUserInsideTheOrg( + data: Omit, + orgId: number +) { + const org = await prismock.team.findUnique({ + where: { + id: orgId, + }, + }); + if (!org) { + throw new Error(`Org with id ${orgId} does not exist`); + } + return await prismock.user.create({ + data: { + ...data, + organization: { + connect: { + id: orgId, + }, + }, + }, + }); +} + +async function addMemberShipOfUserWithTeam({ + teamId, + userId, + role, + accepted, +}: { + teamId: number; + userId: number; + role: MembershipRole; + accepted: boolean; +}) { + await prismock.membership.create({ + data: { + role, + accepted, + team: { + connect: { + id: teamId, + }, + }, + user: { + connect: { + id: userId, + }, + }, + }, + }); + + const membership = await prismock.membership.findUnique({ + where: { + userId_teamId: { + teamId, + userId, + }, + }, + }); + if (!membership) { + throw new Error(`Membership between teamId ${teamId} and userId ${userId} couldn't be created`); + } +} + +const addMemberShipOfUserWithOrg = addMemberShipOfUserWithTeam; diff --git a/apps/web/lib/orgMigration.ts b/apps/web/lib/orgMigration.ts new file mode 100644 index 0000000000..5a8b4d1fdf --- /dev/null +++ b/apps/web/lib/orgMigration.ts @@ -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, + }, + }); +} diff --git a/apps/web/pages/api/orgMigration/moveTeamToOrg.ts b/apps/web/pages/api/orgMigration/moveTeamToOrg.ts new file mode 100644 index 0000000000..bed2778fba --- /dev/null +++ b/apps/web/pages/api/orgMigration/moveTeamToOrg.ts @@ -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" + }`, + }); +} diff --git a/apps/web/pages/api/orgMigration/moveUserToOrg.ts b/apps/web/pages/api/orgMigration/moveUserToOrg.ts new file mode 100644 index 0000000000..82b299c5b8 --- /dev/null +++ b/apps/web/pages/api/orgMigration/moveUserToOrg.ts @@ -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) }); +} diff --git a/apps/web/pages/api/orgMigration/removeTeamFromOrg.ts b/apps/web/pages/api/orgMigration/removeTeamFromOrg.ts new file mode 100644 index 0000000000..fc4a88e4bb --- /dev/null +++ b/apps/web/pages/api/orgMigration/removeTeamFromOrg.ts @@ -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}` }); +} diff --git a/apps/web/pages/api/orgMigration/removeUserFromOrg.ts b/apps/web/pages/api/orgMigration/removeUserFromOrg.ts new file mode 100644 index 0000000000..ce48fc07e9 --- /dev/null +++ b/apps/web/pages/api/orgMigration/removeUserFromOrg.ts @@ -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) }); +} diff --git a/apps/web/pages/settings/admin/orgMigrations/_OrgMigrationLayout.tsx b/apps/web/pages/settings/admin/orgMigrations/_OrgMigrationLayout.tsx new file mode 100644 index 0000000000..cf51a4a792 --- /dev/null +++ b/apps/web/pages/settings/admin/orgMigrations/_OrgMigrationLayout.tsx @@ -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( +
+ + {children} +
+ ); +} +export const getLayout = (page: React.ReactElement) => { + return {page}; +}; diff --git a/apps/web/pages/settings/admin/orgMigrations/moveTeamToOrg.tsx b/apps/web/pages/settings/admin/orgMigrations/moveTeamToOrg.tsx new file mode 100644 index 0000000000..4ed978df88 --- /dev/null +++ b/apps/web/pages/settings/admin/orgMigrations/moveTeamToOrg.tsx @@ -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 ( +
+ + {children} +
+ ); +} + +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 ( + +
{ + 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); + } + }}> +
+ + +
+ ( + { + onChange(option?.value === "true"); + }} + value={moveUsersOptions.find((opt) => opt.value === value)} + options={moveUsersOptions} + /> + )} + /> + + {moveMembers === true ? ( +
Members of the team will also be moved to the organization
+ ) : moveMembers === false ? ( +
Members of the team will not be moved to the organization
+ ) : null} +
+
+ +
+
+ ); +} + +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; diff --git a/apps/web/pages/settings/admin/orgMigrations/moveUserToOrg.tsx b/apps/web/pages/settings/admin/orgMigrations/moveUserToOrg.tsx new file mode 100644 index 0000000000..784152caa7 --- /dev/null +++ b/apps/web/pages/settings/admin/orgMigrations/moveUserToOrg.tsx @@ -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 ( +
+ + {children} +
+ ); +} + +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 ( + + {/* 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 */} +
{ + 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); + } + }}> +
+ + ( + { + if (!option) return; + onChange(option.value); + }} + value={roles.find((role) => role.value === value)} + required + placeholder="Enter userId" + /> + )} + /> + + + ( + { + if (!option) return; + onChange(option.value === "true"); + }} + value={moveTeamsOptions.find((opt) => opt.value === value)} + required + options={moveTeamsOptions} + /> + )} + /> +
+ + +
+
+ ); +} + +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; diff --git a/apps/web/pages/settings/admin/orgMigrations/removeTeamFromOrg.tsx b/apps/web/pages/settings/admin/orgMigrations/removeTeamFromOrg.tsx new file mode 100644 index 0000000000..17a90f115a --- /dev/null +++ b/apps/web/pages/settings/admin/orgMigrations/removeTeamFromOrg.tsx @@ -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 ( +
+ + {children} +
+ ); +} + +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 ( + + {/* 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 */} +
{ + 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); + } + }}> +
+ + +
+ +
+
+ ); +} + +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; diff --git a/apps/web/pages/settings/admin/orgMigrations/removeUserFromOrg.tsx b/apps/web/pages/settings/admin/orgMigrations/removeUserFromOrg.tsx new file mode 100644 index 0000000000..ef7710ed37 --- /dev/null +++ b/apps/web/pages/settings/admin/orgMigrations/removeUserFromOrg.tsx @@ -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 ( +
+ + {children} +
+ ); +} + +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 ( + + {/* 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 */} +
{ + 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); + } + }}> +
+ + +
+ +
+
+ ); +} + +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; diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 99db8fd19c..484ef740ed 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -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; export const coerceToDate = z.coerce.date(); +export const getStringAsNumberRequiredSchema = (t: TFunction) => + z.string().min(1, t("error_required_field")).pipe(z.coerce.number());