Feat/impersonate users (#2503)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
		
							parent
							
								
									9fffaa20a2
								
							
						
					
					
						commit
						6d5db1cb3a
					
				
					 13 changed files with 285 additions and 34 deletions
				
			
		|  | @ -1,29 +1,31 @@ | |||
| import { AdminRequired } from "components/ui/AdminRequired"; | ||||
| import Link, { LinkProps } from "next/link"; | ||||
| import { useRouter } from "next/router"; | ||||
| import React, { ElementType, FC } from "react"; | ||||
| import React, { ElementType, FC, Fragment } from "react"; | ||||
| 
 | ||||
| import classNames from "@lib/classNames"; | ||||
| 
 | ||||
| interface Props { | ||||
| export interface NavTabProps { | ||||
|   tabs: { | ||||
|     name: string; | ||||
|     href: string; | ||||
|     icon?: ElementType; | ||||
|     adminRequired?: boolean; | ||||
|   }[]; | ||||
|   linkProps?: Omit<LinkProps, "href">; | ||||
| } | ||||
| 
 | ||||
| const NavTabs: FC<Props> = ({ tabs, linkProps }) => { | ||||
| const NavTabs: FC<NavTabProps> = ({ tabs, linkProps }) => { | ||||
|   const router = useRouter(); | ||||
|   return ( | ||||
|     <> | ||||
|       <nav | ||||
|         className="-mb-px flex space-x-2 space-x-5 rtl:space-x-reverse sm:rtl:space-x-reverse" | ||||
|         aria-label="Tabs"> | ||||
|       <nav className="-mb-px flex space-x-5 rtl:space-x-reverse sm:rtl:space-x-reverse" aria-label="Tabs"> | ||||
|         {tabs.map((tab) => { | ||||
|           const isCurrent = router.asPath === tab.href; | ||||
|           const Component = tab.adminRequired ? AdminRequired : Fragment; | ||||
|           return ( | ||||
|             <Link key={tab.name} href={tab.href} {...linkProps}> | ||||
|             <Component key={tab.name}> | ||||
|               <Link href={tab.href} {...linkProps}> | ||||
|                 <a | ||||
|                   className={classNames( | ||||
|                     isCurrent | ||||
|  | @ -44,6 +46,7 @@ const NavTabs: FC<Props> = ({ tabs, linkProps }) => { | |||
|                   <span>{tab.name}</span> | ||||
|                 </a> | ||||
|               </Link> | ||||
|             </Component> | ||||
|           ); | ||||
|         })} | ||||
|       </nav> | ||||
|  |  | |||
|  | @ -1,9 +1,9 @@ | |||
| import { CreditCardIcon, KeyIcon, UserGroupIcon, UserIcon } from "@heroicons/react/solid"; | ||||
| import { CreditCardIcon, KeyIcon, LockClosedIcon, UserGroupIcon, UserIcon } from "@heroicons/react/solid"; | ||||
| import React from "react"; | ||||
| 
 | ||||
| import { useLocale } from "@lib/hooks/useLocale"; | ||||
| import { useLocale } from "@calcom/lib/hooks/useLocale"; | ||||
| 
 | ||||
| import NavTabs from "./NavTabs"; | ||||
| import NavTabs, { NavTabProps } from "./NavTabs"; | ||||
| 
 | ||||
| export default function SettingsShell({ children }: { children: React.ReactNode }) { | ||||
|   const { t } = useLocale(); | ||||
|  | @ -29,6 +29,12 @@ export default function SettingsShell({ children }: { children: React.ReactNode | |||
|       href: "/settings/billing", | ||||
|       icon: CreditCardIcon, | ||||
|     }, | ||||
|     { | ||||
|       name: t("admin"), | ||||
|       href: "/settings/admin", | ||||
|       icon: LockClosedIcon, | ||||
|       adminRequired: true, | ||||
|     }, | ||||
|   ]; | ||||
| 
 | ||||
|   return ( | ||||
|  |  | |||
|  | @ -15,7 +15,7 @@ import { SessionContextValue, signOut, useSession } from "next-auth/react"; | |||
| import Link from "next/link"; | ||||
| import { useRouter } from "next/router"; | ||||
| import React, { Fragment, ReactNode, useEffect } from "react"; | ||||
| import { Toaster } from "react-hot-toast"; | ||||
| import toast, { Toaster } from "react-hot-toast"; | ||||
| 
 | ||||
| import { useIsEmbed } from "@calcom/embed-core"; | ||||
| import { useLocale } from "@calcom/lib/hooks/useLocale"; | ||||
|  | @ -40,6 +40,7 @@ import { trpc } from "@lib/trpc"; | |||
| import CustomBranding from "@components/CustomBranding"; | ||||
| import Loader from "@components/Loader"; | ||||
| import { HeadSeo } from "@components/seo/head-seo"; | ||||
| import ImpersonatingBanner from "@components/ui/ImpersonatingBanner"; | ||||
| 
 | ||||
| import pkg from "../package.json"; | ||||
| import { useViewerI18n } from "./I18nLanguageHandler"; | ||||
|  | @ -128,6 +129,7 @@ const Layout = ({ | |||
| }: LayoutProps & { status: SessionContextValue["status"]; plan?: UserPlan; isLoading: boolean }) => { | ||||
|   const isEmbed = useIsEmbed(); | ||||
|   const router = useRouter(); | ||||
| 
 | ||||
|   const { t } = useLocale(); | ||||
|   const navigation = [ | ||||
|     { | ||||
|  | @ -311,6 +313,7 @@ const Layout = ({ | |||
|                 props.flexChildrenContainer && "flex flex-1 flex-col", | ||||
|                 !props.large && "py-8" | ||||
|               )}> | ||||
|               <ImpersonatingBanner /> | ||||
|               {!!props.backPath && ( | ||||
|                 <div className="mx-3 mb-8 sm:mx-8"> | ||||
|                   <Button | ||||
|  |  | |||
							
								
								
									
										14
									
								
								apps/web/components/ui/AdminRequired.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								apps/web/components/ui/AdminRequired.tsx
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,14 @@ | |||
| import { useSession } from "next-auth/react"; | ||||
| import { FC, Fragment } from "react"; | ||||
| 
 | ||||
| type AdminRequiredProps = { | ||||
|   as?: keyof JSX.IntrinsicElements; | ||||
| }; | ||||
| 
 | ||||
| export const AdminRequired: FC<AdminRequiredProps> = ({ children, as, ...rest }) => { | ||||
|   const session = useSession(); | ||||
| 
 | ||||
|   if (session.data?.user.role !== "ADMIN") return null; | ||||
|   const Component = as ?? Fragment; | ||||
|   return <Component {...rest}>{children}</Component>; | ||||
| }; | ||||
							
								
								
									
										34
									
								
								apps/web/components/ui/ImpersonatingBanner.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								apps/web/components/ui/ImpersonatingBanner.tsx
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,34 @@ | |||
| import { useSession } from "next-auth/react"; | ||||
| import { Trans } from "next-i18next"; | ||||
| 
 | ||||
| import { useLocale } from "@calcom/lib/hooks/useLocale"; | ||||
| import { Alert } from "@calcom/ui/Alert"; | ||||
| 
 | ||||
| type Props = {}; | ||||
| 
 | ||||
| function ImpersonatingBanner({}: Props) { | ||||
|   const { t } = useLocale(); | ||||
|   const { data } = useSession(); | ||||
| 
 | ||||
|   if (!data?.user.impersonatedByUID) return null; | ||||
| 
 | ||||
|   return ( | ||||
|     <Alert | ||||
|       severity="warning" | ||||
|       title={ | ||||
|         <> | ||||
|           {t("impersonating_user_warning", { user: data.user.username })}{" "} | ||||
|           <Trans i18nKey="impersonating_stop_instructions"> | ||||
|             <a href="/auth/logout" className="underline"> | ||||
|               Click Here To stop | ||||
|             </a> | ||||
|             . | ||||
|           </Trans> | ||||
|         </> | ||||
|       } | ||||
|       className="mx-4 mb-2 sm:mx-6  md:mx-8" | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export default ImpersonatingBanner; | ||||
							
								
								
									
										62
									
								
								apps/web/ee/lib/impersonation/ImpersonationProvider.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								apps/web/ee/lib/impersonation/ImpersonationProvider.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,62 @@ | |||
| import CredentialsProvider from "next-auth/providers/credentials"; | ||||
| import { getSession } from "next-auth/react"; | ||||
| 
 | ||||
| import prisma from "@lib/prisma"; | ||||
| 
 | ||||
| const ImpersonationProvider = CredentialsProvider({ | ||||
|   id: "impersonation-auth", | ||||
|   name: "Impersonation", | ||||
|   type: "credentials", | ||||
|   credentials: { | ||||
|     username: { label: "Username", type: "text " }, | ||||
|   }, | ||||
|   async authorize(creds, req) { | ||||
|     // @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."); | ||||
|     } | ||||
| 
 | ||||
|     if (session?.user.username === creds?.username) { | ||||
|       throw new Error("You cannot impersonate yourself."); | ||||
|     } | ||||
| 
 | ||||
|     const user = await prisma.user.findUnique({ | ||||
|       where: { | ||||
|         username: creds?.username, | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     if (!user) { | ||||
|       throw new Error("This user does not exist"); | ||||
|     } | ||||
| 
 | ||||
|     // Log impersonations for audit purposes
 | ||||
|     await prisma.impersonations.create({ | ||||
|       data: { | ||||
|         impersonatedBy: { | ||||
|           connect: { | ||||
|             id: session.user.id, | ||||
|           }, | ||||
|         }, | ||||
|         impersonatedUser: { | ||||
|           connect: { | ||||
|             id: user.id, | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     const obj = { | ||||
|       id: user.id, | ||||
|       username: user.username, | ||||
|       email: user.email, | ||||
|       name: user.name, | ||||
|       role: user.role, | ||||
|       impersonatedByUID: session?.user.id, | ||||
|     }; | ||||
|     return obj; | ||||
|   }, | ||||
| }); | ||||
| 
 | ||||
| export default ImpersonationProvider; | ||||
|  | @ -1,5 +1,5 @@ | |||
| import { PrismaAdapter } from "@next-auth/prisma-adapter"; | ||||
| import { IdentityProvider } from "@prisma/client"; | ||||
| import { IdentityProvider, UserPermissionRole } from "@prisma/client"; | ||||
| import { readFileSync } from "fs"; | ||||
| import Handlebars from "handlebars"; | ||||
| import NextAuth, { Session } from "next-auth"; | ||||
|  | @ -15,6 +15,7 @@ import { WEBSITE_URL } from "@calcom/lib/constants"; | |||
| import { symmetricDecrypt } from "@calcom/lib/crypto"; | ||||
| import { defaultCookies } from "@calcom/lib/default-cookies"; | ||||
| import { serverConfig } from "@calcom/lib/serverConfig"; | ||||
| import ImpersonationProvider from "@ee/lib/impersonation/ImpersonationProvider"; | ||||
| 
 | ||||
| import { ErrorCode, verifyPassword } from "@lib/auth"; | ||||
| import prisma from "@lib/prisma"; | ||||
|  | @ -103,9 +104,11 @@ const providers: Provider[] = [ | |||
|         username: user.username, | ||||
|         email: user.email, | ||||
|         name: user.name, | ||||
|         role: user.role, | ||||
|       }; | ||||
|     }, | ||||
|   }), | ||||
|   ImpersonationProvider, | ||||
| ]; | ||||
| 
 | ||||
| if (IS_GOOGLE_LOGIN_ENABLED) { | ||||
|  | @ -213,6 +216,8 @@ export default NextAuth({ | |||
|             username: existingUser.username, | ||||
|             name: existingUser.name, | ||||
|             email: existingUser.email, | ||||
|             role: existingUser.role, | ||||
|             impersonatedByUID: token?.impersonatedByUID as number, | ||||
|           }; | ||||
|         } | ||||
| 
 | ||||
|  | @ -229,6 +234,8 @@ export default NextAuth({ | |||
|           name: user.name, | ||||
|           username: user.username, | ||||
|           email: user.email, | ||||
|           role: user.role, | ||||
|           impersonatedByUID: user?.impersonatedByUID as number, | ||||
|         }; | ||||
|       } | ||||
| 
 | ||||
|  | @ -262,6 +269,8 @@ export default NextAuth({ | |||
|           name: existingUser.name, | ||||
|           username: existingUser.username, | ||||
|           email: existingUser.email, | ||||
|           role: existingUser.role, | ||||
|           impersonatedByUID: token.impersonatedByUID as number, | ||||
|         }; | ||||
|       } | ||||
| 
 | ||||
|  | @ -275,6 +284,8 @@ export default NextAuth({ | |||
|           id: token.id as number, | ||||
|           name: token.name, | ||||
|           username: token.username as string, | ||||
|           role: token.role as UserPermissionRole, | ||||
|           impersonatedByUID: token.impersonatedByUID as number, | ||||
|         }, | ||||
|       }; | ||||
|       return calendsoSession; | ||||
|  |  | |||
							
								
								
									
										73
									
								
								apps/web/pages/settings/admin.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								apps/web/pages/settings/admin.tsx
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,73 @@ | |||
| import { GetServerSidePropsContext } from "next"; | ||||
| import { signIn } from "next-auth/react"; | ||||
| import { useRef } from "react"; | ||||
| 
 | ||||
| import { useLocale } from "@calcom/lib/hooks/useLocale"; | ||||
| import Button from "@calcom/ui/Button"; | ||||
| import { TextField } from "@calcom/ui/form/fields"; | ||||
| 
 | ||||
| import { getSession } from "@lib/auth"; | ||||
| 
 | ||||
| import SettingsShell from "@components/SettingsShell"; | ||||
| import Shell from "@components/Shell"; | ||||
| 
 | ||||
| function AdminView() { | ||||
|   const { t } = useLocale(); | ||||
| 
 | ||||
|   const usernameRef = useRef<HTMLInputElement>(null!); | ||||
| 
 | ||||
|   return ( | ||||
|     <div className="divide-y divide-gray-200 lg:col-span-9"> | ||||
|       <div className="py-6 lg:pb-8"> | ||||
|         <form | ||||
|           className="mb-6 w-full sm:w-1/2" | ||||
|           onSubmit={(e) => { | ||||
|             e.preventDefault(); | ||||
|             const enteredUsername = usernameRef.current.value.toLowerCase(); | ||||
|             signIn("impersonation-auth", { username: enteredUsername }).then((res) => { | ||||
|               console.log(res); | ||||
|             }); | ||||
|           }}> | ||||
|           <TextField | ||||
|             name="Impersonate User" | ||||
|             addOnLeading={ | ||||
|               <span className="inline-flex items-center rounded-l-sm border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500"> | ||||
|                 {process.env.NEXT_PUBLIC_WEBSITE_URL}/ | ||||
|               </span> | ||||
|             } | ||||
|             ref={usernameRef} | ||||
|             defaultValue={undefined} | ||||
|           /> | ||||
|           <p className="mt-2 text-sm text-gray-500" id="email-description"> | ||||
|             {t("impersonate_user_tip")} | ||||
|           </p> | ||||
|           <div className="flex justify-end py-4"> | ||||
|             <Button type="submit">{t("impersonate")}</Button> | ||||
|           </div> | ||||
|         </form> | ||||
|       </div> | ||||
|       <hr className="mt-8" /> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export default function Admin() { | ||||
|   const { t } = useLocale(); | ||||
| 
 | ||||
|   return ( | ||||
|     <Shell heading={t("profile")} subtitle={t("edit_profile_info_description")}> | ||||
|       <SettingsShell> | ||||
|         <AdminView /> | ||||
|       </SettingsShell> | ||||
|     </Shell> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export const getServerSideProps = async (context: GetServerSidePropsContext) => { | ||||
|   const session = await getSession(context); | ||||
| 
 | ||||
|   if (!session?.user?.id || session.user.role !== "ADMIN") { | ||||
|     return { redirect: { permanent: false, destination: "/settings/profile" } }; | ||||
|   } | ||||
|   return { props: {} }; | ||||
| }; | ||||
|  | @ -763,5 +763,9 @@ | |||
|   "send_reschedule_request": "Request reschedule ", | ||||
|   "edit_booking": "Edit booking", | ||||
|   "reschedule_booking": "Reschedule booking", | ||||
|   "former_time": "Former time" | ||||
|   "former_time": "Former time", | ||||
|   "impersonate":"Impersonate", | ||||
|   "impersonate_user_tip":"All uses of this feature is audited.", | ||||
|   "impersonating_user_warning":"Impersonating username \"{{user}}\".", | ||||
|   "impersonating_stop_instructions": "<0>Click Here to stop</0>." | ||||
| } | ||||
|  |  | |||
|  | @ -8,7 +8,7 @@ | |||
|     "jsx": "preserve", | ||||
|     "paths": { | ||||
|       "@lib/*": ["../../../apps/web/lib/*"] | ||||
|     }, | ||||
|     } | ||||
|   }, | ||||
|   "include": ["."], | ||||
|   "exclude": ["dist", "build", "node_modules", "test-cal.tsx"] | ||||
|  |  | |||
|  | @ -0,0 +1,21 @@ | |||
| -- CreateEnum | ||||
| CREATE TYPE "UserPermissionRole" AS ENUM ('USER', 'ADMIN'); | ||||
| 
 | ||||
| -- AlterTable | ||||
| ALTER TABLE "users" ADD COLUMN     "role" "UserPermissionRole" NOT NULL DEFAULT E'USER'; | ||||
| 
 | ||||
| -- CreateTable | ||||
| CREATE TABLE "Impersonations" ( | ||||
|     "id" SERIAL NOT NULL, | ||||
|     "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||||
|     "impersonatedUserId" INTEGER NOT NULL, | ||||
|     "impersonatedById" INTEGER NOT NULL, | ||||
| 
 | ||||
|     CONSTRAINT "Impersonations_pkey" PRIMARY KEY ("id") | ||||
| ); | ||||
| 
 | ||||
| -- AddForeignKey | ||||
| ALTER TABLE "Impersonations" ADD CONSTRAINT "Impersonations_impersonatedUserId_fkey" FOREIGN KEY ("impersonatedUserId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; | ||||
| 
 | ||||
| -- AddForeignKey | ||||
| ALTER TABLE "Impersonations" ADD CONSTRAINT "Impersonations_impersonatedById_fkey" FOREIGN KEY ("impersonatedById") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; | ||||
|  | @ -107,6 +107,11 @@ model DestinationCalendar { | |||
|   eventTypeId Int?       @unique | ||||
| } | ||||
| 
 | ||||
| enum UserPermissionRole { | ||||
|   USER | ||||
|   ADMIN | ||||
| } | ||||
| 
 | ||||
| model User { | ||||
|   id                  Int                  @id @default(autoincrement()) | ||||
|   username            String?              @unique | ||||
|  | @ -155,6 +160,9 @@ model User { | |||
|   allowDynamicBooking Boolean?             @default(true) | ||||
|   metadata            Json? | ||||
|   verified            Boolean?             @default(false) | ||||
|   role                UserPermissionRole   @default(USER) | ||||
|   impersonatedUsers   Impersonations[]     @relation("impersonated_user") | ||||
|   impersonatedBy      Impersonations[]     @relation("impersonated_by_user") | ||||
|   apiKeys             ApiKey[] | ||||
| 
 | ||||
|   @@map(name: "users") | ||||
|  | @ -376,6 +384,15 @@ model Webhook { | |||
|   eventType       EventType?             @relation(fields: [eventTypeId], references: [id], onDelete: Cascade) | ||||
| } | ||||
| 
 | ||||
| model Impersonations { | ||||
|   id                 Int      @id @default(autoincrement()) | ||||
|   createdAt          DateTime @default(now()) | ||||
|   impersonatedUser   User     @relation("impersonated_user", fields: [impersonatedUserId], references: [id]) | ||||
|   impersonatedBy     User     @relation("impersonated_by_user", fields: [impersonatedById], references: [id]) | ||||
|   impersonatedUserId Int | ||||
|   impersonatedById   Int | ||||
| } | ||||
| 
 | ||||
| model ApiKey { | ||||
|   id         String    @id @unique @default(cuid()) | ||||
|   userId     Int | ||||
|  |  | |||
							
								
								
									
										3
									
								
								packages/types/next-auth.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								packages/types/next-auth.d.ts
									
									
									
									
										vendored
									
									
								
							|  | @ -1,3 +1,4 @@ | |||
| import { UserPermissionRole } from "@prisma/client"; | ||||
| import NextAuth, { DefaultSession } from "next-auth"; | ||||
| 
 | ||||
| declare module "next-auth" { | ||||
|  | @ -5,6 +6,8 @@ declare module "next-auth" { | |||
|   type CalendsoSessionUser = DefaultSessionUser & { | ||||
|     id: number; | ||||
|     username: string; | ||||
|     impersonatedByUID?: number; | ||||
|     role: UserPermissionRole; | ||||
|   }; | ||||
|   /** | ||||
|    * Returned by `useSession`, `getSession` and received as a prop on the `Provider` React Context | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue
	
	 sean-brydon
						sean-brydon