From d3fcb8bf7dc58450e305aa638365a2e36f3539fe Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Thu, 21 Jul 2022 18:02:20 +0100 Subject: [PATCH] Team Impersonation (#3450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Team Impersonation * Refactor - Disable Imeprsonate button * Change copy * Add .env toggle * Fix eslint * Update role selection Co-authored-by: Omar López * New 'admin' seed user, improve flow ImpersonationProvider * Fix impersonation string * Adds fullstop Co-authored-by: Sean Brydon Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: Omar López Co-authored-by: Alex van Andel Co-authored-by: Peer Richelsen --- .env.example | 3 + .../team/DisableTeamImpersonation.tsx | 63 ++++++++ apps/web/components/team/MemberListItem.tsx | 55 ++++++- .../impersonation/ImpersonationProvider.ts | 138 +++++++++++++----- apps/web/lib/queries/teams/index.ts | 1 + apps/web/pages/settings/teams/[id]/index.tsx | 4 + apps/web/public/static/locales/en/common.json | 1 + apps/web/server/routers/viewer/teams.tsx | 51 +++++++ .../migration.sql | 2 + packages/prisma/schema.prisma | 13 +- packages/prisma/seed.ts | 16 +- 11 files changed, 305 insertions(+), 42 deletions(-) create mode 100644 apps/web/components/team/DisableTeamImpersonation.tsx create mode 100644 packages/prisma/migrations/20220719144253_disabled_impersonation_teams/migration.sql diff --git a/.env.example b/.env.example index d2da9e421c..f45725ad14 100644 --- a/.env.example +++ b/.env.example @@ -127,3 +127,6 @@ EMAIL_SERVER_PASSWORD='' ## @see https://support.google.com/accounts/answer/185833 # EMAIL_SERVER_PASSWORD='' # ********************************************************************************************************** + +# Set the following value to true if you wish to enable Team Impersonation +NEXT_PUBLIC_TEAM_IMPERSONATION=false diff --git a/apps/web/components/team/DisableTeamImpersonation.tsx b/apps/web/components/team/DisableTeamImpersonation.tsx new file mode 100644 index 0000000000..1e1065f8b0 --- /dev/null +++ b/apps/web/components/team/DisableTeamImpersonation.tsx @@ -0,0 +1,63 @@ +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import showToast from "@calcom/lib/notification"; +import Button from "@calcom/ui/Button"; + +import { trpc } from "@lib/trpc"; + +import Badge from "@components/ui/Badge"; + +const DisableTeamImpersonation = ({ teamId, memberId }: { teamId: number; memberId: number }) => { + const { t } = useLocale(); + + const utils = trpc.useContext(); + + const query = trpc.useQuery(["viewer.teams.getMembershipbyUser", { teamId, memberId }]); + + const mutation = trpc.useMutation("viewer.teams.updateMembership", { + onSuccess: async () => { + showToast(t("your_user_profile_updated_successfully"), "success"); + await utils.invalidateQueries(["viewer.teams.getMembershipbyUser"]); + }, + async onSettled() { + await utils.invalidateQueries(["viewer.public.i18n"]); + }, + }); + if (query.isLoading) return <>; + + return ( + <> +

{t("settings")}

+
+
+
+
+

+ {t("user_impersonation_heading")} +

+ + {!query.data?.disableImpersonation ? t("enabled") : t("disabled")} + +
+

{t("team_impersonation_description")}

+
+
+ +
+
+
+ + ); +}; + +export default DisableTeamImpersonation; diff --git a/apps/web/components/team/MemberListItem.tsx b/apps/web/components/team/MemberListItem.tsx index f77e022228..952308e3bd 100644 --- a/apps/web/components/team/MemberListItem.tsx +++ b/apps/web/components/team/MemberListItem.tsx @@ -1,6 +1,7 @@ -import { PencilIcon, UserRemoveIcon } from "@heroicons/react/outline"; +import { LockClosedIcon, PencilIcon, UserRemoveIcon } from "@heroicons/react/outline"; import { ClockIcon, DotsHorizontalIcon, ExternalLinkIcon } from "@heroicons/react/solid"; import { MembershipRole } from "@prisma/client"; +import { signIn } from "next-auth/react"; import Link from "next/link"; import { useState } from "react"; @@ -39,6 +40,7 @@ export default function MemberListItem(props: Props) { const utils = trpc.useContext(); const [showChangeMemberRoleModal, setShowChangeMemberRoleModal] = useState(false); const [showTeamAvailabilityModal, setShowTeamAvailabilityModal] = useState(false); + const [showImpersonateModal, setShowImpersonateModal] = useState(false); const removeMemberMutation = trpc.useMutation("viewer.teams.removeMember", { async onSuccess() { @@ -147,6 +149,24 @@ export default function MemberListItem(props: Props) { + {/* Only show impersonate box if - The user has impersonation enabled, + They have accepted the team invite, and it is enabled for this instance */} + {!props.member.disableImpersonation && + props.member.accepted && + process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "true" && ( + <> + + + + + + )} @@ -185,6 +205,39 @@ export default function MemberListItem(props: Props) { onExit={() => setShowChangeMemberRoleModal(false)} /> )} + {showImpersonateModal && props.member.username && ( + setShowImpersonateModal(false)}> + <> +
+
+ +
+
+
{ + e.preventDefault(); + await signIn("impersonation-auth", { + username: props.member.username, + teamId: props.team.id, + }); + }}> +

+ {t("impersonate_user_tip")} +

+
+ + +
+
+ +
+ )} {showTeamAvailabilityModal && ( , + impersonatedByUID: number +) => { + // Log impersonations for audit purposes + await prisma.impersonations.create({ + data: { + impersonatedBy: { + connect: { + id: impersonatedByUID, + }, + }, + impersonatedUser: { + connect: { + id: impersonatedUser.id, + }, + }, + }, + }); + + const obj = { + id: impersonatedUser.id, + username: impersonatedUser.username, + email: impersonatedUser.email, + name: impersonatedUser.name, + role: impersonatedUser.role, + impersonatedByUID, + }; + + return obj; +}; + const ImpersonationProvider = CredentialsProvider({ id: "impersonation-auth", name: "Impersonation", type: "credentials", credentials: { - username: { label: "Username", type: "text " }, + username: { type: "text" }, + teamId: { type: "text" }, }, async authorize(creds, req) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore need to figure out how to correctly type this const session = await getSession({ req }); - if (session?.user.role !== "ADMIN") { - throw new Error("You do not have permission to do this."); - } + const teamId = creds?.teamId ? asNumberOrThrow(creds.teamId) : undefined; if (session?.user.username === creds?.username) { throw new Error("You cannot impersonate yourself."); } - const user = await prisma.user.findUnique({ + if (!creds?.username) throw new Error("Username must be present"); + // If you are an ADMIN we return way before team impersonation logic is executed, so NEXT_PUBLIC_TEAM_IMPERSONATION certainly true + if (session?.user.role !== "ADMIN" && process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "false") { + throw new Error("You do not have permission to do this."); + } + + // Get user who is being impersonated + const impersonatedUser = await prisma.user.findUnique({ where: { username: creds?.username, }, - }); - - if (!user) { - throw new Error("This user does not exist"); - } - - if (user.disableImpersonation) { - throw new Error("This user has disabled Impersonation."); - } - - // Log impersonations for audit purposes - await prisma.impersonations.create({ - data: { - impersonatedBy: { - connect: { - id: session.user.id, + select: { + id: true, + username: true, + role: true, + name: true, + email: true, + disableImpersonation: true, + teams: { + where: { + disableImpersonation: false, // Ensure they have impersonation enabled + accepted: true, // Ensure they are apart of the team and not just invited. + team: { + id: teamId, // Bring back only the right team + }, }, - }, - impersonatedUser: { - connect: { - id: user.id, + select: { + teamId: true, + disableImpersonation: true, }, }, }, }); - const obj = { - id: user.id, - username: user.username, - email: user.email, - name: user.name, - role: user.role, - impersonatedByUID: session?.user.id, - }; - return obj; + // Check if impersonating is allowed for this user + if (!impersonatedUser) { + throw new Error("This user does not exist"); + } + + if (session?.user.role === "ADMIN") { + if (impersonatedUser.disableImpersonation) { + throw new Error("This user has disabled Impersonation."); + } + return auditAndReturnNextUser(impersonatedUser, session?.user.id as number); + } + + // Check session + const sessionUserFromDb = await prisma.user.findUnique({ + where: { + id: session?.user.id, + }, + include: { + teams: { + where: { + AND: [ + { + role: { + in: ["ADMIN", "OWNER"], + }, + }, + { + team: { + id: teamId, + }, + }, + ], + }, + }, + }, + }); + + if (sessionUserFromDb?.teams.length === 0 || impersonatedUser.teams.length === 0) { + throw new Error("You do not have permission to do this."); + } + + return auditAndReturnNextUser(impersonatedUser, session?.user.id as number); }, }); diff --git a/apps/web/lib/queries/teams/index.ts b/apps/web/lib/queries/teams/index.ts index b60081a0da..7b3c2f3932 100644 --- a/apps/web/lib/queries/teams/index.ts +++ b/apps/web/lib/queries/teams/index.ts @@ -63,6 +63,7 @@ export async function getTeamWithMembers(id?: number, slug?: string) { isMissingSeat: obj.user.plan === UserPlan.FREE, role: membership?.role, accepted: membership?.accepted, + disableImpersonation: membership?.disableImpersonation, }; }); diff --git a/apps/web/pages/settings/teams/[id]/index.tsx b/apps/web/pages/settings/teams/[id]/index.tsx index 2f1a4d417e..7032bd7568 100644 --- a/apps/web/pages/settings/teams/[id]/index.tsx +++ b/apps/web/pages/settings/teams/[id]/index.tsx @@ -11,10 +11,12 @@ import SAMLConfiguration from "@ee/components/saml/Configuration"; import { QueryCell } from "@lib/QueryCell"; import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar"; +import useCurrentUserId from "@lib/hooks/useCurrentUserId"; import { useLocale } from "@lib/hooks/useLocale"; import { trpc } from "@lib/trpc"; import Shell from "@components/Shell"; +import DisableTeamImpersonation from "@components/team/DisableTeamImpersonation"; import MemberInvitationModal from "@components/team/MemberInvitationModal"; import MemberList from "@components/team/MemberList"; import TeamSettings from "@components/team/TeamSettings"; @@ -25,6 +27,7 @@ import Avatar from "@components/ui/Avatar"; export function TeamSettingsPage() { const { t } = useLocale(); const router = useRouter(); + const userId = useCurrentUserId(); const upgraded = router.query.upgraded as string; @@ -165,6 +168,7 @@ export function TeamSettingsPage() { {isAdmin && } + {userId && }
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index a0c35c80ac..62881b9158 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -838,6 +838,7 @@ "impersonate": "Impersonate", "user_impersonation_heading": "User Impersonation", "user_impersonation_description": "Allows our support team to temporarily sign in as you to help us quickly resolve any issues you report to us.", + "team_impersonation_description": "Allows your team admins to temporarily sign in as you.", "impersonate_user_tip": "All uses of this feature is audited.", "impersonating_user_warning": "Impersonating username \"{{user}}\".", "impersonating_stop_instructions": "<0>Click Here to stop.", diff --git a/apps/web/server/routers/viewer/teams.tsx b/apps/web/server/routers/viewer/teams.tsx index 7a32ae2c98..f6e7238669 100644 --- a/apps/web/server/routers/viewer/teams.tsx +++ b/apps/web/server/routers/viewer/teams.tsx @@ -1,5 +1,6 @@ import { MembershipRole, Prisma, UserPlan } from "@prisma/client"; import { randomBytes } from "crypto"; +import { resolve } from "path"; import { z } from "zod"; import { getUserAvailability } from "@calcom/core/getUserAvailability"; @@ -460,4 +461,54 @@ export const viewerTeamsRouter = createProtectedRouter() async resolve({ ctx, input }) { return await ensureSubscriptionQuantityCorrectness(ctx.user.id, input.teamId); }, + }) + .query("getMembershipbyUser", { + input: z.object({ + teamId: z.number(), + memberId: z.number(), + }), + async resolve({ ctx, input }) { + if (ctx.user.id !== input.memberId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You cannot view memberships that are not your own.", + }); + } + + return await ctx.prisma.membership.findUnique({ + where: { + userId_teamId: { + userId: input.memberId, + teamId: input.teamId, + }, + }, + }); + }, + }) + .mutation("updateMembership", { + input: z.object({ + teamId: z.number(), + memberId: z.number(), + disableImpersonation: z.boolean(), + }), + async resolve({ ctx, input }) { + if (ctx.user.id !== input.memberId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You cannot edit memberships that are not your own.", + }); + } + + return await ctx.prisma.membership.update({ + where: { + userId_teamId: { + userId: input.memberId, + teamId: input.teamId, + }, + }, + data: { + disableImpersonation: input.disableImpersonation, + }, + }); + }, }); diff --git a/packages/prisma/migrations/20220719144253_disabled_impersonation_teams/migration.sql b/packages/prisma/migrations/20220719144253_disabled_impersonation_teams/migration.sql new file mode 100644 index 0000000000..9a38e15bf7 --- /dev/null +++ b/packages/prisma/migrations/20220719144253_disabled_impersonation_teams/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Membership" ADD COLUMN "disableImpersonation" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 6e1ea1a6b5..752857ef33 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -206,12 +206,13 @@ enum MembershipRole { } model Membership { - teamId Int - userId Int - accepted Boolean @default(false) - role MembershipRole - team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + teamId Int + userId Int + accepted Boolean @default(false) + role MembershipRole + team Team @relation(fields: [teamId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + disableImpersonation Boolean @default(false) @@id([userId, teamId]) } diff --git a/packages/prisma/seed.ts b/packages/prisma/seed.ts index 30c7991f1f..812bf9fd10 100644 --- a/packages/prisma/seed.ts +++ b/packages/prisma/seed.ts @@ -1,4 +1,4 @@ -import { BookingStatus, MembershipRole, Prisma, UserPlan } from "@prisma/client"; +import { BookingStatus, MembershipRole, Prisma, UserPermissionRole, UserPlan } from "@prisma/client"; import { uuid } from "short-uuid"; import dayjs from "@calcom/dayjs"; @@ -18,6 +18,7 @@ async function createUserAndEventType(opts: { name: string; completedOnboarding?: boolean; timeZone?: string; + role?: UserPermissionRole; }; eventTypes: Array< Prisma.EventTypeCreateInput & { @@ -468,6 +469,18 @@ async function main() { eventTypes: [], }); + await createUserAndEventType({ + user: { + email: "admin@example.com", + password: "admin", + username: "admin", + name: "Admin Example", + plan: "PRO", + role: "ADMIN", + }, + eventTypes: [], + }); + const pro2UserTeam = await createUserAndEventType({ user: { email: "teampro2@example.com", @@ -536,6 +549,7 @@ async function main() { { id: pro2UserTeam.id, username: pro2UserTeam.name || "Unknown", + role: "MEMBER", }, { id: pro3UserTeam.id,